123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- // Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file
- // for details. All rights reserved. Use of this source code is governed by a
- // BSD-style license that can be found in the LICENSE file.
- import 'dart:async';
-
- import 'package:quill_delta/quill_delta.dart';
-
- import 'document/attributes.dart';
- import 'document/block.dart';
- import 'document/leaf.dart';
- import 'document/line.dart';
- import 'document/node.dart';
- import 'heuristics.dart';
-
- /// Source of a [NotusChange].
- enum ChangeSource {
- /// Change originated from a local action. Typically triggered by user.
- local,
-
- /// Change originated from a remote action.
- remote,
- }
-
- /// Represents a change in a [NotusDocument].
- class NotusChange {
- NotusChange(this.before, this.change, this.source);
-
- /// Document state before [change].
- final Delta before;
-
- /// Change delta applied to the document.
- final Delta change;
-
- /// The source of this change.
- final ChangeSource source;
- }
-
- /// A rich text document.
- class NotusDocument {
- /// Creates new empty Notus document.
- NotusDocument()
- : _heuristics = NotusHeuristics.fallback,
- _delta = new Delta()..insert('\n') {
- _loadDocument(_delta);
- }
-
- NotusDocument.fromJson(List data)
- : _heuristics = NotusHeuristics.fallback,
- _delta = Delta.fromJson(data) {
- _loadDocument(_delta);
- }
-
- NotusDocument.fromDelta(Delta delta)
- : assert(delta != null),
- _heuristics = NotusHeuristics.fallback,
- _delta = delta {
- _loadDocument(_delta);
- }
-
- final NotusHeuristics _heuristics;
-
- /// The root node of this document tree.
- RootNode get root => _root;
- final RootNode _root = new RootNode();
-
- /// Length of this document.
- int get length => _root.length;
-
- /// Stream of [NotusChange]s applied to this document.
- Stream<NotusChange> get changes => _controller.stream;
-
- final StreamController<NotusChange> _controller =
- new StreamController.broadcast();
-
- /// Returns contents of this document as [Delta].
- Delta toDelta() => new Delta.from(_delta);
- Delta _delta;
-
- /// Returns plain text representation of this document.
- String toPlainText() => _delta.toList().map((op) => op.data).join();
-
- dynamic toJson() {
- return _delta.toJson();
- }
-
- /// Returns `true` if this document and associated stream of [changes]
- /// is closed.
- ///
- /// Modifying a closed document is not allowed.
- bool get isClosed => _controller.isClosed;
-
- /// Closes [changes] stream.
- void close() {
- _controller.close();
- }
-
- /// Inserts [text] in this document at specified [index].
- ///
- /// This method applies heuristic rules before modifying this document and
- /// produces a [NotusChange] with source set to [ChangeSource.local].
- ///
- /// Returns an instance of [Delta] actually composed into this document.
- Delta insert(int index, String text) {
- assert(index >= 0);
- assert(text.isNotEmpty);
- text = _sanitizeString(text);
- if (text.isEmpty) return new Delta();
- final change = _heuristics.applyInsertRules(this, index, text);
- compose(change, ChangeSource.local);
- return change;
- }
-
- /// Deletes [length] of characters from this document starting at [index].
- ///
- /// This method applies heuristic rules before modifying this document and
- /// produces a [NotusChange] with source set to [ChangeSource.local].
- ///
- /// Returns an instance of [Delta] actually composed into this document.
- Delta delete(int index, int length) {
- assert(index >= 0 && length > 0);
- // TODO: need a heuristic rule to ensure last line-break.
- final change = _heuristics.applyDeleteRules(this, index, length);
- if (change.isNotEmpty) {
- // Delete rules are allowed to prevent the edit so it may be empty.
- compose(change, ChangeSource.local);
- }
- return change;
- }
-
- /// Replaces [length] of characters starting at [index] [text].
- ///
- /// This method applies heuristic rules before modifying this document and
- /// produces a [NotusChange] with source set to [ChangeSource.local].
- ///
- /// Returns an instance of [Delta] actually composed into this document.
- Delta replace(int index, int length, String text) {
- assert(index >= 0 && (text.isNotEmpty || length > 0),
- 'With index $index, length $length and text "$text"');
- Delta delta = new Delta();
- // We have to compose before applying delete rules
- // Otherwise delete would be operating on stale document snapshot.
- if (text.isNotEmpty) {
- delta = insert(index, text);
- index = delta.transformPosition(index);
- }
-
- if (length > 0) {
- final deleteDelta = delete(index, length);
- delta = delta.compose(deleteDelta);
- }
- return delta;
- }
-
- /// Formats segment of this document with specified [attribute].
- ///
- /// Applies heuristic rules before modifying this document and
- /// produces a [NotusChange] with source set to [ChangeSource.local].
- ///
- /// Returns an instance of [Delta] actually composed into this document.
- /// The returned [Delta] may be empty in which case this document remains
- /// unchanged and no [NotusChange] is published to [changes] stream.
- Delta format(int index, int length, NotusAttribute attribute) {
- assert(index >= 0 && length >= 0 && attribute != null);
- final change = _heuristics.applyFormatRules(this, index, length, attribute);
- if (change.isNotEmpty) {
- compose(change, ChangeSource.local);
- }
- return change;
- }
-
- /// Returns style of specified text range.
- ///
- /// Only attributes applied to all characters within this range are
- /// included in the result. Inline and block level attributes are
- /// handled separately, e.g.:
- ///
- /// - block attribute X is included in the result only if it exists for
- /// every line within this range (partially included lines are counted).
- /// - inline attribute X is included in the result only if it exists
- /// for every character within this range (line-break characters excluded).
- NotusStyle collectStyle(int index, int length) {
- var result = lookupLine(index);
- LineNode line = result.node;
- return line.collectStyle(result.offset, length);
- }
-
- /// Returns [LineNode] located at specified character [offset].
- LookupResult lookupLine(int offset) {
- // TODO: prevent user from moving caret after last line-break.
- var result = _root.lookup(offset, inclusive: true);
- if (result.node is LineNode) return result;
- BlockNode block = result.node;
- return block.lookup(result.offset, inclusive: true);
- }
-
- /// Composes [change] into this document.
- ///
- /// Use this method with caution as it does not apply heuristic rules to the
- /// [change].
- ///
- /// It is callers responsibility to ensure that the [change] conforms to
- /// the document model semantics and can be composed with the current state
- /// of this document.
- ///
- /// In case the [change] is invalid, behavior of this method is unspecified.
- void compose(Delta change, ChangeSource source) {
- _checkMutable();
- change.trim();
- assert(change.isNotEmpty);
-
- int offset = 0;
- final before = toDelta();
- for (final Operation op in change.toList()) {
- final attributes =
- op.attributes != null ? NotusStyle.fromJson(op.attributes) : null;
- if (op.isInsert) {
- _root.insert(offset, op.data, attributes);
- } else if (op.isDelete) {
- _root.delete(offset, op.length);
- } else if (op.attributes != null) {
- _root.retain(offset, op.length, attributes);
- }
- if (!op.isDelete) offset += op.length;
- }
- _delta = _delta.compose(change);
-
- if (_delta != _root.toDelta()) {
- throw new StateError('Compose produced inconsistent results. '
- 'This is likely due to a bug in the library. Tried to compose change $change from $source.');
- }
- _controller.add(new NotusChange(before, change, source));
- }
-
- //
- // Overridden members
- //
- @override
- String toString() => _root.toString();
-
- //
- // Private members
- //
-
- void _checkMutable() {
- assert(!_controller.isClosed,
- 'Cannot modify Notus document after it was closed.');
- }
-
- String _sanitizeString(String value) {
- if (value.contains(EmbedNode.kPlainTextPlaceholder)) {
- return value.replaceAll(EmbedNode.kPlainTextPlaceholder, '');
- } else {
- return value;
- }
- }
-
- /// Loads [document] delta into this document.
- void _loadDocument(Delta doc) {
- assert(doc.last.data.endsWith('\n'),
- 'Invalid document delta. Document delta must always end with a line-break.');
- int offset = 0;
- for (final Operation op in doc.toList()) {
- final style =
- op.attributes != null ? NotusStyle.fromJson(op.attributes) : null;
- if (op.isInsert) {
- _root.insert(offset, op.data, style);
- } else {
- throw new ArgumentError.value(doc,
- "Document Delta can only contain insert operations but ${op.key} found.");
- }
- offset += op.length;
- }
- // Must remove last line if it's empty and with no styles.
- // TODO: find a way for DocumentRoot to not create extra line when composing initial delta.
- Node node = _root.last;
- if (node is LineNode &&
- node.parent is! BlockNode &&
- node.style.isEmpty &&
- _root.childCount > 1) {
- _root.remove(node);
- }
- }
- }
|