mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
chore: resolve conflicts.
This commit is contained in:
commit
ec83a68602
10
frontend/.vscode/settings.json
vendored
10
frontend/.vscode/settings.json
vendored
@ -2,23 +2,21 @@
|
||||
"[dart]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
"editor.rulers": [
|
||||
120
|
||||
],
|
||||
"editor.rulers": [80],
|
||||
"editor.selectionHighlight": false,
|
||||
"editor.suggest.snippetsPreventQuickSuggestions": false,
|
||||
"editor.suggestSelection": "first",
|
||||
"editor.tabCompletion": "onlySnippets",
|
||||
"editor.wordBasedSuggestions": false
|
||||
"editor.wordBasedSuggestions": false,
|
||||
},
|
||||
"svgviewer.enableautopreview": true,
|
||||
"svgviewer.previewcolumn": "Active",
|
||||
"svgviewer.showzoominout": true,
|
||||
"editor.wordWrapColumn": 120,
|
||||
"editor.wordWrapColumn": 80,
|
||||
"editor.minimap.maxColumn": 140,
|
||||
"prettier.printWidth": 140,
|
||||
"editor.wordWrap": "wordWrapColumn",
|
||||
"dart.lineLength": 120,
|
||||
"dart.lineLength": 80,
|
||||
"files.associations": {
|
||||
"*.log.*": "log"
|
||||
},
|
||||
|
45
frontend/app_flowy/packages/flowy_editor/.vscode/launch.json
vendored
Normal file
45
frontend/app_flowy/packages/flowy_editor/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "example",
|
||||
"cwd": "example",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
},
|
||||
{
|
||||
"name": "example (profile mode)",
|
||||
"cwd": "example",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "example (release mode)",
|
||||
"cwd": "example",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
},
|
||||
{
|
||||
"name": "flowy_editor",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
},
|
||||
{
|
||||
"name": "flowy_editor (profile mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "flowy_editor (release mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
},
|
||||
]
|
||||
}
|
@ -63,6 +63,11 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Rect getCursorRectInPosition(Position position) {
|
||||
// TODO: implement getCursorRectInPosition
|
||||
|
@ -70,6 +70,11 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset) {
|
||||
return _renderParagraph.localToGlobal(offset);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(Selection selection) {
|
||||
assert(pathEquals(selection.start.path, selection.end.path));
|
||||
|
@ -32,7 +32,11 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
||||
required this.children,
|
||||
required this.attributes,
|
||||
this.parent,
|
||||
});
|
||||
}) {
|
||||
for (final child in children) {
|
||||
child.parent = this;
|
||||
}
|
||||
}
|
||||
|
||||
factory Node.fromJson(Map<String, Object> json) {
|
||||
assert(json['type'] is String);
|
||||
|
@ -38,7 +38,22 @@ class EditorState {
|
||||
final service = FlowyService();
|
||||
|
||||
final UndoManager undoManager = UndoManager();
|
||||
Selection? cursorSelection;
|
||||
Selection? _cursorSelection;
|
||||
|
||||
Selection? get cursorSelection {
|
||||
return _cursorSelection;
|
||||
}
|
||||
|
||||
/// add the set reason in the future, don't use setter
|
||||
updateCursorSelection(Selection? cursorSelection) {
|
||||
// broadcast to other users here
|
||||
if (cursorSelection == null) {
|
||||
service.selectionService.clearSelection();
|
||||
} else {
|
||||
service.selectionService.updateSelection(cursorSelection);
|
||||
}
|
||||
_cursorSelection = cursorSelection;
|
||||
}
|
||||
|
||||
Timer? _debouncedSealHistoryItemTimer;
|
||||
|
||||
@ -74,7 +89,7 @@ class EditorState {
|
||||
for (final op in transaction.operations) {
|
||||
_applyOperation(op);
|
||||
}
|
||||
cursorSelection = transaction.afterSelection;
|
||||
updateCursorSelection(transaction.afterSelection);
|
||||
|
||||
if (options.recordUndo) {
|
||||
final undoItem = undoManager.getUndoHistoryItem();
|
||||
|
@ -1,21 +1,27 @@
|
||||
import 'package:flowy_editor/document/path.dart';
|
||||
import 'package:flowy_editor/document/node.dart';
|
||||
import 'package:flowy_editor/document/text_delta.dart';
|
||||
import 'package:flowy_editor/document/attributes.dart';
|
||||
import 'package:flowy_editor/flowy_editor.dart';
|
||||
|
||||
abstract class Operation {
|
||||
final Path path;
|
||||
Operation({required this.path});
|
||||
Operation copyWithPath(Path path);
|
||||
Operation invert();
|
||||
}
|
||||
|
||||
class InsertOperation extends Operation {
|
||||
final Path path;
|
||||
final Node value;
|
||||
|
||||
InsertOperation({
|
||||
required this.path,
|
||||
required super.path,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
InsertOperation copyWith({Path? path, Node? value}) =>
|
||||
InsertOperation(path: path ?? this.path, value: value ?? this.value);
|
||||
|
||||
@override
|
||||
Operation copyWithPath(Path path) => copyWith(path: path);
|
||||
|
||||
@override
|
||||
Operation invert() {
|
||||
return DeleteOperation(
|
||||
@ -26,16 +32,25 @@ class InsertOperation extends Operation {
|
||||
}
|
||||
|
||||
class UpdateOperation extends Operation {
|
||||
final Path path;
|
||||
final Attributes attributes;
|
||||
final Attributes oldAttributes;
|
||||
|
||||
UpdateOperation({
|
||||
required this.path,
|
||||
required super.path,
|
||||
required this.attributes,
|
||||
required this.oldAttributes,
|
||||
});
|
||||
|
||||
UpdateOperation copyWith(
|
||||
{Path? path, Attributes? attributes, Attributes? oldAttributes}) =>
|
||||
UpdateOperation(
|
||||
path: path ?? this.path,
|
||||
attributes: attributes ?? this.attributes,
|
||||
oldAttributes: oldAttributes ?? this.oldAttributes);
|
||||
|
||||
@override
|
||||
Operation copyWithPath(Path path) => copyWith(path: path);
|
||||
|
||||
@override
|
||||
Operation invert() {
|
||||
return UpdateOperation(
|
||||
@ -47,14 +62,19 @@ class UpdateOperation extends Operation {
|
||||
}
|
||||
|
||||
class DeleteOperation extends Operation {
|
||||
final Path path;
|
||||
final Node removedValue;
|
||||
|
||||
DeleteOperation({
|
||||
required this.path,
|
||||
required super.path,
|
||||
required this.removedValue,
|
||||
});
|
||||
|
||||
DeleteOperation copyWith({Path? path, Node? removedValue}) => DeleteOperation(
|
||||
path: path ?? this.path, removedValue: removedValue ?? this.removedValue);
|
||||
|
||||
@override
|
||||
Operation copyWithPath(Path path) => copyWith(path: path);
|
||||
|
||||
@override
|
||||
Operation invert() {
|
||||
return InsertOperation(
|
||||
@ -65,18 +85,64 @@ class DeleteOperation extends Operation {
|
||||
}
|
||||
|
||||
class TextEditOperation extends Operation {
|
||||
final Path path;
|
||||
final Delta delta;
|
||||
final Delta inverted;
|
||||
|
||||
TextEditOperation({
|
||||
required this.path,
|
||||
required super.path,
|
||||
required this.delta,
|
||||
required this.inverted,
|
||||
});
|
||||
|
||||
TextEditOperation copyWith({Path? path, Delta? delta, Delta? inverted}) =>
|
||||
TextEditOperation(
|
||||
path: path ?? this.path,
|
||||
delta: delta ?? this.delta,
|
||||
inverted: inverted ?? this.inverted);
|
||||
|
||||
@override
|
||||
Operation copyWithPath(Path path) => copyWith(path: path);
|
||||
|
||||
@override
|
||||
Operation invert() {
|
||||
return TextEditOperation(path: path, delta: inverted, inverted: delta);
|
||||
}
|
||||
}
|
||||
|
||||
Path transformPath(Path preInsertPath, Path b, [int delta = 1]) {
|
||||
if (preInsertPath.length > b.length) {
|
||||
return b;
|
||||
}
|
||||
if (preInsertPath.isEmpty || b.isEmpty) {
|
||||
return b;
|
||||
}
|
||||
// check the prefix
|
||||
for (var i = 0; i < preInsertPath.length - 1; i++) {
|
||||
if (preInsertPath[i] != b[i]) {
|
||||
return b;
|
||||
}
|
||||
}
|
||||
final prefix = preInsertPath.sublist(0, preInsertPath.length - 1);
|
||||
final suffix = b.sublist(preInsertPath.length);
|
||||
final preInsertLast = preInsertPath.last;
|
||||
final bAtIndex = b[preInsertPath.length - 1];
|
||||
if (preInsertLast <= bAtIndex) {
|
||||
prefix.add(bAtIndex + delta);
|
||||
} else {
|
||||
prefix.add(bAtIndex);
|
||||
}
|
||||
prefix.addAll(suffix);
|
||||
return prefix;
|
||||
}
|
||||
|
||||
Operation transformOperation(Operation a, Operation b) {
|
||||
if (a is InsertOperation) {
|
||||
final newPath = transformPath(a.path, b.path);
|
||||
return b.copyWithPath(newPath);
|
||||
} else if (b is DeleteOperation) {
|
||||
final newPath = transformPath(a.path, b.path, -1);
|
||||
return b.copyWithPath(newPath);
|
||||
}
|
||||
// TODO: transform update and textedit
|
||||
return b;
|
||||
}
|
||||
|
@ -3,19 +3,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flowy_editor/document/selection.dart';
|
||||
import './operation.dart';
|
||||
|
||||
/// This class to use to store the **changes**
|
||||
/// will be applied to the editor.
|
||||
/// A [Transaction] has a list of [Operation] objects that will be applied
|
||||
/// to the editor. It is an immutable class and used to store and transmit.
|
||||
///
|
||||
/// This class is immutable version the the class
|
||||
/// [[Transaction]]. Is used to stored and
|
||||
/// transmit. If you want to build the transaction,
|
||||
/// use [[Transaction]] directly.
|
||||
/// If you want to build a new [Transaction], use [TransactionBuilder] directly.
|
||||
///
|
||||
/// There will be several ways to consume the transaction:
|
||||
/// 1. Apply to the state to update the UI.
|
||||
/// 2. Send to the backend to store and do operation transforming.
|
||||
/// 3. Stored by the UndoManager to implement redo/undo.
|
||||
///
|
||||
/// 3. Used by the UndoManager to implement redo/undo.
|
||||
@immutable
|
||||
class Transaction {
|
||||
final UnmodifiableListView<Operation> operations;
|
||||
|
@ -11,16 +11,10 @@ import 'package:flowy_editor/document/selection.dart';
|
||||
import './operation.dart';
|
||||
import './transaction.dart';
|
||||
|
||||
///
|
||||
/// This class is used to
|
||||
/// build the transaction from the state.
|
||||
///
|
||||
/// This class automatically save the
|
||||
/// cursor from the state.
|
||||
///
|
||||
/// When the transaction is undo, the
|
||||
/// cursor can be restored.
|
||||
///
|
||||
/// A [TransactionBuilder] is used to build the transaction from the state.
|
||||
/// It will save make a snapshot of the cursor selection state automatically.
|
||||
/// The cursor can be resoted if the transaction is undo.
|
||||
|
||||
class TransactionBuilder {
|
||||
final List<Operation> operations = [];
|
||||
EditorState state;
|
||||
@ -96,6 +90,9 @@ class TransactionBuilder {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < operations.length; i++) {
|
||||
op = transformOperation(operations[i], op);
|
||||
}
|
||||
operations.add(op);
|
||||
}
|
||||
|
||||
|
@ -22,6 +22,11 @@ mixin DefaultSelectable {
|
||||
Selection getSelectionInRange(Offset start, Offset end) =>
|
||||
forward.getSelectionInRange(start, end);
|
||||
|
||||
Offset localToGlobal(Offset offset) => forward.localToGlobal(offset);
|
||||
|
||||
Selection? getWorldBoundaryInOffset(Offset offset) =>
|
||||
forward.getWorldBoundaryInOffset(offset);
|
||||
|
||||
Position start() => forward.start();
|
||||
|
||||
Position end() => forward.end();
|
||||
|
@ -92,6 +92,16 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
|
||||
return Position(path: widget.textNode.path, offset: baseOffset);
|
||||
}
|
||||
|
||||
@override
|
||||
Selection? getWorldBoundaryInOffset(Offset offset) {
|
||||
final localOffset = _renderParagraph.globalToLocal(offset);
|
||||
final textPosition = _renderParagraph.getPositionForOffset(localOffset);
|
||||
final textRange = _renderParagraph.getWordBoundary(textPosition);
|
||||
final start = Position(path: widget.textNode.path, offset: textRange.start);
|
||||
final end = Position(path: widget.textNode.path, offset: textRange.end);
|
||||
return Selection(start: start, end: end);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(Selection selection) {
|
||||
assert(pathEquals(selection.start.path, selection.end.path) &&
|
||||
@ -155,6 +165,11 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset) {
|
||||
return _renderParagraph.localToGlobal(offset);
|
||||
}
|
||||
|
||||
TextSpan get _textSpan => TextSpan(
|
||||
children: widget.textNode.delta.operations
|
||||
.whereType<TextInsert>()
|
||||
|
@ -4,7 +4,6 @@ import 'package:flowy_editor/infra/flowy_svg.dart';
|
||||
import 'package:flowy_editor/render/node_widget_builder.dart';
|
||||
import 'package:flowy_editor/render/rich_text/default_selectable.dart';
|
||||
import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
|
||||
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
|
||||
import 'package:flowy_editor/render/selection/selectable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -17,10 +17,10 @@ class CursorWidget extends StatefulWidget {
|
||||
final LayerLink layerLink;
|
||||
|
||||
@override
|
||||
State<CursorWidget> createState() => _CursorWidgetState();
|
||||
State<CursorWidget> createState() => CursorWidgetState();
|
||||
}
|
||||
|
||||
class _CursorWidgetState extends State<CursorWidget> {
|
||||
class CursorWidgetState extends State<CursorWidget> {
|
||||
bool showCursor = true;
|
||||
late Timer timer;
|
||||
|
||||
@ -28,7 +28,17 @@ class _CursorWidgetState extends State<CursorWidget> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
timer = Timer.periodic(
|
||||
timer = _initTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Timer _initTimer() {
|
||||
return Timer.periodic(
|
||||
Duration(milliseconds: (widget.blinkingInterval * 1000).toInt()),
|
||||
(timer) {
|
||||
setState(() {
|
||||
@ -37,10 +47,13 @@ class _CursorWidgetState extends State<CursorWidget> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
/// force the cursor widget to show for a while
|
||||
show() {
|
||||
setState(() {
|
||||
showCursor = true;
|
||||
});
|
||||
timer.cancel();
|
||||
super.dispose();
|
||||
timer = _initTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -21,8 +21,14 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
|
||||
///
|
||||
/// The return result must be an offset of the local coordinate system.
|
||||
Position getPositionInOffset(Offset start);
|
||||
Selection? getWorldBoundaryInOffset(Offset start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Rect getCursorRectInPosition(Position position);
|
||||
|
||||
Offset localToGlobal(Offset offset);
|
||||
|
||||
Position start();
|
||||
Position end();
|
||||
|
||||
|
@ -1,14 +1,143 @@
|
||||
import 'package:flowy_editor/flowy_editor.dart';
|
||||
import 'package:flowy_editor/service/keyboard_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
FlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
|
||||
if (event.logicalKey != LogicalKeyboardKey.arrowUp &&
|
||||
event.logicalKey != LogicalKeyboardKey.arrowDown &&
|
||||
event.logicalKey != LogicalKeyboardKey.arrowLeft &&
|
||||
event.logicalKey != LogicalKeyboardKey.arrowRight) {
|
||||
int _endOffsetOfNode(Node node) {
|
||||
if (node is TextNode) {
|
||||
return node.delta.length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
extension on Position {
|
||||
Position? goLeft(EditorState editorState) {
|
||||
if (offset == 0) {
|
||||
final node = editorState.document.nodeAtPath(path)!;
|
||||
final prevNode = node.previous;
|
||||
if (prevNode != null) {
|
||||
return Position(
|
||||
path: prevNode.path, offset: _endOffsetOfNode(prevNode));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return Position(path: path, offset: offset - 1);
|
||||
}
|
||||
|
||||
Position? goRight(EditorState editorState) {
|
||||
final node = editorState.document.nodeAtPath(path)!;
|
||||
final lengthOfNode = _endOffsetOfNode(node);
|
||||
if (offset >= lengthOfNode) {
|
||||
final nextNode = node.next;
|
||||
if (nextNode != null) {
|
||||
return Position(path: nextNode.path, offset: 0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return Position(path: path, offset: offset + 1);
|
||||
}
|
||||
}
|
||||
|
||||
Position? _goUp(EditorState editorState) {
|
||||
final rects = editorState.service.selectionService.rects();
|
||||
if (rects.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final first = rects.first;
|
||||
final firstOffset = Offset(first.left, first.top);
|
||||
final hitOffset = firstOffset - Offset(0, first.height * 0.5);
|
||||
return editorState.service.selectionService.hitTest(hitOffset);
|
||||
}
|
||||
|
||||
Position? _goDown(EditorState editorState) {
|
||||
final rects = editorState.service.selectionService.rects();
|
||||
if (rects.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final first = rects.last;
|
||||
final firstOffset = Offset(first.right, first.bottom);
|
||||
final hitOffset = firstOffset + Offset(0, first.height * 0.5);
|
||||
return editorState.service.selectionService.hitTest(hitOffset);
|
||||
}
|
||||
|
||||
KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) {
|
||||
final currentSelection = editorState.cursorSelection;
|
||||
if (currentSelection == null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
final leftPosition = currentSelection.end.goLeft(editorState);
|
||||
editorState.updateCursorSelection(leftPosition == null
|
||||
? null
|
||||
: Selection(start: currentSelection.start, end: leftPosition));
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
final rightPosition = currentSelection.start.goRight(editorState);
|
||||
editorState.updateCursorSelection(rightPosition == null
|
||||
? null
|
||||
: Selection(start: rightPosition, end: currentSelection.end));
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
final position = _goUp(editorState);
|
||||
editorState.updateCursorSelection(position == null
|
||||
? null
|
||||
: Selection(start: position, end: currentSelection.end));
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
final position = _goDown(editorState);
|
||||
editorState.updateCursorSelection(position == null
|
||||
? null
|
||||
: Selection(start: currentSelection.start, end: position));
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
FlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
|
||||
if (event.isShiftPressed) {
|
||||
return _handleShiftKey(editorState, event);
|
||||
}
|
||||
|
||||
final currentSelection = editorState.cursorSelection;
|
||||
if (currentSelection == null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
if (currentSelection.isCollapsed) {
|
||||
final leftPosition = currentSelection.start.goLeft(editorState);
|
||||
if (leftPosition != null) {
|
||||
editorState.updateCursorSelection(Selection.collapsed(leftPosition));
|
||||
}
|
||||
} else {
|
||||
editorState
|
||||
.updateCursorSelection(currentSelection.collapse(atStart: true));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
if (currentSelection.isCollapsed) {
|
||||
final rightPosition = currentSelection.end.goRight(editorState);
|
||||
if (rightPosition != null) {
|
||||
editorState.updateCursorSelection(Selection.collapsed(rightPosition));
|
||||
}
|
||||
} else {
|
||||
editorState.updateCursorSelection(currentSelection.collapse());
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
final position = _goUp(editorState);
|
||||
editorState.updateCursorSelection(
|
||||
position == null ? null : Selection.collapsed(position));
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
final position = _goDown(editorState);
|
||||
editorState.updateCursorSelection(
|
||||
position == null ? null : Selection.collapsed(position));
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
|
@ -1,15 +1,17 @@
|
||||
import 'package:flowy_editor/document/path.dart';
|
||||
import 'dart:async';
|
||||
|
||||
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/render/selection/selectable.dart';
|
||||
import 'package:flowy_editor/render/selection/cursor_widget.dart';
|
||||
import 'package:flowy_editor/render/selection/flowy_selection_widget.dart';
|
||||
import 'package:flowy_editor/extensions/object_extensions.dart';
|
||||
import 'package:flowy_editor/extensions/node_extensions.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flowy_editor/service/shortcut_service.dart';
|
||||
import 'package:flowy_editor/editor_state.dart';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Process selection and cursor
|
||||
@ -28,6 +30,10 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
|
||||
///
|
||||
void clearSelection();
|
||||
|
||||
List<Rect> rects();
|
||||
|
||||
Position? hitTest(Offset? offset);
|
||||
|
||||
///
|
||||
List<Node> getNodesInSelection(Selection selection);
|
||||
|
||||
@ -50,7 +56,7 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
|
||||
/// [start] is the offset under the global coordinate system.
|
||||
Node? computeNodeInOffset(Node node, Offset offset);
|
||||
|
||||
/// Return the [Node]s in multiple selection. Emtpy list would be returned
|
||||
/// Return the [Node]s in multiple selection. Empty list would be returned
|
||||
/// if no nodes are in range.
|
||||
///
|
||||
/// [start] is the offset under the global coordinate system.
|
||||
@ -95,6 +101,92 @@ class FlowySelection extends StatefulWidget {
|
||||
State<FlowySelection> createState() => _FlowySelectionState();
|
||||
}
|
||||
|
||||
/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer]
|
||||
/// for a while. So we need to implement our own GestureDetector.
|
||||
@immutable
|
||||
class _SelectionGestureDetector extends StatefulWidget {
|
||||
const _SelectionGestureDetector(
|
||||
{Key? key,
|
||||
this.child,
|
||||
this.onTapDown,
|
||||
this.onDoubleTapDown,
|
||||
this.onPanStart,
|
||||
this.onPanUpdate,
|
||||
this.onPanEnd})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<_SelectionGestureDetector> createState() =>
|
||||
_SelectionGestureDetectorState();
|
||||
|
||||
final Widget? child;
|
||||
|
||||
final GestureTapDownCallback? onTapDown;
|
||||
final GestureTapDownCallback? onDoubleTapDown;
|
||||
final GestureDragStartCallback? onPanStart;
|
||||
final GestureDragUpdateCallback? onPanUpdate;
|
||||
final GestureDragEndCallback? onPanEnd;
|
||||
}
|
||||
|
||||
class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> {
|
||||
bool _isDoubleTap = false;
|
||||
Timer? _doubleTapTimer;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
gestures: {
|
||||
PanGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
|
||||
() => PanGestureRecognizer(),
|
||||
(recognizer) {
|
||||
recognizer
|
||||
..onStart = widget.onPanStart
|
||||
..onUpdate = widget.onPanUpdate
|
||||
..onEnd = widget.onPanEnd;
|
||||
},
|
||||
),
|
||||
TapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(),
|
||||
(recognizer) {
|
||||
recognizer.onTapDown = _tapDownDelegate;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
_tapDownDelegate(TapDownDetails tapDownDetails) {
|
||||
if (_isDoubleTap) {
|
||||
_isDoubleTap = false;
|
||||
_doubleTapTimer?.cancel();
|
||||
_doubleTapTimer = null;
|
||||
if (widget.onDoubleTapDown != null) {
|
||||
widget.onDoubleTapDown!(tapDownDetails);
|
||||
}
|
||||
} else {
|
||||
if (widget.onTapDown != null) {
|
||||
widget.onTapDown!(tapDownDetails);
|
||||
}
|
||||
|
||||
_isDoubleTap = true;
|
||||
_doubleTapTimer?.cancel();
|
||||
_doubleTapTimer = Timer(kDoubleTapTimeout, () {
|
||||
_isDoubleTap = false;
|
||||
_doubleTapTimer = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_doubleTapTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FlowySelectionState extends State<FlowySelection>
|
||||
with FlowySelectionService, WidgetsBindingObserver {
|
||||
final _cursorKey = GlobalKey(debugLabel: 'cursor');
|
||||
@ -110,6 +202,8 @@ class _FlowySelectionState extends State<FlowySelection>
|
||||
/// Tap
|
||||
Offset? tapOffset;
|
||||
|
||||
final List<Rect> _rects = [];
|
||||
|
||||
EditorState get editorState => widget.editorState;
|
||||
|
||||
@override
|
||||
@ -146,33 +240,24 @@ class _FlowySelectionState extends State<FlowySelection>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
gestures: {
|
||||
PanGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
|
||||
() => PanGestureRecognizer(),
|
||||
(recognizer) {
|
||||
recognizer
|
||||
..onStart = _onPanStart
|
||||
..onUpdate = _onPanUpdate
|
||||
..onEnd = _onPanEnd;
|
||||
},
|
||||
),
|
||||
TapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(),
|
||||
(recognizer) {
|
||||
recognizer.onTapDown = _onTapDown;
|
||||
},
|
||||
),
|
||||
},
|
||||
return _SelectionGestureDetector(
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
onPanEnd: _onPanEnd,
|
||||
onTapDown: _onTapDown,
|
||||
onDoubleTapDown: _onDoubleTapDown,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> rects() {
|
||||
return _rects;
|
||||
}
|
||||
|
||||
@override
|
||||
void updateSelection(Selection selection) {
|
||||
_rects.clear();
|
||||
_clearSelection();
|
||||
|
||||
// cursor
|
||||
@ -267,6 +352,22 @@ class _FlowySelectionState extends State<FlowySelection>
|
||||
return false;
|
||||
}
|
||||
|
||||
void _onDoubleTapDown(TapDownDetails details) {
|
||||
final offset = details.globalPosition;
|
||||
final nodes = getNodesInRange(offset);
|
||||
if (nodes.isEmpty) {
|
||||
editorState.updateCursorSelection(null);
|
||||
return;
|
||||
}
|
||||
final selectable = nodes.first.selectable;
|
||||
if (selectable == null) {
|
||||
editorState.updateCursorSelection(null);
|
||||
return;
|
||||
}
|
||||
editorState
|
||||
.updateCursorSelection(selectable.getWorldBoundaryInOffset(offset));
|
||||
}
|
||||
|
||||
void _onTapDown(TapDownDetails details) {
|
||||
// clear old state.
|
||||
panStartOffset = null;
|
||||
@ -274,16 +375,32 @@ class _FlowySelectionState extends State<FlowySelection>
|
||||
|
||||
tapOffset = details.globalPosition;
|
||||
|
||||
final nodes = getNodesInRange(tapOffset!);
|
||||
if (nodes.isNotEmpty) {
|
||||
assert(nodes.length == 1);
|
||||
final selectable = nodes.first.selectable;
|
||||
if (selectable != null) {
|
||||
final position = selectable.getPositionInOffset(tapOffset!);
|
||||
final selection = Selection.collapsed(position);
|
||||
updateSelection(selection);
|
||||
}
|
||||
final position = hitTest(tapOffset);
|
||||
if (position == null) {
|
||||
return;
|
||||
}
|
||||
final selection = Selection.collapsed(position);
|
||||
editorState.updateCursorSelection(selection);
|
||||
}
|
||||
|
||||
@override
|
||||
Position? hitTest(Offset? offset) {
|
||||
if (offset == null) {
|
||||
editorState.updateCursorSelection(null);
|
||||
return null;
|
||||
}
|
||||
final nodes = getNodesInRange(offset);
|
||||
if (nodes.isEmpty) {
|
||||
editorState.updateCursorSelection(null);
|
||||
return null;
|
||||
}
|
||||
assert(nodes.length == 1);
|
||||
final selectable = nodes.first.selectable;
|
||||
if (selectable == null) {
|
||||
editorState.updateCursorSelection(null);
|
||||
return null;
|
||||
}
|
||||
return selectable.getPositionInOffset(offset);
|
||||
}
|
||||
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
@ -314,7 +431,7 @@ class _FlowySelectionState extends State<FlowySelection>
|
||||
final selection = Selection(
|
||||
start: isDownward ? start : end, end: isDownward ? end : start);
|
||||
debugPrint('[_onPanUpdate] $selection');
|
||||
updateSelection(selection);
|
||||
editorState.updateCursorSelection(selection);
|
||||
}
|
||||
}
|
||||
|
||||
@ -385,6 +502,7 @@ class _FlowySelectionState extends State<FlowySelection>
|
||||
final rects = selectable.getRectsInSelection(newSelection);
|
||||
|
||||
for (final rect in rects) {
|
||||
_rects.add(_transformRectToGlobal(selectable, rect));
|
||||
final overlay = OverlayEntry(
|
||||
builder: ((context) => SelectionWidget(
|
||||
color: widget.selectionColor,
|
||||
@ -399,6 +517,11 @@ class _FlowySelectionState extends State<FlowySelection>
|
||||
Overlay.of(context)?.insertAll(_selectionOverlays);
|
||||
}
|
||||
|
||||
Rect _transformRectToGlobal(Selectable selectable, Rect r) {
|
||||
final Offset topLeft = selectable.localToGlobal(Offset(r.left, r.top));
|
||||
return Rect.fromLTWH(topLeft.dx, topLeft.dy, r.width, r.height);
|
||||
}
|
||||
|
||||
void _updateCursor(Position position) {
|
||||
final node = editorState.document.root.childAtPath(position.path);
|
||||
|
||||
@ -413,6 +536,7 @@ class _FlowySelectionState extends State<FlowySelection>
|
||||
final selectable = node.selectable;
|
||||
final rect = selectable?.getCursorRectInPosition(position);
|
||||
if (rect != null) {
|
||||
_rects.add(_transformRectToGlobal(selectable!, rect));
|
||||
final cursor = OverlayEntry(
|
||||
builder: ((context) => CursorWidget(
|
||||
key: _cursorKey,
|
||||
@ -423,9 +547,15 @@ class _FlowySelectionState extends State<FlowySelection>
|
||||
);
|
||||
_cursorOverlays.add(cursor);
|
||||
Overlay.of(context)?.insertAll(_cursorOverlays);
|
||||
_forceShowCursor();
|
||||
}
|
||||
}
|
||||
|
||||
_forceShowCursor() {
|
||||
final currentState = _cursorKey.currentState as CursorWidgetState?;
|
||||
currentState?.show();
|
||||
}
|
||||
|
||||
List<Node> _selectedNodesInSelection(Node node, Selection selection) {
|
||||
List<Node> result = [];
|
||||
if (node.parent != null) {
|
||||
|
@ -7,7 +7,7 @@ import 'package:flowy_editor/operation/transaction.dart';
|
||||
import 'package:flowy_editor/editor_state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// This class contains operations committed by users.
|
||||
/// A [HistoryItem] contains list of operations committed by users.
|
||||
/// If a [HistoryItem] is not sealed, operations can be added sequentially.
|
||||
/// Otherwise, the operations should be added to a new [HistoryItem].
|
||||
class HistoryItem extends LinkedListEntry<HistoryItem> {
|
||||
|
@ -24,11 +24,11 @@ dev_dependencies:
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# To add assets to your package, add an assets section, like this:
|
||||
assets:
|
||||
- assets/images/uncheck.svg
|
||||
- assets/images/
|
||||
- assets/document.json
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
|
@ -0,0 +1,83 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flowy_editor/document/node.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flowy_editor/operation/operation.dart';
|
||||
import 'package:flowy_editor/operation/transaction_builder.dart';
|
||||
import 'package:flowy_editor/editor_state.dart';
|
||||
import 'package:flowy_editor/document/state_tree.dart';
|
||||
import 'package:flowy_editor/render/render_plugins.dart';
|
||||
|
||||
void main() {
|
||||
group('transform path', () {
|
||||
test('transform path changed', () {
|
||||
expect(transformPath([0, 1], [0, 1]), [0, 2]);
|
||||
expect(transformPath([0, 1], [0, 2]), [0, 3]);
|
||||
expect(transformPath([0, 1], [0, 2, 7, 8, 9]), [0, 3, 7, 8, 9]);
|
||||
expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]);
|
||||
});
|
||||
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, 1]), [0, 1]);
|
||||
expect(transformPath([1, 1], [1, 0]), [1, 0]);
|
||||
});
|
||||
test("transform path delta", () {
|
||||
expect(transformPath([0, 1], [0, 1], 5), [0, 6]);
|
||||
});
|
||||
});
|
||||
group('transform operation', () {
|
||||
test('insert + insert', () {
|
||||
final t = transformOperation(
|
||||
InsertOperation(path: [
|
||||
0,
|
||||
1
|
||||
], value: Node(type: "node", attributes: {}, children: LinkedList())),
|
||||
InsertOperation(
|
||||
path: [0, 1],
|
||||
value:
|
||||
Node(type: "node", attributes: {}, children: LinkedList())));
|
||||
expect(t.path, [0, 2]);
|
||||
});
|
||||
test('delete + delete', () {
|
||||
final t = transformOperation(
|
||||
DeleteOperation(
|
||||
path: [0, 1],
|
||||
removedValue:
|
||||
Node(type: "node", attributes: {}, children: LinkedList())),
|
||||
DeleteOperation(
|
||||
path: [0, 2],
|
||||
removedValue:
|
||||
Node(type: "node", attributes: {}, children: LinkedList())));
|
||||
expect(t.path, [0, 1]);
|
||||
});
|
||||
});
|
||||
test('transform transaction builder', () {
|
||||
final item1 = Node(type: "node", attributes: {}, children: LinkedList());
|
||||
final item2 = Node(type: "node", attributes: {}, children: LinkedList());
|
||||
final item3 = Node(type: "node", attributes: {}, children: LinkedList());
|
||||
final root = Node(
|
||||
type: "root",
|
||||
attributes: {},
|
||||
children: LinkedList()
|
||||
..addAll([
|
||||
item1,
|
||||
item2,
|
||||
item3,
|
||||
]));
|
||||
final state = EditorState(
|
||||
document: StateTree(root: root), renderPlugins: RenderPlugins());
|
||||
|
||||
expect(item1.path, [0]);
|
||||
expect(item2.path, [1]);
|
||||
expect(item3.path, [2]);
|
||||
|
||||
final tb = TransactionBuilder(state);
|
||||
tb.deleteNode(item1);
|
||||
tb.deleteNode(item2);
|
||||
tb.deleteNode(item3);
|
||||
final transaction = tb.finish();
|
||||
expect(transaction.operations[0].path, [0]);
|
||||
expect(transaction.operations[1].path, [0]);
|
||||
expect(transaction.operations[2].path, [0]);
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user