mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge branch 'feat/flowy_editor' into feat/handle-arrow-keys
This commit is contained in:
commit
3e2883aa3b
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="2" y="2" width="12" height="12" rx="4" fill="#00BCF0"/>
|
||||||
|
<path d="M6 8L7.61538 9.5L10.5 6.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 268 B |
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="6" y="6" width="4" height="4" rx="2" fill="#333333"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 166 B |
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="40" height="160" x="80" y="20" fill="#00BCF0"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 135 B |
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="2.5" y="2.5" width="11" height="11" rx="3.5" stroke="#BDBDBD"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 176 B |
@ -37,7 +37,22 @@
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"delta": [{ "insert": "Click anywhere and just start typing." }],
|
"delta": [{ "insert": "Click anywhere and just start typing." }],
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"checkbox": true
|
"list": "todo",
|
||||||
|
"todo": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [{ "insert": "Click anywhere and just start typing." }],
|
||||||
|
"attributes": {
|
||||||
|
"list": "bullet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [{ "insert": "Click anywhere and just start typing." }],
|
||||||
|
"attributes": {
|
||||||
|
"list": "bullet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -77,7 +92,9 @@
|
|||||||
"insert": "1. Click the '?' at the bottom right for help and support."
|
"insert": "1. Click the '?' at the bottom right for help and support."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"attributes": {}
|
"attributes": {
|
||||||
|
"quotes": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
@ -0,0 +1,193 @@
|
|||||||
|
{
|
||||||
|
"document": {
|
||||||
|
"type": "editor",
|
||||||
|
"attributes": {},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"attributes": {
|
||||||
|
"image_src": "https://images.pexels.com/photos/2253275/pexels-photo-2253275.jpeg?cs=srgb&dl=pexels-helena-lopes-2253275.jpg&fm=jpg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "🌶 Read Me"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"heading": "h1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "👋 Welcome to Appflowy"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"heading": "h2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Here are the basics:"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"heading": "h3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{ "insert": "Click " },
|
||||||
|
{ "insert": "anywhere", "attributes": { "underline": true } },
|
||||||
|
{ "insert": " and just typing." }
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"list": "todo",
|
||||||
|
"todo": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Hit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"insert": " / ",
|
||||||
|
"attributes": { "highlightColor": "0xFFFFFF00" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"insert": "to see all the types of content you can add - entity, headers, videos, sub pages, etc."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"list": "todo",
|
||||||
|
"todo": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Highlight any text, and use the menu that pops up to "
|
||||||
|
},
|
||||||
|
{ "insert": "style", "attributes": { "bold": true } },
|
||||||
|
{ "insert": " your ", "attributes": { "italic": true } },
|
||||||
|
{ "insert": "writing", "attributes": { "strikethrough": true } },
|
||||||
|
{ "insert": "." }
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"list": "todo",
|
||||||
|
"todo": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Here are the examples:"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"heading": "h3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Hello world"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"list": "bullet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Hello world"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"list": "bullet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Hello world"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"list": "bullet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Hello world"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"quote": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Hello world"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"quote": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Hello world"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"number": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Hello world"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"number": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Hello world"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"number": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,234 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
// copy from https://docs.flutter.dev/cookbook/effects/expandable-fab
|
||||||
|
@immutable
|
||||||
|
class ExpandableFab extends StatefulWidget {
|
||||||
|
const ExpandableFab({
|
||||||
|
super.key,
|
||||||
|
this.initialOpen,
|
||||||
|
required this.distance,
|
||||||
|
required this.children,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool? initialOpen;
|
||||||
|
final double distance;
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExpandableFab> createState() => _ExpandableFabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpandableFabState extends State<ExpandableFab>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final Animation<double> _expandAnimation;
|
||||||
|
bool _open = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_open = widget.initialOpen ?? false;
|
||||||
|
_controller = AnimationController(
|
||||||
|
value: _open ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_expandAnimation = CurvedAnimation(
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
reverseCurve: Curves.easeOutQuad,
|
||||||
|
parent: _controller,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggle() {
|
||||||
|
setState(() {
|
||||||
|
_open = !_open;
|
||||||
|
if (_open) {
|
||||||
|
_controller.forward();
|
||||||
|
} else {
|
||||||
|
_controller.reverse();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox.expand(
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
_buildTapToCloseFab(),
|
||||||
|
..._buildExpandingActionButtons(),
|
||||||
|
_buildTapToOpenFab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTapToCloseFab() {
|
||||||
|
return SizedBox(
|
||||||
|
width: 56.0,
|
||||||
|
height: 56.0,
|
||||||
|
child: Center(
|
||||||
|
child: Material(
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
elevation: 4.0,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _toggle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildExpandingActionButtons() {
|
||||||
|
final children = <Widget>[];
|
||||||
|
final count = widget.children.length;
|
||||||
|
final step = 90.0 / (count - 1);
|
||||||
|
for (var i = 0, angleInDegrees = 0.0;
|
||||||
|
i < count;
|
||||||
|
i++, angleInDegrees += step) {
|
||||||
|
children.add(
|
||||||
|
_ExpandingActionButton(
|
||||||
|
directionInDegrees: angleInDegrees,
|
||||||
|
maxDistance: widget.distance,
|
||||||
|
progress: _expandAnimation,
|
||||||
|
child: widget.children[i],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTapToOpenFab() {
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: _open,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
transformAlignment: Alignment.center,
|
||||||
|
transform: Matrix4.diagonal3Values(
|
||||||
|
_open ? 0.7 : 1.0,
|
||||||
|
_open ? 0.7 : 1.0,
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _open ? 0.0 : 1.0,
|
||||||
|
curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
child: FloatingActionButton(
|
||||||
|
onPressed: _toggle,
|
||||||
|
child: const Icon(Icons.create),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class _ExpandingActionButton extends StatelessWidget {
|
||||||
|
const _ExpandingActionButton({
|
||||||
|
required this.directionInDegrees,
|
||||||
|
required this.maxDistance,
|
||||||
|
required this.progress,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double directionInDegrees;
|
||||||
|
final double maxDistance;
|
||||||
|
final Animation<double> progress;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: progress,
|
||||||
|
builder: (context, child) {
|
||||||
|
final offset = Offset.fromDirection(
|
||||||
|
directionInDegrees * (math.pi / 180.0),
|
||||||
|
progress.value * maxDistance,
|
||||||
|
);
|
||||||
|
return Positioned(
|
||||||
|
right: 4.0 + offset.dx,
|
||||||
|
bottom: 4.0 + offset.dy,
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: (1.0 - progress.value) * math.pi / 2,
|
||||||
|
child: child!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: progress,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class ActionButton extends StatelessWidget {
|
||||||
|
const ActionButton({
|
||||||
|
super.key,
|
||||||
|
this.onPressed,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final Widget icon;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Material(
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
color: theme.colorScheme.secondary,
|
||||||
|
elevation: 4.0,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: icon,
|
||||||
|
color: theme.colorScheme.onSecondary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class FakeItem extends StatelessWidget {
|
||||||
|
const FakeItem({
|
||||||
|
super.key,
|
||||||
|
required this.isBig,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isBig;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0),
|
||||||
|
height: isBig ? 128.0 : 36.0,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,11 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:example/expandable_floating_action_button.dart';
|
||||||
import 'package:example/plugin/document_node_widget.dart';
|
import 'package:example/plugin/document_node_widget.dart';
|
||||||
import 'package:example/plugin/selected_text_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/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/text_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: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';
|
||||||
@ -60,13 +61,13 @@ class MyHomePage extends StatefulWidget {
|
|||||||
class _MyHomePageState extends State<MyHomePage> {
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
final RenderPlugins renderPlugins = RenderPlugins();
|
final RenderPlugins renderPlugins = RenderPlugins();
|
||||||
late EditorState _editorState;
|
late EditorState _editorState;
|
||||||
|
int page = 0;
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
renderPlugins
|
renderPlugins
|
||||||
..register('editor', EditorNodeWidgetBuilder.create)
|
..register('editor', EditorNodeWidgetBuilder.create)
|
||||||
..register('text', SelectedTextNodeBuilder.create)
|
|
||||||
..register('image', ImageNodeBuilder.create)
|
..register('image', ImageNodeBuilder.create)
|
||||||
..register('text/with-checkbox', TextWithCheckBoxNodeBuilder.create)
|
..register('text/with-checkbox', TextWithCheckBoxNodeBuilder.create)
|
||||||
..register('text/with-heading', TextWithHeadingNodeBuilder.create);
|
..register('text/with-heading', TextWithHeadingNodeBuilder.create);
|
||||||
@ -80,8 +81,45 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
// the App.build method, and use it to set our appbar title.
|
// the App.build method, and use it to set our appbar title.
|
||||||
title: Text(widget.title),
|
title: Text(widget.title),
|
||||||
),
|
),
|
||||||
body: FutureBuilder<String>(
|
body: _buildBody(),
|
||||||
future: rootBundle.loadString('assets/document.json'),
|
floatingActionButton: ExpandableFab(
|
||||||
|
distance: 112.0,
|
||||||
|
children: [
|
||||||
|
ActionButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (page == 0) return;
|
||||||
|
setState(() {
|
||||||
|
page = 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.note_add),
|
||||||
|
),
|
||||||
|
ActionButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (page == 1) return;
|
||||||
|
setState(() {
|
||||||
|
page = 1;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.text_fields),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (page == 0) {
|
||||||
|
return _buildFlowyEditor();
|
||||||
|
} else if (page == 1) {
|
||||||
|
return _buildTextField();
|
||||||
|
}
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFlowyEditor() {
|
||||||
|
return FutureBuilder<String>(
|
||||||
|
future: rootBundle.loadString('assets/example.json'),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(
|
return const Center(
|
||||||
@ -126,7 +164,12 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField() {
|
||||||
|
return const Center(
|
||||||
|
child: TextField(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ class EditorNodeWidgetBuilder extends NodeWidgetBuilder {
|
|||||||
}) : super.create();
|
}) : super.create();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext buildContext) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
key: key,
|
key: key,
|
||||||
child: _EditorNodeWidget(
|
child: _EditorNodeWidget(
|
||||||
|
@ -11,7 +11,7 @@ class ImageNodeBuilder extends NodeWidgetBuilder {
|
|||||||
}) : super.create();
|
}) : super.create();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext buildContext) {
|
Widget build(BuildContext context) {
|
||||||
return _ImageNodeWidget(
|
return _ImageNodeWidget(
|
||||||
key: key,
|
key: key,
|
||||||
node: node,
|
node: node,
|
||||||
@ -88,7 +88,10 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
|
|||||||
Widget _build(BuildContext context) {
|
Widget _build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Image.network(src),
|
Image.network(
|
||||||
|
src,
|
||||||
|
height: 150.0,
|
||||||
|
),
|
||||||
if (node.children.isNotEmpty)
|
if (node.children.isNotEmpty)
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
@ -0,0 +1,352 @@
|
|||||||
|
// 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),
|
||||||
|
// );
|
||||||
|
// }
|
@ -22,7 +22,7 @@ class SelectedTextNodeBuilder extends NodeWidgetBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext buildContext) {
|
Widget build(BuildContext context) {
|
||||||
return _SelectedTextNodeWidget(
|
return _SelectedTextNodeWidget(
|
||||||
key: key,
|
key: key,
|
||||||
node: node,
|
node: node,
|
||||||
@ -101,14 +101,15 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TextSelection? getCurrentTextSelection() {
|
TextSelection? getTextSelectionInSelection(Selection selection) {
|
||||||
return _textSelection;
|
assert(selection.isCollapsed);
|
||||||
|
if (!selection.isCollapsed) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
return TextSelection(
|
||||||
@override
|
baseOffset: selection.start.offset,
|
||||||
Offset getOffsetByTextSelection(TextSelection textSelection) {
|
extentOffset: selection.end.offset,
|
||||||
final offset = _computeCursorRect(textSelection.baseOffset).center;
|
);
|
||||||
return _renderParagraph.localToGlobal(offset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -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 buildContext) {
|
|
||||||
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.updateCursorSelection(_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),
|
|
||||||
);
|
|
||||||
}
|
|
@ -12,7 +12,7 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder {
|
|||||||
bool get isCompleted => node.attributes['checkbox'] as bool;
|
bool get isCompleted => node.attributes['checkbox'] as bool;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext buildContext) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -20,7 +20,7 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: renderPlugins.buildWidget(
|
child: renderPlugins.buildWidget(
|
||||||
context: NodeWidgetContext(
|
context: NodeWidgetContext(
|
||||||
buildContext: buildContext,
|
buildContext: context,
|
||||||
node: node,
|
node: node,
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
),
|
),
|
||||||
|
@ -27,13 +27,13 @@ class TextWithHeadingNodeBuilder extends NodeWidgetBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext buildContext) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
buildPadding(),
|
buildPadding(),
|
||||||
renderPlugins.buildWidget(
|
renderPlugins.buildWidget(
|
||||||
context: NodeWidgetContext(
|
context: NodeWidgetContext(
|
||||||
buildContext: buildContext,
|
buildContext: context,
|
||||||
node: node,
|
node: node,
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
),
|
),
|
||||||
|
@ -76,6 +76,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "2.0.1"
|
||||||
|
flutter_svg:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_svg
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1+1"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -135,6 +142,27 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.8.1"
|
version: "1.8.1"
|
||||||
|
path_drawing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_drawing
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
path_parsing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_parsing
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.0"
|
||||||
plugin_platform_interface:
|
plugin_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -259,6 +287,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.0"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.17.0 <3.0.0"
|
dart: ">=2.17.0 <3.0.0"
|
||||||
flutter: ">=2.10.0"
|
flutter: ">=2.11.0-0.1.pre"
|
||||||
|
@ -64,6 +64,7 @@ flutter:
|
|||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
assets:
|
assets:
|
||||||
- document.json
|
- document.json
|
||||||
|
- example.json
|
||||||
# - images/a_dot_ham.jpeg
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'package:flowy_editor/document/path.dart';
|
import 'package:flowy_editor/document/path.dart';
|
||||||
import 'package:flowy_editor/document/text_delta.dart';
|
import 'package:flowy_editor/document/text_delta.dart';
|
||||||
|
import 'package:flowy_editor/operation/operation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import './attributes.dart';
|
import './attributes.dart';
|
||||||
|
|
||||||
@ -176,6 +177,14 @@ class TextNode extends Node {
|
|||||||
required Delta delta,
|
required Delta delta,
|
||||||
}) : _delta = delta;
|
}) : _delta = delta;
|
||||||
|
|
||||||
|
TextNode.empty()
|
||||||
|
: _delta = Delta([TextInsert('')]),
|
||||||
|
super(
|
||||||
|
type: 'text',
|
||||||
|
children: LinkedList(),
|
||||||
|
attributes: {},
|
||||||
|
);
|
||||||
|
|
||||||
Delta get delta {
|
Delta get delta {
|
||||||
return _delta;
|
return _delta;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:flowy_editor/render/rich_text/flowy_rich_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';
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ class ApplyOptions {
|
|||||||
class EditorState {
|
class EditorState {
|
||||||
final StateTree document;
|
final StateTree document;
|
||||||
final RenderPlugins renderPlugins;
|
final RenderPlugins renderPlugins;
|
||||||
|
|
||||||
List<Node> selectedNodes = [];
|
List<Node> selectedNodes = [];
|
||||||
|
|
||||||
// Service reference.
|
// Service reference.
|
||||||
@ -54,6 +56,8 @@ class EditorState {
|
|||||||
required this.document,
|
required this.document,
|
||||||
required this.renderPlugins,
|
required this.renderPlugins,
|
||||||
}) {
|
}) {
|
||||||
|
// FIXME: abstract render plugins as a service.
|
||||||
|
renderPlugins.register('text', RichTextNodeWidgetBuilder.create);
|
||||||
undoManager.state = this;
|
undoManager.state = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flowy_editor/document/node.dart';
|
import 'package:flowy_editor/document/node.dart';
|
||||||
import 'package:flowy_editor/document/selection.dart';
|
import 'package:flowy_editor/document/selection.dart';
|
||||||
import 'package:flowy_editor/extensions/object_extensions.dart';
|
import 'package:flowy_editor/extensions/object_extensions.dart';
|
||||||
|
@ -22,4 +22,15 @@ extension PathExtensions on Path {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Path get next {
|
||||||
|
Path nextPath = Path.from(this, growable: true);
|
||||||
|
if (isEmpty) {
|
||||||
|
return nextPath;
|
||||||
|
}
|
||||||
|
final last = nextPath.last;
|
||||||
|
return nextPath
|
||||||
|
..removeLast()
|
||||||
|
..add(last + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,3 +12,5 @@ export 'package:flowy_editor/operation/transaction_builder.dart';
|
|||||||
export 'package:flowy_editor/operation/operation.dart';
|
export 'package:flowy_editor/operation/operation.dart';
|
||||||
export 'package:flowy_editor/editor_state.dart';
|
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/position.dart';
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/svg.dart';
|
||||||
|
|
||||||
|
class FlowySvg extends StatelessWidget {
|
||||||
|
const FlowySvg({
|
||||||
|
Key? key,
|
||||||
|
this.name,
|
||||||
|
this.size = const Size(20, 20),
|
||||||
|
this.color,
|
||||||
|
this.number,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final String? name;
|
||||||
|
final Size size;
|
||||||
|
final Color? color;
|
||||||
|
final int? number;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (name != null) {
|
||||||
|
return SizedBox.fromSize(
|
||||||
|
size: size,
|
||||||
|
child: SvgPicture.asset(
|
||||||
|
'assets/images/$name.svg',
|
||||||
|
color: color,
|
||||||
|
package: 'flowy_editor',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (number != null) {
|
||||||
|
final numberText =
|
||||||
|
'<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg"><text x="30" y="150" fill="black" font-size="160">$number.</text></svg>';
|
||||||
|
return SizedBox.fromSize(
|
||||||
|
size: size,
|
||||||
|
child: SvgPicture.string(numberText),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
}
|
@ -128,6 +128,8 @@ Path transformPath(Path preInsertPath, Path b, [int delta = 1]) {
|
|||||||
final bAtIndex = b[preInsertPath.length - 1];
|
final bAtIndex = b[preInsertPath.length - 1];
|
||||||
if (preInsertLast <= bAtIndex) {
|
if (preInsertLast <= bAtIndex) {
|
||||||
prefix.add(bAtIndex + delta);
|
prefix.add(bAtIndex + delta);
|
||||||
|
} else {
|
||||||
|
prefix.add(bAtIndex);
|
||||||
}
|
}
|
||||||
prefix.addAll(suffix);
|
prefix.addAll(suffix);
|
||||||
return prefix;
|
return prefix;
|
||||||
|
@ -26,14 +26,14 @@ class NodeWidgetBuilder<T extends Node> {
|
|||||||
/// Render the current [Node]
|
/// Render the current [Node]
|
||||||
/// and the layout style of [Node.Children].
|
/// and the layout style of [Node.Children].
|
||||||
Widget build(
|
Widget build(
|
||||||
BuildContext buildContext,
|
BuildContext context,
|
||||||
) =>
|
) =>
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
|
|
||||||
/// TODO: refactore this part.
|
/// TODO: refactore this part.
|
||||||
/// return widget embeded with ChangeNotifier and widget itself.
|
/// return widget embedded with ChangeNotifier and widget itself.
|
||||||
Widget call(
|
Widget call(
|
||||||
BuildContext buildContext,
|
BuildContext context,
|
||||||
) {
|
) {
|
||||||
/// TODO: Validate the node
|
/// TODO: Validate the node
|
||||||
/// if failed, stop call build function,
|
/// if failed, stop call build function,
|
||||||
@ -43,21 +43,21 @@ class NodeWidgetBuilder<T extends Node> {
|
|||||||
'Node validate failure, node = { type: ${node.type}, attributes: ${node.attributes} }');
|
'Node validate failure, node = { type: ${node.type}, attributes: ${node.attributes} }');
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildNodeChangeNotifier(buildContext);
|
return _build(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNodeChangeNotifier(BuildContext buildContext) {
|
Widget _build(BuildContext context) {
|
||||||
return ChangeNotifierProvider.value(
|
|
||||||
value: node,
|
|
||||||
builder: (_, __) => Consumer<T>(
|
|
||||||
builder: ((context, value, child) {
|
|
||||||
debugPrint('Node changed, and rebuilding...');
|
|
||||||
return CompositedTransformTarget(
|
return CompositedTransformTarget(
|
||||||
link: node.layerLink,
|
link: node.layerLink,
|
||||||
child: build(context),
|
child: ChangeNotifierProvider.value(
|
||||||
);
|
value: node,
|
||||||
|
builder: (context, child) => Consumer<T>(
|
||||||
|
builder: ((context, value, child) {
|
||||||
|
debugPrint('Node is rebuilding...');
|
||||||
|
return build(context);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,282 @@
|
|||||||
|
import 'package:flowy_editor/document/node.dart';
|
||||||
|
import 'package:flowy_editor/document/position.dart';
|
||||||
|
import 'package:flowy_editor/document/selection.dart';
|
||||||
|
import 'package:flowy_editor/document/text_delta.dart';
|
||||||
|
import 'package:flowy_editor/editor_state.dart';
|
||||||
|
import 'package:flowy_editor/document/path.dart';
|
||||||
|
import 'package:flowy_editor/operation/transaction_builder.dart';
|
||||||
|
import 'package:flowy_editor/render/node_widget_builder.dart';
|
||||||
|
import 'package:flowy_editor/render/render_plugins.dart';
|
||||||
|
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
|
||||||
|
import 'package:flowy_editor/infra/flowy_svg.dart';
|
||||||
|
import 'package:flowy_editor/extensions/object_extensions.dart';
|
||||||
|
import 'package:flowy_editor/render/selection/selectable.dart';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
|
class RichTextNodeWidgetBuilder extends NodeWidgetBuilder {
|
||||||
|
RichTextNodeWidgetBuilder.create({
|
||||||
|
required super.editorState,
|
||||||
|
required super.node,
|
||||||
|
required super.key,
|
||||||
|
}) : super.create();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FlowyRichText(
|
||||||
|
key: key,
|
||||||
|
textNode: node as TextNode,
|
||||||
|
editorState: editorState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlowyRichText extends StatefulWidget {
|
||||||
|
const FlowyRichText({
|
||||||
|
Key? key,
|
||||||
|
this.cursorHeight,
|
||||||
|
this.cursorWidth = 2.0,
|
||||||
|
required this.textNode,
|
||||||
|
required this.editorState,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final double? cursorHeight;
|
||||||
|
final double cursorWidth;
|
||||||
|
final TextNode textNode;
|
||||||
|
final EditorState editorState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FlowyRichText> createState() => _FlowyRichTextState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
|
||||||
|
final _textKey = GlobalKey();
|
||||||
|
final _decorationKey = GlobalKey();
|
||||||
|
|
||||||
|
EditorState get _editorState => widget.editorState;
|
||||||
|
TextNode get _textNode => widget.textNode;
|
||||||
|
RenderParagraph get _renderParagraph =>
|
||||||
|
_textKey.currentContext?.findRenderObject() as RenderParagraph;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final attributes = _textNode.attributes;
|
||||||
|
// TODO: use factory method ??
|
||||||
|
if (attributes.list == 'todo') {
|
||||||
|
return _buildTodoListRichText(context);
|
||||||
|
} else if (attributes.list == 'bullet') {
|
||||||
|
return _buildBulletedListRichText(context);
|
||||||
|
} else if (attributes.quote == true) {
|
||||||
|
return _buildQuotedRichText(context);
|
||||||
|
} else if (attributes.heading != null) {
|
||||||
|
return _buildHeadingRichText(context);
|
||||||
|
} else if (attributes.number != null) {
|
||||||
|
return _buildNumberListRichText(context);
|
||||||
|
}
|
||||||
|
return _buildRichText(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Position start() => Position(path: _textNode.path, offset: 0);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Position end() =>
|
||||||
|
Position(path: _textNode.path, offset: _textNode.toRawString().length);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Rect getCursorRectInPosition(Position position) {
|
||||||
|
final textPosition = TextPosition(offset: position.offset);
|
||||||
|
final baseRect = frontWidgetRect();
|
||||||
|
final cursorOffset =
|
||||||
|
_renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
|
||||||
|
final cursorHeight = widget.cursorHeight ??
|
||||||
|
_renderParagraph.getFullHeightForCaret(textPosition) ??
|
||||||
|
5.0; // default height
|
||||||
|
return Rect.fromLTWH(
|
||||||
|
baseRect.centerRight.dx + cursorOffset.dx - (widget.cursorWidth / 2),
|
||||||
|
cursorOffset.dy,
|
||||||
|
widget.cursorWidth,
|
||||||
|
cursorHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Position getPositionInOffset(Offset start) {
|
||||||
|
final offset = _renderParagraph.globalToLocal(start);
|
||||||
|
final baseOffset = _renderParagraph.getPositionForOffset(offset).offset;
|
||||||
|
return Position(path: _textNode.path, offset: baseOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Rect> getRectsInSelection(Selection selection) {
|
||||||
|
assert(pathEquals(selection.start.path, selection.end.path) &&
|
||||||
|
pathEquals(selection.start.path, _textNode.path));
|
||||||
|
|
||||||
|
final textSelection = TextSelection(
|
||||||
|
baseOffset: selection.start.offset,
|
||||||
|
extentOffset: selection.end.offset,
|
||||||
|
);
|
||||||
|
final baseRect = frontWidgetRect();
|
||||||
|
return _renderParagraph.getBoxesForSelection(textSelection).map((box) {
|
||||||
|
final rect = box.toRect();
|
||||||
|
return rect.translate(baseRect.centerRight.dx, 0);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Selection getSelectionInRange(Offset start, Offset end) {
|
||||||
|
final localStart = _renderParagraph.globalToLocal(start);
|
||||||
|
final localEnd = _renderParagraph.globalToLocal(end);
|
||||||
|
final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset;
|
||||||
|
final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset;
|
||||||
|
return Selection.single(
|
||||||
|
path: _textNode.path,
|
||||||
|
startOffset: baseOffset,
|
||||||
|
endOffset: extentOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRichText(BuildContext context) {
|
||||||
|
if (_textNode.children.isEmpty) {
|
||||||
|
return _buildSingleRichText(context);
|
||||||
|
} else {
|
||||||
|
return _buildRichTextWithChildren(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRichTextWithChildren(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildSingleRichText(context),
|
||||||
|
..._textNode.children
|
||||||
|
.map(
|
||||||
|
(child) => _editorState.renderPlugins.buildWidget(
|
||||||
|
context: NodeWidgetContext(
|
||||||
|
buildContext: context,
|
||||||
|
node: child,
|
||||||
|
editorState: _editorState,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSingleRichText(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width:
|
||||||
|
MediaQuery.of(context).size.width - 20, // FIXME: use the const value
|
||||||
|
child: RichText(key: _textKey, text: _decorateTextSpanWithGlobalStyle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTodoListRichText(BuildContext context) {
|
||||||
|
final name = _textNode.attributes.todo ? 'check' : 'uncheck';
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
child: FlowySvg(
|
||||||
|
key: _decorationKey,
|
||||||
|
name: name,
|
||||||
|
),
|
||||||
|
onTap: () => TransactionBuilder(_editorState)
|
||||||
|
..updateNode(_textNode, {
|
||||||
|
'todo': !_textNode.attributes.todo,
|
||||||
|
})
|
||||||
|
..commit(),
|
||||||
|
),
|
||||||
|
_buildRichText(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBulletedListRichText(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
FlowySvg(
|
||||||
|
key: _decorationKey,
|
||||||
|
name: 'point',
|
||||||
|
),
|
||||||
|
_buildRichText(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNumberListRichText(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
FlowySvg(
|
||||||
|
key: _decorationKey,
|
||||||
|
number: _textNode.attributes.number,
|
||||||
|
),
|
||||||
|
_buildRichText(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuotedRichText(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
FlowySvg(
|
||||||
|
key: _decorationKey,
|
||||||
|
name: 'quote',
|
||||||
|
),
|
||||||
|
_buildRichText(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeadingRichText(BuildContext context) {
|
||||||
|
// TODO: customize
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Padding(padding: EdgeInsets.only(top: 5)),
|
||||||
|
_buildRichText(context),
|
||||||
|
const Padding(padding: EdgeInsets.only(top: 5)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect frontWidgetRect() {
|
||||||
|
// FIXME: find a more elegant way to solve this situation.
|
||||||
|
final renderBox = _decorationKey.currentContext
|
||||||
|
?.findRenderObject()
|
||||||
|
?.unwrapOrNull<RenderBox>();
|
||||||
|
if (renderBox != null) {
|
||||||
|
return renderBox.localToGlobal(Offset.zero) & renderBox.size;
|
||||||
|
}
|
||||||
|
return Rect.zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextSpan get _decorateTextSpanWithGlobalStyle => TextSpan(
|
||||||
|
children: _textSpan.children
|
||||||
|
?.whereType<TextSpan>()
|
||||||
|
.map(
|
||||||
|
(span) => TextSpan(
|
||||||
|
text: span.text,
|
||||||
|
style: span.style?.copyWith(
|
||||||
|
fontSize: _textNode.attributes.fontSize,
|
||||||
|
color: _textNode.attributes.quoteColor,
|
||||||
|
),
|
||||||
|
recognizer: span.recognizer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
TextSpan get _textSpan => TextSpan(
|
||||||
|
children: _textNode.delta.operations
|
||||||
|
.whereType<TextInsert>()
|
||||||
|
.map((insert) => RichTextStyle(
|
||||||
|
attributes: insert.attributes ?? {},
|
||||||
|
text: insert.content,
|
||||||
|
).toTextSpan())
|
||||||
|
.toList(growable: false));
|
||||||
|
}
|
@ -0,0 +1,231 @@
|
|||||||
|
import 'package:flowy_editor/document/attributes.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Supported partial rendering types:
|
||||||
|
/// bold, italic,
|
||||||
|
/// underline, strikethrough,
|
||||||
|
/// color, font,
|
||||||
|
/// href
|
||||||
|
///
|
||||||
|
/// Supported global rendering types:
|
||||||
|
/// heading: h1, h2, h3, h4, h5, h6, ...
|
||||||
|
/// block quote,
|
||||||
|
/// list: ordered list, bulleted list,
|
||||||
|
/// code block
|
||||||
|
///
|
||||||
|
class StyleKey {
|
||||||
|
static String bold = 'bold';
|
||||||
|
static String italic = 'italic';
|
||||||
|
static String underline = 'underline';
|
||||||
|
static String strikethrough = 'strikethrough';
|
||||||
|
static String color = 'color';
|
||||||
|
static String highlightColor = 'highlightColor';
|
||||||
|
static String font = 'font';
|
||||||
|
static String href = 'href';
|
||||||
|
|
||||||
|
static String heading = 'heading';
|
||||||
|
static String quote = 'quote';
|
||||||
|
static String list = 'list';
|
||||||
|
static String number = 'number';
|
||||||
|
static String todo = 'todo';
|
||||||
|
static String code = 'code';
|
||||||
|
}
|
||||||
|
|
||||||
|
double baseFontSize = 16.0;
|
||||||
|
// TODO: customize.
|
||||||
|
Map<String, double> headingToFontSize = {
|
||||||
|
'h1': baseFontSize + 15,
|
||||||
|
'h2': baseFontSize + 12,
|
||||||
|
'h3': baseFontSize + 9,
|
||||||
|
'h4': baseFontSize + 6,
|
||||||
|
'h5': baseFontSize + 3,
|
||||||
|
'h6': baseFontSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
extension NodeAttributesExtensions on Attributes {
|
||||||
|
String? get heading {
|
||||||
|
if (containsKey(StyleKey.heading) && this[StyleKey.heading] is String) {
|
||||||
|
return this[StyleKey.heading];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get fontSize {
|
||||||
|
if (heading != null) {
|
||||||
|
return headingToFontSize[heading]!;
|
||||||
|
}
|
||||||
|
return baseFontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get quote {
|
||||||
|
if (containsKey(StyleKey.quote) && this[StyleKey.quote] == true) {
|
||||||
|
return this[StyleKey.quote];
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? get quoteColor {
|
||||||
|
if (quote) {
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get list {
|
||||||
|
if (containsKey(StyleKey.list) && this[StyleKey.list] is String) {
|
||||||
|
return this[StyleKey.list];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? get number {
|
||||||
|
if (containsKey(StyleKey.number) && this[StyleKey.number] is int) {
|
||||||
|
return this[StyleKey.number];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get todo {
|
||||||
|
if (containsKey(StyleKey.todo) && this[StyleKey.todo] is bool) {
|
||||||
|
return this[StyleKey.todo];
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get code {
|
||||||
|
if (containsKey(StyleKey.code) && this[StyleKey.code] == true) {
|
||||||
|
return this[StyleKey.code];
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DeltaAttributesExtensions on Attributes {
|
||||||
|
bool get bold {
|
||||||
|
return (containsKey(StyleKey.bold) && this[StyleKey.bold] == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get italic {
|
||||||
|
return (containsKey(StyleKey.italic) && this[StyleKey.italic] == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get underline {
|
||||||
|
return (containsKey(StyleKey.underline) &&
|
||||||
|
this[StyleKey.underline] == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get strikethrough {
|
||||||
|
return (containsKey(StyleKey.strikethrough) &&
|
||||||
|
this[StyleKey.strikethrough] == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? get color {
|
||||||
|
if (containsKey(StyleKey.color) && this[StyleKey.color] is String) {
|
||||||
|
return Color(
|
||||||
|
int.parse(this[StyleKey.color]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? get hightlightColor {
|
||||||
|
if (containsKey(StyleKey.highlightColor) &&
|
||||||
|
this[StyleKey.highlightColor] is String) {
|
||||||
|
return Color(
|
||||||
|
int.parse(this[StyleKey.highlightColor]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get font {
|
||||||
|
// TODO: unspport now.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get href {
|
||||||
|
if (containsKey(StyleKey.href) && this[StyleKey.href] is String) {
|
||||||
|
return this[StyleKey.href];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RichTextStyle {
|
||||||
|
// TODO: customize
|
||||||
|
RichTextStyle({
|
||||||
|
required this.attributes,
|
||||||
|
required this.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Attributes attributes;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
TextSpan toTextSpan() {
|
||||||
|
return TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
fontStyle: fontStyle,
|
||||||
|
fontSize: fontSize,
|
||||||
|
color: textColor,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
decoration: textDecoration,
|
||||||
|
),
|
||||||
|
recognizer: recognizer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// bold
|
||||||
|
FontWeight get fontWeight {
|
||||||
|
if (attributes.bold) {
|
||||||
|
return FontWeight.bold;
|
||||||
|
}
|
||||||
|
return FontWeight.normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// underline or strikethrough
|
||||||
|
TextDecoration get textDecoration {
|
||||||
|
if (attributes.underline || attributes.href != null) {
|
||||||
|
return TextDecoration.underline;
|
||||||
|
} else if (attributes.strikethrough) {
|
||||||
|
return TextDecoration.lineThrough;
|
||||||
|
}
|
||||||
|
return TextDecoration.none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// font
|
||||||
|
FontStyle get fontStyle =>
|
||||||
|
attributes.italic ? FontStyle.italic : FontStyle.normal;
|
||||||
|
|
||||||
|
// text color
|
||||||
|
Color get textColor {
|
||||||
|
if (attributes.href != null) {
|
||||||
|
return Colors.lightBlue;
|
||||||
|
}
|
||||||
|
return attributes.color ?? Colors.black;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color get backgroundColor {
|
||||||
|
return attributes.hightlightColor ?? Colors.transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// font size
|
||||||
|
double get fontSize {
|
||||||
|
return baseFontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// recognizer
|
||||||
|
GestureRecognizer? get recognizer {
|
||||||
|
final href = attributes.href;
|
||||||
|
if (href != null) {
|
||||||
|
return TapGestureRecognizer()
|
||||||
|
..onTap = () async {
|
||||||
|
// FIXME: launch the url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@ -11,7 +11,7 @@ class CursorWidget extends StatefulWidget {
|
|||||||
this.blinkingInterval = 0.5,
|
this.blinkingInterval = 0.5,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final double blinkingInterval;
|
final double blinkingInterval; // milliseconds
|
||||||
final Color color;
|
final Color color;
|
||||||
final Rect rect;
|
final Rect rect;
|
||||||
final LayerLink layerLink;
|
final LayerLink layerLink;
|
||||||
|
@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
///
|
///
|
||||||
mixin Selectable<T extends StatefulWidget> on State<T> {
|
mixin Selectable<T extends StatefulWidget> on State<T> {
|
||||||
/// Returns a [List] of the [Rect] selection sorrounded by start and end
|
/// Returns a [List] of the [Rect] selection surrounded by start and end
|
||||||
/// in current widget.
|
/// in current widget.
|
||||||
///
|
///
|
||||||
/// [start] and [end] are the offsets under the global coordinate system.
|
/// [start] and [end] are the offsets under the global coordinate system.
|
||||||
@ -34,12 +34,5 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
|
|||||||
///
|
///
|
||||||
/// Only the widget rendered by [TextNode] need to implement the detail,
|
/// Only the widget rendered by [TextNode] need to implement the detail,
|
||||||
/// and the rest can return null.
|
/// and the rest can return null.
|
||||||
TextSelection? getCurrentTextSelection() => null;
|
TextSelection? getTextSelectionInSelection(Selection selection) => null;
|
||||||
|
|
||||||
/// For [TextNode] only.
|
|
||||||
///
|
|
||||||
/// Retruns a [Offset].
|
|
||||||
/// Only the widget rendered by [TextNode] need to implement the detail,
|
|
||||||
/// and the rest can return [Offset.zero].
|
|
||||||
Offset getOffsetByTextSelection(TextSelection textSelection) => Offset.zero;
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
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/internal_key_event_handlers/enter_in_edge_of_text_node_handler.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';
|
||||||
@ -36,6 +38,9 @@ class _FlowyEditorState extends State<FlowyEditor> {
|
|||||||
return FlowySelection(
|
return FlowySelection(
|
||||||
key: editorState.service.selectionServiceKey,
|
key: editorState.service.selectionServiceKey,
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
|
child: FlowyInput(
|
||||||
|
key: editorState.service.inputServiceKey,
|
||||||
|
editorState: editorState,
|
||||||
child: FlowyKeyboard(
|
child: FlowyKeyboard(
|
||||||
key: editorState.service.keyboardServiceKey,
|
key: editorState.service.keyboardServiceKey,
|
||||||
handlers: [
|
handlers: [
|
||||||
@ -43,6 +48,7 @@ class _FlowyEditorState extends State<FlowyEditor> {
|
|||||||
flowyDeleteNodesHandler,
|
flowyDeleteNodesHandler,
|
||||||
deleteSingleTextNodeHandler,
|
deleteSingleTextNodeHandler,
|
||||||
arrowKeysHandler,
|
arrowKeysHandler,
|
||||||
|
enterInEdgeOfTextNodeHandler,
|
||||||
...widget.keyEventHandlers,
|
...widget.keyEventHandlers,
|
||||||
],
|
],
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
@ -54,6 +60,7 @@ class _FlowyEditorState extends State<FlowyEditor> {
|
|||||||
child: editorState.build(context),
|
child: editorState.build(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,193 @@
|
|||||||
|
import 'package:flowy_editor/document/position.dart';
|
||||||
|
import 'package:flowy_editor/document/selection.dart';
|
||||||
|
import 'package:flowy_editor/editor_state.dart';
|
||||||
|
import 'package:flowy_editor/document/node.dart';
|
||||||
|
import 'package:flowy_editor/operation/transaction_builder.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
mixin FlowyInputService {
|
||||||
|
void attach(TextEditingValue textEditingValue);
|
||||||
|
void setTextEditingValue(TextEditingValue textEditingValue);
|
||||||
|
void apply(List<TextEditingDelta> deltas);
|
||||||
|
void close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// process input
|
||||||
|
class FlowyInput extends StatefulWidget {
|
||||||
|
const FlowyInput({
|
||||||
|
Key? key,
|
||||||
|
required this.editorState,
|
||||||
|
required this.child,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final EditorState editorState;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FlowyInput> createState() => _FlowyInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FlowyInputState extends State<FlowyInput>
|
||||||
|
with FlowyInputService
|
||||||
|
implements DeltaTextInputClient {
|
||||||
|
TextInputConnection? _textInputConnection;
|
||||||
|
|
||||||
|
EditorState get _editorState => widget.editorState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_editorState.service.selectionService.currentSelectedNodes
|
||||||
|
.addListener(_onSelectedNodesChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_editorState.service.selectionService.currentSelectedNodes
|
||||||
|
.removeListener(_onSelectedNodesChange);
|
||||||
|
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void attach(TextEditingValue textEditingValue) {
|
||||||
|
if (_textInputConnection != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_textInputConnection = TextInput.attach(
|
||||||
|
this,
|
||||||
|
const TextInputConfiguration(
|
||||||
|
// TODO: customize
|
||||||
|
enableDeltaModel: true,
|
||||||
|
inputType: TextInputType.multiline,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_textInputConnection
|
||||||
|
?..show()
|
||||||
|
..setEditingState(textEditingValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setTextEditingValue(TextEditingValue textEditingValue) {
|
||||||
|
assert(_textInputConnection != null,
|
||||||
|
'Must call `attach` before set textEditingValue');
|
||||||
|
if (_textInputConnection != null) {
|
||||||
|
_textInputConnection?.setEditingState(textEditingValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void apply(List<TextEditingDelta> deltas) {
|
||||||
|
// TODO: implement the detail
|
||||||
|
for (final delta in deltas) {
|
||||||
|
if (delta is TextEditingDeltaInsertion) {
|
||||||
|
} else if (delta is TextEditingDeltaDeletion) {
|
||||||
|
} else if (delta is TextEditingDeltaReplacement) {
|
||||||
|
} else if (delta is TextEditingDeltaNonTextUpdate) {
|
||||||
|
// We don't need to care the [TextEditingDeltaNonTextUpdate].
|
||||||
|
// Do nothing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() {
|
||||||
|
_textInputConnection?.close();
|
||||||
|
_textInputConnection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void connectionClosed() {
|
||||||
|
// TODO: implement connectionClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// TODO: implement currentAutofillScope
|
||||||
|
AutofillScope? get currentAutofillScope => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
// TODO: implement currentTextEditingValue
|
||||||
|
TextEditingValue? get currentTextEditingValue => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void insertTextPlaceholder(Size size) {
|
||||||
|
// TODO: implement insertTextPlaceholder
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void performAction(TextInputAction action) {
|
||||||
|
// TODO: implement performAction
|
||||||
|
}
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
// TODO: implement updateEditingValue
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
|
||||||
|
debugPrint(textEditingDeltas.map((delta) => delta.toString()).toString());
|
||||||
|
|
||||||
|
apply(textEditingDeltas);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateFloatingCursor(RawFloatingCursorPoint point) {
|
||||||
|
// TODO: implement updateFloatingCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelectedNodesChange() {
|
||||||
|
final nodes =
|
||||||
|
_editorState.service.selectionService.currentSelectedNodes.value;
|
||||||
|
final selection = _editorState.service.selectionService.currentSelection;
|
||||||
|
// FIXME: upward.
|
||||||
|
if (nodes.isNotEmpty && selection != null) {
|
||||||
|
final textNodes = nodes.whereType<TextNode>();
|
||||||
|
final text = textNodes.fold<String>(
|
||||||
|
'', (sum, textNode) => '$sum${textNode.toRawString()}\n');
|
||||||
|
attach(
|
||||||
|
TextEditingValue(
|
||||||
|
text: text,
|
||||||
|
selection: TextSelection(
|
||||||
|
baseOffset: selection.start.offset,
|
||||||
|
extentOffset: selection.end.offset,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -12,58 +12,58 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
final selectionNodes = editorState.selectedNodes;
|
// final selectionNodes = editorState.selectedNodes;
|
||||||
if (selectionNodes.length == 1 && selectionNodes.first is TextNode) {
|
// if (selectionNodes.length == 1 && selectionNodes.first is TextNode) {
|
||||||
final node = selectionNodes.first.unwrapOrNull<TextNode>();
|
// final node = selectionNodes.first.unwrapOrNull<TextNode>();
|
||||||
final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
|
// final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
|
||||||
if (selectable != null) {
|
// if (selectable != null) {
|
||||||
final textSelection = selectable.getCurrentTextSelection();
|
// final textSelection = selectable.getCurrentTextSelection();
|
||||||
if (textSelection != null) {
|
// if (textSelection != null) {
|
||||||
if (textSelection.isCollapsed) {
|
// if (textSelection.isCollapsed) {
|
||||||
/// Three cases:
|
// /// Three cases:
|
||||||
/// Delete the zero character,
|
// /// Delete the zero character,
|
||||||
/// 1. if there is still text node in front of it, then merge them.
|
// /// 1. if there is still text node in front of it, then merge them.
|
||||||
/// 2. if not, just ignore
|
// /// 2. if not, just ignore
|
||||||
/// Delete the non-zero character,
|
// /// Delete the non-zero character,
|
||||||
/// 3. delete the single character.
|
// /// 3. delete the single character.
|
||||||
if (textSelection.baseOffset == 0) {
|
// if (textSelection.baseOffset == 0) {
|
||||||
if (node?.previous != null && node?.previous is TextNode) {
|
// if (node?.previous != null && node?.previous is TextNode) {
|
||||||
final previous = node!.previous! as TextNode;
|
// final previous = node!.previous! as TextNode;
|
||||||
final newTextSelection = TextSelection.collapsed(
|
// final newTextSelection = TextSelection.collapsed(
|
||||||
offset: previous.toRawString().length);
|
// offset: previous.toRawString().length);
|
||||||
final selectionService = editorState.service.selectionService;
|
// final selectionService = editorState.service.selectionService;
|
||||||
final previousSelectable =
|
// final previousSelectable =
|
||||||
previous.key?.currentState?.unwrapOrNull<Selectable>();
|
// previous.key?.currentState?.unwrapOrNull<Selectable>();
|
||||||
final newOfset = previousSelectable
|
// final newOfset = previousSelectable
|
||||||
?.getOffsetByTextSelection(newTextSelection);
|
// ?.getOffsetByTextSelection(newTextSelection);
|
||||||
if (newOfset != null) {
|
// if (newOfset != null) {
|
||||||
// selectionService.updateCursor(newOfset);
|
// // selectionService.updateCursor(newOfset);
|
||||||
}
|
// }
|
||||||
// merge
|
// // merge
|
||||||
TransactionBuilder(editorState)
|
// TransactionBuilder(editorState)
|
||||||
..deleteNode(node)
|
// ..deleteNode(node)
|
||||||
..insertText(
|
// ..insertText(
|
||||||
previous, previous.toRawString().length, node.toRawString())
|
// previous, previous.toRawString().length, node.toRawString())
|
||||||
..commit();
|
// ..commit();
|
||||||
return KeyEventResult.handled;
|
// return KeyEventResult.handled;
|
||||||
} else {
|
// } else {
|
||||||
return KeyEventResult.ignored;
|
// return KeyEventResult.ignored;
|
||||||
}
|
// }
|
||||||
} else {
|
// } else {
|
||||||
TransactionBuilder(editorState)
|
// TransactionBuilder(editorState)
|
||||||
..deleteText(node!, textSelection.baseOffset - 1, 1)
|
// ..deleteText(node!, textSelection.baseOffset - 1, 1)
|
||||||
..commit();
|
// ..commit();
|
||||||
final newTextSelection =
|
// final newTextSelection =
|
||||||
TextSelection.collapsed(offset: textSelection.baseOffset - 1);
|
// TextSelection.collapsed(offset: textSelection.baseOffset - 1);
|
||||||
final selectionService = editorState.service.selectionService;
|
// final selectionService = editorState.service.selectionService;
|
||||||
final newOfset =
|
// final newOfset =
|
||||||
selectable.getOffsetByTextSelection(newTextSelection);
|
// selectable.getOffsetByTextSelection(newTextSelection);
|
||||||
// selectionService.updateCursor(newOfset);
|
// // selectionService.updateCursor(newOfset);
|
||||||
return KeyEventResult.handled;
|
// return KeyEventResult.handled;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flowy_editor/document/node.dart';
|
||||||
|
import 'package:flowy_editor/document/position.dart';
|
||||||
|
import 'package:flowy_editor/document/selection.dart';
|
||||||
|
import 'package:flowy_editor/operation/transaction_builder.dart';
|
||||||
|
import 'package:flowy_editor/service/keyboard_service.dart';
|
||||||
|
import 'package:flowy_editor/extensions/path_extensions.dart';
|
||||||
|
import 'package:flowy_editor/extensions/node_extensions.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
FlowyKeyEventHandler enterInEdgeOfTextNodeHandler = (editorState, event) {
|
||||||
|
if (event.logicalKey != LogicalKeyboardKey.enter) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nodes = editorState.service.selectionService.currentSelectedNodes.value;
|
||||||
|
final selection = editorState.service.selectionService.currentSelection;
|
||||||
|
if (selection == null ||
|
||||||
|
nodes.length != 1 ||
|
||||||
|
nodes.first is! TextNode ||
|
||||||
|
!selection.isCollapsed) {
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
final textNode = nodes.first as TextNode;
|
||||||
|
|
||||||
|
if (textNode.selectable!.end() == selection.end) {
|
||||||
|
TransactionBuilder(editorState)
|
||||||
|
..insertNode(
|
||||||
|
textNode.path.next,
|
||||||
|
TextNode.empty(),
|
||||||
|
)
|
||||||
|
..commit();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
} else if (textNode.selectable!.start() == selection.start) {
|
||||||
|
TransactionBuilder(editorState)
|
||||||
|
..insertNode(
|
||||||
|
textNode.path,
|
||||||
|
TextNode.empty(),
|
||||||
|
)
|
||||||
|
..commit();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
};
|
@ -1,6 +1,4 @@
|
|||||||
import 'package:flowy_editor/flowy_editor.dart';
|
|
||||||
import 'package:flowy_editor/service/keyboard_service.dart';
|
import 'package:flowy_editor/service/keyboard_service.dart';
|
||||||
import 'package:flowy_editor/extensions/object_extensions.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
@ -10,21 +8,5 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
final selectedNodes = editorState.selectedNodes;
|
|
||||||
if (selectedNodes.length != 1) {
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
final textNode = selectedNodes.first.unwrapOrNull<TextNode>();
|
|
||||||
final selectable = textNode?.key?.currentState?.unwrapOrNull<Selectable>();
|
|
||||||
final textSelection = selectable?.getCurrentTextSelection();
|
|
||||||
// if (textNode != null && selectable != null && textSelection != null) {
|
|
||||||
// final offset = selectable.getOffsetByTextSelection(textSelection);
|
|
||||||
// final rect = selectable.getCursorRect(offset);
|
|
||||||
// editorState.service.floatingToolbarService
|
|
||||||
// .showInOffset(rect.topLeft, textNode.layerLink);
|
|
||||||
// return KeyEventResult.handled;
|
|
||||||
// }
|
|
||||||
|
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
};
|
};
|
||||||
|
@ -17,7 +17,8 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
|
|||||||
/// Returns the currently selected [Node]s.
|
/// Returns the currently selected [Node]s.
|
||||||
///
|
///
|
||||||
/// The order of the return is determined according to the selected order.
|
/// The order of the return is determined according to the selected order.
|
||||||
List<Node> get currentSelectedNodes;
|
ValueNotifier<List<Node>> get currentSelectedNodes;
|
||||||
|
Selection? get currentSelection;
|
||||||
|
|
||||||
/// ------------------ Selection ------------------------
|
/// ------------------ Selection ------------------------
|
||||||
|
|
||||||
@ -99,7 +100,7 @@ class FlowySelection extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FlowySelectionState extends State<FlowySelection>
|
class _FlowySelectionState extends State<FlowySelection>
|
||||||
with FlowySelectionService {
|
with FlowySelectionService, WidgetsBindingObserver {
|
||||||
final _cursorKey = GlobalKey(debugLabel: 'cursor');
|
final _cursorKey = GlobalKey(debugLabel: 'cursor');
|
||||||
|
|
||||||
final List<OverlayEntry> _selectionOverlays = [];
|
final List<OverlayEntry> _selectionOverlays = [];
|
||||||
@ -118,12 +119,37 @@ class _FlowySelectionState extends State<FlowySelection>
|
|||||||
EditorState get editorState => widget.editorState;
|
EditorState get editorState => widget.editorState;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Node> currentSelectedNodes = [];
|
Selection? currentSelection;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ValueNotifier<List<Node>> currentSelectedNodes = ValueNotifier([]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Node> getNodesInSelection(Selection selection) =>
|
List<Node> getNodesInSelection(Selection selection) =>
|
||||||
_selectedNodesInSelection(editorState.document.root, selection);
|
_selectedNodesInSelection(editorState.document.root, selection);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeMetrics() {
|
||||||
|
super.didChangeMetrics();
|
||||||
|
|
||||||
|
// Need to refresh the selection when the metrics changed.
|
||||||
|
if (currentSelection != null) {
|
||||||
|
updateSelection(currentSelection!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RawGestureDetector(
|
return RawGestureDetector(
|
||||||
@ -162,8 +188,10 @@ class _FlowySelectionState extends State<FlowySelection>
|
|||||||
|
|
||||||
// cursor
|
// cursor
|
||||||
if (selection.isCollapsed) {
|
if (selection.isCollapsed) {
|
||||||
|
debugPrint('Update cursor');
|
||||||
_updateCursor(selection.start);
|
_updateCursor(selection.start);
|
||||||
} else {
|
} else {
|
||||||
|
debugPrint('Update selection');
|
||||||
_updateSelection(selection);
|
_updateSelection(selection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -295,6 +323,9 @@ class _FlowySelectionState extends State<FlowySelection>
|
|||||||
panEndOffset = details.globalPosition;
|
panEndOffset = details.globalPosition;
|
||||||
|
|
||||||
final nodes = getNodesInRange(panStartOffset!, panEndOffset!);
|
final nodes = getNodesInRange(panStartOffset!, panEndOffset!);
|
||||||
|
if (nodes.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final first = nodes.first.selectable;
|
final first = nodes.first.selectable;
|
||||||
final last = nodes.last.selectable;
|
final last = nodes.last.selectable;
|
||||||
|
|
||||||
@ -316,7 +347,8 @@ class _FlowySelectionState extends State<FlowySelection>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
currentSelectedNodes = [];
|
currentSelection = null;
|
||||||
|
currentSelectedNodes.value = [];
|
||||||
|
|
||||||
// clear selection
|
// clear selection
|
||||||
_selectionOverlays
|
_selectionOverlays
|
||||||
@ -336,7 +368,8 @@ class _FlowySelectionState extends State<FlowySelection>
|
|||||||
final nodes =
|
final nodes =
|
||||||
_selectedNodesInSelection(editorState.document.root, selection);
|
_selectedNodesInSelection(editorState.document.root, selection);
|
||||||
|
|
||||||
currentSelectedNodes = nodes;
|
currentSelection = selection;
|
||||||
|
currentSelectedNodes.value = nodes;
|
||||||
|
|
||||||
var index = 0;
|
var index = 0;
|
||||||
for (final node in nodes) {
|
for (final node in nodes) {
|
||||||
@ -404,7 +437,8 @@ class _FlowySelectionState extends State<FlowySelection>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSelectedNodes = [node];
|
currentSelection = Selection.collapsed(position);
|
||||||
|
currentSelectedNodes.value = [node];
|
||||||
|
|
||||||
final selectable = node.selectable;
|
final selectable = node.selectable;
|
||||||
final rect = selectable?.getCursorRectInPosition(position);
|
final rect = selectable?.getCursorRectInPosition(position);
|
||||||
|
@ -14,6 +14,9 @@ class FlowyService {
|
|||||||
// keyboard service
|
// keyboard service
|
||||||
final keyboardServiceKey = GlobalKey(debugLabel: 'flowy_keyboard_service');
|
final keyboardServiceKey = GlobalKey(debugLabel: 'flowy_keyboard_service');
|
||||||
|
|
||||||
|
// input service
|
||||||
|
final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service');
|
||||||
|
|
||||||
// floating shortcut service
|
// floating shortcut service
|
||||||
final floatingShortcutServiceKey =
|
final floatingShortcutServiceKey =
|
||||||
GlobalKey(debugLabel: 'flowy_floating_shortcut_service');
|
GlobalKey(debugLabel: 'flowy_floating_shortcut_service');
|
||||||
|
@ -11,6 +11,7 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
flutter_svg: ^1.1.1+1
|
||||||
provider: ^6.0.3
|
provider: ^6.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
@ -26,7 +27,8 @@ flutter:
|
|||||||
|
|
||||||
# To add assets to your package, add an assets section, like this:
|
# To add assets to your package, add an assets section, like this:
|
||||||
assets:
|
assets:
|
||||||
- document.json
|
- assets/images/uncheck.svg
|
||||||
|
- assets/images/
|
||||||
# - images/a_dot_burr.jpeg
|
# - images/a_dot_burr.jpeg
|
||||||
# - images/a_dot_ham.jpeg
|
# - images/a_dot_ham.jpeg
|
||||||
#
|
#
|
||||||
|
@ -19,6 +19,7 @@ void main() {
|
|||||||
test("transform path not changed", () {
|
test("transform path not changed", () {
|
||||||
expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]);
|
expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]);
|
||||||
expect(transformPath([0, 1, 2], [0, 1]), [0, 1]);
|
expect(transformPath([0, 1, 2], [0, 1]), [0, 1]);
|
||||||
|
expect(transformPath([1, 1], [1, 0]), [1, 0]);
|
||||||
});
|
});
|
||||||
test("transform path delta", () {
|
test("transform path delta", () {
|
||||||
expect(transformPath([0, 1], [0, 1], 5), [0, 6]);
|
expect(transformPath([0, 1], [0, 1], 5), [0, 6]);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user