Browse Source

Merge pull request #606 from vincentdchan/feat/flowy_editor

feat: position and selection
Nathan.fooo 2 years ago
parent
commit
bfca0a17e0

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/document/node.dart

@@ -1,7 +1,7 @@
 import 'dart:collection';
 import 'package:flowy_editor/document/path.dart';
 
-typedef Attributes = Map<String, Object>;
+typedef Attributes = Map<String, dynamic>;
 
 class Node extends LinkedListEntry<Node> {
   Node? parent;

+ 6 - 0
frontend/app_flowy/packages/flowy_editor/lib/document/path.dart

@@ -1 +1,7 @@
+import 'package:flutter/foundation.dart';
+
 typedef Path = List<int>;
+
+bool pathEquals(Path path1, Path path2) {
+  return listEquals(path1, path2);
+}

+ 27 - 0
frontend/app_flowy/packages/flowy_editor/lib/document/position.dart

@@ -0,0 +1,27 @@
+import 'package:flutter/material.dart';
+
+import './path.dart';
+
+class Position {
+  final Path path;
+  final int offset;
+
+  Position({
+    required this.path,
+    this.offset = 0,
+  });
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! Position) {
+      return false;
+    }
+    return pathEquals(path, other.path) && offset == other.offset;
+  }
+
+  @override
+  int get hashCode {
+    final pathHash = hashList(path);
+    return Object.hash(pathHash, offset);
+  }
+}

+ 27 - 0
frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart

@@ -0,0 +1,27 @@
+import './position.dart';
+
+class Selection {
+  final Position start;
+  final Position end;
+
+  Selection({
+    required this.start,
+    required this.end,
+  });
+
+  factory Selection.collapsed(Position pos) {
+    return Selection(start: pos, end: pos);
+  }
+
+  Selection collapse({bool atStart = false}) {
+    if (atStart) {
+      return Selection(start: start, end: start);
+    } else {
+      return Selection(start: end, end: end);
+    }
+  }
+
+  bool isCollapsed() {
+    return start == end;
+  }
+}

+ 415 - 0
frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart

@@ -0,0 +1,415 @@
+import 'dart:collection';
+import 'dart:math';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import './node.dart';
+
+// constant number: 2^53 - 1
+const int _maxInt = 9007199254740991;
+
+class TextOperation {
+  bool get isEmpty {
+    return length == 0;
+  }
+
+  int get length {
+    return 0;
+  }
+
+  Attributes? get attributes {
+    return null;
+  }
+}
+
+int _hashAttributes(Attributes attributes) {
+  return Object.hashAllUnordered(
+      attributes.entries.map((e) => Object.hash(e.key, e.value)));
+}
+
+class TextInsert extends TextOperation {
+  String content;
+  final Attributes? _attributes;
+
+  TextInsert(this.content, [Attributes? attrs]) : _attributes = attrs;
+
+  @override
+  int get length {
+    return content.length;
+  }
+
+  @override
+  Attributes? get attributes {
+    return _attributes;
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! TextInsert) {
+      return false;
+    }
+    return content == other.content &&
+        mapEquals(_attributes, other._attributes);
+  }
+
+  @override
+  int get hashCode {
+    final contentHash = content.hashCode;
+    final attrs = _attributes;
+    return Object.hash(
+        contentHash, attrs == null ? null : _hashAttributes(attrs));
+  }
+}
+
+class TextRetain extends TextOperation {
+  int _length;
+  final Attributes? _attributes;
+
+  TextRetain({
+    required length,
+    attributes,
+  })  : _length = length,
+        _attributes = attributes;
+
+  @override
+  bool get isEmpty {
+    return length == 0;
+  }
+
+  @override
+  int get length {
+    return _length;
+  }
+
+  set length(int v) {
+    _length = v;
+  }
+
+  @override
+  Attributes? get attributes {
+    return _attributes;
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! TextRetain) {
+      return false;
+    }
+    return _length == other.length && mapEquals(_attributes, other._attributes);
+  }
+
+  @override
+  int get hashCode {
+    final attrs = _attributes;
+    return Object.hash(_length, attrs == null ? null : _hashAttributes(attrs));
+  }
+}
+
+class TextDelete extends TextOperation {
+  int _length;
+
+  TextDelete({
+    required int length,
+  }) : _length = length;
+
+  @override
+  bool get isEmpty {
+    return length == 0;
+  }
+
+  @override
+  int get length {
+    return _length;
+  }
+
+  set length(int v) {
+    _length = v;
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! TextDelete) {
+      return false;
+    }
+    return _length == other.length;
+  }
+
+  @override
+  int get hashCode {
+    return _length.hashCode;
+  }
+}
+
+class _OpIterator {
+  final UnmodifiableListView<TextOperation> _operations;
+  int _index = 0;
+  int _offset = 0;
+
+  _OpIterator(List<TextOperation> operations) : _operations = UnmodifiableListView(operations);
+
+  bool get hasNext {
+    return peekLength() < _maxInt;
+  }
+
+  TextOperation? peek() {
+    if (_index >= _operations.length) {
+      return null;
+    }
+
+    return _operations[_index];
+  }
+
+  int peekLength() {
+    if (_index < _operations.length) {
+      final op = _operations[_index];
+      return op.length - _offset;
+    }
+    return _maxInt;
+  }
+
+  TextOperation next([int? length]) {
+    length ??= _maxInt;
+
+    if (_index >= _operations.length) {
+      return TextRetain(length: _maxInt);
+    }
+
+    final nextOp = _operations[_index];
+
+    final offset = _offset;
+    final opLength = nextOp.length;
+    if (length >= opLength - offset) {
+      length = opLength - offset;
+      _index += 1;
+      _offset = 0;
+    } else {
+      _offset += length;
+    }
+    if (nextOp is TextDelete) {
+      return TextDelete(length: length);
+    }
+
+    if (nextOp is TextRetain) {
+      return TextRetain(length: length, attributes: nextOp.attributes);
+    }
+
+    if (nextOp is TextInsert) {
+      return TextInsert(
+          nextOp.content.substring(offset, offset + length), nextOp.attributes);
+    }
+
+    return TextRetain(length: _maxInt);
+  }
+
+  List<TextOperation> rest() {
+    if (!hasNext) {
+      return [];
+    } else if (_offset == 0) {
+      return _operations.sublist(_index);
+    } else {
+      final offset = _offset;
+      final index = _index;
+      final _next = next();
+      final rest = _operations.sublist(_index);
+      _offset = offset;
+      _index = index;
+      return [_next] + rest;
+    }
+  }
+}
+
+// basically copy from: https://github.com/quilljs/delta
+class Delta {
+  final List<TextOperation> operations;
+
+  Delta([List<TextOperation>? ops]) : operations = ops ?? <TextOperation>[];
+
+  Delta add(TextOperation textOp) {
+    if (textOp.isEmpty) {
+      return this;
+    }
+
+    if (operations.isNotEmpty) {
+      final lastOp = operations.last;
+      if (lastOp is TextDelete && textOp is TextDelete) {
+        lastOp.length += textOp.length;
+        return this;
+      }
+      if (mapEquals(lastOp.attributes, textOp.attributes)) {
+        if (lastOp is TextInsert && textOp is TextInsert) {
+          lastOp.content += textOp.content;
+          return this;
+        }
+        // if there is an delete before the insert
+        // swap the order
+        if (lastOp is TextDelete && textOp is TextInsert) {
+          operations.removeLast();
+          operations.add(textOp);
+          operations.add(lastOp);
+          return this;
+        }
+        if (lastOp is TextRetain && textOp is TextRetain) {
+          lastOp.length += textOp.length;
+          return this;
+        }
+      }
+    }
+
+    operations.add(textOp);
+    return this;
+  }
+
+  Delta slice(int start, [int? end]) {
+    final result = Delta();
+    final iterator = _OpIterator(operations);
+    int index = 0;
+
+    while ((end == null || index < end) && iterator.hasNext) {
+      TextOperation? nextOp;
+      if (index < start) {
+        nextOp = iterator.next(start - index);
+      } else {
+        nextOp = iterator.next(end == null ? null : end - index);
+        result.add(nextOp);
+      }
+
+      index += nextOp.length;
+    }
+
+    return result;
+  }
+
+  Delta insert(String content, [Attributes? attributes]) {
+    final op = TextInsert(content, attributes);
+    return add(op);
+  }
+
+  Delta retain(int length, [Attributes? attributes]) {
+    final op = TextRetain(length: length, attributes: attributes);
+    return add(op);
+  }
+
+  Delta delete(int length) {
+    final op = TextDelete(length: length);
+    return add(op);
+  }
+
+  int get length {
+    return operations.fold(
+        0, (previousValue, element) => previousValue + element.length);
+  }
+
+  Delta compose(Delta other) {
+    final thisIter = _OpIterator(operations);
+    final otherIter = _OpIterator(other.operations);
+    final ops = <TextOperation>[];
+
+    final firstOther = otherIter.peek();
+    if (firstOther != null &&
+        firstOther is TextRetain &&
+        firstOther.attributes == null) {
+      int firstLeft = firstOther.length;
+      while (
+          thisIter.peek() is TextInsert && thisIter.peekLength() <= firstLeft) {
+        firstLeft -= thisIter.peekLength();
+        final next = thisIter.next();
+        ops.add(next);
+      }
+      if (firstOther.length - firstLeft > 0) {
+        otherIter.next(firstOther.length - firstLeft);
+      }
+    }
+
+    final delta = Delta(ops);
+    while (thisIter.hasNext || otherIter.hasNext) {
+      if (otherIter.peek() is TextInsert) {
+        final next = otherIter.next();
+        delta.add(next);
+      } else if (thisIter.peek() is TextDelete) {
+        final next = thisIter.next();
+        delta.add(next);
+      } else {
+        // otherIs
+        final length = min(thisIter.peekLength(), otherIter.peekLength());
+        final thisOp = thisIter.next(length);
+        final otherOp = otherIter.next(length);
+        final attributes = _composeMap(thisOp.attributes, otherOp.attributes);
+        if (otherOp is TextRetain && otherOp.length > 0) {
+          TextOperation? newOp;
+          if (thisOp is TextRetain) {
+            newOp = TextRetain(length: length, attributes: attributes);
+          } else if (thisOp is TextInsert) {
+            newOp = TextInsert(thisOp.content, attributes);
+          }
+
+          if (newOp != null) {
+            delta.add(newOp);
+          }
+
+          // Optimization if rest of other is just retain
+          if (!otherIter.hasNext &&
+              delta.operations[delta.operations.length - 1] == newOp) {
+            final rest = Delta(thisIter.rest());
+            return delta.concat(rest).chop();
+          }
+        } else if (otherOp is TextDelete && (thisOp is TextRetain)) {
+          delta.add(otherOp);
+        }
+      }
+    }
+
+    return delta.chop();
+  }
+
+  Delta concat(Delta other) {
+    var ops = [...operations];
+    if (other.operations.isNotEmpty) {
+      ops.add(other.operations[0]);
+      ops.addAll(other.operations.sublist(1));
+    }
+    return Delta(ops);
+  }
+
+  Delta chop() {
+    if (operations.isEmpty) {
+      return this;
+    }
+    final lastOp = operations.last;
+    if (lastOp is TextRetain && (lastOp.attributes?.length ?? 0) == 0) {
+      operations.removeLast();
+    }
+    return this;
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! Delta) {
+      return false;
+    }
+    return listEquals(operations, other.operations);
+  }
+
+  @override
+  int get hashCode {
+    return hashList(operations);
+  }
+}
+
+Attributes? _composeMap(Attributes? a, Attributes? b) {
+  a ??= {};
+  b ??= {};
+  final Attributes attributes = {};
+  attributes.addAll(b);
+
+  for (final entry in a.entries) {
+    if (!b.containsKey(entry.key)) {
+      attributes[entry.key] = entry.value;
+    }
+  }
+
+  if (attributes.isEmpty) {
+    return null;
+  }
+
+  return attributes;
+}

+ 13 - 0
frontend/app_flowy/packages/flowy_editor/lib/document/text_node.dart

@@ -0,0 +1,13 @@
+
+import './text_delta.dart';
+import './node.dart';
+
+class TextNode extends Node {
+  final Delta delta;
+
+  TextNode(
+      {required super.type,
+      required super.children,
+      required super.attributes,
+      required this.delta});
+}

+ 31 - 0
frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart

@@ -0,0 +1,31 @@
+import 'package:flowy_editor/operation/operation.dart';
+
+import './document/state_tree.dart';
+import './document/selection.dart';
+import './operation/operation.dart';
+import './operation/transaction.dart';
+
+class EditorState {
+  final StateTree document;
+  Selection? cursorSelection;
+
+  EditorState({
+    required this.document,
+  });
+
+  apply(Transaction transaction) {
+    for (final op in transaction.operations) {
+      _applyOperation(op);
+    }
+  }
+
+  _applyOperation(Operation op) {
+    if (op is InsertOperation) {
+      document.insert(op.path, op.value);
+    } else if (op is UpdateOperation) {
+      document.update(op.path, op.attributes);
+    } else if (op is DeleteOperation) {
+      document.delete(op.path);
+    }
+  }
+}

+ 58 - 0
frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart

@@ -0,0 +1,58 @@
+import 'package:flowy_editor/document/path.dart';
+import 'package:flowy_editor/document/node.dart';
+
+abstract class Operation {
+
+  Operation invert();
+
+}
+
+class InsertOperation extends Operation {
+  final Path path;
+  final Node value;
+
+  InsertOperation({
+    required this.path,
+    required this.value,
+  });
+
+  @override
+  Operation invert() {
+    return DeleteOperation(path: path, removedValue: value);
+  }
+
+}
+
+class UpdateOperation extends Operation {
+  final Path path;
+  final Attributes attributes;
+  final Attributes oldAttributes;
+
+  UpdateOperation({
+    required this.path,
+    required this.attributes,
+    required this.oldAttributes,
+  });
+
+  @override
+  Operation invert() {
+    return UpdateOperation(path: path, attributes: oldAttributes, oldAttributes: attributes);
+  }
+
+}
+
+class DeleteOperation extends Operation {
+  final Path path;
+  final Node removedValue;
+
+  DeleteOperation({
+    required this.path,
+    required this.removedValue,
+  });
+
+  @override
+  Operation invert() {
+    return InsertOperation(path: path, value: removedValue);
+  }
+
+}

+ 6 - 0
frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart

@@ -0,0 +1,6 @@
+import './operation.dart';
+
+class Transaction {
+  final List<Operation> operations = [];
+
+}

+ 175 - 0
frontend/app_flowy/packages/flowy_editor/test/delta_test.dart

@@ -0,0 +1,175 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flowy_editor/document/text_delta.dart';
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+  test('test delta', () {
+    final delta = Delta(<TextOperation>[
+      TextInsert('Gandalf', {
+        'bold': true,
+      }),
+      TextInsert(' the '),
+      TextInsert('Grey', {
+        'color': '#ccc',
+      })
+    ]);
+
+    final death = Delta().retain(12).insert("White", {
+      'color': '#fff',
+    }).delete(4);
+
+    final restores = delta.compose(death);
+    expect(restores.operations, <TextOperation>[
+      TextInsert('Gandalf', {'bold': true}),
+      TextInsert(' the '),
+      TextInsert('White', {'color': '#fff'}),
+    ]);
+  });
+  test('compose()', () {
+    final a = Delta().insert('A');
+    final b = Delta().insert('B');
+    final expected = Delta().insert('B').insert('A');
+    expect(a.compose(b), expected);
+  });
+  test('insert + retain', () {
+    final a = Delta().insert('A');
+    final b = Delta().retain(1, {
+      'bold': true,
+      'color': 'red',
+    });
+    final expected = Delta().insert('A', {
+      'bold': true,
+      'color': 'red',
+    });
+    expect(a.compose(b), expected);
+  });
+  test('insert + delete', () {
+    final a = Delta().insert('A');
+    final b = Delta().delete(1);
+    final expected = Delta();
+    expect(a.compose(b), expected);
+  });
+  test('delete + insert', () {
+    final a = Delta().delete(1);
+    final b = Delta().insert('B');
+    final expected = Delta().insert('B').delete(1);
+    expect(a.compose(b), expected);
+  });
+  test('delete + retain', () {
+    final a = Delta().delete(1);
+    final b = Delta().retain(1, {
+      'bold': true,
+      'color': 'red',
+    });
+    final expected = Delta().delete(1).retain(1, {
+      'bold': true,
+      'color': 'red',
+    });
+    expect(a.compose(b), expected);
+  });
+  test('delete + delete', () {
+    final a = Delta().delete(1);
+    final b = Delta().delete(1);
+    final expected = Delta().delete(2);
+    expect(a.compose(b), expected);
+  });
+  test('retain + insert', () {
+    final a = Delta().retain(1, {
+      'color': 'blue'
+    });
+    final b = Delta().insert('B');
+    final expected = Delta().insert('B').retain(1, {
+      'color': 'blue',
+    });
+    expect(a.compose(b), expected);
+  });
+  test('retain + retain', () {
+    final a = Delta().retain(1, {
+      'color': 'blue',
+    });
+    final b = Delta().retain(1, {
+      'bold': true,
+      'color': 'red',
+    });
+    final expected = Delta().retain(1, {
+      'bold': true,
+      'color': 'red',
+    });
+    expect(a.compose(b), expected);
+  });
+  test('retain + delete', () {
+    final a = Delta().retain(1, {
+      'color': 'blue',
+    });
+    final b = Delta().delete(1);
+    final expected = Delta().delete(1);
+    expect(a.compose(b), expected);
+  });
+  test('insert in middle of text', () {
+    final a = Delta().insert('Hello');
+    final b = Delta().retain(3).insert('X');
+    final expected = Delta().insert('HelXlo');
+    expect(a.compose(b), expected);
+  });
+  test('insert and delete ordering', () {
+    final a = Delta().insert('Hello');
+    final b = Delta().insert('Hello');
+    final insertFirst = Delta().retain(3).insert('X').delete(1);
+    final deleteFirst = Delta().retain(3).delete(1).insert('X');
+    final expected = Delta().insert('HelXo');
+    expect(a.compose(insertFirst), expected);
+    expect(b.compose(deleteFirst), expected);
+  });
+  test('delete entire text', () {
+    final a = Delta().retain(4).insert('Hello');
+    final b = Delta().delete(9);
+    final expected = Delta().delete(4);
+    expect(a.compose(b), expected);
+  });
+  test('retain more than length of text', () {
+    final a = Delta().insert('Hello');
+    final b = Delta().retain(10);
+    final expected = Delta().insert('Hello');
+    expect(a.compose(b), expected);
+  });
+  test('retain start optimization', () {
+    final a = Delta()
+        .insert('A', {'bold': true})
+        .insert('B')
+        .insert('C', {'bold': true})
+        .delete(1);
+    final b = Delta().retain(3).insert('D');
+    final expected = Delta()
+        .insert('A', {'bold': true})
+        .insert('B')
+        .insert('C', {'bold': true})
+        .insert('D')
+        .delete(1);
+    expect(a.compose(b), expected);
+  });
+  test('retain end optimization', () {
+    final a = Delta()
+        .insert('A', {'bold': true})
+        .insert('B')
+        .insert('C', {'bold': true});
+    final b = Delta().delete(1);
+    final expected = Delta().insert('B').insert('C', {'bold': true});
+    expect(a.compose(b), expected);
+  });
+  test('retain end optimization join', () {
+    final a = Delta()
+        .insert('A', {'bold': true})
+        .insert('B')
+        .insert('C', {'bold': true})
+        .insert('D')
+        .insert('E', {'bold': true})
+        .insert('F');
+    final b = Delta().retain(1).delete(1);
+    final expected = Delta()
+        .insert('AC', {'bold': true})
+        .insert('D')
+        .insert('E', {'bold': true})
+        .insert('F');
+    expect(a.compose(b), expected);
+  });
+}

+ 69 - 0
frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart

@@ -2,6 +2,10 @@ import 'dart:convert';
 
 import 'package:flowy_editor/document/node.dart';
 import 'package:flowy_editor/document/state_tree.dart';
+import 'package:flowy_editor/document/path.dart';
+import 'package:flowy_editor/document/position.dart';
+import 'package:flowy_editor/document/selection.dart';
+import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
 
@@ -61,4 +65,69 @@ void main() {
     expect(updatedNode != null, true);
     expect(updatedNode!.attributes['text-type'], 'heading1');
   });
+
+  test('test path utils 1', () {
+    final path1 = <int>[1];
+    final path2 = <int>[1];
+    expect(pathEquals(path1, path2), true);
+
+    expect(hashList(path1), hashList(path2));
+  });
+
+  test('test path utils 2', () {
+    final path1 = <int>[1];
+    final path2 = <int>[2];
+    expect(pathEquals(path1, path2), false);
+
+    expect(hashList(path1) != hashList(path2), true);
+  });
+
+  test('test position comparator', () {
+    final pos1 = Position(path: [1], offset: 0);
+    final pos2 = Position(path: [1], offset: 0);
+    expect(pos1 == pos2, true);
+    expect(pos1.hashCode == pos2.hashCode, true);
+  });
+
+  test('test position comparator with offset', () {
+    final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100);
+    final pos2 = Position(path: [1, 1, 1, 1, 1], offset: 100);
+    expect(pos1, pos2);
+    expect(pos1.hashCode, pos2.hashCode);
+  });
+
+  test('test position comparator false', () {
+    final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100);
+    final pos2 = Position(path: [1, 1, 2, 1, 1], offset: 100);
+    expect(pos1 == pos2, false);
+    expect(pos1.hashCode == pos2.hashCode, false);
+  });
+
+  test('test position comparator with offset false', () {
+    final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100);
+    final pos2 = Position(path: [1, 1, 1, 1, 1], offset: 101);
+    expect(pos1 == pos2, false);
+    expect(pos1.hashCode == pos2.hashCode, false);
+  });
+
+  test('test selection comparator', () {
+    final pos = Position(path: [0], offset: 0);
+    final sel = Selection.collapsed(pos);
+    expect(sel.start, sel.end);
+    expect(sel.isCollapsed(), true);
+  });
+
+  test('test selection collapse', () {
+    final start = Position(path: [0], offset: 0);
+    final end = Position(path: [0], offset: 10);
+    final sel = Selection(start: start, end: end);
+
+    final collapsedSelAtStart = sel.collapse(atStart: true);
+    expect(collapsedSelAtStart.start, start);
+    expect(collapsedSelAtStart.end, start);
+
+    final collapsedSelAtEnd = sel.collapse();
+    expect(collapsedSelAtEnd.start, end);
+    expect(collapsedSelAtEnd.end, end);
+  });
 }