| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497 |
- import 'dart:io' as io;
- import 'dart:convert';
- import 'dart:ui';
- import 'package:flowy_editor/widget/image_viewer_screen.dart';
- import 'package:flutter/cupertino.dart';
- import 'package:flutter/material.dart';
- import 'package:url_launcher/url_launcher.dart';
- import 'package:string_validator/string_validator.dart';
- import '../widget/raw_editor.dart';
- import '../widget/builder.dart';
- import '../widget/proxy.dart';
- import '../model/document/attribute.dart';
- import '../model/document/document.dart';
- import '../model/document/node/embed.dart';
- import '../model/document/node/line.dart';
- import '../model/document/node/container.dart' as container_node;
- import '../model/document/node/leaf.dart' as leaf;
- import '../service/controller.dart';
- import '../service/cursor.dart';
- import '../service/style.dart';
- const linkPrefixes = [
- 'mailto:', // email
- 'tel:', // telephone
- 'sms:', // SMS
- 'callto:',
- 'wtai:',
- 'market:',
- 'geopoint:',
- 'ymsgr:',
- 'msnim:',
- 'gtalk:', // Google Talk
- 'skype:',
- 'sip:', // Lync
- 'whatsapp:',
- 'http'
- ];
- /* ------------------------------ Flowy Editor ------------------------------ */
- class FlowyEditor extends StatefulWidget {
- const FlowyEditor({
- Key? key,
- required this.controller,
- required this.focusNode,
- required this.scrollController,
- required this.scrollable,
- required this.scrollBottomInset,
- required this.padding,
- required this.autoFocus,
- required this.readOnly,
- required this.expands,
- this.showCursor,
- this.placeholder,
- this.enableInteractiveSelection = true,
- this.minHeight,
- this.maxHeight,
- this.customStyles,
- this.textCapitalization = TextCapitalization.sentences,
- this.keyboardAppearance = Brightness.light,
- this.scrollPhysics,
- this.embedBuilder = EmbedBuilder.defaultBuilder,
- this.onLaunchUrl,
- this.onTapDown,
- this.onTapUp,
- this.onLongPressStart,
- this.onLongPressMoveUpdate,
- this.onLongPressEnd,
- });
- factory FlowyEditor.basic({
- required EditorController controller,
- required bool readOnly,
- }) {
- return FlowyEditor(
- controller: controller,
- focusNode: FocusNode(),
- scrollController: ScrollController(),
- scrollable: true,
- scrollBottomInset: 0,
- padding: EdgeInsets.zero,
- autoFocus: true,
- readOnly: readOnly,
- expands: false,
- );
- }
- final EditorController controller;
- final FocusNode focusNode;
- final ScrollController scrollController;
- final bool scrollable;
- final double scrollBottomInset;
- final EdgeInsetsGeometry padding;
- final bool autoFocus;
- final bool? showCursor;
- final bool readOnly;
- final String? placeholder;
- final bool enableInteractiveSelection;
- final double? minHeight;
- final double? maxHeight;
- final DefaultStyles? customStyles;
- final bool expands;
- final TextCapitalization textCapitalization;
- final Brightness keyboardAppearance;
- final ScrollPhysics? scrollPhysics;
- final EmbedBuilderFuncion embedBuilder;
- // Callback
- final ValueChanged<String>? onLaunchUrl;
- /// Returns whether gesture is handled
- final bool Function(TapDownDetails details, TextPosition textPosition)? onTapDown;
- /// Returns whether gesture is handled
- final bool Function(TapUpDetails details, TextPosition textPosition)? onTapUp;
- /// Returns whether gesture is handled
- final bool Function(LongPressStartDetails details, TextPosition textPosition)? onLongPressStart;
- /// Returns whether gesture is handled
- final bool Function(LongPressMoveUpdateDetails details, TextPosition textPosition)? onLongPressMoveUpdate;
- /// Returns whether gesture is handled
- final bool Function(LongPressEndDetails details, TextPosition textPosition)? onLongPressEnd;
- @override
- _FlowyEditorState createState() => _FlowyEditorState();
- }
- class _FlowyEditorState extends State<FlowyEditor> implements EditorTextSelectionGestureDetectorBuilderDelegate {
- final GlobalKey<EditorState> _editorKey = GlobalKey<EditorState>();
- late EditorTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder;
- @override
- void initState() {
- super.initState();
- _selectionGestureDetectorBuilder = _FlowyEditorSelectionGestureDetectorBuilder(this);
- }
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
- final selectionTheme = TextSelectionTheme.of(context);
- TextSelectionControls textSelectionControls;
- bool paintCursorAboveText;
- bool cursorOpacityAnimates;
- Offset? cursorOffset;
- Color? cursorColor;
- Color selectionColor;
- Radius? cursorRadius;
- switch (theme.platform) {
- case TargetPlatform.android:
- case TargetPlatform.fuchsia:
- case TargetPlatform.linux:
- case TargetPlatform.windows:
- textSelectionControls = materialTextSelectionControls;
- paintCursorAboveText = false;
- cursorOpacityAnimates = false;
- cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary;
- selectionColor = selectionTheme.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40);
- break;
- case TargetPlatform.iOS:
- case TargetPlatform.macOS:
- final cupertinoTheme = CupertinoTheme.of(context);
- textSelectionControls = cupertinoTextSelectionControls;
- paintCursorAboveText = true;
- cursorOpacityAnimates = true;
- cursorColor ??= selectionTheme.cursorColor ?? cupertinoTheme.primaryColor;
- selectionColor = selectionTheme.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40);
- cursorRadius ??= const Radius.circular(2);
- cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
- break;
- default:
- throw UnimplementedError();
- }
- final showSelectionHandles = theme.platform == TargetPlatform.iOS || theme.platform == TargetPlatform.android;
- return _selectionGestureDetectorBuilder.build(
- HitTestBehavior.translucent,
- RawEditor(
- _editorKey,
- widget.controller,
- widget.focusNode,
- widget.scrollController,
- widget.scrollable,
- widget.scrollBottomInset,
- widget.padding,
- widget.readOnly,
- widget.placeholder,
- widget.onLaunchUrl,
- ToolbarOptions(
- copy: widget.enableInteractiveSelection,
- cut: widget.enableInteractiveSelection,
- paste: widget.enableInteractiveSelection,
- selectAll: widget.enableInteractiveSelection,
- ),
- showSelectionHandles,
- widget.showCursor,
- CursorStyle(
- color: cursorColor,
- backgroundColor: Colors.grey,
- width: 2,
- radius: cursorRadius,
- offset: cursorOffset,
- paintAboveText: paintCursorAboveText,
- opacityAnimates: cursorOpacityAnimates,
- ),
- widget.textCapitalization,
- widget.maxHeight,
- widget.minHeight,
- widget.customStyles,
- widget.expands,
- widget.autoFocus,
- selectionColor,
- textSelectionControls,
- widget.keyboardAppearance,
- widget.enableInteractiveSelection,
- widget.scrollPhysics,
- widget.embedBuilder,
- ),
- );
- }
- @override
- GlobalKey<EditorState> getEditableTextKey() => _editorKey;
- @override
- bool getForcePressEnabled() => false;
- @override
- bool getSelectionEnabled() => widget.enableInteractiveSelection;
- void _requestKeyboard() {
- _editorKey.currentState!.requestKeyboard();
- }
- }
- /* --------------------------------- Gesture -------------------------------- */
- class _FlowyEditorSelectionGestureDetectorBuilder extends EditorTextSelectionGestureDetectorBuilder {
- _FlowyEditorSelectionGestureDetectorBuilder(this._state) : super(_state);
- final _FlowyEditorState _state;
- @override
- void onForcePressStart(ForcePressDetails details) {
- super.onForcePressStart(details);
- if (delegate.getSelectionEnabled() && shouldShowSelectionToolbar) {
- getEditor()!.showToolbar();
- }
- }
- @override
- void onForcePressEnd(ForcePressDetails details) {}
- @override
- void onLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
- if (_state.widget.onLongPressMoveUpdate != null) {
- final renderEditor = getRenderEditor();
- if (renderEditor != null) {
- if (_state.widget.onLongPressMoveUpdate!(details, renderEditor.getPositionForOffset(details.globalPosition))) {
- return;
- }
- }
- }
- if (!delegate.getSelectionEnabled()) {
- return;
- }
- switch (Theme.of(_state.context).platform) {
- case TargetPlatform.iOS:
- case TargetPlatform.macOS:
- getRenderEditor()!.selectPositionAt(
- details.globalPosition,
- null,
- SelectionChangedCause.longPress,
- );
- break;
- case TargetPlatform.android:
- case TargetPlatform.fuchsia:
- case TargetPlatform.linux:
- case TargetPlatform.windows:
- getRenderEditor()!.selectWordsInRange(
- details.globalPosition - details.offsetFromOrigin,
- details.globalPosition,
- SelectionChangedCause.longPress,
- );
- break;
- default:
- throw 'Invalid platform';
- }
- }
- @override
- void onTapDown(TapDownDetails details) {
- if (_state.widget.onTapDown != null) {
- final renderEditor = getRenderEditor();
- if (renderEditor != null) {
- if (_state.widget.onTapDown!(details, renderEditor.getPositionForOffset(details.globalPosition))) {
- return;
- }
- }
- }
- super.onTapDown(details);
- }
- @override
- void onTapUp(TapUpDetails details) {
- if (_state.widget.onTapUp != null) {
- final renderEditor = getRenderEditor();
- if (renderEditor != null) {
- if (_state.widget.onTapUp!(details, renderEditor.getPositionForOffset(details.globalPosition))) {
- return;
- }
- }
- }
- getEditor()!.hideToolbar();
- final positionSelected = _onTappingBlock(details);
- if (delegate.getSelectionEnabled() && !positionSelected) {
- switch (Theme.of(_state.context).platform) {
- case TargetPlatform.iOS:
- case TargetPlatform.macOS:
- switch (details.kind) {
- case PointerDeviceKind.mouse:
- case PointerDeviceKind.stylus:
- case PointerDeviceKind.invertedStylus:
- getRenderEditor()!.selectPosition(SelectionChangedCause.tap);
- break;
- case PointerDeviceKind.touch:
- case PointerDeviceKind.unknown:
- getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap);
- break;
- }
- break;
- case TargetPlatform.android:
- case TargetPlatform.fuchsia:
- case TargetPlatform.linux:
- case TargetPlatform.windows:
- getRenderEditor()!.selectPosition(SelectionChangedCause.tap);
- break;
- }
- }
- _state._requestKeyboard();
- }
- @override
- void onLongPressStart(LongPressStartDetails details) {
- if (_state.widget.onLongPressStart != null) {
- final renderEditor = getRenderEditor();
- if (renderEditor != null) {
- if (_state.widget.onLongPressStart!(details, renderEditor.getPositionForOffset(details.globalPosition))) {
- return;
- }
- }
- }
- if (delegate.getSelectionEnabled()) {
- switch (Theme.of(_state.context).platform) {
- case TargetPlatform.iOS:
- case TargetPlatform.macOS:
- getRenderEditor()!.selectPositionAt(
- details.globalPosition,
- null,
- SelectionChangedCause.longPress,
- );
- break;
- case TargetPlatform.android:
- case TargetPlatform.fuchsia:
- case TargetPlatform.linux:
- case TargetPlatform.windows:
- getRenderEditor()!.selectWord(SelectionChangedCause.longPress);
- Feedback.forLongPress(_state.context);
- break;
- default:
- throw 'Invalid platform';
- }
- }
- }
- @override
- void onLongPressEnd(LongPressEndDetails details) {
- if (_state.widget.onLongPressEnd != null) {
- final renderEditor = getRenderEditor();
- if (renderEditor != null) {
- if (_state.widget.onLongPressEnd!(details, renderEditor.getPositionForOffset(details.globalPosition))) {
- return;
- }
- }
- super.onLongPressEnd(details);
- }
- }
- // Util
- bool _onTappingBlock(TapUpDetails details) {
- if (_state.widget.controller.document.isEmpty()) {
- return false;
- }
- final position = getRenderEditor()!.getPositionForOffset(details.globalPosition);
- final result = getEditor()!.widget.controller.document.queryChild(position.offset);
- if (result.node == null) {
- return false;
- }
- final line = result.node as Line;
- final segmentResult = line.queryChild(result.offset, false);
- // Checkbox
- if (segmentResult.node == null) {
- if (line.length == 1) {
- // tapping when no text yet on this line
- _flipListCheckbox(position, line, segmentResult);
- getEditor()!.widget.controller.updateSelection(
- TextSelection.collapsed(offset: position.offset),
- ChangeSource.LOCAL,
- );
- return true;
- }
- return false;
- }
- // Link
- final segment = segmentResult.node as leaf.Leaf;
- if (segment.style.containsKey(Attribute.link.key)) {
- var launchUrl = getEditor()!.widget.onLaunchUrl;
- launchUrl ??= _launchUrl;
- String? link = segment.style.attributes[Attribute.link.key]!.value;
- if (getEditor()!.widget.readOnly && link != null) {
- link = link.trim();
- if (!linkPrefixes.any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) {
- link = 'https://$link';
- }
- launchUrl(link);
- }
- return false;
- }
- // Image
- if (getEditor()!.widget.readOnly && segment.value is BlockEmbed) {
- final blockEmbed = segment.value as BlockEmbed;
- if (blockEmbed.type == 'image') {
- final imageUrl = EmbedBuilder.standardizeImageUrl(blockEmbed.data);
- Navigator.push(
- getEditor()!.context,
- MaterialPageRoute(builder: (context) {
- return ImageTapWrapper(
- imageProvider: imageUrl.startsWith('http')
- ? NetworkImage(imageUrl)
- : isBase64(imageUrl)
- ? Image.memory(base64.decode(imageUrl)) as ImageProvider<Object>?
- : FileImage(io.File(imageUrl)),
- );
- }),
- );
- }
- return false;
- }
- // Fallback
- if (_flipListCheckbox(position, line, segmentResult)) {
- return true;
- }
- return false;
- }
- bool _flipListCheckbox(TextPosition position, Line line, container_node.ChildQuery segmentResult) {
- if (getEditor()!.widget.readOnly || !line.style.containsKey(Attribute.list.key) || segmentResult.offset != 0) {
- return false;
- }
- // segmentResult.offset == 0 means tap at the beginning of the TextLine
- final String? listVal = line.style.attributes[Attribute.list.key]!.value;
- if (Attribute.unchecked.value == listVal) {
- getEditor()!.widget.controller.formatText(position.offset, 0, Attribute.checked);
- } else if (Attribute.checked.value == listVal) {
- getEditor()!.widget.controller.formatText(position.offset, 0, Attribute.unchecked);
- }
- getEditor()!.widget.controller.updateSelection(
- TextSelection.collapsed(offset: position.offset),
- ChangeSource.LOCAL,
- );
- return true;
- }
- Future<void> _launchUrl(String url) async {
- await launch(url);
- }
- }
|