zefyr

document.dart 9.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. // Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file
  2. // for details. All rights reserved. Use of this source code is governed by a
  3. // BSD-style license that can be found in the LICENSE file.
  4. import 'dart:async';
  5. import 'package:quill_delta/quill_delta.dart';
  6. import 'document/attributes.dart';
  7. import 'document/block.dart';
  8. import 'document/leaf.dart';
  9. import 'document/line.dart';
  10. import 'document/node.dart';
  11. import 'heuristics.dart';
  12. /// Source of a [NotusChange].
  13. enum ChangeSource {
  14. /// Change originated from a local action. Typically triggered by user.
  15. local,
  16. /// Change originated from a remote action.
  17. remote,
  18. }
  19. /// Represents a change in a [NotusDocument].
  20. class NotusChange {
  21. NotusChange(this.before, this.change, this.source);
  22. /// Document state before [change].
  23. final Delta before;
  24. /// Change delta applied to the document.
  25. final Delta change;
  26. /// The source of this change.
  27. final ChangeSource source;
  28. }
  29. /// A rich text document.
  30. class NotusDocument {
  31. /// Creates new empty Notus document.
  32. NotusDocument()
  33. : _heuristics = NotusHeuristics.fallback,
  34. _delta = Delta()..insert('\n') {
  35. _loadDocument(_delta);
  36. }
  37. NotusDocument.fromJson(List data)
  38. : _heuristics = NotusHeuristics.fallback,
  39. _delta = Delta.fromJson(data) {
  40. _loadDocument(_delta);
  41. }
  42. NotusDocument.fromDelta(Delta delta)
  43. : assert(delta != null),
  44. _heuristics = NotusHeuristics.fallback,
  45. _delta = delta {
  46. _loadDocument(_delta);
  47. }
  48. final NotusHeuristics _heuristics;
  49. /// The root node of this document tree.
  50. RootNode get root => _root;
  51. final RootNode _root = RootNode();
  52. /// Length of this document.
  53. int get length => _root.length;
  54. /// Stream of [NotusChange]s applied to this document.
  55. Stream<NotusChange> get changes => _controller.stream;
  56. final StreamController<NotusChange> _controller =
  57. StreamController.broadcast();
  58. /// Returns contents of this document as [Delta].
  59. Delta toDelta() => Delta.from(_delta);
  60. Delta _delta;
  61. /// Returns plain text representation of this document.
  62. String toPlainText() => _delta.toList().map((op) => op.data).join();
  63. dynamic toJson() {
  64. return _delta.toJson();
  65. }
  66. /// Returns `true` if this document and associated stream of [changes]
  67. /// is closed.
  68. ///
  69. /// Modifying a closed document is not allowed.
  70. bool get isClosed => _controller.isClosed;
  71. /// Closes [changes] stream.
  72. void close() {
  73. _controller.close();
  74. }
  75. /// Inserts [text] in this document at specified [index].
  76. ///
  77. /// This method applies heuristic rules before modifying this document and
  78. /// produces a [NotusChange] with source set to [ChangeSource.local].
  79. ///
  80. /// Returns an instance of [Delta] actually composed into this document.
  81. Delta insert(int index, String text) {
  82. assert(index >= 0);
  83. assert(text.isNotEmpty);
  84. text = _sanitizeString(text);
  85. if (text.isEmpty) return Delta();
  86. final change = _heuristics.applyInsertRules(this, index, text);
  87. compose(change, ChangeSource.local);
  88. return change;
  89. }
  90. /// Deletes [length] of characters from this document starting at [index].
  91. ///
  92. /// This method applies heuristic rules before modifying this document and
  93. /// produces a [NotusChange] with source set to [ChangeSource.local].
  94. ///
  95. /// Returns an instance of [Delta] actually composed into this document.
  96. Delta delete(int index, int length) {
  97. assert(index >= 0 && length > 0);
  98. // TODO: need a heuristic rule to ensure last line-break.
  99. final change = _heuristics.applyDeleteRules(this, index, length);
  100. if (change.isNotEmpty) {
  101. // Delete rules are allowed to prevent the edit so it may be empty.
  102. compose(change, ChangeSource.local);
  103. }
  104. return change;
  105. }
  106. /// Replaces [length] of characters starting at [index] [text].
  107. ///
  108. /// This method applies heuristic rules before modifying this document and
  109. /// produces a [NotusChange] with source set to [ChangeSource.local].
  110. ///
  111. /// Returns an instance of [Delta] actually composed into this document.
  112. Delta replace(int index, int length, String text) {
  113. assert(index >= 0 && (text.isNotEmpty || length > 0),
  114. 'With index $index, length $length and text "$text"');
  115. var delta = Delta();
  116. // We have to compose before applying delete rules
  117. // Otherwise delete would be operating on stale document snapshot.
  118. if (text.isNotEmpty) {
  119. delta = insert(index, text);
  120. index = delta.transformPosition(index);
  121. }
  122. if (length > 0) {
  123. final deleteDelta = delete(index, length);
  124. delta = delta.compose(deleteDelta);
  125. }
  126. return delta;
  127. }
  128. /// Formats segment of this document with specified [attribute].
  129. ///
  130. /// Applies heuristic rules before modifying this document and
  131. /// produces a [NotusChange] with source set to [ChangeSource.local].
  132. ///
  133. /// Returns an instance of [Delta] actually composed into this document.
  134. /// The returned [Delta] may be empty in which case this document remains
  135. /// unchanged and no [NotusChange] is published to [changes] stream.
  136. Delta format(int index, int length, NotusAttribute attribute) {
  137. assert(index >= 0 && length >= 0 && attribute != null);
  138. var change = Delta();
  139. if (attribute is EmbedAttribute && length > 0) {
  140. // Must delete selected length of text before applying embed attribute
  141. // since inserting an embed in non-empty selection is essentially a
  142. // replace operation.
  143. change = delete(index, length);
  144. length = 0;
  145. }
  146. final formatChange =
  147. _heuristics.applyFormatRules(this, index, length, attribute);
  148. if (formatChange.isNotEmpty) {
  149. compose(formatChange, ChangeSource.local);
  150. change = change.compose(formatChange);
  151. }
  152. return change;
  153. }
  154. /// Returns style of specified text range.
  155. ///
  156. /// Only attributes applied to all characters within this range are
  157. /// included in the result. Inline and block level attributes are
  158. /// handled separately, e.g.:
  159. ///
  160. /// - block attribute X is included in the result only if it exists for
  161. /// every line within this range (partially included lines are counted).
  162. /// - inline attribute X is included in the result only if it exists
  163. /// for every character within this range (line-break characters excluded).
  164. NotusStyle collectStyle(int index, int length) {
  165. var result = lookupLine(index);
  166. LineNode line = result.node;
  167. return line.collectStyle(result.offset, length);
  168. }
  169. /// Returns [LineNode] located at specified character [offset].
  170. LookupResult lookupLine(int offset) {
  171. // TODO: prevent user from moving caret after last line-break.
  172. var result = _root.lookup(offset, inclusive: true);
  173. if (result.node is LineNode) return result;
  174. BlockNode block = result.node;
  175. return block.lookup(result.offset, inclusive: true);
  176. }
  177. /// Composes [change] into this document.
  178. ///
  179. /// Use this method with caution as it does not apply heuristic rules to the
  180. /// [change].
  181. ///
  182. /// It is callers responsibility to ensure that the [change] conforms to
  183. /// the document model semantics and can be composed with the current state
  184. /// of this document.
  185. ///
  186. /// In case the [change] is invalid, behavior of this method is unspecified.
  187. void compose(Delta change, ChangeSource source) {
  188. _checkMutable();
  189. change.trim();
  190. assert(change.isNotEmpty);
  191. var offset = 0;
  192. final before = toDelta();
  193. for (final op in change.toList()) {
  194. final attributes =
  195. op.attributes != null ? NotusStyle.fromJson(op.attributes) : null;
  196. if (op.isInsert) {
  197. _root.insert(offset, op.data, attributes);
  198. } else if (op.isDelete) {
  199. _root.delete(offset, op.length);
  200. } else if (op.attributes != null) {
  201. _root.retain(offset, op.length, attributes);
  202. }
  203. if (!op.isDelete) offset += op.length;
  204. }
  205. _delta = _delta.compose(change);
  206. if (_delta != _root.toDelta()) {
  207. throw StateError('Compose produced inconsistent results. '
  208. 'This is likely due to a bug in the library. Tried to compose change $change from $source.');
  209. }
  210. _controller.add(NotusChange(before, change, source));
  211. }
  212. //
  213. // Overridden members
  214. //
  215. @override
  216. String toString() => _root.toString();
  217. //
  218. // Private members
  219. //
  220. void _checkMutable() {
  221. assert(!_controller.isClosed,
  222. 'Cannot modify Notus document after it was closed.');
  223. }
  224. String _sanitizeString(String value) {
  225. if (value.contains(EmbedNode.kPlainTextPlaceholder)) {
  226. return value.replaceAll(EmbedNode.kPlainTextPlaceholder, '');
  227. } else {
  228. return value;
  229. }
  230. }
  231. /// Loads [document] delta into this document.
  232. void _loadDocument(Delta doc) {
  233. assert(doc.last.data.endsWith('\n'),
  234. 'Invalid document delta. Document delta must always end with a line-break.');
  235. var offset = 0;
  236. for (final op in doc.toList()) {
  237. final style =
  238. op.attributes != null ? NotusStyle.fromJson(op.attributes) : null;
  239. if (op.isInsert) {
  240. _root.insert(offset, op.data, style);
  241. } else {
  242. throw ArgumentError.value(doc,
  243. 'Document Delta can only contain insert operations but ${op.key} found.');
  244. }
  245. offset += op.length;
  246. }
  247. // Must remove last line if it's empty and with no styles.
  248. // TODO: find a way for DocumentRoot to not create extra line when composing initial delta.
  249. final node = _root.last;
  250. if (node is LineNode &&
  251. node.parent is! BlockNode &&
  252. node.style.isEmpty &&
  253. _root.childCount > 1) {
  254. _root.remove(node);
  255. }
  256. }
  257. }