editor.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. import 'dart:io' as io;
  2. import 'dart:convert';
  3. import 'dart:ui';
  4. import 'package:flowy_editor/widget/image_viewer_screen.dart';
  5. import 'package:flutter/cupertino.dart';
  6. import 'package:flutter/material.dart';
  7. import 'package:url_launcher/url_launcher.dart';
  8. import 'package:string_validator/string_validator.dart';
  9. import '../widget/raw_editor.dart';
  10. import '../widget/builder.dart';
  11. import '../widget/proxy.dart';
  12. import '../model/document/attribute.dart';
  13. import '../model/document/document.dart';
  14. import '../model/document/node/embed.dart';
  15. import '../model/document/node/line.dart';
  16. import '../model/document/node/container.dart' as container_node;
  17. import '../model/document/node/leaf.dart' as leaf;
  18. import '../service/controller.dart';
  19. import '../service/cursor.dart';
  20. import '../service/style.dart';
  21. const linkPrefixes = [
  22. 'mailto:', // email
  23. 'tel:', // telephone
  24. 'sms:', // SMS
  25. 'callto:',
  26. 'wtai:',
  27. 'market:',
  28. 'geopoint:',
  29. 'ymsgr:',
  30. 'msnim:',
  31. 'gtalk:', // Google Talk
  32. 'skype:',
  33. 'sip:', // Lync
  34. 'whatsapp:',
  35. 'http'
  36. ];
  37. /* ------------------------------ Flowy Editor ------------------------------ */
  38. class FlowyEditor extends StatefulWidget {
  39. const FlowyEditor({
  40. Key? key,
  41. required this.controller,
  42. required this.focusNode,
  43. required this.scrollController,
  44. required this.scrollable,
  45. required this.scrollBottomInset,
  46. required this.padding,
  47. required this.autoFocus,
  48. required this.readOnly,
  49. required this.expands,
  50. this.showCursor,
  51. this.placeholder,
  52. this.enableInteractiveSelection = true,
  53. this.minHeight,
  54. this.maxHeight,
  55. this.customStyles,
  56. this.textCapitalization = TextCapitalization.sentences,
  57. this.keyboardAppearance = Brightness.light,
  58. this.scrollPhysics,
  59. this.embedBuilder = EmbedBuilder.defaultBuilder,
  60. this.onLaunchUrl,
  61. this.onTapDown,
  62. this.onTapUp,
  63. this.onLongPressStart,
  64. this.onLongPressMoveUpdate,
  65. this.onLongPressEnd,
  66. });
  67. factory FlowyEditor.basic({
  68. required EditorController controller,
  69. required bool readOnly,
  70. }) {
  71. return FlowyEditor(
  72. controller: controller,
  73. focusNode: FocusNode(),
  74. scrollController: ScrollController(),
  75. scrollable: true,
  76. scrollBottomInset: 0,
  77. padding: EdgeInsets.zero,
  78. autoFocus: true,
  79. readOnly: readOnly,
  80. expands: false,
  81. );
  82. }
  83. final EditorController controller;
  84. final FocusNode focusNode;
  85. final ScrollController scrollController;
  86. final bool scrollable;
  87. final double scrollBottomInset;
  88. final EdgeInsetsGeometry padding;
  89. final bool autoFocus;
  90. final bool? showCursor;
  91. final bool readOnly;
  92. final String? placeholder;
  93. final bool enableInteractiveSelection;
  94. final double? minHeight;
  95. final double? maxHeight;
  96. final DefaultStyles? customStyles;
  97. final bool expands;
  98. final TextCapitalization textCapitalization;
  99. final Brightness keyboardAppearance;
  100. final ScrollPhysics? scrollPhysics;
  101. final EmbedBuilderFuncion embedBuilder;
  102. // Callback
  103. final ValueChanged<String>? onLaunchUrl;
  104. /// Returns whether gesture is handled
  105. final bool Function(TapDownDetails details, TextPosition textPosition)? onTapDown;
  106. /// Returns whether gesture is handled
  107. final bool Function(TapUpDetails details, TextPosition textPosition)? onTapUp;
  108. /// Returns whether gesture is handled
  109. final bool Function(LongPressStartDetails details, TextPosition textPosition)? onLongPressStart;
  110. /// Returns whether gesture is handled
  111. final bool Function(LongPressMoveUpdateDetails details, TextPosition textPosition)? onLongPressMoveUpdate;
  112. /// Returns whether gesture is handled
  113. final bool Function(LongPressEndDetails details, TextPosition textPosition)? onLongPressEnd;
  114. @override
  115. _FlowyEditorState createState() => _FlowyEditorState();
  116. }
  117. class _FlowyEditorState extends State<FlowyEditor> implements EditorTextSelectionGestureDetectorBuilderDelegate {
  118. final GlobalKey<EditorState> _editorKey = GlobalKey<EditorState>();
  119. late EditorTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder;
  120. @override
  121. void initState() {
  122. super.initState();
  123. _selectionGestureDetectorBuilder = _FlowyEditorSelectionGestureDetectorBuilder(this);
  124. }
  125. @override
  126. Widget build(BuildContext context) {
  127. final theme = Theme.of(context);
  128. final selectionTheme = TextSelectionTheme.of(context);
  129. TextSelectionControls textSelectionControls;
  130. bool paintCursorAboveText;
  131. bool cursorOpacityAnimates;
  132. Offset? cursorOffset;
  133. Color? cursorColor;
  134. Color selectionColor;
  135. Radius? cursorRadius;
  136. switch (theme.platform) {
  137. case TargetPlatform.android:
  138. case TargetPlatform.fuchsia:
  139. case TargetPlatform.linux:
  140. case TargetPlatform.windows:
  141. textSelectionControls = materialTextSelectionControls;
  142. paintCursorAboveText = false;
  143. cursorOpacityAnimates = false;
  144. cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary;
  145. selectionColor = selectionTheme.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40);
  146. break;
  147. case TargetPlatform.iOS:
  148. case TargetPlatform.macOS:
  149. final cupertinoTheme = CupertinoTheme.of(context);
  150. textSelectionControls = cupertinoTextSelectionControls;
  151. paintCursorAboveText = true;
  152. cursorOpacityAnimates = true;
  153. cursorColor ??= selectionTheme.cursorColor ?? cupertinoTheme.primaryColor;
  154. selectionColor = selectionTheme.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40);
  155. cursorRadius ??= const Radius.circular(2);
  156. cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
  157. break;
  158. default:
  159. throw UnimplementedError();
  160. }
  161. final showSelectionHandles = theme.platform == TargetPlatform.iOS || theme.platform == TargetPlatform.android;
  162. return _selectionGestureDetectorBuilder.build(
  163. HitTestBehavior.translucent,
  164. RawEditor(
  165. _editorKey,
  166. widget.controller,
  167. widget.focusNode,
  168. widget.scrollController,
  169. widget.scrollable,
  170. widget.scrollBottomInset,
  171. widget.padding,
  172. widget.readOnly,
  173. widget.placeholder,
  174. widget.onLaunchUrl,
  175. ToolbarOptions(
  176. copy: widget.enableInteractiveSelection,
  177. cut: widget.enableInteractiveSelection,
  178. paste: widget.enableInteractiveSelection,
  179. selectAll: widget.enableInteractiveSelection,
  180. ),
  181. showSelectionHandles,
  182. widget.showCursor,
  183. CursorStyle(
  184. color: cursorColor,
  185. backgroundColor: Colors.grey,
  186. width: 2,
  187. radius: cursorRadius,
  188. offset: cursorOffset,
  189. paintAboveText: paintCursorAboveText,
  190. opacityAnimates: cursorOpacityAnimates,
  191. ),
  192. widget.textCapitalization,
  193. widget.maxHeight,
  194. widget.minHeight,
  195. widget.customStyles,
  196. widget.expands,
  197. widget.autoFocus,
  198. selectionColor,
  199. textSelectionControls,
  200. widget.keyboardAppearance,
  201. widget.enableInteractiveSelection,
  202. widget.scrollPhysics,
  203. widget.embedBuilder,
  204. ),
  205. );
  206. }
  207. @override
  208. GlobalKey<EditorState> getEditableTextKey() => _editorKey;
  209. @override
  210. bool getForcePressEnabled() => false;
  211. @override
  212. bool getSelectionEnabled() => widget.enableInteractiveSelection;
  213. void _requestKeyboard() {
  214. _editorKey.currentState!.requestKeyboard();
  215. }
  216. }
  217. /* --------------------------------- Gesture -------------------------------- */
  218. class _FlowyEditorSelectionGestureDetectorBuilder extends EditorTextSelectionGestureDetectorBuilder {
  219. _FlowyEditorSelectionGestureDetectorBuilder(this._state) : super(_state);
  220. final _FlowyEditorState _state;
  221. @override
  222. void onForcePressStart(ForcePressDetails details) {
  223. super.onForcePressStart(details);
  224. if (delegate.getSelectionEnabled() && shouldShowSelectionToolbar) {
  225. getEditor()!.showToolbar();
  226. }
  227. }
  228. @override
  229. void onForcePressEnd(ForcePressDetails details) {}
  230. @override
  231. void onLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
  232. if (_state.widget.onLongPressMoveUpdate != null) {
  233. final renderEditor = getRenderEditor();
  234. if (renderEditor != null) {
  235. if (_state.widget.onLongPressMoveUpdate!(details, renderEditor.getPositionForOffset(details.globalPosition))) {
  236. return;
  237. }
  238. }
  239. }
  240. if (!delegate.getSelectionEnabled()) {
  241. return;
  242. }
  243. switch (Theme.of(_state.context).platform) {
  244. case TargetPlatform.iOS:
  245. case TargetPlatform.macOS:
  246. getRenderEditor()!.selectPositionAt(
  247. details.globalPosition,
  248. null,
  249. SelectionChangedCause.longPress,
  250. );
  251. break;
  252. case TargetPlatform.android:
  253. case TargetPlatform.fuchsia:
  254. case TargetPlatform.linux:
  255. case TargetPlatform.windows:
  256. getRenderEditor()!.selectWordsInRange(
  257. details.globalPosition - details.offsetFromOrigin,
  258. details.globalPosition,
  259. SelectionChangedCause.longPress,
  260. );
  261. break;
  262. default:
  263. throw 'Invalid platform';
  264. }
  265. }
  266. @override
  267. void onTapDown(TapDownDetails details) {
  268. if (_state.widget.onTapDown != null) {
  269. final renderEditor = getRenderEditor();
  270. if (renderEditor != null) {
  271. if (_state.widget.onTapDown!(details, renderEditor.getPositionForOffset(details.globalPosition))) {
  272. return;
  273. }
  274. }
  275. }
  276. super.onTapDown(details);
  277. }
  278. @override
  279. void onTapUp(TapUpDetails details) {
  280. if (_state.widget.onTapUp != null) {
  281. final renderEditor = getRenderEditor();
  282. if (renderEditor != null) {
  283. if (_state.widget.onTapUp!(details, renderEditor.getPositionForOffset(details.globalPosition))) {
  284. return;
  285. }
  286. }
  287. }
  288. getEditor()!.hideToolbar();
  289. final positionSelected = _onTappingBlock(details);
  290. if (delegate.getSelectionEnabled() && !positionSelected) {
  291. switch (Theme.of(_state.context).platform) {
  292. case TargetPlatform.iOS:
  293. case TargetPlatform.macOS:
  294. switch (details.kind) {
  295. case PointerDeviceKind.mouse:
  296. case PointerDeviceKind.stylus:
  297. case PointerDeviceKind.invertedStylus:
  298. getRenderEditor()!.selectPosition(SelectionChangedCause.tap);
  299. break;
  300. case PointerDeviceKind.touch:
  301. case PointerDeviceKind.unknown:
  302. getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap);
  303. break;
  304. }
  305. break;
  306. case TargetPlatform.android:
  307. case TargetPlatform.fuchsia:
  308. case TargetPlatform.linux:
  309. case TargetPlatform.windows:
  310. getRenderEditor()!.selectPosition(SelectionChangedCause.tap);
  311. break;
  312. }
  313. }
  314. _state._requestKeyboard();
  315. }
  316. @override
  317. void onLongPressStart(LongPressStartDetails details) {
  318. if (_state.widget.onLongPressStart != null) {
  319. final renderEditor = getRenderEditor();
  320. if (renderEditor != null) {
  321. if (_state.widget.onLongPressStart!(details, renderEditor.getPositionForOffset(details.globalPosition))) {
  322. return;
  323. }
  324. }
  325. }
  326. if (delegate.getSelectionEnabled()) {
  327. switch (Theme.of(_state.context).platform) {
  328. case TargetPlatform.iOS:
  329. case TargetPlatform.macOS:
  330. getRenderEditor()!.selectPositionAt(
  331. details.globalPosition,
  332. null,
  333. SelectionChangedCause.longPress,
  334. );
  335. break;
  336. case TargetPlatform.android:
  337. case TargetPlatform.fuchsia:
  338. case TargetPlatform.linux:
  339. case TargetPlatform.windows:
  340. getRenderEditor()!.selectWord(SelectionChangedCause.longPress);
  341. Feedback.forLongPress(_state.context);
  342. break;
  343. default:
  344. throw 'Invalid platform';
  345. }
  346. }
  347. }
  348. @override
  349. void onLongPressEnd(LongPressEndDetails details) {
  350. if (_state.widget.onLongPressEnd != null) {
  351. final renderEditor = getRenderEditor();
  352. if (renderEditor != null) {
  353. if (_state.widget.onLongPressEnd!(details, renderEditor.getPositionForOffset(details.globalPosition))) {
  354. return;
  355. }
  356. }
  357. super.onLongPressEnd(details);
  358. }
  359. }
  360. // Util
  361. bool _onTappingBlock(TapUpDetails details) {
  362. if (_state.widget.controller.document.isEmpty()) {
  363. return false;
  364. }
  365. final position = getRenderEditor()!.getPositionForOffset(details.globalPosition);
  366. final result = getEditor()!.widget.controller.document.queryChild(position.offset);
  367. if (result.node == null) {
  368. return false;
  369. }
  370. final line = result.node as Line;
  371. final segmentResult = line.queryChild(result.offset, false);
  372. // Checkbox
  373. if (segmentResult.node == null) {
  374. if (line.length == 1) {
  375. // tapping when no text yet on this line
  376. _flipListCheckbox(position, line, segmentResult);
  377. getEditor()!.widget.controller.updateSelection(
  378. TextSelection.collapsed(offset: position.offset),
  379. ChangeSource.LOCAL,
  380. );
  381. return true;
  382. }
  383. return false;
  384. }
  385. // Link
  386. final segment = segmentResult.node as leaf.Leaf;
  387. if (segment.style.containsKey(Attribute.link.key)) {
  388. var launchUrl = getEditor()!.widget.onLaunchUrl;
  389. launchUrl ??= _launchUrl;
  390. String? link = segment.style.attributes[Attribute.link.key]!.value;
  391. if (getEditor()!.widget.readOnly && link != null) {
  392. link = link.trim();
  393. if (!linkPrefixes.any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) {
  394. link = 'https://$link';
  395. }
  396. launchUrl(link);
  397. }
  398. return false;
  399. }
  400. // Image
  401. if (getEditor()!.widget.readOnly && segment.value is BlockEmbed) {
  402. final blockEmbed = segment.value as BlockEmbed;
  403. if (blockEmbed.type == 'image') {
  404. final imageUrl = EmbedBuilder.standardizeImageUrl(blockEmbed.data);
  405. Navigator.push(
  406. getEditor()!.context,
  407. MaterialPageRoute(builder: (context) {
  408. return ImageTapWrapper(
  409. imageProvider: imageUrl.startsWith('http')
  410. ? NetworkImage(imageUrl)
  411. : isBase64(imageUrl)
  412. ? Image.memory(base64.decode(imageUrl)) as ImageProvider<Object>?
  413. : FileImage(io.File(imageUrl)),
  414. );
  415. }),
  416. );
  417. }
  418. return false;
  419. }
  420. // Fallback
  421. if (_flipListCheckbox(position, line, segmentResult)) {
  422. return true;
  423. }
  424. return false;
  425. }
  426. bool _flipListCheckbox(TextPosition position, Line line, container_node.ChildQuery segmentResult) {
  427. if (getEditor()!.widget.readOnly || !line.style.containsKey(Attribute.list.key) || segmentResult.offset != 0) {
  428. return false;
  429. }
  430. // segmentResult.offset == 0 means tap at the beginning of the TextLine
  431. final String? listVal = line.style.attributes[Attribute.list.key]!.value;
  432. if (Attribute.unchecked.value == listVal) {
  433. getEditor()!.widget.controller.formatText(position.offset, 0, Attribute.checked);
  434. } else if (Attribute.checked.value == listVal) {
  435. getEditor()!.widget.controller.formatText(position.offset, 0, Attribute.unchecked);
  436. }
  437. getEditor()!.widget.controller.updateSelection(
  438. TextSelection.collapsed(offset: position.offset),
  439. ChangeSource.LOCAL,
  440. );
  441. return true;
  442. }
  443. Future<void> _launchUrl(String url) async {
  444. await launch(url);
  445. }
  446. }