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",
|
"type": "editor",
|
||||||
"attributes": {},
|
"attributes": {},
|
||||||
"children": [
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Hello world"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"subtype": "quote"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
|
@ -1,12 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:example/expandable_floating_action_button.dart';
|
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/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:flutter/material.dart';
|
||||||
import 'package:flowy_editor/flowy_editor.dart';
|
import 'package:flowy_editor/flowy_editor.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@ -59,19 +54,8 @@ class MyHomePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MyHomePageState extends State<MyHomePage> {
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
final RenderPlugins renderPlugins = RenderPlugins();
|
|
||||||
late EditorState _editorState;
|
late EditorState _editorState;
|
||||||
int page = 0;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -130,11 +114,13 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
final document = StateTree.fromJson(data);
|
final document = StateTree.fromJson(data);
|
||||||
_editorState = EditorState(
|
_editorState = EditorState(
|
||||||
document: document,
|
document: document,
|
||||||
renderPlugins: renderPlugins,
|
|
||||||
);
|
);
|
||||||
return FlowyEditor(
|
return FlowyEditor(
|
||||||
editorState: _editorState,
|
editorState: _editorState,
|
||||||
keyEventHandlers: const [],
|
keyEventHandlers: const [],
|
||||||
|
customBuilders: {
|
||||||
|
'image': ImageNodeBuilder(),
|
||||||
|
},
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
// TODO: this won't work, just a example for now.
|
// 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:flowy_editor/flowy_editor.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ImageNodeBuilder extends NodeWidgetBuilder {
|
class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
|
||||||
ImageNodeBuilder.create({
|
|
||||||
required super.node,
|
|
||||||
required super.editorState,
|
|
||||||
required super.key,
|
|
||||||
}) : super.create();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(NodeWidgetContext<Node> context) {
|
||||||
return _ImageNodeWidget(
|
return ImageNodeWidget(
|
||||||
key: key,
|
key: context.node.key,
|
||||||
node: node,
|
node: context.node,
|
||||||
editorState: editorState,
|
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 Node node;
|
||||||
final EditorState editorState;
|
final EditorState editorState;
|
||||||
|
|
||||||
const _ImageNodeWidget({
|
const ImageNodeWidget({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.node,
|
required this.node,
|
||||||
required this.editorState,
|
required this.editorState,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@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;
|
Node get node => widget.node;
|
||||||
EditorState get editorState => widget.editorState;
|
EditorState get editorState => widget.editorState;
|
||||||
String get src => widget.node.attributes['image_src'] as String;
|
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 '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:flowy_editor/service/service.dart';
|
||||||
import 'package:flutter/material.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/operation.dart';
|
||||||
import 'package:flowy_editor/operation/transaction.dart';
|
import 'package:flowy_editor/operation/transaction.dart';
|
||||||
import 'package:flowy_editor/undo_manager.dart';
|
import 'package:flowy_editor/undo_manager.dart';
|
||||||
import 'package:flowy_editor/render/render_plugins.dart';
|
|
||||||
|
|
||||||
class ApplyOptions {
|
class ApplyOptions {
|
||||||
/// This flag indicates that
|
/// This flag indicates that
|
||||||
@ -30,7 +23,6 @@ class ApplyOptions {
|
|||||||
|
|
||||||
class EditorState {
|
class EditorState {
|
||||||
final StateTree document;
|
final StateTree document;
|
||||||
final RenderPlugins renderPlugins;
|
|
||||||
|
|
||||||
List<Node> selectedNodes = [];
|
List<Node> selectedNodes = [];
|
||||||
|
|
||||||
@ -59,31 +51,10 @@ class EditorState {
|
|||||||
|
|
||||||
EditorState({
|
EditorState({
|
||||||
required this.document,
|
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;
|
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,
|
apply(Transaction transaction,
|
||||||
[ApplyOptions options = const ApplyOptions()]) {
|
[ApplyOptions options = const ApplyOptions()]) {
|
||||||
for (final op in transaction.operations) {
|
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/node.dart';
|
||||||
export 'package:flowy_editor/document/path.dart';
|
export 'package:flowy_editor/document/path.dart';
|
||||||
export 'package:flowy_editor/document/text_delta.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/render/selection/selectable.dart';
|
||||||
export 'package:flowy_editor/operation/transaction.dart';
|
export 'package:flowy_editor/operation/transaction.dart';
|
||||||
export 'package:flowy_editor/operation/transaction_builder.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/service/editor_service.dart';
|
||||||
export 'package:flowy_editor/document/selection.dart';
|
export 'package:flowy_editor/document/selection.dart';
|
||||||
export 'package:flowy_editor/document/position.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/document/node.dart';
|
||||||
import 'package:flowy_editor/editor_state.dart';
|
import 'package:flowy_editor/editor_state.dart';
|
||||||
import 'package:flowy_editor/infra/flowy_svg.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/default_selectable.dart';
|
||||||
import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
|
import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
|
||||||
import 'package:flowy_editor/render/selection/selectable.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/material.dart';
|
||||||
|
|
||||||
class BulletedListTextNodeWidgetBuilder extends NodeWidgetBuilder {
|
class BulletedListTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||||
BulletedListTextNodeWidgetBuilder.create({
|
|
||||||
required super.editorState,
|
|
||||||
required super.node,
|
|
||||||
required super.key,
|
|
||||||
}) : super.create();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(NodeWidgetContext<TextNode> context) {
|
||||||
return BulletedListTextNodeWidget(
|
return BulletedListTextNodeWidget(
|
||||||
key: key,
|
key: context.node.key,
|
||||||
textNode: node as TextNode,
|
textNode: context.node,
|
||||||
editorState: editorState,
|
editorState: context.editorState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
NodeValidator<Node> get nodeValidator => ((node) {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class BulletedListTextNodeWidget extends StatefulWidget {
|
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/editor_state.dart';
|
||||||
import 'package:flowy_editor/infra/flowy_svg.dart';
|
import 'package:flowy_editor/infra/flowy_svg.dart';
|
||||||
import 'package:flowy_editor/operation/transaction_builder.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/default_selectable.dart';
|
||||||
import 'package:flowy_editor/render/rich_text/flowy_rich_text.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/rich_text/rich_text_style.dart';
|
||||||
import 'package:flowy_editor/render/selection/selectable.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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder {
|
class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||||
CheckboxNodeWidgetBuilder.create({
|
|
||||||
required super.editorState,
|
|
||||||
required super.node,
|
|
||||||
required super.key,
|
|
||||||
}) : super.create();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(NodeWidgetContext<TextNode> context) {
|
||||||
return CheckboxNodeWidget(
|
return CheckboxNodeWidget(
|
||||||
key: key,
|
key: context.node.key,
|
||||||
textNode: node as TextNode,
|
textNode: context.node,
|
||||||
editorState: editorState,
|
editorState: context.editorState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
NodeValidator<Node> get nodeValidator => ((node) {
|
||||||
|
return node.attributes.containsKey(StyleKey.check);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class CheckboxNodeWidget extends StatefulWidget {
|
class CheckboxNodeWidget extends StatefulWidget {
|
||||||
@ -67,7 +64,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildWithSingle(BuildContext context) {
|
Widget _buildWithSingle(BuildContext context) {
|
||||||
final check = widget.textNode.attributes.checkbox;
|
final check = widget.textNode.attributes.check;
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -107,9 +104,10 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
|||||||
Column(
|
Column(
|
||||||
children: widget.textNode.children
|
children: widget.textNode.children
|
||||||
.map(
|
.map(
|
||||||
(child) => widget.editorState.renderPlugins.buildWidget(
|
(child) => widget.editorState.service.renderPluginService
|
||||||
context: NodeWidgetContext(
|
.buildPluginWidget(
|
||||||
buildContext: context,
|
NodeWidgetContext(
|
||||||
|
context: context,
|
||||||
node: child,
|
node: child,
|
||||||
editorState: widget.editorState,
|
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/document/text_delta.dart';
|
||||||
import 'package:flowy_editor/editor_state.dart';
|
import 'package:flowy_editor/editor_state.dart';
|
||||||
import 'package:flowy_editor/document/path.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/rich_text/rich_text_style.dart';
|
||||||
import 'package:flowy_editor/render/selection/selectable.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/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
class RichTextNodeWidgetBuilder extends NodeWidgetBuilder {
|
class RichTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||||
RichTextNodeWidgetBuilder.create({
|
|
||||||
required super.editorState,
|
|
||||||
required super.node,
|
|
||||||
required super.key,
|
|
||||||
}) : super.create();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(NodeWidgetContext<TextNode> context) {
|
||||||
return FlowyRichText(
|
return FlowyRichText(
|
||||||
key: key,
|
key: context.node.key,
|
||||||
textNode: node as TextNode,
|
textNode: context.node,
|
||||||
editorState: editorState,
|
editorState: context.editorState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
NodeValidator<Node> get nodeValidator => ((node) {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
|
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
|
||||||
@ -152,9 +150,10 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
|
|||||||
_buildSingleRichText(context),
|
_buildSingleRichText(context),
|
||||||
...widget.textNode.children
|
...widget.textNode.children
|
||||||
.map(
|
.map(
|
||||||
(child) => widget.editorState.renderPlugins.buildWidget(
|
(child) => widget.editorState.service.renderPluginService
|
||||||
context: NodeWidgetContext(
|
.buildPluginWidget(
|
||||||
buildContext: context,
|
NodeWidgetContext(
|
||||||
|
context: context,
|
||||||
node: child,
|
node: child,
|
||||||
editorState: widget.editorState,
|
editorState: widget.editorState,
|
||||||
),
|
),
|
||||||
|
@ -1,27 +1,26 @@
|
|||||||
import 'package:flowy_editor/document/node.dart';
|
import 'package:flowy_editor/document/node.dart';
|
||||||
import 'package:flowy_editor/editor_state.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/default_selectable.dart';
|
||||||
import 'package:flowy_editor/render/rich_text/flowy_rich_text.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/rich_text/rich_text_style.dart';
|
||||||
import 'package:flowy_editor/render/selection/selectable.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/material.dart';
|
||||||
|
|
||||||
class HeadingTextNodeWidgetBuilder extends NodeWidgetBuilder {
|
class HeadingTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||||
HeadingTextNodeWidgetBuilder.create({
|
|
||||||
required super.editorState,
|
|
||||||
required super.node,
|
|
||||||
required super.key,
|
|
||||||
}) : super.create();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(NodeWidgetContext<TextNode> context) {
|
||||||
return HeadingTextNodeWidget(
|
return HeadingTextNodeWidget(
|
||||||
key: key,
|
key: context.node.key,
|
||||||
textNode: node as TextNode,
|
textNode: context.node,
|
||||||
editorState: editorState,
|
editorState: context.editorState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
NodeValidator<Node> get nodeValidator => ((node) {
|
||||||
|
return node.attributes.heading != null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class HeadingTextNodeWidget extends StatefulWidget {
|
class HeadingTextNodeWidget extends StatefulWidget {
|
||||||
|
@ -1,28 +1,27 @@
|
|||||||
import 'package:flowy_editor/document/node.dart';
|
import 'package:flowy_editor/document/node.dart';
|
||||||
import 'package:flowy_editor/editor_state.dart';
|
import 'package:flowy_editor/editor_state.dart';
|
||||||
import 'package:flowy_editor/infra/flowy_svg.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/default_selectable.dart';
|
||||||
import 'package:flowy_editor/render/rich_text/flowy_rich_text.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/rich_text/rich_text_style.dart';
|
||||||
import 'package:flowy_editor/render/selection/selectable.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/material.dart';
|
||||||
|
|
||||||
class NumberListTextNodeWidgetBuilder extends NodeWidgetBuilder {
|
class NumberListTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||||
NumberListTextNodeWidgetBuilder.create({
|
|
||||||
required super.editorState,
|
|
||||||
required super.node,
|
|
||||||
required super.key,
|
|
||||||
}) : super.create();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(NodeWidgetContext<TextNode> context) {
|
||||||
return NumberListTextNodeWidget(
|
return NumberListTextNodeWidget(
|
||||||
key: key,
|
key: context.node.key,
|
||||||
textNode: node as TextNode,
|
textNode: context.node,
|
||||||
editorState: editorState,
|
editorState: context.editorState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
NodeValidator<Node> get nodeValidator => ((node) {
|
||||||
|
return node.attributes.number != null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class NumberListTextNodeWidget extends StatefulWidget {
|
class NumberListTextNodeWidget extends StatefulWidget {
|
||||||
|
@ -1,27 +1,27 @@
|
|||||||
import 'package:flowy_editor/document/node.dart';
|
import 'package:flowy_editor/document/node.dart';
|
||||||
import 'package:flowy_editor/editor_state.dart';
|
import 'package:flowy_editor/editor_state.dart';
|
||||||
import 'package:flowy_editor/infra/flowy_svg.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/default_selectable.dart';
|
||||||
import 'package:flowy_editor/render/rich_text/flowy_rich_text.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/render/selection/selectable.dart';
|
||||||
|
import 'package:flowy_editor/service/render_plugin_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class QuotedTextNodeWidgetBuilder extends NodeWidgetBuilder {
|
class QuotedTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
|
||||||
QuotedTextNodeWidgetBuilder.create({
|
|
||||||
required super.editorState,
|
|
||||||
required super.node,
|
|
||||||
required super.key,
|
|
||||||
}) : super.create();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(NodeWidgetContext<TextNode> context) {
|
||||||
return QuotedTextNodeWidget(
|
return QuotedTextNodeWidget(
|
||||||
key: key,
|
key: context.node.key,
|
||||||
textNode: node as TextNode,
|
textNode: context.node,
|
||||||
editorState: editorState,
|
editorState: context.editorState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
NodeValidator<Node> get nodeValidator => ((node) {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class QuotedTextNodeWidget extends StatefulWidget {
|
class QuotedTextNodeWidget extends StatefulWidget {
|
||||||
|
@ -32,7 +32,7 @@ class StyleKey {
|
|||||||
static String code = 'code';
|
static String code = 'code';
|
||||||
|
|
||||||
static String subtype = 'subtype';
|
static String subtype = 'subtype';
|
||||||
static String checkbox = 'checkbox';
|
static String check = 'checkbox';
|
||||||
static String heading = 'heading';
|
static String heading = 'heading';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,10 +63,7 @@ extension NodeAttributesExtensions on Attributes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool get quote {
|
bool get quote {
|
||||||
if (containsKey(StyleKey.quote) && this[StyleKey.quote] == true) {
|
return containsKey(StyleKey.quote);
|
||||||
return this[StyleKey.quote];
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Color? get quoteColor {
|
Color? get quoteColor {
|
||||||
@ -104,9 +101,9 @@ extension NodeAttributesExtensions on Attributes {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get checkbox {
|
bool get check {
|
||||||
if (containsKey(StyleKey.checkbox) && this[StyleKey.checkbox] is bool) {
|
if (containsKey(StyleKey.check) && this[StyleKey.check] is bool) {
|
||||||
return this[StyleKey.checkbox];
|
return this[StyleKey.check];
|
||||||
}
|
}
|
||||||
return false;
|
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/render/selection/floating_shortcut_widget.dart';
|
||||||
import 'package:flowy_editor/service/input_service.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/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/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/arrow_keys_handler.dart';
|
||||||
import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_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/keyboard_service.dart';
|
||||||
import 'package:flowy_editor/service/selection_service.dart';
|
import 'package:flowy_editor/service/selection_service.dart';
|
||||||
import 'package:flowy_editor/editor_state.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';
|
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 {
|
class FlowyEditor extends StatefulWidget {
|
||||||
const FlowyEditor({
|
const FlowyEditor({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.editorState,
|
required this.editorState,
|
||||||
required this.keyEventHandlers,
|
this.customBuilders = const {},
|
||||||
required this.shortcuts,
|
this.keyEventHandlers = const [],
|
||||||
|
this.shortcuts = const [],
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final EditorState editorState;
|
final EditorState editorState;
|
||||||
|
|
||||||
|
/// Render plugins.
|
||||||
|
final NodeWidgetBuilders customBuilders;
|
||||||
|
|
||||||
|
/// Keyboard event handlers.
|
||||||
final List<FlowyKeyEventHandler> keyEventHandlers;
|
final List<FlowyKeyEventHandler> keyEventHandlers;
|
||||||
|
|
||||||
/// shortcuts
|
/// Shortcuts
|
||||||
final FloatingShortcuts shortcuts;
|
final FloatingShortcuts shortcuts;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -33,6 +57,19 @@ class FlowyEditor extends StatefulWidget {
|
|||||||
class _FlowyEditorState extends State<FlowyEditor> {
|
class _FlowyEditorState extends State<FlowyEditor> {
|
||||||
EditorState get editorState => widget.editorState;
|
EditorState get editorState => widget.editorState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
editorState.service.renderPluginService = FlowyRenderPlugin(
|
||||||
|
editorState: editorState,
|
||||||
|
builders: {
|
||||||
|
...defaultBuilders,
|
||||||
|
...widget.customBuilders,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FlowySelection(
|
return FlowySelection(
|
||||||
@ -57,7 +94,13 @@ class _FlowyEditorState extends State<FlowyEditor> {
|
|||||||
size: const Size(200, 150), // TODO: support customize size.
|
size: const Size(200, 150), // TODO: support customize size.
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
floatingShortcuts: widget.shortcuts,
|
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/shortcut_service.dart';
|
||||||
import 'package:flowy_editor/service/selection_service.dart';
|
import 'package:flowy_editor/service/selection_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -17,6 +18,9 @@ class FlowyService {
|
|||||||
// input service
|
// input service
|
||||||
final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service');
|
final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service');
|
||||||
|
|
||||||
|
// render plugin service
|
||||||
|
late FlowyRenderPlugin renderPluginService;
|
||||||
|
|
||||||
// floating shortcut service
|
// floating shortcut service
|
||||||
final floatingShortcutServiceKey =
|
final floatingShortcutServiceKey =
|
||||||
GlobalKey(debugLabel: 'flowy_floating_shortcut_service');
|
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/operation/transaction_builder.dart';
|
||||||
import 'package:flowy_editor/editor_state.dart';
|
import 'package:flowy_editor/editor_state.dart';
|
||||||
import 'package:flowy_editor/document/state_tree.dart';
|
import 'package:flowy_editor/document/state_tree.dart';
|
||||||
import 'package:flowy_editor/render/render_plugins.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('transform path', () {
|
group('transform path', () {
|
||||||
@ -64,8 +63,7 @@ void main() {
|
|||||||
item2,
|
item2,
|
||||||
item3,
|
item3,
|
||||||
]));
|
]));
|
||||||
final state = EditorState(
|
final state = EditorState(document: StateTree(root: root));
|
||||||
document: StateTree(root: root), renderPlugins: RenderPlugins());
|
|
||||||
|
|
||||||
expect(item1.path, [0]);
|
expect(item1.path, [0]);
|
||||||
expect(item2.path, [1]);
|
expect(item2.path, [1]);
|
||||||
|
Loading…
Reference in New Issue
Block a user