diff --git a/frontend/app_flowy/packages/flowy_editor/.vscode/launch.json b/frontend/app_flowy/packages/flowy_editor/.vscode/launch.json new file mode 100644 index 0000000000..f27c363a13 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/.vscode/launch.json @@ -0,0 +1,45 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "example", + "cwd": "example", + "request": "launch", + "type": "dart" + }, + { + "name": "example (profile mode)", + "cwd": "example", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "example (release mode)", + "cwd": "example", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flowy_editor", + "request": "launch", + "type": "dart" + }, + { + "name": "flowy_editor (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flowy_editor (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + ] +} \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json index b90aec8369..307b4bf92f 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -68,7 +68,7 @@ { "insert": " your ", "attributes": { "bold": true } }, { "insert": "writing", "attributes": { "underline": true } }, { - "insert": " howeverv you like.", + "insert": " however you like.", "attributes": { "strikethrough": true } } ], diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json b/frontend/app_flowy/packages/flowy_editor/example/assets/example.json index 0dad009cd2..2e982f98e4 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/example.json @@ -3,10 +3,21 @@ "type": "editor", "attributes": {}, "children": [ + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "subtype": "quote" + } + }, { "type": "image", "attributes": { - "image_src": "https://images.pexels.com/photos/2253275/pexels-photo-2253275.jpeg?cs=srgb&dl=pexels-helena-lopes-2253275.jpg&fm=jpg" + "image_src": "https://s1.ax1x.com/2022/07/28/vCgz1x.png" } }, { @@ -17,6 +28,7 @@ } ], "attributes": { + "subtype": "heading", "heading": "h1" } }, @@ -28,9 +40,18 @@ } ], "attributes": { + "subtype": "heading", "heading": "h2" } }, + { + "type": "text", + "delta": [ + { + "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." + } + ] + }, { "type": "text", "delta": [ @@ -39,6 +60,7 @@ } ], "attributes": { + "subtype": "heading", "heading": "h3" } }, @@ -48,11 +70,7 @@ { "insert": "Click " }, { "insert": "anywhere", "attributes": { "underline": true } }, { "insert": " and just typing." } - ], - "attributes": { - "list": "todo", - "todo": true - } + ] }, { "type": "text", @@ -67,11 +85,7 @@ { "insert": "to see all the types of content you can add - entity, headers, videos, sub pages, etc." } - ], - "attributes": { - "list": "todo", - "todo": true - } + ] }, { "type": "text", @@ -83,20 +97,17 @@ { "insert": " your ", "attributes": { "italic": true } }, { "insert": "writing", "attributes": { "strikethrough": true } }, { "insert": "." } - ], - "attributes": { - "list": "todo", - "todo": true - } + ] }, { "type": "text", "delta": [ { - "insert": "Here are the examples:" + "insert": "Here are the plugins:" } ], "attributes": { + "subtype": "heading", "heading": "h3" } }, @@ -108,7 +119,8 @@ } ], "attributes": { - "list": "bullet" + "subtype": "checkbox", + "checkbox": false } }, { @@ -119,7 +131,8 @@ } ], "attributes": { - "list": "bullet" + "subtype": "checkbox", + "checkbox": false } }, { @@ -130,7 +143,8 @@ } ], "attributes": { - "list": "bullet" + "subtype": "checkbox", + "checkbox": false } }, { @@ -141,7 +155,7 @@ } ], "attributes": { - "quote": true + "subtype": "bullet-list" } }, { @@ -152,7 +166,7 @@ } ], "attributes": { - "quote": true + "subtype": "bullet-list" } }, { @@ -163,6 +177,51 @@ } ], "attributes": { + "subtype": "bullet-list" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "subtype": "quote" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "subtype": "quote" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "subtype": "quote" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Hello world" + } + ], + "attributes": { + "subtype": "number-list", "number": 1 } }, @@ -174,6 +233,7 @@ } ], "attributes": { + "subtype": "number-list", "number": 2 } }, @@ -185,6 +245,7 @@ } ], "attributes": { + "subtype": "number-list", "number": 3 } } diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart index 6105703fa0..7ebb340f2d 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -1,12 +1,7 @@ import 'dart:convert'; import 'package:example/expandable_floating_action_button.dart'; -import 'package:example/plugin/document_node_widget.dart'; -import 'package:example/plugin/selected_text_node_widget.dart'; -import 'package:example/plugin/text_with_heading_node_widget.dart'; import 'package:example/plugin/image_node_widget.dart'; -import 'package:example/plugin/old_text_node_widget.dart'; -import 'package:example/plugin/text_with_check_box_node_widget.dart'; import 'package:flutter/material.dart'; import 'package:flowy_editor/flowy_editor.dart'; import 'package:flutter/services.dart'; @@ -59,19 +54,8 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { - final RenderPlugins renderPlugins = RenderPlugins(); late EditorState _editorState; int page = 0; - @override - void initState() { - super.initState(); - - renderPlugins - ..register('editor', EditorNodeWidgetBuilder.create) - ..register('image', ImageNodeBuilder.create) - ..register('text/with-checkbox', TextWithCheckBoxNodeBuilder.create) - ..register('text/with-heading', TextWithHeadingNodeBuilder.create); - } @override Widget build(BuildContext context) { @@ -130,11 +114,13 @@ class _MyHomePageState extends State { final document = StateTree.fromJson(data); _editorState = EditorState( document: document, - renderPlugins: renderPlugins, ); return FlowyEditor( editorState: _editorState, keyEventHandlers: const [], + customBuilders: { + 'image': ImageNodeBuilder(), + }, shortcuts: [ // TODO: this won't work, just a example for now. { diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/debuggable_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/debuggable_rich_text.dart deleted file mode 100644 index 6028774ba9..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/debuggable_rich_text.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -class DebuggableRichText extends StatefulWidget { - final InlineSpan text; - final GlobalKey textKey; - - const DebuggableRichText({ - Key? key, - required this.text, - required this.textKey, - }) : super(key: key); - - @override - State createState() => _DebuggableRichTextState(); -} - -class _DebuggableRichTextState extends State { - final List _textRects = []; - - RenderParagraph get _renderParagraph => - widget.textKey.currentContext?.findRenderObject() as RenderParagraph; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _updateTextRects(); - }); - } - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - CustomPaint( - painter: _BoxPainter( - rects: _textRects, - ), - ), - RichText( - key: widget.textKey, - text: widget.text, - ), - ], - ); - } - - void _updateTextRects() { - setState(() { - _textRects - ..clear() - ..addAll( - _computeLocalSelectionRects( - TextSelection( - baseOffset: 0, - extentOffset: widget.text.toPlainText().length, - ), - ), - ); - }); - } - - List _computeLocalSelectionRects(TextSelection selection) { - final textBoxes = _renderParagraph.getBoxesForSelection(selection); - return textBoxes.map((box) => box.toRect()).toList(); - } -} - -class _BoxPainter extends CustomPainter { - final List _rects; - final Paint _paint; - - _BoxPainter({ - required List rects, - bool fill = false, - }) : _rects = rects, - _paint = Paint() { - _paint.style = fill ? PaintingStyle.fill : PaintingStyle.stroke; - } - - @override - void paint(Canvas canvas, Size size) { - for (final rect in _rects) { - canvas.drawRect( - rect, - _paint - ..color = Color( - (Random().nextDouble() * 0xFFFFFF).toInt(), - ).withOpacity(1.0), - ); - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return true; - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart deleted file mode 100644 index 2a70da2ba2..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flutter/material.dart'; - -class EditorNodeWidgetBuilder extends NodeWidgetBuilder { - EditorNodeWidgetBuilder.create({ - required super.editorState, - required super.node, - required super.key, - }) : super.create(); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - key: key, - child: _EditorNodeWidget( - node: node, - editorState: editorState, - ), - ); - } -} - -class _EditorNodeWidget extends StatelessWidget { - final Node node; - final EditorState editorState; - - const _EditorNodeWidget({ - Key? key, - required this.node, - required this.editorState, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: node.children - .map( - (e) => editorState.renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: e, - editorState: editorState, - ), - ), - ) - .toList(), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/flowy_selectable_text.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/flowy_selectable_text.dart deleted file mode 100644 index 0454f1cdc1..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/flowy_selectable_text.dart +++ /dev/null @@ -1,758 +0,0 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; - -import 'package:flutter/material.dart'; - -/// An eyeballed value that moves the cursor slightly left of where it is -/// rendered for text on Android so its positioning more accurately matches the -/// native iOS text cursor positioning. -/// -/// This value is in device pixels, not logical pixels as is typically used -/// throughout the codebase. -const int iOSHorizontalOffset = -2; - -class _TextSpanEditingController extends TextEditingController { - _TextSpanEditingController({required TextSpan textSpan}) - : assert(textSpan != null), - _textSpan = textSpan, - super(text: textSpan.toPlainText(includeSemanticsLabels: false)); - - final TextSpan _textSpan; - - @override - TextSpan buildTextSpan( - {required BuildContext context, - TextStyle? style, - required bool withComposing}) { - // This does not care about composing. - return TextSpan( - style: style, - children: [_textSpan], - ); - } - - @override - set text(String? newText) { - // This should never be reached. - throw UnimplementedError(); - } -} - -class _SelectableTextSelectionGestureDetectorBuilder - extends TextSelectionGestureDetectorBuilder { - _SelectableTextSelectionGestureDetectorBuilder({ - required _FlowySelectableTextState state, - }) : _state = state, - super(delegate: state); - - final _FlowySelectableTextState _state; - - @override - void onForcePressStart(ForcePressDetails details) { - super.onForcePressStart(details); - if (delegate.selectionEnabled && shouldShowSelectionToolbar) { - editableText.showToolbar(); - } - } - - @override - void onForcePressEnd(ForcePressDetails details) { - // Not required. - } - - @override - void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { - if (delegate.selectionEnabled) { - renderEditable.selectWordsInRange( - from: details.globalPosition - details.offsetFromOrigin, - to: details.globalPosition, - cause: SelectionChangedCause.longPress, - ); - } - } - - @override - void onSingleTapUp(TapUpDetails details) { - editableText.hideToolbar(); - if (delegate.selectionEnabled) { - switch (Theme.of(_state.context).platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - // renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); - // break; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - renderEditable.selectPosition(cause: SelectionChangedCause.tap); - break; - } - } - _state.widget.onTap?.call(); - } - - @override - void onSingleLongTapStart(LongPressStartDetails details) { - if (delegate.selectionEnabled) { - renderEditable.selectWord(cause: SelectionChangedCause.longPress); - Feedback.forLongPress(_state.context); - } - } -} - -/// A run of selectable text with a single style. -/// -/// The [FlowySelectableText] widget displays a string of text with a single style. -/// The string might break across multiple lines or might all be displayed on -/// the same line depending on the layout constraints. -/// -/// {@youtube 560 315 https://www.youtube.com/watch?v=ZSU3ZXOs6hc} -/// -/// The [style] argument is optional. When omitted, the text will use the style -/// from the closest enclosing [DefaultTextStyle]. If the given style's -/// [TextStyle.inherit] property is true (the default), the given style will -/// be merged with the closest enclosing [DefaultTextStyle]. This merging -/// behavior is useful, for example, to make the text bold while using the -/// default font family and size. -/// -/// {@macro flutter.material.textfield.wantKeepAlive} -/// -/// {@tool snippet} -/// -/// ```dart -/// const SelectableText( -/// 'Hello! How are you?', -/// textAlign: TextAlign.center, -/// style: TextStyle(fontWeight: FontWeight.bold), -/// ) -/// ``` -/// {@end-tool} -/// -/// Using the [SelectableText.rich] constructor, the [FlowySelectableText] widget can -/// display a paragraph with differently styled [TextSpan]s. The sample -/// that follows displays "Hello beautiful world" with different styles -/// for each word. -/// -/// {@tool snippet} -/// -/// ```dart -/// const SelectableText.rich( -/// TextSpan( -/// text: 'Hello', // default text style -/// children: [ -/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)), -/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)), -/// ], -/// ), -/// ) -/// ``` -/// {@end-tool} -/// -/// ## Interactivity -/// -/// To make [FlowySelectableText] react to touch events, use callback [onTap] to achieve -/// the desired behavior. -/// -/// See also: -/// -/// * [Text], which is the non selectable version of this widget. -/// * [TextField], which is the editable version of this widget. -class FlowySelectableText extends StatefulWidget { - /// Creates a selectable text widget. - /// - /// If the [style] argument is null, the text will use the style from the - /// closest enclosing [DefaultTextStyle]. - /// - - /// The [showCursor], [autofocus], [dragStartBehavior], [selectionHeightStyle], - /// [selectionWidthStyle] and [data] parameters must not be null. If specified, - /// the [maxLines] argument must be greater than zero. - const FlowySelectableText( - String this.data, { - Key? key, - this.focusNode, - this.style, - this.strutStyle, - this.textAlign, - this.textDirection, - this.textScaleFactor, - this.showCursor = false, - this.autofocus = false, - ToolbarOptions? toolbarOptions, - this.minLines, - this.maxLines, - this.cursorWidth = 2.0, - this.cursorHeight, - this.cursorRadius, - this.cursorColor, - this.selectionHeightStyle = ui.BoxHeightStyle.tight, - this.selectionWidthStyle = ui.BoxWidthStyle.tight, - this.dragStartBehavior = DragStartBehavior.start, - this.enableInteractiveSelection = true, - this.selectionControls, - this.onTap, - this.scrollPhysics, - this.semanticsLabel, - this.textHeightBehavior, - this.textWidthBasis, - this.onSelectionChanged, - }) : assert(showCursor != null), - assert(autofocus != null), - assert(dragStartBehavior != null), - assert(selectionHeightStyle != null), - assert(selectionWidthStyle != null), - assert(maxLines == null || maxLines > 0), - assert(minLines == null || minLines > 0), - assert( - (maxLines == null) || (minLines == null) || (maxLines >= minLines), - "minLines can't be greater than maxLines", - ), - assert( - data != null, - 'A non-null String must be provided to a SelectableText widget.', - ), - textSpan = null, - toolbarOptions = toolbarOptions ?? - const ToolbarOptions( - selectAll: true, - copy: true, - ), - super(key: key); - - /// Creates a selectable text widget with a [TextSpan]. - /// - /// The [textSpan] parameter must not be null and only contain [TextSpan] in - /// [textSpan].children. Other type of [InlineSpan] is not allowed. - /// - /// The [autofocus] and [dragStartBehavior] arguments must not be null. - const FlowySelectableText.rich( - TextSpan this.textSpan, { - Key? key, - this.focusNode, - this.style, - this.strutStyle, - this.textAlign, - this.textDirection, - this.textScaleFactor, - this.showCursor = false, - this.autofocus = false, - ToolbarOptions? toolbarOptions, - this.minLines, - this.maxLines, - this.cursorWidth = 2.0, - this.cursorHeight, - this.cursorRadius, - this.cursorColor, - this.selectionHeightStyle = ui.BoxHeightStyle.tight, - this.selectionWidthStyle = ui.BoxWidthStyle.tight, - this.dragStartBehavior = DragStartBehavior.start, - this.enableInteractiveSelection = true, - this.selectionControls, - this.onTap, - this.scrollPhysics, - this.semanticsLabel, - this.textHeightBehavior, - this.textWidthBasis, - this.onSelectionChanged, - }) : assert(showCursor != null), - assert(autofocus != null), - assert(dragStartBehavior != null), - assert(maxLines == null || maxLines > 0), - assert(minLines == null || minLines > 0), - assert( - (maxLines == null) || (minLines == null) || (maxLines >= minLines), - "minLines can't be greater than maxLines", - ), - assert( - textSpan != null, - 'A non-null TextSpan must be provided to a SelectableText.rich widget.', - ), - data = null, - toolbarOptions = toolbarOptions ?? - const ToolbarOptions( - selectAll: true, - copy: true, - ), - super(key: key); - - /// The text to display. - /// - /// This will be null if a [textSpan] is provided instead. - final String? data; - - /// The text to display as a [TextSpan]. - /// - /// This will be null if [data] is provided instead. - final TextSpan? textSpan; - - /// Defines the focus for this widget. - /// - /// Text is only selectable when widget is focused. - /// - /// The [focusNode] is a long-lived object that's typically managed by a - /// [StatefulWidget] parent. See [FocusNode] for more information. - /// - /// To give the focus to this widget, provide a [focusNode] and then - /// use the current [FocusScope] to request the focus: - /// - /// ```dart - /// FocusScope.of(context).requestFocus(myFocusNode); - /// ``` - /// - /// This happens automatically when the widget is tapped. - /// - /// To be notified when the widget gains or loses the focus, add a listener - /// to the [focusNode]: - /// - /// ```dart - /// focusNode.addListener(() { print(myFocusNode.hasFocus); }); - /// ``` - /// - /// If null, this widget will create its own [FocusNode] with - /// [FocusNode.skipTraversal] parameter set to `true`, which causes the widget - /// to be skipped over during focus traversal. - final FocusNode? focusNode; - - /// The style to use for the text. - /// - /// If null, defaults [DefaultTextStyle] of context. - final TextStyle? style; - - /// {@macro flutter.widgets.editableText.strutStyle} - final StrutStyle? strutStyle; - - /// {@macro flutter.widgets.editableText.textAlign} - final TextAlign? textAlign; - - /// {@macro flutter.widgets.editableText.textDirection} - final TextDirection? textDirection; - - /// {@macro flutter.widgets.editableText.textScaleFactor} - final double? textScaleFactor; - - /// {@macro flutter.widgets.editableText.autofocus} - final bool autofocus; - - /// {@macro flutter.widgets.editableText.minLines} - final int? minLines; - - /// {@macro flutter.widgets.editableText.maxLines} - final int? maxLines; - - /// {@macro flutter.widgets.editableText.showCursor} - final bool showCursor; - - /// {@macro flutter.widgets.editableText.cursorWidth} - final double cursorWidth; - - /// {@macro flutter.widgets.editableText.cursorHeight} - final double? cursorHeight; - - /// {@macro flutter.widgets.editableText.cursorRadius} - final Radius? cursorRadius; - - /// The color to use when painting the cursor. - /// - /// Defaults to the theme's `cursorColor` when null. - final Color? cursorColor; - - /// Controls how tall the selection highlight boxes are computed to be. - /// - /// See [ui.BoxHeightStyle] for details on available styles. - final ui.BoxHeightStyle selectionHeightStyle; - - /// Controls how wide the selection highlight boxes are computed to be. - /// - /// See [ui.BoxWidthStyle] for details on available styles. - final ui.BoxWidthStyle selectionWidthStyle; - - /// {@macro flutter.widgets.editableText.enableInteractiveSelection} - final bool enableInteractiveSelection; - - /// {@macro flutter.widgets.editableText.selectionControls} - final TextSelectionControls? selectionControls; - - /// {@macro flutter.widgets.scrollable.dragStartBehavior} - final DragStartBehavior dragStartBehavior; - - /// Configuration of toolbar options. - /// - /// Paste and cut will be disabled regardless. - /// - /// If not set, select all and copy will be enabled by default. - final ToolbarOptions toolbarOptions; - - /// {@macro flutter.widgets.editableText.selectionEnabled} - bool get selectionEnabled => enableInteractiveSelection; - - /// Called when the user taps on this selectable text. - /// - /// The selectable text builds a [GestureDetector] to handle input events like tap, - /// to trigger focus requests, to move the caret, adjust the selection, etc. - /// Handling some of those events by wrapping the selectable text with a competing - /// GestureDetector is problematic. - /// - /// To unconditionally handle taps, without interfering with the selectable text's - /// internal gesture detector, provide this callback. - /// - /// To be notified when the text field gains or loses the focus, provide a - /// [focusNode] and add a listener to that. - /// - /// To listen to arbitrary pointer events without competing with the - /// selectable text's internal gesture detector, use a [Listener]. - final GestureTapCallback? onTap; - - /// {@macro flutter.widgets.editableText.scrollPhysics} - final ScrollPhysics? scrollPhysics; - - /// {@macro flutter.widgets.Text.semanticsLabel} - final String? semanticsLabel; - - /// {@macro dart.ui.textHeightBehavior} - final TextHeightBehavior? textHeightBehavior; - - /// {@macro flutter.painting.textPainter.textWidthBasis} - final TextWidthBasis? textWidthBasis; - - /// {@macro flutter.widgets.editableText.onSelectionChanged} - final SelectionChangedCallback? onSelectionChanged; - - @override - State createState() => _FlowySelectableTextState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - .add(DiagnosticsProperty('data', data, defaultValue: null)); - properties.add(DiagnosticsProperty('semanticsLabel', semanticsLabel, - defaultValue: null)); - properties.add(DiagnosticsProperty('focusNode', focusNode, - defaultValue: null)); - properties.add( - DiagnosticsProperty('style', style, defaultValue: null)); - properties.add( - DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); - properties.add(DiagnosticsProperty('showCursor', showCursor, - defaultValue: false)); - properties.add(IntProperty('minLines', minLines, defaultValue: null)); - properties.add(IntProperty('maxLines', maxLines, defaultValue: null)); - properties.add( - EnumProperty('textAlign', textAlign, defaultValue: null)); - properties.add(EnumProperty('textDirection', textDirection, - defaultValue: null)); - properties.add( - DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null)); - properties - .add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); - properties - .add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null)); - properties.add(DiagnosticsProperty('cursorRadius', cursorRadius, - defaultValue: null)); - properties.add(DiagnosticsProperty('cursorColor', cursorColor, - defaultValue: null)); - properties.add(FlagProperty('selectionEnabled', - value: selectionEnabled, - defaultValue: true, - ifFalse: 'selection disabled')); - properties.add(DiagnosticsProperty( - 'selectionControls', selectionControls, - defaultValue: null)); - properties.add(DiagnosticsProperty( - 'scrollPhysics', scrollPhysics, - defaultValue: null)); - properties.add(DiagnosticsProperty( - 'textHeightBehavior', textHeightBehavior, - defaultValue: null)); - } -} - -class _FlowySelectableTextState extends State - implements TextSelectionGestureDetectorBuilderDelegate { - EditableTextState? get _editableText => editableTextKey.currentState; - - late _TextSpanEditingController _controller; - - FocusNode? _focusNode; - FocusNode get _effectiveFocusNode => - widget.focusNode ?? (_focusNode ??= FocusNode(skipTraversal: true)); - - bool _showSelectionHandles = false; - - late _SelectableTextSelectionGestureDetectorBuilder - _selectionGestureDetectorBuilder; - - // API for TextSelectionGestureDetectorBuilderDelegate. - @override - late bool forcePressEnabled; - - @override - final GlobalKey editableTextKey = - GlobalKey(); - - @override - bool get selectionEnabled => widget.selectionEnabled; - // End of API for TextSelectionGestureDetectorBuilderDelegate. - - @override - void initState() { - super.initState(); - _selectionGestureDetectorBuilder = - _SelectableTextSelectionGestureDetectorBuilder(state: this); - _controller = _TextSpanEditingController( - textSpan: widget.textSpan ?? TextSpan(text: widget.data), - ); - _controller.addListener(_onControllerChanged); - } - - @override - void didUpdateWidget(FlowySelectableText oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.data != oldWidget.data || - widget.textSpan != oldWidget.textSpan) { - _controller.removeListener(_onControllerChanged); - _controller = _TextSpanEditingController( - textSpan: widget.textSpan ?? TextSpan(text: widget.data), - ); - _controller.addListener(_onControllerChanged); - } - if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) { - _showSelectionHandles = false; - } else { - _showSelectionHandles = true; - } - } - - @override - void dispose() { - _focusNode?.dispose(); - _controller.removeListener(_onControllerChanged); - super.dispose(); - } - - void _onControllerChanged() { - final bool showSelectionHandles = - !_effectiveFocusNode.hasFocus || !_controller.selection.isCollapsed; - if (showSelectionHandles == _showSelectionHandles) { - return; - } - setState(() { - _showSelectionHandles = showSelectionHandles; - }); - } - - TextSelection? _lastSeenTextSelection; - - void _handleSelectionChanged( - TextSelection selection, SelectionChangedCause? cause) { - final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); - if (willShowSelectionHandles != _showSelectionHandles) { - setState(() { - _showSelectionHandles = willShowSelectionHandles; - }); - } - // TODO(chunhtai): The selection may be the same. We should remove this - // check once this is fixed https://github.com/flutter/flutter/issues/76349. - if (widget.onSelectionChanged != null && - _lastSeenTextSelection != selection) { - widget.onSelectionChanged!(selection, cause); - } - _lastSeenTextSelection = selection; - - switch (Theme.of(context).platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - if (cause == SelectionChangedCause.longPress) { - _editableText?.bringIntoView(selection.base); - } - return; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - // Do nothing. - } - } - - /// Toggle the toolbar when a selection handle is tapped. - void _handleSelectionHandleTapped() { - if (_controller.selection.isCollapsed) { - _editableText!.toggleToolbar(); - } - } - - bool _shouldShowSelectionHandles(SelectionChangedCause? cause) { - // When the text field is activated by something that doesn't trigger the - // selection overlay, we shouldn't show the handles either. - if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar) - return false; - - if (_controller.selection.isCollapsed) return false; - - if (cause == SelectionChangedCause.keyboard) return false; - - if (cause == SelectionChangedCause.longPress) return true; - - if (_controller.text.isNotEmpty) return true; - - return false; - } - - @override - Widget build(BuildContext context) { - // TODO(garyq): Assert to block WidgetSpans from being used here are removed, - // but we still do not yet have nice handling of things like carets, clipboard, - // and other features. We should add proper support. Currently, caret handling - // is blocked on SkParagraph switch and https://github.com/flutter/engine/pull/27010 - // should be landed in SkParagraph after the switch is complete. - assert(debugCheckHasMediaQuery(context)); - assert(debugCheckHasDirectionality(context)); - assert( - !(widget.style != null && - widget.style!.inherit == false && - (widget.style!.fontSize == null || - widget.style!.textBaseline == null)), - 'inherit false style must supply fontSize and textBaseline', - ); - - final ThemeData theme = Theme.of(context); - final TextSelectionThemeData selectionTheme = - TextSelectionTheme.of(context); - final FocusNode focusNode = _effectiveFocusNode; - - TextSelectionControls? textSelectionControls = widget.selectionControls; - final bool paintCursorAboveText; - final bool cursorOpacityAnimates; - Offset? cursorOffset; - Color? cursorColor = widget.cursorColor; - final Color selectionColor; - Radius? cursorRadius = widget.cursorRadius; - - switch (theme.platform) { - case TargetPlatform.iOS: - final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); - forcePressEnabled = true; - textSelectionControls ??= cupertinoTextSelectionControls; - paintCursorAboveText = true; - cursorOpacityAnimates = true; - cursorColor ??= - selectionTheme.cursorColor ?? cupertinoTheme.primaryColor; - selectionColor = selectionTheme.selectionColor ?? - cupertinoTheme.primaryColor.withOpacity(0.40); - cursorRadius ??= const Radius.circular(2.0); - cursorOffset = Offset( - iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); - break; - - case TargetPlatform.macOS: - final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); - forcePressEnabled = false; - textSelectionControls ??= cupertinoDesktopTextSelectionControls; - paintCursorAboveText = true; - cursorOpacityAnimates = true; - cursorColor ??= - selectionTheme.cursorColor ?? cupertinoTheme.primaryColor; - selectionColor = selectionTheme.selectionColor ?? - cupertinoTheme.primaryColor.withOpacity(0.40); - cursorRadius ??= const Radius.circular(2.0); - cursorOffset = Offset( - iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); - break; - - case TargetPlatform.android: - case TargetPlatform.fuchsia: - forcePressEnabled = false; - textSelectionControls ??= materialTextSelectionControls; - paintCursorAboveText = false; - cursorOpacityAnimates = false; - cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary; - selectionColor = selectionTheme.selectionColor ?? - theme.colorScheme.primary.withOpacity(0.40); - break; - - case TargetPlatform.linux: - case TargetPlatform.windows: - forcePressEnabled = false; - textSelectionControls ??= desktopTextSelectionControls; - paintCursorAboveText = false; - cursorOpacityAnimates = false; - cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary; - selectionColor = selectionTheme.selectionColor ?? - theme.colorScheme.primary.withOpacity(0.40); - break; - } - - final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); - TextStyle? effectiveTextStyle = widget.style; - if (effectiveTextStyle == null || effectiveTextStyle.inherit) - effectiveTextStyle = defaultTextStyle.style.merge(widget.style); - if (MediaQuery.boldTextOverride(context)) - effectiveTextStyle = effectiveTextStyle - .merge(const TextStyle(fontWeight: FontWeight.bold)); - final Widget child = RepaintBoundary( - child: EditableText( - key: editableTextKey, - style: effectiveTextStyle, - readOnly: true, - textWidthBasis: - widget.textWidthBasis ?? defaultTextStyle.textWidthBasis, - textHeightBehavior: - widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior, - showSelectionHandles: _showSelectionHandles, - showCursor: widget.showCursor, - controller: _controller, - focusNode: focusNode, - strutStyle: widget.strutStyle ?? const StrutStyle(), - textAlign: - widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, - textDirection: widget.textDirection, - textScaleFactor: widget.textScaleFactor, - autofocus: widget.autofocus, - forceLine: false, - toolbarOptions: widget.toolbarOptions, - minLines: widget.minLines, - maxLines: widget.maxLines ?? defaultTextStyle.maxLines, - selectionColor: selectionColor, - selectionControls: - widget.selectionEnabled ? textSelectionControls : null, - onSelectionChanged: _handleSelectionChanged, - onSelectionHandleTapped: _handleSelectionHandleTapped, - rendererIgnoresPointer: true, - cursorWidth: widget.cursorWidth, - cursorHeight: widget.cursorHeight, - cursorRadius: cursorRadius, - cursorColor: cursorColor, - selectionHeightStyle: widget.selectionHeightStyle, - selectionWidthStyle: widget.selectionWidthStyle, - cursorOpacityAnimates: cursorOpacityAnimates, - cursorOffset: cursorOffset, - paintCursorAboveText: paintCursorAboveText, - backgroundCursorColor: CupertinoColors.inactiveGray, - enableInteractiveSelection: widget.enableInteractiveSelection, - dragStartBehavior: widget.dragStartBehavior, - scrollPhysics: widget.scrollPhysics, - autofillHints: null, - ), - ); - - return Semantics( - label: widget.semanticsLabel, - excludeSemantics: widget.semanticsLabel != null, - onLongPress: () { - _effectiveFocusNode.requestFocus(); - }, - child: _selectionGestureDetectorBuilder.buildGestureDetector( - behavior: HitTestBehavior.translucent, - child: child, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart index ce91d98597..25d432b759 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart @@ -1,40 +1,37 @@ -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; import 'package:flowy_editor/flowy_editor.dart'; import 'package:flutter/material.dart'; -class ImageNodeBuilder extends NodeWidgetBuilder { - ImageNodeBuilder.create({ - required super.node, - required super.editorState, - required super.key, - }) : super.create(); - +class ImageNodeBuilder extends NodeWidgetBuilder { @override - Widget build(BuildContext context) { - return _ImageNodeWidget( - key: key, - node: node, - editorState: editorState, + Widget build(NodeWidgetContext context) { + return ImageNodeWidget( + key: context.node.key, + node: context.node, + editorState: context.editorState, ); } + + @override + NodeValidator get nodeValidator => ((node) { + return node.type == 'image'; + }); } -class _ImageNodeWidget extends StatefulWidget { +class ImageNodeWidget extends StatefulWidget { final Node node; final EditorState editorState; - const _ImageNodeWidget({ + const ImageNodeWidget({ Key? key, required this.node, required this.editorState, }) : super(key: key); @override - State<_ImageNodeWidget> createState() => __ImageNodeWidgetState(); + State createState() => _ImageNodeWidgetState(); } -class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable { +class _ImageNodeWidgetState extends State with Selectable { Node get node => widget.node; EditorState get editorState => widget.editorState; String get src => widget.node.attributes['image_src'] as String; @@ -90,23 +87,8 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable { children: [ Image.network( src, - height: 150.0, - ), - if (node.children.isNotEmpty) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: node.children - .map( - (e) => editorState.renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: e, - editorState: editorState, - ), - ), - ) - .toList(), - ), + width: MediaQuery.of(context).size.width, + ) ], ); } diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart deleted file mode 100644 index bad07fe6a6..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart +++ /dev/null @@ -1,352 +0,0 @@ -// import 'package:flowy_editor/document/position.dart'; -// import 'package:flowy_editor/document/selection.dart'; -// import 'package:flutter/gestures.dart'; -// import 'package:flutter/material.dart'; -// import 'package:flowy_editor/flowy_editor.dart'; -// import 'package:flutter/services.dart'; -// import 'package:url_launcher/url_launcher_string.dart'; -// import 'flowy_selectable_text.dart'; - -// class TextNodeBuilder extends NodeWidgetBuilder { -// TextNodeBuilder.create({ -// required super.node, -// required super.editorState, -// required super.key, -// }) : super.create() { -// nodeValidator = ((node) { -// return node.type == 'text'; -// }); -// } - -// @override -// Widget build(BuildContext context) { -// return _TextNodeWidget(key: key, node: node, editorState: editorState); -// } -// } - -// class _TextNodeWidget extends StatefulWidget { -// final Node node; -// final EditorState editorState; - -// const _TextNodeWidget({ -// Key? key, -// required this.node, -// required this.editorState, -// }) : super(key: key); - -// @override -// State<_TextNodeWidget> createState() => __TextNodeWidgetState(); -// } - -// class __TextNodeWidgetState extends State<_TextNodeWidget> -// implements DeltaTextInputClient { -// TextNode get node => widget.node as TextNode; -// EditorState get editorState => widget.editorState; -// bool _metaKeyDown = false; -// bool _shiftKeyDown = false; - -// TextInputConnection? _textInputConnection; - -// @override -// Widget build(BuildContext context) { -// return Column( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// FlowySelectableText.rich( -// node.toTextSpan(), -// showCursor: true, -// enableInteractiveSelection: true, -// onSelectionChanged: _onSelectionChanged, -// // autofocus: true, -// focusNode: FocusNode( -// onKey: _onKey, -// ), -// ), -// if (node.children.isNotEmpty) -// ...node.children.map( -// (e) => editorState.renderPlugins.buildWidget( -// context: NodeWidgetContext( -// buildContext: context, -// node: e, -// editorState: editorState, -// ), -// ), -// ), -// const SizedBox( -// height: 10, -// ), -// ], -// ); -// } - -// KeyEventResult _onKey(FocusNode focusNode, RawKeyEvent event) { -// debugPrint('key: $event'); -// if (event is RawKeyDownEvent) { -// final sel = _globalSelectionToLocal(node, editorState.cursorSelection); -// if (event.logicalKey == LogicalKeyboardKey.backspace) { -// _backDeleteTextAtSelection(sel); -// return KeyEventResult.handled; -// } else if (event.logicalKey == LogicalKeyboardKey.delete) { -// _forwardDeleteTextAtSelection(sel); -// return KeyEventResult.handled; -// } else if (event.logicalKey == LogicalKeyboardKey.metaLeft || -// event.logicalKey == LogicalKeyboardKey.metaRight) { -// _metaKeyDown = true; -// } else if (event.logicalKey == LogicalKeyboardKey.shiftLeft || -// event.logicalKey == LogicalKeyboardKey.shiftRight) { -// _shiftKeyDown = true; -// } else if (event.logicalKey == LogicalKeyboardKey.keyZ && _metaKeyDown) { -// if (_shiftKeyDown) { -// editorState.undoManager.redo(); -// } else { -// editorState.undoManager.undo(); -// } -// } -// } else if (event is RawKeyUpEvent) { -// if (event.logicalKey == LogicalKeyboardKey.metaLeft || -// event.logicalKey == LogicalKeyboardKey.metaRight) { -// _metaKeyDown = false; -// } -// if (event.logicalKey == LogicalKeyboardKey.shiftLeft || -// event.logicalKey == LogicalKeyboardKey.shiftRight) { -// _shiftKeyDown = false; -// } -// } -// return KeyEventResult.ignored; -// } - -// void _onSelectionChanged( -// TextSelection selection, SelectionChangedCause? cause) { -// _textInputConnection?.close(); -// _textInputConnection = TextInput.attach( -// this, -// const TextInputConfiguration( -// enableDeltaModel: true, -// inputType: TextInputType.multiline, -// textCapitalization: TextCapitalization.sentences, -// ), -// ); -// editorState.cursorSelection = _localSelectionToGlobal(node, selection); -// _textInputConnection -// ?..show() -// ..setEditingState( -// TextEditingValue( -// text: node.toRawString(), -// selection: selection, -// ), -// ); -// } - -// _backDeleteTextAtSelection(TextSelection? sel) { -// if (sel == null) { -// return; -// } -// if (sel.start == 0) { -// return; -// } - -// if (sel.isCollapsed) { -// TransactionBuilder(editorState) -// ..deleteText(node, sel.start - 1, 1) -// ..commit(); -// } else { -// TransactionBuilder(editorState) -// ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset) -// ..commit(); -// } - -// _setEditingStateFromGlobal(); -// } - -// _forwardDeleteTextAtSelection(TextSelection? sel) { -// if (sel == null) { -// return; -// } - -// if (sel.isCollapsed) { -// TransactionBuilder(editorState) -// ..deleteText(node, sel.start, 1) -// ..commit(); -// } else { -// TransactionBuilder(editorState) -// ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset) -// ..commit(); -// } -// _setEditingStateFromGlobal(); -// } - -// _setEditingStateFromGlobal() { -// _textInputConnection?.setEditingState(TextEditingValue( -// text: node.toRawString(), -// selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? -// const TextSelection.collapsed(offset: 0))); -// } - -// @override -// void connectionClosed() { -// // TODO: implement connectionClosed -// } - -// @override -// // TODO: implement currentAutofillScope -// AutofillScope? get currentAutofillScope => throw UnimplementedError(); - -// @override -// // TODO: implement currentTextEditingValue -// TextEditingValue? get currentTextEditingValue => TextEditingValue( -// text: node.toRawString(), -// selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? -// const TextSelection.collapsed(offset: 0)); - -// @override -// void insertTextPlaceholder(Size size) { -// // TODO: implement insertTextPlaceholder -// } - -// @override -// void performAction(TextInputAction action) {} - -// @override -// void performPrivateCommand(String action, Map data) { -// // TODO: implement performPrivateCommand -// } - -// @override -// void removeTextPlaceholder() { -// // TODO: implement removeTextPlaceholder -// } - -// @override -// void showAutocorrectionPromptRect(int start, int end) { -// // TODO: implement showAutocorrectionPromptRect -// } - -// @override -// void showToolbar() { -// // TODO: implement showToolbar -// } - -// @override -// void updateEditingValue(TextEditingValue value) {} - -// @override -// void updateEditingValueWithDeltas(List textEditingDeltas) { -// for (final textDelta in textEditingDeltas) { -// if (textDelta is TextEditingDeltaInsertion) { -// TransactionBuilder(editorState) -// ..insertText(node, textDelta.insertionOffset, textDelta.textInserted) -// ..commit(); -// } else if (textDelta is TextEditingDeltaDeletion) { -// TransactionBuilder(editorState) -// ..deleteText(node, textDelta.deletedRange.start, -// textDelta.deletedRange.end - textDelta.deletedRange.start) -// ..commit(); -// } -// } -// } - -// @override -// void updateFloatingCursor(RawFloatingCursorPoint point) { -// // TODO: implement updateFloatingCursor -// } -// } - -// extension on TextNode { -// TextSpan toTextSpan() => TextSpan( -// children: delta.operations -// .whereType() -// .map((op) => op.toTextSpan()) -// .toList()); -// } - -// extension on TextInsert { -// TextSpan toTextSpan() { -// FontWeight? fontWeight; -// FontStyle? fontStyle; -// TextDecoration? decoration; -// GestureRecognizer? gestureRecognizer; -// Color? color; -// Color highLightColor = Colors.transparent; -// double fontSize = 16.0; -// final attributes = this.attributes; -// if (attributes?['bold'] == true) { -// fontWeight = FontWeight.bold; -// } -// if (attributes?['italic'] == true) { -// fontStyle = FontStyle.italic; -// } -// if (attributes?['underline'] == true) { -// decoration = TextDecoration.underline; -// } -// if (attributes?['strikethrough'] == true) { -// decoration = TextDecoration.lineThrough; -// } -// if (attributes?['highlight'] is String) { -// highLightColor = Color(int.parse(attributes!['highlight'])); -// } -// if (attributes?['href'] is String) { -// color = const Color.fromARGB(255, 55, 120, 245); -// decoration = TextDecoration.underline; -// gestureRecognizer = TapGestureRecognizer() -// ..onTap = () { -// launchUrlString(attributes?['href']); -// }; -// } -// final heading = attributes?['heading'] as String?; -// if (heading != null) { -// // TODO: make it better -// if (heading == 'h1') { -// fontSize = 30.0; -// } else if (heading == 'h2') { -// fontSize = 20.0; -// } -// fontWeight = FontWeight.bold; -// } -// return TextSpan( -// text: content, -// style: TextStyle( -// fontWeight: fontWeight, -// fontStyle: fontStyle, -// decoration: decoration, -// color: color, -// fontSize: fontSize, -// backgroundColor: highLightColor, -// ), -// recognizer: gestureRecognizer, -// ); -// } -// } - -// TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) { -// if (globalSel == null) { -// return null; -// } -// final nodePath = node.path; - -// if (!pathEquals(nodePath, globalSel.start.path)) { -// return null; -// } -// if (globalSel.isCollapsed) { -// return TextSelection( -// baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset); -// } else { -// if (pathEquals(globalSel.start.path, globalSel.end.path)) { -// return TextSelection( -// baseOffset: globalSel.start.offset, -// extentOffset: globalSel.end.offset); -// } -// } -// return null; -// } - -// Selection? _localSelectionToGlobal(Node node, TextSelection? sel) { -// if (sel == null) { -// return null; -// } -// final nodePath = node.path; - -// return Selection( -// start: Position(path: nodePath, offset: sel.baseOffset), -// end: Position(path: nodePath, offset: sel.extentOffset), -// ); -// } diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart deleted file mode 100644 index 94c6e56a5e..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart +++ /dev/null @@ -1,281 +0,0 @@ -import 'dart:math'; - -import 'package:example/plugin/debuggable_rich_text.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class SelectedTextNodeBuilder extends NodeWidgetBuilder { - SelectedTextNodeBuilder.create({ - required super.node, - required super.editorState, - required super.key, - }) : super.create() { - nodeValidator = ((node) { - return node.type == 'text'; - }); - } - - @override - Widget build(BuildContext context) { - return _SelectedTextNodeWidget( - key: key, - node: node, - editorState: editorState, - ); - } -} - -class _SelectedTextNodeWidget extends StatefulWidget { - final Node node; - final EditorState editorState; - - const _SelectedTextNodeWidget({ - Key? key, - required this.node, - required this.editorState, - }) : super(key: key); - - @override - State<_SelectedTextNodeWidget> createState() => - _SelectedTextNodeWidgetState(); -} - -class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> - with Selectable { - TextNode get node => widget.node as TextNode; - EditorState get editorState => widget.editorState; - - final _textKey = GlobalKey(); - TextSelection? _textSelection; - - RenderParagraph get _renderParagraph => - _textKey.currentContext?.findRenderObject() as RenderParagraph; - - @override - Selection getSelectionInRange(Offset start, Offset end) { - final localStart = _renderParagraph.globalToLocal(start); - final localEnd = _renderParagraph.globalToLocal(end); - final baseOffset = _getTextPositionAtOffset(localStart).offset; - final extentOffset = _getTextPositionAtOffset(localEnd).offset; - return Selection.single( - path: node.path, - startOffset: baseOffset, - endOffset: extentOffset, - ); - } - - @override - Offset localToGlobal(Offset offset) { - return _renderParagraph.localToGlobal(offset); - } - - @override - List getRectsInSelection(Selection selection) { - assert(pathEquals(selection.start.path, selection.end.path)); - assert(pathEquals(selection.start.path, node.path)); - final textSelection = TextSelection( - baseOffset: selection.start.offset, - extentOffset: selection.end.offset, - ); - return _computeSelectionRects(textSelection); - } - - @override - Rect getCursorRectInPosition(Position position) { - final textSelection = TextSelection.collapsed(offset: position.offset); - _textSelection = textSelection; - return _computeCursorRect(textSelection.baseOffset); - } - - @override - Position getPositionInOffset(Offset start) { - final localStart = _renderParagraph.globalToLocal(start); - final baseOffset = _getTextPositionAtOffset(localStart).offset; - return Position(path: node.path, offset: baseOffset); - } - - @override - TextSelection? getTextSelectionInSelection(Selection selection) { - assert(selection.isCollapsed); - if (!selection.isCollapsed) { - return null; - } - return TextSelection( - baseOffset: selection.start.offset, - extentOffset: selection.end.offset, - ); - } - - @override - Position start() => Position(path: node.path, offset: 0); - - @override - Position end() => - Position(path: node.path, offset: node.toRawString().length); - - @override - Widget build(BuildContext context) { - Widget richText; - if (kDebugMode) { - richText = DebuggableRichText(text: node.toTextSpan(), textKey: _textKey); - } else { - richText = RichText(key: _textKey, text: node.toTextSpan()); - } - - if (node.children.isEmpty) { - return richText; - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: MediaQuery.of(context).size.width, - child: richText, - ), - if (node.children.isNotEmpty) - ...node.children.map( - (e) => editorState.renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: e, - editorState: editorState, - ), - ), - ), - const SizedBox( - height: 5, - ), - ], - ); - } - - TextPosition _getTextPositionAtOffset(Offset offset) { - return _renderParagraph.getPositionForOffset(offset); - } - - List _computeSelectionRects(TextSelection textSelection) { - final textBoxes = _renderParagraph.getBoxesForSelection(textSelection); - return textBoxes.map((box) => box.toRect()).toList(); - } - - Rect _computeCursorRect(int offset) { - final position = TextPosition(offset: offset); - final cursorOffset = - _renderParagraph.getOffsetForCaret(position, Rect.zero); - final cursorHeight = _renderParagraph.getFullHeightForCaret(position); - if (cursorHeight != null) { - const cursorWidth = 2; - return Rect.fromLTWH( - cursorOffset.dx - (cursorWidth / 2), - cursorOffset.dy, - cursorWidth.toDouble(), - cursorHeight.toDouble(), - ); - } else { - return Rect.zero; - } - } -} - -extension on TextNode { - TextSpan toTextSpan() => TextSpan( - children: delta.operations - .whereType() - .map((op) => op.toTextSpan()) - .toList()); -} - -extension on TextInsert { - TextSpan toTextSpan() { - FontWeight? fontWeight; - FontStyle? fontStyle; - TextDecoration? decoration; - GestureRecognizer? gestureRecognizer; - Color color = Colors.black; - Color highLightColor = Colors.transparent; - double fontSize = 16.0; - final attributes = this.attributes; - if (attributes?['bold'] == true) { - fontWeight = FontWeight.bold; - } - if (attributes?['italic'] == true) { - fontStyle = FontStyle.italic; - } - if (attributes?['underline'] == true) { - decoration = TextDecoration.underline; - } - if (attributes?['strikethrough'] == true) { - decoration = TextDecoration.lineThrough; - } - if (attributes?['highlight'] is String) { - highLightColor = Color(int.parse(attributes!['highlight'])); - } - if (attributes?['href'] is String) { - color = const Color.fromARGB(255, 55, 120, 245); - decoration = TextDecoration.underline; - gestureRecognizer = TapGestureRecognizer() - ..onTap = () { - launchUrlString(attributes?['href']); - }; - } - final heading = attributes?['heading'] as String?; - if (heading != null) { - // TODO: make it better - if (heading == 'h1') { - fontSize = 30.0; - } else if (heading == 'h2') { - fontSize = 20.0; - } - fontWeight = FontWeight.bold; - } - return TextSpan( - text: content, - style: TextStyle( - fontWeight: fontWeight, - fontStyle: fontStyle, - decoration: decoration, - color: color, - fontSize: fontSize, - backgroundColor: highLightColor, - ), - recognizer: gestureRecognizer, - ); - } -} - -class FlowyPainter extends CustomPainter { - final List _rects; - final Paint _paint; - - FlowyPainter({ - Key? key, - required Color color, - required List rects, - bool fill = false, - }) : _rects = rects, - _paint = Paint()..color = color { - _paint.style = fill ? PaintingStyle.fill : PaintingStyle.stroke; - } - - @override - void paint(Canvas canvas, Size size) { - for (final rect in _rects) { - canvas.drawRect( - rect, - _paint, - ); - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return true; - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart deleted file mode 100644 index f7985ed564..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flutter/material.dart'; - -class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder { - TextWithCheckBoxNodeBuilder.create({ - required super.node, - required super.editorState, - required super.key, - }) : super.create(); - - // TODO: check the type - bool get isCompleted => node.attributes['checkbox'] as bool; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Checkbox(value: isCompleted, onChanged: (value) {}), - Expanded( - child: renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: node, - editorState: editorState, - ), - withSubtype: false, - ), - ) - ], - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart deleted file mode 100644 index c4bd027888..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flutter/material.dart'; - -class TextWithHeadingNodeBuilder extends NodeWidgetBuilder { - TextWithHeadingNodeBuilder.create({ - required super.editorState, - required super.node, - required super.key, - }) : super.create() { - nodeValidator = (node) => node.attributes.containsKey('heading'); - } - - String get heading => node.attributes['heading'] as String; - Widget buildPadding() { - if (heading == 'h1') { - return const Padding( - padding: EdgeInsets.only(top: 10), - ); - } else if (heading == 'h2') { - return const Padding( - padding: EdgeInsets.only(top: 5), - ); - } - return const Padding( - padding: EdgeInsets.only(top: 0), - ); - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - buildPadding(), - renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: node, - editorState: editorState, - ), - withSubtype: false, - ), - buildPadding(), - ], - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart index afbb8d079b..1019ad3510 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; import 'package:flowy_editor/service/service.dart'; import 'package:flutter/material.dart'; @@ -9,7 +8,6 @@ import 'package:flowy_editor/document/state_tree.dart'; import 'package:flowy_editor/operation/operation.dart'; import 'package:flowy_editor/operation/transaction.dart'; import 'package:flowy_editor/undo_manager.dart'; -import 'package:flowy_editor/render/render_plugins.dart'; class ApplyOptions { /// This flag indicates that @@ -25,7 +23,6 @@ class ApplyOptions { class EditorState { final StateTree document; - final RenderPlugins renderPlugins; List selectedNodes = []; @@ -54,24 +51,10 @@ class EditorState { EditorState({ required this.document, - required this.renderPlugins, }) { - // FIXME: abstract render plugins as a service. - renderPlugins.register('text', RichTextNodeWidgetBuilder.create); undoManager.state = this; } - /// TODO: move to a better place. - Widget build(BuildContext context) { - return renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: document.root, - editorState: this, - ), - ); - } - apply(Transaction transaction, [ApplyOptions options = const ApplyOptions()]) { for (final op in transaction.operations) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart index 91c6b1c4b0..c3e15959a6 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -4,8 +4,6 @@ export 'package:flowy_editor/document/state_tree.dart'; export 'package:flowy_editor/document/node.dart'; export 'package:flowy_editor/document/path.dart'; export 'package:flowy_editor/document/text_delta.dart'; -export 'package:flowy_editor/render/render_plugins.dart'; -export 'package:flowy_editor/render/node_widget_builder.dart'; export 'package:flowy_editor/render/selection/selectable.dart'; export 'package:flowy_editor/operation/transaction.dart'; export 'package:flowy_editor/operation/transaction_builder.dart'; @@ -14,3 +12,4 @@ export 'package:flowy_editor/editor_state.dart'; export 'package:flowy_editor/service/editor_service.dart'; export 'package:flowy_editor/document/selection.dart'; export 'package:flowy_editor/document/position.dart'; +export 'package:flowy_editor/service/render_plugin_service.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart new file mode 100644 index 0000000000..650732f9f9 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart @@ -0,0 +1,58 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; + +class EditorEntryWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return EditorNodeWidget( + key: context.node.key, + node: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return node.type == 'editor'; + }); +} + +class EditorNodeWidget extends StatelessWidget { + const EditorNodeWidget({ + Key? key, + required this.node, + required this.editorState, + }) : super(key: key); + + final Node node; + final EditorState editorState; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: node.children + .map( + (child) => + editorState.service.renderPluginService.buildPluginWidget( + child is TextNode + ? NodeWidgetContext( + context: context, + node: child, + editorState: editorState, + ) + : NodeWidgetContext( + context: context, + node: child, + editorState: editorState, + ), + ), + ) + .toList(), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart deleted file mode 100644 index 214818f60a..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/render/render_plugins.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -typedef NodeValidator = bool Function(T node); - -class NodeWidgetBuilder { - final EditorState editorState; - final T node; - final Key key; - - bool rebuildOnNodeChanged; - NodeValidator? nodeValidator; - - RenderPlugins get renderPlugins => editorState.renderPlugins; - - NodeWidgetBuilder.create({ - required this.editorState, - required this.node, - required this.key, - this.rebuildOnNodeChanged = true, - }); - - /// Render the current [Node] - /// and the layout style of [Node.Children]. - Widget build( - BuildContext context, - ) => - throw UnimplementedError(); - - /// TODO: refactore this part. - /// return widget embedded with ChangeNotifier and widget itself. - Widget call( - BuildContext context, - ) { - /// TODO: Validate the node - /// if failed, stop call build function, - /// return Empty widget, and throw Error. - if (nodeValidator != null && nodeValidator!(node) != true) { - throw Exception( - 'Node validate failure, node = { type: ${node.type}, attributes: ${node.attributes} }'); - } - - return _build(context); - } - - Widget _build(BuildContext context) { - return CompositedTransformTarget( - link: node.layerLink, - child: ChangeNotifierProvider.value( - value: node, - builder: (context, child) => Consumer( - builder: ((context, value, child) { - debugPrint('Node is rebuilding...'); - return build(context); - }), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart deleted file mode 100644 index efe5865d64..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; -import '../document/node.dart'; -import './node_widget_builder.dart'; -import 'package:flowy_editor/editor_state.dart'; - -class NodeWidgetContext { - final BuildContext buildContext; - final Node node; - final EditorState editorState; - - NodeWidgetContext({ - required this.buildContext, - required this.node, - required this.editorState, - }); -} - -typedef NodeWidgetBuilderF = A - Function({ - required T node, - required EditorState editorState, - required GlobalKey key, -}); - -// unused -// typedef NodeBuilder = T Function(Node node); - -class RenderPlugins { - final Map _nodeWidgetBuilders = {}; - // unused - // Map nodeBuilders = {}; - - /// Register plugin to render specified [name]. - /// - /// [name] should be [Node].type - /// or [Node].type + '/' + [Node].attributes['subtype']. - /// - /// e.g. 'text', 'text/with-checkbox', or 'text/with-heading' - /// - /// [name] could be empty. - void register(String name, NodeWidgetBuilderF builder) { - _validatePluginName(name); - - _nodeWidgetBuilders[name] = builder; - } - - /// UnRegister plugin with specified [name]. - void unRegister(String name) { - _validatePluginName(name); - - _nodeWidgetBuilders.removeWhere((key, _) => key == name); - } - - Widget buildWidget({ - required NodeWidgetContext context, - bool withSubtype = true, - }) { - /// Find node widget builder - /// 1. If node's attributes contains subtype, return. - /// 2. If node's attributes do no contains substype, return. - final node = context.node; - var name = node.type; - if (withSubtype && node.subtype != null) { - name += '/${node.subtype}'; - } - final nodeWidgetBuilder = _nodeWidgetBuilder(name); - final key = GlobalKey(); - node.key = key; - return nodeWidgetBuilder( - node: context.node, - editorState: context.editorState, - key: key, - )(context.buildContext); - } - - NodeWidgetBuilderF _nodeWidgetBuilder(String name) { - assert(_nodeWidgetBuilders.containsKey(name), - 'Could not query the builder with this $name'); - return _nodeWidgetBuilders[name]!; - } - - void _validatePluginName(String name) { - final paths = name.split('/'); - if (paths.length > 2) { - throw Exception('[Name] must contains zero or one slash("/")'); - } - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/bulleted_list_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/bulleted_list_text.dart new file mode 100644 index 0000000000..0eae3f22f2 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/bulleted_list_text.dart @@ -0,0 +1,73 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/infra/flowy_svg.dart'; +import 'package:flowy_editor/render/rich_text/default_selectable.dart'; +import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; + +class BulletedListTextNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return BulletedListTextNodeWidget( + key: context.node.key, + textNode: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return true; + }); +} + +class BulletedListTextNodeWidget extends StatefulWidget { + const BulletedListTextNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => + _BulletedListTextNodeWidgetState(); +} + +// customize + +class _BulletedListTextNodeWidgetState extends State + with Selectable, DefaultSelectable { + final _richTextKey = GlobalKey(debugLabel: 'bulleted_list_text'); + final leftPadding = 20.0; + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Offset get baseOffset { + return Offset(leftPadding, 0); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + FlowySvg( + size: Size.square(leftPadding), + name: 'point', + ), + FlowyRichText( + key: _richTextKey, + textNode: widget.textNode, + editorState: widget.editorState, + ), + ], + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart new file mode 100644 index 0000000000..4d52e41867 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart @@ -0,0 +1,123 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/infra/flowy_svg.dart'; +import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:flowy_editor/render/rich_text/default_selectable.dart'; +import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; +import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; + +class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return CheckboxNodeWidget( + key: context.node.key, + textNode: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return node.attributes.containsKey(StyleKey.check); + }); +} + +class CheckboxNodeWidget extends StatefulWidget { + const CheckboxNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => _CheckboxNodeWidgetState(); +} + +class _CheckboxNodeWidgetState extends State + with Selectable, DefaultSelectable { + final _richTextKey = GlobalKey(debugLabel: 'checkbox_text'); + + final leftPadding = 20.0; + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Offset get baseOffset { + return Offset(leftPadding, 0); + } + + @override + Widget build(BuildContext context) { + if (widget.textNode.children.isEmpty) { + return _buildWithSingle(context); + } else { + return _buildWithChildren(context); + } + } + + Widget _buildWithSingle(BuildContext context) { + final check = widget.textNode.attributes.check; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + child: FlowySvg( + size: Size.square(leftPadding), + name: check ? 'check' : 'uncheck', + ), + onTap: () { + debugPrint('[Checkbox] onTap...'); + TransactionBuilder(widget.editorState) + ..updateNode(widget.textNode, { + 'checkbox': !check, + }) + ..commit(); + }, + ), + FlowyRichText( + key: _richTextKey, + textNode: widget.textNode, + editorState: widget.editorState, + ) + ], + ); + } + + Widget _buildWithChildren(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildWithSingle(context), + Row( + children: [ + const SizedBox( + width: 20, + ), + Column( + children: widget.textNode.children + .map( + (child) => widget.editorState.service.renderPluginService + .buildPluginWidget( + NodeWidgetContext( + context: context, + node: child, + editorState: widget.editorState, + ), + ), + ) + .toList(), + ) + ], + ) + ], + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart new file mode 100644 index 0000000000..21cc5108f3 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart @@ -0,0 +1,33 @@ +import 'package:flowy_editor/document/position.dart'; +import 'package:flowy_editor/document/selection.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flutter/material.dart'; + +mixin DefaultSelectable { + Selectable get forward; + + Offset get baseOffset; + + Position getPositionInOffset(Offset start) => + forward.getPositionInOffset(start); + + Rect getCursorRectInPosition(Position position) => + forward.getCursorRectInPosition(position).shift(baseOffset); + + List getRectsInSelection(Selection selection) => forward + .getRectsInSelection(selection) + .map((rect) => rect.shift(baseOffset)) + .toList(growable: false); + + Selection getSelectionInRange(Offset start, Offset end) => + forward.getSelectionInRange(start, end); + + Offset localToGlobal(Offset offset) => forward.localToGlobal(offset); + + Selection? getWorldBoundaryInOffset(Offset offset) => + forward.getWorldBoundaryInOffset(offset); + + Position start() => forward.start(); + + Position end() => forward.end(); +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart index 122b65991e..70834184cc 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart @@ -4,39 +4,37 @@ import 'package:flowy_editor/document/selection.dart'; import 'package:flowy_editor/document/text_delta.dart'; import 'package:flowy_editor/editor_state.dart'; import 'package:flowy_editor/document/path.dart'; -import 'package:flowy_editor/operation/transaction_builder.dart'; -import 'package:flowy_editor/render/node_widget_builder.dart'; -import 'package:flowy_editor/render/render_plugins.dart'; import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flowy_editor/infra/flowy_svg.dart'; -import 'package:flowy_editor/extensions/object_extensions.dart'; import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flowy_editor/service/render_plugin_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -class RichTextNodeWidgetBuilder extends NodeWidgetBuilder { - RichTextNodeWidgetBuilder.create({ - required super.editorState, - required super.node, - required super.key, - }) : super.create(); - +class RichTextNodeWidgetBuilder extends NodeWidgetBuilder { @override - Widget build(BuildContext context) { + Widget build(NodeWidgetContext context) { return FlowyRichText( - key: key, - textNode: node as TextNode, - editorState: editorState, + key: context.node.key, + textNode: context.node, + editorState: context.editorState, ); } + + @override + NodeValidator get nodeValidator => ((node) { + return true; + }); } +typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); + class FlowyRichText extends StatefulWidget { const FlowyRichText({ Key? key, this.cursorHeight, this.cursorWidth = 2.0, + this.textSpanDecorator, required this.textNode, required this.editorState, }) : super(key: key); @@ -45,6 +43,7 @@ class FlowyRichText extends StatefulWidget { final double cursorWidth; final TextNode textNode; final EditorState editorState; + final FlowyTextSpanDecorator? textSpanDecorator; @override State createState() => _FlowyRichTextState(); @@ -52,49 +51,32 @@ class FlowyRichText extends StatefulWidget { class _FlowyRichTextState extends State with Selectable { final _textKey = GlobalKey(); - final _decorationKey = GlobalKey(); - EditorState get _editorState => widget.editorState; - TextNode get _textNode => widget.textNode; RenderParagraph get _renderParagraph => _textKey.currentContext?.findRenderObject() as RenderParagraph; @override Widget build(BuildContext context) { - final attributes = _textNode.attributes; - // TODO: use factory method ?? - if (attributes.list == 'todo') { - return _buildTodoListRichText(context); - } else if (attributes.list == 'bullet') { - return _buildBulletedListRichText(context); - } else if (attributes.quote == true) { - return _buildQuotedRichText(context); - } else if (attributes.heading != null) { - return _buildHeadingRichText(context); - } else if (attributes.number != null) { - return _buildNumberListRichText(context); - } return _buildRichText(context); } @override - Position start() => Position(path: _textNode.path, offset: 0); + Position start() => Position(path: widget.textNode.path, offset: 0); @override - Position end() => - Position(path: _textNode.path, offset: _textNode.toRawString().length); + Position end() => Position( + path: widget.textNode.path, offset: widget.textNode.toRawString().length); @override Rect getCursorRectInPosition(Position position) { final textPosition = TextPosition(offset: position.offset); - final baseRect = frontWidgetRect(); final cursorOffset = _renderParagraph.getOffsetForCaret(textPosition, Rect.zero); final cursorHeight = widget.cursorHeight ?? _renderParagraph.getFullHeightForCaret(textPosition) ?? 5.0; // default height return Rect.fromLTWH( - baseRect.centerRight.dx + cursorOffset.dx - (widget.cursorWidth / 2), + cursorOffset.dx - (widget.cursorWidth / 2), cursorOffset.dy, widget.cursorWidth, cursorHeight, @@ -105,7 +87,7 @@ class _FlowyRichTextState extends State with Selectable { Position getPositionInOffset(Offset start) { final offset = _renderParagraph.globalToLocal(start); final baseOffset = _renderParagraph.getPositionForOffset(offset).offset; - return Position(path: _textNode.path, offset: baseOffset); + return Position(path: widget.textNode.path, offset: baseOffset); } @override @@ -113,25 +95,24 @@ class _FlowyRichTextState extends State with Selectable { final localOffset = _renderParagraph.globalToLocal(offset); final textPosition = _renderParagraph.getPositionForOffset(localOffset); final textRange = _renderParagraph.getWordBoundary(textPosition); - final start = Position(path: _textNode.path, offset: textRange.start); - final end = Position(path: _textNode.path, offset: textRange.end); + final start = Position(path: widget.textNode.path, offset: textRange.start); + final end = Position(path: widget.textNode.path, offset: textRange.end); return Selection(start: start, end: end); } @override List getRectsInSelection(Selection selection) { assert(pathEquals(selection.start.path, selection.end.path) && - pathEquals(selection.start.path, _textNode.path)); + pathEquals(selection.start.path, widget.textNode.path)); final textSelection = TextSelection( baseOffset: selection.start.offset, extentOffset: selection.end.offset, ); - final baseRect = frontWidgetRect(); - return _renderParagraph.getBoxesForSelection(textSelection).map((box) { - final rect = box.toRect(); - return rect.translate(baseRect.centerRight.dx, 0); - }).toList(); + return _renderParagraph + .getBoxesForSelection(textSelection) + .map((box) => box.toRect()) + .toList(); } @override @@ -141,32 +122,40 @@ class _FlowyRichTextState extends State with Selectable { final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset; final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset; return Selection.single( - path: _textNode.path, + path: widget.textNode.path, startOffset: baseOffset, endOffset: extentOffset, ); } Widget _buildRichText(BuildContext context) { - if (_textNode.children.isEmpty) { - return _buildSingleRichText(context); - } else { - return _buildRichTextWithChildren(context); - } + return _buildSingleRichText(context); } + Widget _buildSingleRichText(BuildContext context) { + final textSpan = _textSpan; + return RichText( + key: _textKey, + text: widget.textSpanDecorator != null + ? widget.textSpanDecorator!(textSpan) + : textSpan, + ); + } + + // unused now. Widget _buildRichTextWithChildren(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSingleRichText(context), - ..._textNode.children + ...widget.textNode.children .map( - (child) => _editorState.renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, + (child) => widget.editorState.service.renderPluginService + .buildPluginWidget( + NodeWidgetContext( + context: context, node: child, - editorState: _editorState, + editorState: widget.editorState, ), ), ) @@ -175,118 +164,13 @@ class _FlowyRichTextState extends State with Selectable { ); } - Widget _buildSingleRichText(BuildContext context) { - return SizedBox( - width: - MediaQuery.of(context).size.width - 20, // FIXME: use the const value - child: RichText(key: _textKey, text: _decorateTextSpanWithGlobalStyle), - ); - } - - Widget _buildTodoListRichText(BuildContext context) { - final name = _textNode.attributes.todo ? 'check' : 'uncheck'; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - child: FlowySvg( - key: _decorationKey, - name: name, - ), - onTap: () => TransactionBuilder(_editorState) - ..updateNode(_textNode, { - 'todo': !_textNode.attributes.todo, - }) - ..commit(), - ), - _buildRichText(context), - ], - ); - } - - Widget _buildBulletedListRichText(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - FlowySvg( - key: _decorationKey, - name: 'point', - ), - _buildRichText(context), - ], - ); - } - - Widget _buildNumberListRichText(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - FlowySvg( - key: _decorationKey, - number: _textNode.attributes.number, - ), - _buildRichText(context), - ], - ); - } - - Widget _buildQuotedRichText(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowySvg( - key: _decorationKey, - name: 'quote', - ), - _buildRichText(context), - ], - ); - } - - Widget _buildHeadingRichText(BuildContext context) { - // TODO: customize - return Column( - children: [ - const Padding(padding: EdgeInsets.only(top: 5)), - _buildRichText(context), - const Padding(padding: EdgeInsets.only(top: 5)), - ], - ); - } - - Rect frontWidgetRect() { - // FIXME: find a more elegant way to solve this situation. - final renderBox = _decorationKey.currentContext - ?.findRenderObject() - ?.unwrapOrNull(); - if (renderBox != null) { - return renderBox.localToGlobal(Offset.zero) & renderBox.size; - } - return Rect.zero; - } - + @override Offset localToGlobal(Offset offset) { return _renderParagraph.localToGlobal(offset); } - TextSpan get _decorateTextSpanWithGlobalStyle => TextSpan( - children: _textSpan.children - ?.whereType() - .map( - (span) => TextSpan( - text: span.text, - style: span.style?.copyWith( - fontSize: _textNode.attributes.fontSize, - color: _textNode.attributes.quoteColor, - ), - recognizer: span.recognizer, - ), - ) - .toList(), - ); - TextSpan get _textSpan => TextSpan( - children: _textNode.delta.operations + children: widget.textNode.delta.operations .whereType() .map((insert) => RichTextStyle( attributes: insert.attributes ?? {}, diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/heading_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/heading_text.dart new file mode 100644 index 0000000000..4990e90dcf --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/heading_text.dart @@ -0,0 +1,93 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/render/rich_text/default_selectable.dart'; +import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; +import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; + +class HeadingTextNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return HeadingTextNodeWidget( + key: context.node.key, + textNode: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return node.attributes.heading != null; + }); +} + +class HeadingTextNodeWidget extends StatefulWidget { + const HeadingTextNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => _HeadingTextNodeWidgetState(); +} + +// customize + +class _HeadingTextNodeWidgetState extends State + with Selectable, DefaultSelectable { + final _richTextKey = GlobalKey(debugLabel: 'heading_text'); + final topPadding = 5.0; + final bottomPadding = 2.0; + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Offset get baseOffset { + return Offset(0, topPadding); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: EdgeInsets.only( + top: topPadding, + bottom: bottomPadding, + ), + child: FlowyRichText( + key: _richTextKey, + textSpanDecorator: _textSpanDecorator, + textNode: widget.textNode, + editorState: widget.editorState, + ), + ) + ], + ); + } + + TextSpan _textSpanDecorator(TextSpan textSpan) { + return TextSpan( + children: textSpan.children + ?.whereType() + .map( + (span) => TextSpan( + text: span.text, + style: span.style?.copyWith( + fontSize: widget.textNode.attributes.fontSize, + ), + recognizer: span.recognizer, + ), + ) + .toList(), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/number_list_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/number_list_text.dart new file mode 100644 index 0000000000..1c52b93d4b --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/number_list_text.dart @@ -0,0 +1,74 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/infra/flowy_svg.dart'; +import 'package:flowy_editor/render/rich_text/default_selectable.dart'; +import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; +import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; + +class NumberListTextNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return NumberListTextNodeWidget( + key: context.node.key, + textNode: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return node.attributes.number != null; + }); +} + +class NumberListTextNodeWidget extends StatefulWidget { + const NumberListTextNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => + _NumberListTextNodeWidgetState(); +} + +// customize + +class _NumberListTextNodeWidgetState extends State + with Selectable, DefaultSelectable { + final _richTextKey = GlobalKey(debugLabel: 'number_list_text'); + final leftPadding = 20.0; + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Offset get baseOffset { + return Offset(leftPadding, 0); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + FlowySvg( + size: Size.square(leftPadding), + number: widget.textNode.attributes.number, + ), + FlowyRichText( + key: _richTextKey, + textNode: widget.textNode, + editorState: widget.editorState, + ), + ], + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart new file mode 100644 index 0000000000..41520c560f --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart @@ -0,0 +1,73 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/infra/flowy_svg.dart'; +import 'package:flowy_editor/render/rich_text/default_selectable.dart'; +import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; +import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; + +class QuotedTextNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return QuotedTextNodeWidget( + key: context.node.key, + textNode: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return true; + }); +} + +class QuotedTextNodeWidget extends StatefulWidget { + const QuotedTextNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => _QuotedTextNodeWidgetState(); +} + +// customize + +class _QuotedTextNodeWidgetState extends State + with Selectable, DefaultSelectable { + final _richTextKey = GlobalKey(debugLabel: 'quoted_text'); + final leftPadding = 20.0; + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Offset get baseOffset { + return Offset(leftPadding, 0); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + FlowySvg( + size: Size.square(leftPadding), + name: 'quote', + ), + FlowyRichText( + key: _richTextKey, + textNode: widget.textNode, + editorState: widget.editorState, + ), + ], + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart index b4100f9b87..26d4275774 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart @@ -25,12 +25,15 @@ class StyleKey { static String font = 'font'; static String href = 'href'; - static String heading = 'heading'; static String quote = 'quote'; static String list = 'list'; static String number = 'number'; static String todo = 'todo'; static String code = 'code'; + + static String subtype = 'subtype'; + static String check = 'checkbox'; + static String heading = 'heading'; } double baseFontSize = 16.0; @@ -60,10 +63,7 @@ extension NodeAttributesExtensions on Attributes { } bool get quote { - if (containsKey(StyleKey.quote) && this[StyleKey.quote] == true) { - return this[StyleKey.quote]; - } - return false; + return containsKey(StyleKey.quote); } Color? get quoteColor { @@ -100,6 +100,13 @@ extension NodeAttributesExtensions on Attributes { } return false; } + + bool get check { + if (containsKey(StyleKey.check) && this[StyleKey.check] is bool) { + return this[StyleKey.check]; + } + return false; + } } extension DeltaAttributesExtensions on Attributes { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart index d5223ec36a..f01cbe8a28 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart @@ -1,6 +1,10 @@ +import 'package:flowy_editor/render/editor/editor_entry.dart'; +import 'package:flowy_editor/render/rich_text/checkbox_text.dart'; +import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; import 'package:flowy_editor/render/selection/floating_shortcut_widget.dart'; import 'package:flowy_editor/service/input_service.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart'; +import 'package:flowy_editor/service/render_plugin_service.dart'; import 'package:flowy_editor/service/shortcut_service.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart'; @@ -9,21 +13,41 @@ import 'package:flowy_editor/service/internal_key_event_handlers/shortcut_handle import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/service/selection_service.dart'; import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/render/rich_text/bulleted_list_text.dart'; +import 'package:flowy_editor/render/rich_text/heading_text.dart'; +import 'package:flowy_editor/render/rich_text/number_list_text.dart'; +import 'package:flowy_editor/render/rich_text/quoted_text.dart'; import 'package:flutter/material.dart'; +NodeWidgetBuilders defaultBuilders = { + 'editor': EditorEntryWidgetBuilder(), + 'text': RichTextNodeWidgetBuilder(), + 'text/checkbox': CheckboxNodeWidgetBuilder(), + 'text/heading': HeadingTextNodeWidgetBuilder(), + 'text/bullet-list': BulletedListTextNodeWidgetBuilder(), + 'text/number-list': NumberListTextNodeWidgetBuilder(), + 'text/quote': QuotedTextNodeWidgetBuilder(), +}; + class FlowyEditor extends StatefulWidget { const FlowyEditor({ Key? key, required this.editorState, - required this.keyEventHandlers, - required this.shortcuts, + this.customBuilders = const {}, + this.keyEventHandlers = const [], + this.shortcuts = const [], }) : super(key: key); final EditorState editorState; + + /// Render plugins. + final NodeWidgetBuilders customBuilders; + + /// Keyboard event handlers. final List keyEventHandlers; - /// Shortcusts + /// Shortcuts final FloatingShortcuts shortcuts; @override @@ -33,6 +57,19 @@ class FlowyEditor extends StatefulWidget { class _FlowyEditorState extends State { EditorState get editorState => widget.editorState; + @override + void initState() { + super.initState(); + + editorState.service.renderPluginService = FlowyRenderPlugin( + editorState: editorState, + builders: { + ...defaultBuilders, + ...widget.customBuilders, + }, + ); + } + @override Widget build(BuildContext context) { return FlowySelection( @@ -57,7 +94,13 @@ class _FlowyEditorState extends State { size: const Size(200, 150), // TODO: support customize size. editorState: editorState, floatingShortcuts: widget.shortcuts, - child: editorState.build(context), + child: editorState.service.renderPluginService.buildPluginWidget( + NodeWidgetContext( + context: context, + node: editorState.document.root, + editorState: editorState, + ), + ), ), ), ), diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart index bdbcd24467..38309414f4 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart @@ -90,7 +90,7 @@ class _FlowyInputState extends State @override void apply(List deltas) { -// TODO: implement the detail + // TODO: implement the detail for (final delta in deltas) { if (delta is TextEditingDeltaInsertion) { } else if (delta is TextEditingDeltaDeletion) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/render_plugin_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/render_plugin_service.dart new file mode 100644 index 0000000000..47159097b5 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/render_plugin_service.dart @@ -0,0 +1,131 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +typedef NodeValidator = bool Function(T node); + +abstract class NodeWidgetBuilder { + NodeValidator get nodeValidator; + + Widget build(NodeWidgetContext context); +} + +typedef NodeWidgetBuilders = Map; + +abstract class FlowyRenderPluginService { + /// Register render plugin with specified [name]. + /// + /// [name] should be [Node].type + /// or [Node].type + '/' + [Node].attributes['subtype']. + /// + /// e.g. 'text', 'text/checkbox', or 'text/heading' + /// + /// [name] could be empty. + void register(String name, NodeWidgetBuilder builder); + void registerAll(Map builders); + + /// UnRegister plugin with specified [name]. + void unRegister(String name); + + Widget buildPluginWidget(NodeWidgetContext context); +} + +class NodeWidgetContext { + final BuildContext context; + final T node; + final EditorState editorState; + + NodeWidgetContext({ + required this.context, + required this.node, + required this.editorState, + }); + + NodeWidgetContext copyWith({ + BuildContext? context, + T? node, + EditorState? editorState, + }) { + return NodeWidgetContext( + context: context ?? this.context, + node: node ?? this.node, + editorState: editorState ?? this.editorState, + ); + } +} + +class FlowyRenderPlugin extends FlowyRenderPluginService { + FlowyRenderPlugin({ + required this.editorState, + required NodeWidgetBuilders builders, + }) { + registerAll(builders); + } + + final NodeWidgetBuilders _builders = {}; + final EditorState editorState; + + @override + Widget buildPluginWidget(NodeWidgetContext context) { + final node = context.node; + final name = + node.subtype == null ? node.type : '${node.type}/${node.subtype!}'; + final builder = _builders[name]; + if (builder != null && builder.nodeValidator(node)) { + final key = GlobalKey(debugLabel: name); + node.key = key; + return _wrap( + builder.build(context), + context, + ); + } else { + assert(false, 'Could not query the builder with this $name'); + // TODO: return a placeholder widget with tips. + return Container(); + } + } + + @override + void register(String name, NodeWidgetBuilder builder) { + debugPrint('[Plugins] registering $name...'); + _validatePlugin(name); + _builders[name] = builder; + } + + @override + void registerAll(Map> builders) { + builders.forEach(register); + } + + @override + void unRegister(String name) { + _validatePlugin(name); + _builders.remove(name); + } + + Widget _wrap(Widget widget, NodeWidgetContext context) { + return CompositedTransformTarget( + link: context.node.layerLink, + child: ChangeNotifierProvider.value( + value: context.node, + builder: (context, child) => Consumer( + builder: ((context, value, child) { + debugPrint('Node is rebuilding...'); + return widget; + }), + ), + ), + ); + } + + void _validatePlugin(String name) { + final paths = name.split('/'); + if (paths.length > 2) { + throw Exception('Plugin name must contain at most one or zero slash'); + } + if (_builders.containsKey(name)) { + throw Exception('Plugin name($name) already exists.'); + } + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index 43b77baeaf..975677d508 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -250,6 +250,7 @@ class _FlowySelectionState extends State ); } + @override List rects() { return _rects; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart index 8fe715bbe7..551002499d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart @@ -1,3 +1,4 @@ +import 'package:flowy_editor/service/render_plugin_service.dart'; import 'package:flowy_editor/service/shortcut_service.dart'; import 'package:flowy_editor/service/selection_service.dart'; import 'package:flutter/material.dart'; @@ -17,6 +18,9 @@ class FlowyService { // input service final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service'); + // render plugin service + late FlowyRenderPlugin renderPluginService; + // floating shortcut service final floatingShortcutServiceKey = GlobalKey(debugLabel: 'flowy_floating_shortcut_service'); diff --git a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart index 176f00b734..b0b6cec141 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart +++ b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart @@ -6,7 +6,6 @@ import 'package:flowy_editor/operation/operation.dart'; import 'package:flowy_editor/operation/transaction_builder.dart'; import 'package:flowy_editor/editor_state.dart'; import 'package:flowy_editor/document/state_tree.dart'; -import 'package:flowy_editor/render/render_plugins.dart'; void main() { group('transform path', () { @@ -64,8 +63,7 @@ void main() { item2, item3, ])); - final state = EditorState( - document: StateTree(root: root), renderPlugins: RenderPlugins()); + final state = EditorState(document: StateTree(root: root)); expect(item1.path, [0]); expect(item2.path, [1]);