mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: refactor render plugin service
1. abstract render plugin as service. 2. simplify plugin development. 3. delete unused code
This commit is contained in:
parent
c5e9008f4b
commit
ed1dc8ccef
@ -3,6 +3,17 @@
|
||||
"type": "editor",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"delta": [
|
||||
{
|
||||
"insert": "Hello world"
|
||||
}
|
||||
],
|
||||
"attributes": {
|
||||
"subtype": "quote"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"attributes": {
|
||||
|
@ -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<MyHomePage> {
|
||||
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<MyHomePage> {
|
||||
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.
|
||||
{
|
||||
|
@ -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<DebuggableRichText> createState() => _DebuggableRichTextState();
|
||||
}
|
||||
|
||||
class _DebuggableRichTextState extends State<DebuggableRichText> {
|
||||
final List<Rect> _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<Rect> _computeLocalSelectionRects(TextSelection selection) {
|
||||
final textBoxes = _renderParagraph.getBoxesForSelection(selection);
|
||||
return textBoxes.map((box) => box.toRect()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class _BoxPainter extends CustomPainter {
|
||||
final List<Rect> _rects;
|
||||
final Paint _paint;
|
||||
|
||||
_BoxPainter({
|
||||
required List<Rect> 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;
|
||||
}
|
||||
}
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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>[_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>[
|
||||
/// 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<FlowySelectableText> createState() => _FlowySelectableTextState();
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties
|
||||
.add(DiagnosticsProperty<String>('data', data, defaultValue: null));
|
||||
properties.add(DiagnosticsProperty<String>('semanticsLabel', semanticsLabel,
|
||||
defaultValue: null));
|
||||
properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode,
|
||||
defaultValue: null));
|
||||
properties.add(
|
||||
DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
|
||||
properties.add(
|
||||
DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
|
||||
properties.add(DiagnosticsProperty<bool>('showCursor', showCursor,
|
||||
defaultValue: false));
|
||||
properties.add(IntProperty('minLines', minLines, defaultValue: null));
|
||||
properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
|
||||
properties.add(
|
||||
EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
|
||||
properties.add(EnumProperty<TextDirection>('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<Radius>('cursorRadius', cursorRadius,
|
||||
defaultValue: null));
|
||||
properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor,
|
||||
defaultValue: null));
|
||||
properties.add(FlagProperty('selectionEnabled',
|
||||
value: selectionEnabled,
|
||||
defaultValue: true,
|
||||
ifFalse: 'selection disabled'));
|
||||
properties.add(DiagnosticsProperty<TextSelectionControls>(
|
||||
'selectionControls', selectionControls,
|
||||
defaultValue: null));
|
||||
properties.add(DiagnosticsProperty<ScrollPhysics>(
|
||||
'scrollPhysics', scrollPhysics,
|
||||
defaultValue: null));
|
||||
properties.add(DiagnosticsProperty<TextHeightBehavior>(
|
||||
'textHeightBehavior', textHeightBehavior,
|
||||
defaultValue: null));
|
||||
}
|
||||
}
|
||||
|
||||
class _FlowySelectableTextState extends State<FlowySelectableText>
|
||||
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<EditableTextState> editableTextKey =
|
||||
GlobalKey<EditableTextState>();
|
||||
|
||||
@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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<Node> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _ImageNodeWidget(
|
||||
key: key,
|
||||
node: node,
|
||||
editorState: editorState,
|
||||
Widget build(NodeWidgetContext<Node> context) {
|
||||
return ImageNodeWidget(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> 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<ImageNodeWidget> createState() => _ImageNodeWidgetState();
|
||||
}
|
||||
|
||||
class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
|
||||
class _ImageNodeWidgetState extends State<ImageNodeWidget> with Selectable {
|
||||
Node get node => widget.node;
|
||||
EditorState get editorState => widget.editorState;
|
||||
String get src => widget.node.attributes['image_src'] as String;
|
||||
|
@ -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<String, dynamic> 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<TextEditingDelta> 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<TextInsert>()
|
||||
// .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),
|
||||
// );
|
||||
// }
|
@ -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<Rect> 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<Rect> _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<TextInsert>()
|
||||
.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<Rect> _rects;
|
||||
final Paint _paint;
|
||||
|
||||
FlowyPainter({
|
||||
Key? key,
|
||||
required Color color,
|
||||
required List<Rect> 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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -1,10 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flowy_editor/render/rich_text/bulleted_list_text.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/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:flowy_editor/service/service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@ -14,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
|
||||
@ -30,7 +23,6 @@ class ApplyOptions {
|
||||
|
||||
class EditorState {
|
||||
final StateTree document;
|
||||
final RenderPlugins renderPlugins;
|
||||
|
||||
List<Node> selectedNodes = [];
|
||||
|
||||
@ -59,31 +51,10 @@ class EditorState {
|
||||
|
||||
EditorState({
|
||||
required this.document,
|
||||
required this.renderPlugins,
|
||||
}) {
|
||||
// FIXME: abstract render plugins as a service.
|
||||
renderPlugins.register('text', RichTextNodeWidgetBuilder.create);
|
||||
renderPlugins.register('text/checkbox', CheckboxNodeWidgetBuilder.create);
|
||||
renderPlugins.register('text/heading', HeadingTextNodeWidgetBuilder.create);
|
||||
renderPlugins.register(
|
||||
'text/bullet-list', BulletedListTextNodeWidgetBuilder.create);
|
||||
renderPlugins.register(
|
||||
'text/number-list', NumberListTextNodeWidgetBuilder.create);
|
||||
renderPlugins.register('text/quote', QuotedTextNodeWidgetBuilder.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) {
|
||||
|
@ -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';
|
||||
|
@ -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<Node> {
|
||||
@override
|
||||
Widget build(NodeWidgetContext context) {
|
||||
return EditorNodeWidget(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> 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<TextNode>(
|
||||
context: context,
|
||||
node: child,
|
||||
editorState: editorState,
|
||||
)
|
||||
: NodeWidgetContext<Node>(
|
||||
context: context,
|
||||
node: child,
|
||||
editorState: editorState,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<T extends Node> = bool Function(T node);
|
||||
|
||||
class NodeWidgetBuilder<T extends Node> {
|
||||
final EditorState editorState;
|
||||
final T node;
|
||||
final Key key;
|
||||
|
||||
bool rebuildOnNodeChanged;
|
||||
NodeValidator<T>? 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<T>(
|
||||
builder: ((context, value, child) {
|
||||
debugPrint('Node is rebuilding...');
|
||||
return build(context);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<T extends Node, A extends NodeWidgetBuilder> = A
|
||||
Function({
|
||||
required T node,
|
||||
required EditorState editorState,
|
||||
required GlobalKey key,
|
||||
});
|
||||
|
||||
// unused
|
||||
// typedef NodeBuilder<T extends Node> = T Function(Node node);
|
||||
|
||||
class RenderPlugins {
|
||||
final Map<String, NodeWidgetBuilderF> _nodeWidgetBuilders = {};
|
||||
// unused
|
||||
// Map<String, NodeBuilder> 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("/")');
|
||||
}
|
||||
}
|
||||
}
|
@ -1,27 +1,26 @@
|
||||
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/node_widget_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/selection/selectable.dart';
|
||||
import 'package:flowy_editor/service/render_plugin_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BulletedListTextNodeWidgetBuilder extends NodeWidgetBuilder {
|
||||
BulletedListTextNodeWidgetBuilder.create({
|
||||
required super.editorState,
|
||||
required super.node,
|
||||
required super.key,
|
||||
}) : super.create();
|
||||
|
||||
class BulletedListTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(NodeWidgetContext<TextNode> context) {
|
||||
return BulletedListTextNodeWidget(
|
||||
key: key,
|
||||
textNode: node as TextNode,
|
||||
editorState: editorState,
|
||||
key: context.node.key,
|
||||
textNode: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => ((node) {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
class BulletedListTextNodeWidget extends StatefulWidget {
|
||||
|
@ -2,30 +2,27 @@ 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/node_widget_builder.dart';
|
||||
import 'package:flowy_editor/render/render_plugins.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/extensions/object_extensions.dart';
|
||||
import 'package:flowy_editor/service/render_plugin_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder {
|
||||
CheckboxNodeWidgetBuilder.create({
|
||||
required super.editorState,
|
||||
required super.node,
|
||||
required super.key,
|
||||
}) : super.create();
|
||||
|
||||
class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(NodeWidgetContext<TextNode> context) {
|
||||
return CheckboxNodeWidget(
|
||||
key: key,
|
||||
textNode: node as TextNode,
|
||||
editorState: editorState,
|
||||
key: context.node.key,
|
||||
textNode: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => ((node) {
|
||||
return node.attributes.containsKey(StyleKey.check);
|
||||
});
|
||||
}
|
||||
|
||||
class CheckboxNodeWidget extends StatefulWidget {
|
||||
@ -67,7 +64,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
||||
}
|
||||
|
||||
Widget _buildWithSingle(BuildContext context) {
|
||||
final check = widget.textNode.attributes.checkbox;
|
||||
final check = widget.textNode.attributes.check;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -107,9 +104,10 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
||||
Column(
|
||||
children: widget.textNode.children
|
||||
.map(
|
||||
(child) => widget.editorState.renderPlugins.buildWidget(
|
||||
context: NodeWidgetContext(
|
||||
buildContext: context,
|
||||
(child) => widget.editorState.service.renderPluginService
|
||||
.buildPluginWidget(
|
||||
NodeWidgetContext(
|
||||
context: context,
|
||||
node: child,
|
||||
editorState: widget.editorState,
|
||||
),
|
||||
|
@ -4,29 +4,27 @@ 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/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/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<TextNode> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(NodeWidgetContext<TextNode> context) {
|
||||
return FlowyRichText(
|
||||
key: key,
|
||||
textNode: node as TextNode,
|
||||
editorState: editorState,
|
||||
key: context.node.key,
|
||||
textNode: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => ((node) {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
|
||||
@ -152,9 +150,10 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
|
||||
_buildSingleRichText(context),
|
||||
...widget.textNode.children
|
||||
.map(
|
||||
(child) => widget.editorState.renderPlugins.buildWidget(
|
||||
context: NodeWidgetContext(
|
||||
buildContext: context,
|
||||
(child) => widget.editorState.service.renderPluginService
|
||||
.buildPluginWidget(
|
||||
NodeWidgetContext(
|
||||
context: context,
|
||||
node: child,
|
||||
editorState: widget.editorState,
|
||||
),
|
||||
|
@ -1,27 +1,26 @@
|
||||
import 'package:flowy_editor/document/node.dart';
|
||||
import 'package:flowy_editor/editor_state.dart';
|
||||
import 'package:flowy_editor/render/node_widget_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 HeadingTextNodeWidgetBuilder extends NodeWidgetBuilder {
|
||||
HeadingTextNodeWidgetBuilder.create({
|
||||
required super.editorState,
|
||||
required super.node,
|
||||
required super.key,
|
||||
}) : super.create();
|
||||
|
||||
class HeadingTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(NodeWidgetContext<TextNode> context) {
|
||||
return HeadingTextNodeWidget(
|
||||
key: key,
|
||||
textNode: node as TextNode,
|
||||
editorState: editorState,
|
||||
key: context.node.key,
|
||||
textNode: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => ((node) {
|
||||
return node.attributes.heading != null;
|
||||
});
|
||||
}
|
||||
|
||||
class HeadingTextNodeWidget extends StatefulWidget {
|
||||
|
@ -1,28 +1,27 @@
|
||||
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/node_widget_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 NumberListTextNodeWidgetBuilder extends NodeWidgetBuilder {
|
||||
NumberListTextNodeWidgetBuilder.create({
|
||||
required super.editorState,
|
||||
required super.node,
|
||||
required super.key,
|
||||
}) : super.create();
|
||||
|
||||
class NumberListTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(NodeWidgetContext<TextNode> context) {
|
||||
return NumberListTextNodeWidget(
|
||||
key: key,
|
||||
textNode: node as TextNode,
|
||||
editorState: editorState,
|
||||
key: context.node.key,
|
||||
textNode: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => ((node) {
|
||||
return node.attributes.number != null;
|
||||
});
|
||||
}
|
||||
|
||||
class NumberListTextNodeWidget extends StatefulWidget {
|
||||
|
@ -1,27 +1,27 @@
|
||||
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/node_widget_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 QuotedTextNodeWidgetBuilder extends NodeWidgetBuilder {
|
||||
QuotedTextNodeWidgetBuilder.create({
|
||||
required super.editorState,
|
||||
required super.node,
|
||||
required super.key,
|
||||
}) : super.create();
|
||||
|
||||
class QuotedTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(NodeWidgetContext<TextNode> context) {
|
||||
return QuotedTextNodeWidget(
|
||||
key: key,
|
||||
textNode: node as TextNode,
|
||||
editorState: editorState,
|
||||
key: context.node.key,
|
||||
textNode: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => ((node) {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
class QuotedTextNodeWidget extends StatefulWidget {
|
||||
|
@ -32,7 +32,7 @@ class StyleKey {
|
||||
static String code = 'code';
|
||||
|
||||
static String subtype = 'subtype';
|
||||
static String checkbox = 'checkbox';
|
||||
static String check = 'checkbox';
|
||||
static String heading = 'heading';
|
||||
}
|
||||
|
||||
@ -63,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 {
|
||||
@ -104,9 +101,9 @@ extension NodeAttributesExtensions on Attributes {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool get checkbox {
|
||||
if (containsKey(StyleKey.checkbox) && this[StyleKey.checkbox] is bool) {
|
||||
return this[StyleKey.checkbox];
|
||||
bool get check {
|
||||
if (containsKey(StyleKey.check) && this[StyleKey.check] is bool) {
|
||||
return this[StyleKey.check];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -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<FlowyKeyEventHandler> keyEventHandlers;
|
||||
|
||||
/// shortcuts
|
||||
/// Shortcuts
|
||||
final FloatingShortcuts shortcuts;
|
||||
|
||||
@override
|
||||
@ -33,6 +57,19 @@ class FlowyEditor extends StatefulWidget {
|
||||
class _FlowyEditorState extends State<FlowyEditor> {
|
||||
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<FlowyEditor> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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<T extends Node> = bool Function(T node);
|
||||
|
||||
abstract class NodeWidgetBuilder<T extends Node> {
|
||||
NodeValidator get nodeValidator;
|
||||
|
||||
Widget build(NodeWidgetContext<T> context);
|
||||
}
|
||||
|
||||
typedef NodeWidgetBuilders = Map<String, NodeWidgetBuilder>;
|
||||
|
||||
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<String, NodeWidgetBuilder> builders);
|
||||
|
||||
/// UnRegister plugin with specified [name].
|
||||
void unRegister(String name);
|
||||
|
||||
Widget buildPluginWidget(NodeWidgetContext context);
|
||||
}
|
||||
|
||||
class NodeWidgetContext<T extends Node> {
|
||||
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<Node> builder) {
|
||||
debugPrint('[Plugins] registering $name...');
|
||||
_validatePlugin(name);
|
||||
_builders[name] = builder;
|
||||
}
|
||||
|
||||
@override
|
||||
void registerAll(Map<String, NodeWidgetBuilder<Node>> 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<Node>.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.');
|
||||
}
|
||||
}
|
||||
}
|
@ -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');
|
||||
|
@ -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]);
|
||||
|
Loading…
Reference in New Issue
Block a user