diff --git a/README.md b/README.md index 96c065b..4d5e766 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ WYSIWYG editor for Android and JavaFX with a rich set of supported formatting options. -Based on https://github.com/dankito/RichTextEditor, but but for Flutter. +Based on https://github.com/dankito/RichTextEditor, but for Flutter. ## ✨ Features - [x] Bold, Italic, Underline, Strike through, Subscript, Superscript diff --git a/example/pubspec.lock b/example/pubspec.lock index 2933127..da1c2d2 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -427,6 +427,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "5.1.1" + xml2json: + dependency: transitive + description: + name: xml2json + url: "https://pub.dartlang.org" + source: hosted + version: "5.2.0" sdks: dart: ">=2.13.0 <3.0.0" flutter: ">=2.0.0" diff --git a/lib/src/models/alias.dart b/lib/src/models/alias.dart new file mode 100644 index 0000000..d628c1a --- /dev/null +++ b/lib/src/models/alias.dart @@ -0,0 +1,21 @@ +class Alias { + String? name; + String? to; + String? weight; + + Alias({this.name, this.to, this.weight}); + + Alias.fromJson(Map json) { + name = json['name']; + to = json['to']; + weight = json['weight']; + } + + Map toJson() { + final Map data = new Map(); + data['name'] = this.name; + data['to'] = this.to; + data['weight'] = this.weight; + return data; + } +} \ No newline at end of file diff --git a/lib/src/models/config.dart b/lib/src/models/config.dart new file mode 100644 index 0000000..3bc8b9a --- /dev/null +++ b/lib/src/models/config.dart @@ -0,0 +1,27 @@ +import 'package:rich_editor/src/models/alias.dart'; + +import 'family.dart'; + +class Config { + String? version; + List? families; + List? aliases; + + Config({this.version, this.families, this.aliases}); + + Config.fromJson(Map json) { + version = json['version']; + if (json['family'] != null) { + families = []; + json['family'].forEach((v) { + families!.add(new Family.fromJson(v)); + }); + } + if (json['alias'] != null) { + aliases = []; + json['alias'].forEach((v) { + aliases!.add(new Alias.fromJson(v)); + }); + } + } +} diff --git a/lib/src/models/editor_state.dart b/lib/src/models/editor_state.dart index f5936cc..b43f354 100644 --- a/lib/src/models/editor_state.dart +++ b/lib/src/models/editor_state.dart @@ -3,13 +3,28 @@ import 'package:rich_editor/src/models/enum.dart'; import 'command_state.dart'; class EditorState { - bool didHtmlChange; - String html; - Map commandStates; + bool? didHtmlChange; + String? html; + Map? commandStates; EditorState({ this.didHtmlChange = false, this.html = '', this.commandStates = const {}, }); + + EditorState.fromJson(Map json) { + didHtmlChange = json['didHtmlChange']; + html = json['html']; + commandStates = json['commandStates']; + } + + Map toJson() { + final Map data = new Map(); + data['didHtmlChange'] = this.didHtmlChange; + data['html'] = this.html; + data['commandStates'] = this.commandStates; + return data; + } } + diff --git a/lib/src/models/family.dart b/lib/src/models/family.dart new file mode 100644 index 0000000..a56b684 --- /dev/null +++ b/lib/src/models/family.dart @@ -0,0 +1,36 @@ +import 'font.dart'; + +class Family { + String? name; + List? fonts; + String? lang; + String? variant; + + Family({this.name, this.fonts, this.lang, this.variant}); + + Family.fromJson(Map json) { + name = json['name']; + if (json['font'] != null) { + fonts = []; + if(json['font'] is List) { + json['font'].forEach((v) { + fonts!.add(new Font.fromJson(v)); + }); + } + + } + lang = json['lang']; + variant = json['variant']; + } + + Map toJson() { + final Map data = new Map(); + data['name'] = this.name; + if (this.fonts != null) { + data['font'] = this.fonts!.map((v) => v.toJson()).toList(); + } + data['lang'] = this.lang; + data['variant'] = this.variant; + return data; + } +} diff --git a/lib/src/models/font.dart b/lib/src/models/font.dart new file mode 100644 index 0000000..2be50ae --- /dev/null +++ b/lib/src/models/font.dart @@ -0,0 +1,60 @@ +class Font { + String? weight; + String? style; + String? t; + String? fallbackFor; + Axis? axis; + String? index; + + Font( + {this.weight, + this.style, + this.t, + this.fallbackFor, + this.axis, + this.index}); + + Font.fromJson(Map json) { + weight = json['weight']; + style = json['style']; + t = json[r'$t']; + fallbackFor = json['fallbackFor']; + if (json['axis'] is Map) { + axis = json['axis'] != null ? new Axis.fromJson(json['axis']) : null; + } + + index = json['index']; + } + + Map toJson() { + final Map data = new Map(); + data['weight'] = this.weight; + data['style'] = this.style; + data[r'$t'] = this.t; + data['fallbackFor'] = this.fallbackFor; + if (this.axis != null) { + data['axis'] = this.axis!.toJson(); + } + data['index'] = this.index; + return data; + } +} + +class Axis { + String? tag; + String? stylevalue; + + Axis({this.tag, this.stylevalue}); + + Axis.fromJson(Map json) { + tag = json['tag']; + stylevalue = json['stylevalue']; + } + + Map toJson() { + final Map data = new Map(); + data['tag'] = this.tag; + data['stylevalue'] = this.stylevalue; + return data; + } +} diff --git a/lib/src/models/system_font.dart b/lib/src/models/system_font.dart new file mode 100644 index 0000000..da1c5c5 --- /dev/null +++ b/lib/src/models/system_font.dart @@ -0,0 +1,9 @@ +class SystemFont { + String? name; + String? path; + + SystemFont(String name, String path) { + this.name = name; + this.path = path; + } +} diff --git a/lib/src/utils/font_list_parser.dart b/lib/src/utils/font_list_parser.dart new file mode 100644 index 0000000..fa83f2d --- /dev/null +++ b/lib/src/utils/font_list_parser.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:rich_editor/src/models/alias.dart'; +import 'package:rich_editor/src/models/config.dart'; +import 'package:rich_editor/src/models/family.dart'; +import 'package:rich_editor/src/models/font.dart'; +import 'package:rich_editor/src/models/system_font.dart'; +import 'package:xml2json/xml2json.dart'; + +// A simple port of FontListParser from Java to Kotlin +// See https://stackoverflow.com/a/29533686/10835183 +class FontListParser { + File androidFontsFile = File("/system/etc/fonts.xml"); + File androidSystemFontsFile = File("/system/etc/system_fonts.xml"); + + List getSystemFonts() { + String fontsXml; + if (androidFontsFile.existsSync()) { + fontsXml = androidFontsFile.path; + } else if (androidSystemFontsFile.existsSync()) { + fontsXml = androidSystemFontsFile.path; + } else { + throw ("fonts.xml does not exist on this system"); + } + Xml2Json xml2json = new Xml2Json(); + xml2json.parse(File(fontsXml).readAsStringSync()); + Map json = jsonDecode(xml2json.toGData()); + Config parser = Config.fromJson(json['familyset']); + List fonts = []; + + for (Family family in parser.families!) { + if (family.name != null) { + Font font = Font(); + for (Font f in family.fonts!) { + font = f; + if (int.tryParse(f.weight!) == 400) { + break; + } + } + SystemFont systemFont = new SystemFont(family.name!, font.t ?? ''); + if (fonts.contains(systemFont)) { + continue; + } + fonts.add(new SystemFont(family.name!, font.t ?? '')); + } + } + + for (Alias alias in parser.aliases!) { + if (alias.name == null || + alias.to == null || + int.tryParse(alias.weight ?? '') == 0) { + continue; + } + for (Family family in parser.families!) { + if (family.name == null || family.name! == alias.to) { + continue; + } + for (Font font in family.fonts!) { + if (font.weight == alias.weight) { + fonts.add(new SystemFont(alias.name!, font.t ?? '')); + break; + } + } + } + } + + if (fonts.isEmpty) { + throw Exception("No system fonts found."); + } + // fonts + // print(fonts); + return fonts; + } + + List safelyGetSystemFonts() { + try { + return getSystemFonts(); + } catch (e) { + List defaultSystemFonts = [ + ["cursive", "DancingScript-Regular.ttf"], + ["monospace", "DroidSansMono.ttf"], + ["sans-serif", "Roboto-Regular.ttf"], + ["sans-serif-light" "Roboto-Light.ttf"], + ["sans-serif-medium", "Roboto-Medium.ttf"], + ["sans-serif-black", "Roboto-Black.ttf"], + ["sans-serif-condensed", "RobotoCondensed-Regular.ttf"], + ["sans-serif-thin", "Roboto-Thin.ttf"], + ["serif", "NotoSerif-Regular.ttf"] + ]; + List fonts = []; + for (List names in defaultSystemFonts) { + File file = new File("/system/fonts/"+ names[1]); + if (file.existsSync()) { + fonts.add(new SystemFont(names[0], file.path)); + } + } + return fonts; + } + } +} diff --git a/lib/src/utils/javascript_executor_base.dart b/lib/src/utils/javascript_executor_base.dart index cf398cf..b67044d 100644 --- a/lib/src/utils/javascript_executor_base.dart +++ b/lib/src/utils/javascript_executor_base.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:rich_editor/src/extensions/extensions.dart'; +import 'package:rich_editor/src/models/editor_state.dart'; import 'package:rich_editor/src/models/enum.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -15,6 +18,7 @@ class JavascriptExecutorBase { String defaultEncoding = "UTF-8"; String? htmlField = ""; + var didHtmlChange = false; Map commandStates = {}; init(WebViewController controller) { @@ -196,4 +200,88 @@ class JavascriptExecutorBase { static encodeHtml(String html) { return Uri.encodeFull(html); } + + // bool shouldOverrideUrlLoading(String url) { + // String decodedUrl; + // try { + // decodedUrl = decodeHtml(url); + // } catch (e) { + // // No handling + // return false; + // } + // + // if (url.indexOf(editorStateChangedCallbackScheme) == 0) { + // editorStateChanged( + // decodedUrl.substring(editorStateChangedCallbackScheme.length)); + // return true; + // } + // + // return false; + // } + // + // editorStateChanged(String statesString) { + // try { + // var editorState = EditorState.fromJson(jsonDecode(statesString)); + // + // bool currentHtmlChanged = this.htmlField != editorState.html; + // this.htmlField = editorState.html; + // + // retrievedEditorState(editorState.didHtmlChange, editorState.commandStates) + // + // if (currentHtmlChanged) { + // fireHtmlChangedListenersAsync(editorState.html); + // } + // } + // catch (e) { + // throw("Could not parse command states: $statesString $e"); + // } + // } + // + // retrievedEditorState(bool didHtmlChange, + // Map commandStates) { + // if (this.didHtmlChange != didHtmlChange) { + // this.didHtmlChange = didHtmlChange; + // didHtmlChangeListeners.forEach { + // it.didHtmlChange(didHtmlChange); + // } + // } + // + // handleRetrievedCommandStates(commandStates) + // } + // + // handleRetrievedCommandStates(Map commandStates) { + // determineDerivedCommandStates(commandStates) + // + // this.commandStates = commandStates; + // + // commandStatesChangedListeners.forEach { + // it.invoke(this.commandStates) + // } + // } + + // determineDerivedCommandStates(Map commandStates) { + // commandStates[CommandName.FORMATBLOCK]?.let { formatCommandState -> + // commandStates.put(CommandName.H1, CommandState(formatCommandState.executable, isFormatActivated(formatCommandState, "h1"))) + // commandStates.put(CommandName.H2, CommandState(formatCommandState.executable, isFormatActivated(formatCommandState, "h2"))) + // commandStates.put(CommandName.H3, CommandState(formatCommandState.executable, isFormatActivated(formatCommandState, "h3"))) + // commandStates.put(CommandName.H4, CommandState(formatCommandState.executable, isFormatActivated(formatCommandState, "h4"))) + // commandStates.put(CommandName.H5, CommandState(formatCommandState.executable, isFormatActivated(formatCommandState, "h5"))) + // commandStates.put(CommandName.H6, CommandState(formatCommandState.executable, isFormatActivated(formatCommandState, "h6"))) + // commandStates.put(CommandName.P, CommandState(formatCommandState.executable, isFormatActivated(formatCommandState, "p"))) + // commandStates.put(CommandName.PRE, CommandState(formatCommandState.executable, isFormatActivated(formatCommandState, "pre"))) + // commandStates.put(CommandName.BR, CommandState(formatCommandState.executable, isFormatActivated(formatCommandState, ""))) + // commandStates.put(CommandName.BLOCKQUOTE, CommandState(formatCommandState.executable, isFormatActivated(formatCommandState, "blockquote"))) + // } + // + // commandStates[CommandName.INSERTHTML]?.let { insertHtmlState -> + // commandStates.put(CommandName.INSERTLINK, insertHtmlState) + // commandStates.put(CommandName.INSERTIMAGE, insertHtmlState) + // commandStates.put(CommandName.INSERTCHECKBOX, insertHtmlState) + // } + // } + + // String isFormatActivated(CommandState formatCommandState, String format) { + // return (formatCommandState.value == format) + // .toString(); // rich_text_editor.js reports boolean values as string, so we also have to convert it to string + // } } diff --git a/lib/src/widgets/editor_tool_bar.dart b/lib/src/widgets/editor_tool_bar.dart index 1e95ea0..18302fb 100644 --- a/lib/src/widgets/editor_tool_bar.dart +++ b/lib/src/widgets/editor_tool_bar.dart @@ -177,21 +177,27 @@ class EditorToolBar extends StatelessWidget { } }, ), - TabButton( - tooltip: 'Font face', - icon: Icons.font_download, - onTap: () async { - _closeKeyboard(); - var command = await showDialog( - // isScrollControlled: true, - context: context, - builder: (_) { - return FontsDialog(); - }, - ); - if (command != null) - await javascriptExecutorBase.setFontName(command); - }, + // TODO: Show font button on iOS + Visibility( + visible: Platform.isAndroid, + child: TabButton( + tooltip: 'Font face', + icon: Icons.font_download, + onTap: () async { + Directory fontsDir = Directory("/system/fonts/"); + File file = File('/system/etc/fonts.xml'); + // debugPrint(await file.readAsString()); + var command = await showDialog( + // isScrollControlled: true, + context: context, + builder: (_) { + return FontsDialog(); + }, + ); + if (command != null) + await javascriptExecutorBase.setFontName(command); + }, + ), ), TabButton( icon: Icons.format_size, diff --git a/lib/src/widgets/fonts_dialog.dart b/lib/src/widgets/fonts_dialog.dart index 74b65e2..8846127 100644 --- a/lib/src/widgets/fonts_dialog.dart +++ b/lib/src/widgets/fonts_dialog.dart @@ -1,17 +1,15 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:path/path.dart'; +import 'package:rich_editor/src/models/system_font.dart'; +import 'package:rich_editor/src/utils/font_list_parser.dart'; class FontsDialog extends StatelessWidget { - List fonts = [ - { - 'id': 'cursive', - 'title': '

This is a paragraph.

' - }, - { - 'id': 'monospace', - 'title': '

This is a paragraph.

' - } - ]; + List getSystemFonts() { + return FontListParser().getSystemFonts(); + } @override Widget build(BuildContext context) { @@ -21,14 +19,23 @@ class FontsDialog extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - for (Map font in fonts) + for (SystemFont font in getSystemFonts()) InkWell( - child: Html(data: font['title']), - onTap: () => Navigator.pop(context, font['id']), + child: Html(data: '

' + '${basename(font.path!)}

'), + onTap: () => Navigator.pop(context, font.path), ) ], ), ), ); } + + fontSlug(FileSystemEntity font) { + String name = basename(font.path); + String slug = name.toLowerCase(); + slug = slug.replaceAll(extension(font.path), ''); + // print(slug); + return slug; + } } diff --git a/pubspec.lock b/pubspec.lock index d4eb76e..efe17d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -220,7 +220,7 @@ packages: source: hosted version: "1.0.0" path: - dependency: transitive + dependency: "direct main" description: name: path url: "https://pub.dartlang.org" @@ -420,6 +420,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "5.1.1" + xml2json: + dependency: "direct main" + description: + name: xml2json + url: "https://pub.dartlang.org" + source: hosted + version: "5.2.0" sdks: dart: ">=2.13.0 <3.0.0" flutter: ">=2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 5ca06b2..663164b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,8 @@ dependencies: image_picker: ^0.7.5+3 flutter_html: ^2.0.0 flutter_colorpicker: ^0.4.0 + path: ^1.8.0 + xml2json: ^5.2.0 dev_dependencies: flutter_test: