1+ import 'dart:convert' ;
2+ import 'dart:io' ;
3+ import 'dart:typed_data' ;
4+
5+ import 'package:dart_bbcode_parser/dart_bbcode_parser.dart' ;
6+ import 'package:dart_quill_delta/dart_quill_delta.dart' ;
7+ import 'package:file_picker/file_picker.dart' ;
18import 'package:flutter/material.dart' ;
9+ import 'package:flutter/services.dart' show Clipboard, ClipboardData;
210import 'package:flutter_bbcode_editor/flutter_bbcode_editor.dart' ;
311import 'package:tsdm_client/constants/layout.dart' ;
12+ import 'package:tsdm_client/extensions/date_time.dart' ;
413import 'package:tsdm_client/features/editor/widgets/color_bottom_sheet.dart' ;
514import 'package:tsdm_client/features/editor/widgets/emoji_bottom_sheet.dart' ;
615import 'package:tsdm_client/features/editor/widgets/image_dialog.dart' ;
716import 'package:tsdm_client/features/editor/widgets/url_dialog.dart' ;
817import 'package:tsdm_client/features/editor/widgets/username_picker_dialog.dart' ;
18+ import 'package:tsdm_client/i18n/strings.g.dart' ;
19+ import 'package:tsdm_client/shared/models/models.dart' ;
20+ import 'package:tsdm_client/utils/logger.dart' ;
921import 'package:tsdm_client/utils/platform.dart' ;
22+ import 'package:tsdm_client/utils/show_bottom_sheet.dart' ;
23+ import 'package:tsdm_client/utils/show_toast.dart' ;
24+ import 'package:tsdm_client/widgets/tips_card.dart' ;
1025
1126/// Representing all features types.
1227enum EditorFeatures {
@@ -99,13 +114,15 @@ enum EditorFeatures {
99114}
100115
101116/// Toolbar for the bbcode editor.
102- class EditorToolbar extends StatelessWidget {
117+ class EditorToolbar extends StatelessWidget with LoggerMixin {
103118 /// Constructor.
104119 const EditorToolbar ({
105120 required this .bbcodeController,
106121 this .disabledFeatures = const {},
107122 this .afterButtonPressed,
108123 this .editorFocusNode,
124+ this .applyDocumentMetadata,
125+ this .collectDocumentMetadata,
109126 super .key,
110127 });
111128
@@ -126,8 +143,231 @@ class EditorToolbar extends StatelessWidget {
126143 /// Use This field to update editor focus state.
127144 final FocusNode ? editorFocusNode;
128145
146+ /// Optional callback function, collect document metadata info from UI.
147+ ///
148+ /// Use this function to send quill delta json document metadata from outside
149+ /// toolbar.
150+ final EditorDocumentMetadata Function ()? collectDocumentMetadata;
151+
152+ /// Optional callback function, apply document metadata on UI.
153+ ///
154+ /// Use this function to update UI with values in metadata.
155+ final void Function (EditorDocumentMetadata metadata)? applyDocumentMetadata;
156+
129157 bool _hasFeature (EditorFeatures feature) => ! disabledFeatures.contains (feature);
130158
159+ /// The callback function when portation button is clicked.
160+ ///
161+ /// Show a bottom sheet provides (copy/paste + import/export) -> (bbcode/quill delta)
162+ ///
163+ /// To support versioned quill delta document files, provides optional parameters:
164+ ///
165+ /// * [collectMetadata] callback function that provides document metadata to
166+ /// save in the quill delta document. Caller may use this parameter where
167+ /// UI have document metadata.
168+ /// * [applyMetadata] callback function to apply document metadata on UI. Caller
169+ /// may use this parameter where updating metadata in UI is needed.
170+ ///
171+ /// No matter [collectMetadata] and [applyMetadata] are provided or not, the exported
172+ /// quill delta document will always in the latest format, not orignial quill delta json.
173+ Future <void > _onPortationButtonClicked (
174+ BuildContext context,
175+ BBCodeEditorController controller, {
176+ EditorDocumentMetadata Function ()? collectMetadata,
177+ void Function (EditorDocumentMetadata )? applyMetadata,
178+ }) async {
179+ final tr = context.t.postEditPage.portation;
180+
181+ final topBar = BottomSheetTopBar (
182+ height: 110 ,
183+ alignment: .center,
184+ child: Padding (
185+ padding: edgeInsetsL12R12,
186+ child: Align (
187+ child: Column (
188+ spacing: 10 ,
189+ children: [
190+ TipsCard (
191+ iconData: Icons .warning_outlined,
192+ tips: '${tr .tip } ' ,
193+ color: Theme .of (context).colorScheme.onPrimaryContainer,
194+ backgroundColor: Theme .of (context).colorScheme.primaryContainer,
195+ ),
196+ TipsCard (
197+ tips: '${tr .typesTip } ' ,
198+ color: Theme .of (context).colorScheme.onSecondaryContainer,
199+ backgroundColor: Theme .of (context).colorScheme.secondaryContainer,
200+ ),
201+ ],
202+ ),
203+ ),
204+ ),
205+ );
206+
207+ await showCustomBottomSheet <void >(
208+ title: tr.title,
209+ topBar: topBar,
210+ context: context,
211+ childrenBuilder: (_) => [
212+ ListTile (
213+ title: Text (tr.copyBBCode),
214+ onTap: () async {
215+ await Clipboard .setData (ClipboardData (text: controller.toBBCode ()));
216+ if (! context.mounted) {
217+ return ;
218+ }
219+ showSnackBar (context: context, message: context.t.general.copiedToClipboard);
220+ Navigator .of (context).pop ();
221+ },
222+ ),
223+ ListTile (
224+ title: Text (tr.pasteBBCode),
225+ onTap: () async {
226+ final bbcode = (await Clipboard .getData (Clipboard .kTextPlain))? .text;
227+ if (bbcode == null ) {
228+ return ;
229+ }
230+ try {
231+ final delta = parseBBCodeTextToDelta (bbcode.replaceAll ('\r ' , '' ));
232+ controller.setDocumentFromDelta (delta);
233+ } on Exception catch (e, st) {
234+ error ('failed to paste bbcode: exception thrown' );
235+ handleRaw (e, st);
236+ return ;
237+ }
238+
239+ if (! context.mounted) {
240+ return ;
241+ }
242+ Navigator .of (context).pop ();
243+ },
244+ ),
245+ ListTile (
246+ title: Text (tr.exportBBCode),
247+ onTap: () async {
248+ await _exportFile (context, 'bbcode_' , 'txt' , controller.toBBCode ());
249+ if (! context.mounted) {
250+ return ;
251+ }
252+ Navigator .of (context).pop ();
253+ },
254+ ),
255+ ListTile (
256+ title: Text (tr.importBBCode),
257+ onTap: () async {
258+ final data = await _importFile (context, ['txt' ]);
259+ if (data == null ) {
260+ return ;
261+ }
262+ if (! context.mounted) {
263+ return ;
264+ }
265+ try {
266+ final delta = parseBBCodeTextToDelta (data.replaceAll ('\r ' , '' ));
267+ controller.setDocumentFromDelta (delta);
268+ } on Exception catch (e, st) {
269+ error ('failed to import bbcode: exception thrown' );
270+ handleRaw (e, st);
271+ return ;
272+ }
273+ Navigator .of (context).pop ();
274+ },
275+ ),
276+ ListTile (
277+ title: Text (tr.copyQuilllDelta),
278+ onTap: () async {
279+ final data = EditorDocument .build (collectMetadata? .call (), controller.toQuillDeltaJson ());
280+ await Clipboard .setData (ClipboardData (text: data.toJson ()));
281+ if (! context.mounted) {
282+ return ;
283+ }
284+ showSnackBar (context: context, message: context.t.general.copiedToClipboard);
285+ Navigator .of (context).pop ();
286+ },
287+ ),
288+ ListTile (
289+ title: Text (tr.pasteQuillDelta),
290+ onTap: () async {
291+ final data = (await Clipboard .getData (Clipboard .kTextPlain))? .text;
292+ if (data == null ) {
293+ return ;
294+ }
295+ try {
296+ final d = jsonDecode (data.replaceAll ('\r ' , '' ));
297+ if (d is List <Map <String , dynamic >>) {
298+ // Not versioned.
299+ final delta = Delta .fromJson (d);
300+ controller.setDocumentFromDelta (delta);
301+ } else if (d is Map <String , dynamic >) {
302+ // Versioned.
303+ final doc = EditorDocumentMapper .fromMap (d);
304+ applyMetadata? .call (doc.metadata);
305+ final delta = Delta .fromJson (doc.operations);
306+ controller.setDocumentFromDelta (delta);
307+ } else {
308+ throw FormatException ('invalid quill delta document type ${d .runtimeType }' );
309+ }
310+ } on Exception catch (e, st) {
311+ error ('failed to paste quill delta: exception thrown' );
312+ handleRaw (e, st);
313+ return ;
314+ }
315+
316+ if (! context.mounted) {
317+ return ;
318+ }
319+ Navigator .of (context).pop ();
320+ },
321+ ),
322+ ListTile (
323+ title: Text (tr.exportQuillDelta),
324+ onTap: () async {
325+ final data = EditorDocument .build (collectMetadata? .call (), controller.toQuillDeltaJson ());
326+ await _exportFile (context, 'quilldata_' , 'json' , data.toJson ());
327+ if (! context.mounted) {
328+ return ;
329+ }
330+ Navigator .of (context).pop ();
331+ },
332+ ),
333+ ListTile (
334+ title: Text (tr.importQuillDelta),
335+ onTap: () async {
336+ final data = await _importFile (context, ['json' ]);
337+ if (data == null ) {
338+ return ;
339+ }
340+ if (! context.mounted) {
341+ return ;
342+ }
343+
344+ try {
345+ final d = jsonDecode (data.replaceAll ('\r ' , '' ));
346+ if (d is List <dynamic >) {
347+ // Not versioned.
348+ final delta = Delta .fromJson (d);
349+ controller.setDocumentFromDelta (delta);
350+ } else if (d is Map <String , dynamic >) {
351+ // Versioned.
352+ final doc = EditorDocumentMapper .fromMap (d);
353+ applyMetadata? .call (doc.metadata);
354+ final delta = Delta .fromJson (doc.operations);
355+ controller.setDocumentFromDelta (delta);
356+ } else {
357+ throw FormatException ('invalid quill delta document type ${d .runtimeType }' );
358+ }
359+ } on Exception catch (e, st) {
360+ error ('failed to import quill delta: exception thrown' );
361+ handleRaw (e, st);
362+ return ;
363+ }
364+ Navigator .of (context).pop ();
365+ },
366+ ),
367+ ],
368+ );
369+ }
370+
131371 @override
132372 Widget build (BuildContext context) {
133373 final toolbar = BBCodeEditorToolbar (
@@ -141,6 +381,12 @@ class EditorToolbar extends StatelessWidget {
141381 showColorPicker (context, initialColor, PickerType .background),
142382 imagePicker: (context, url, width, height) => showImagePicker (context, url: url, width: width, height: height),
143383 usernamePicker: showUsernamePickerDialog,
384+ onPortationButtonClicked: (_, controller) async => _onPortationButtonClicked (
385+ context,
386+ controller,
387+ collectMetadata: collectDocumentMetadata,
388+ applyMetadata: applyDocumentMetadata,
389+ ),
144390 // Features.
145391 showUndo: _hasFeature (EditorFeatures .undo),
146392 showRedo: _hasFeature (EditorFeatures .redo),
@@ -178,3 +424,22 @@ class EditorToolbar extends StatelessWidget {
178424 return toolbar;
179425 }
180426}
427+
428+ Future <String ?> _importFile (BuildContext context, List <String > exts) async {
429+ final result = await FilePicker .platform.pickFiles (type: FileType .custom, allowedExtensions: exts);
430+ if (result == null ) {
431+ return null ;
432+ }
433+ return File (result.files.single.path! ).readAsString ();
434+ }
435+
436+ Future <void > _exportFile (BuildContext context, String prefix, String ext, String data) async {
437+ final result = await FilePicker .platform.saveFile (
438+ fileName: '$prefix ${DateTime .now ().yyyyMMDDHHMMSS ()}.$ext ' ,
439+ bytes: Uint8List .fromList (utf8.encode (data)),
440+ );
441+ if (result == null ) {
442+ return ;
443+ }
444+ await File (result).writeAsString (data);
445+ }
0 commit comments