// 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:math' as math; import 'package:quill_delta/quill_delta.dart'; import 'attributes.dart'; import 'line.dart'; import 'node.dart'; /// A leaf node in Notus document tree. abstract class LeafNode extends Node with StyledNodeMixin implements StyledNode { /// Creates a new [LeafNode] with specified [value]. LeafNode._([String value = '']) : assert(value != null && !value.contains('\n')), _value = value; factory LeafNode([String value = '']) { LeafNode node; if (value == kZeroWidthSpace) { // Zero-width space is reserved for embed nodes. node = new EmbedNode(); } else { assert( !value.contains(kZeroWidthSpace), 'Zero-width space is reserved for embed leaf nodes and cannot be used ' 'inside regular text nodes.'); node = new TextNode(value); } return node; } /// Plain-text value of this node. String get value => _value; String _value; /// Splits this leaf node at [index] and returns new node. /// /// If this is the last node in its list and [index] equals this node's /// length then this method returns `null` as there is nothing left to split. /// If there is another leaf node after this one and [index] equals this /// node's length then the next leaf node is returned. /// /// If [index] equals to `0` then this node itself is returned unchanged. /// /// In case a new node is actually split from this one, it inherits this /// node's style. LeafNode splitAt(int index) { assert(index >= 0 && index <= length); if (index == 0) return this; if (index == length && isLast) return null; if (index == length && !isLast) return next; String text = _value; _value = text.substring(0, index); final split = new LeafNode(text.substring(index)); split.applyStyle(style); insertAfter(split); return split; } /// Cuts a leaf node from [index] to the end of this node and returns new node /// in detached state (e.g. [mounted] returns `false`). /// /// Splitting logic is identical to one described in [splitAt], meaning this /// method may return `null`. LeafNode cutAt(int index) { assert(index >= 0 && index <= length); LeafNode cut = splitAt(index); cut?.unlink(); return cut; } /// Isolates a new leaf node starting at [index] with specified [length]. /// /// Splitting logic is identical to one described in [splitAt], with one /// exception that it is required for [index] to always be less than this /// node's length. As a result this method always returns a [LeafNode] /// instance. Note that returned node may still be the same as this node /// if provided [index] is `0`. LeafNode isolate(int index, int length) { assert( index >= 0 && index < this.length && (index + length <= this.length), 'Index or length is out of bounds. Index: $index, length: $length. ' 'Actual node length: ${this.length}.'); // Since `index < this.length` (guarded by assert) below line // always returns a new node. LeafNode target = splitAt(index); target.splitAt(length); return target; } /// Formats this node and optimizes it with adjacent leaf nodes if needed. void formatAndOptimize(NotusStyle style) { if (style != null && style.isNotEmpty) { applyStyle(style); } optimize(); } @override void applyStyle(NotusStyle value) { assert(value != null && (value.isInline || value.isEmpty), "Style cannot be applied to this leaf node: $value"); assert(() { if (value.contains(NotusAttribute.embed)) { if (value.get(NotusAttribute.embed) == NotusAttribute.embed.unset) { throw 'Unsetting embed attribute is not allowed. ' 'This operation means that the embed itself must be deleted from the document. ' 'Make sure there is FormatEmbedsRule in your heuristics registry, ' 'which is responsible for handling this scenario.'; } if (this is! EmbedNode) { throw 'Embed style can only be applied to an EmbedNode.'; } } return true; }()); super.applyStyle(value); } @override LineNode get parent => super.parent; @override int get length => _value.length; @override Delta toDelta() { return new Delta()..insert(_value, style.toJson()); } @override String toPlainText() => _value; @override void insert(int index, String value, NotusStyle style) { assert(index >= 0 && (index <= length), 'Index: $index, Length: $length.'); assert(value.isNotEmpty); final node = new LeafNode(value); if (index == length) { insertAfter(node); } else { splitAt(index).insertBefore(node); } node.formatAndOptimize(style); } @override void retain(int index, int length, NotusStyle style) { if (style == null) return; final local = math.min(this.length - index, length); final node = isolate(index, local); int remaining = length - local; if (remaining > 0) { assert(node.next != null); node.next.retain(0, remaining, style); } // Optimize at the very end node.formatAndOptimize(style); } @override void delete(int index, int length) { assert(index < this.length); final local = math.min(this.length - index, length); final target = isolate(index, local); // Memorize siblings before un-linking. final needsOptimize = target.previous; final actualNext = target.next; target.unlink(); int remaining = length - local; if (remaining > 0) { assert(actualNext != null); actualNext.delete(0, remaining); } if (needsOptimize != null) needsOptimize.optimize(); } @override String toString() { final keys = style.keys.toList(growable: false)..sort(); String styleKeys = keys.join(); return "⟨$value⟩$styleKeys"; } /// Optimizes this text node by merging it with adjacent nodes if they share /// the same style. @override void optimize() { LeafNode node = this; if (!node.isFirst) { LeafNode mergeWith = node.previous; if (mergeWith.style == node.style) { mergeWith._value += node.value; node.unlink(); node = mergeWith; } } if (!node.isLast) { LeafNode mergeWith = node.next; if (mergeWith.style == node.style) { node._value += mergeWith._value; mergeWith.unlink(); } } } } /// A span of formatted text within a line in a Notus document. /// /// TextNode is a leaf node of a document tree. /// /// Parent of a text node is always a [LineNode], and as a consequence text /// node's [value] cannot contain any line-break characters. /// /// See also: /// /// * [LineNode], a node representing a line of text. /// * [BlockNode], a node representing a group of lines. class TextNode extends LeafNode { TextNode([String content = '']) : super._(content); } final kZeroWidthSpace = new String.fromCharCode(0x200b); /// An embed node inside of a line in a Notus document. /// /// Embed node is a leaf node similar to [TextNode]. It represents an /// arbitrary piece of non-text content embedded into a document, such as, /// image, horizontal rule, video, or any other object with defined structure, /// like tweet, for instance. /// /// Embed node's length is always `1` character and it is represented with /// zero-width space in the document text. /// /// Any inline style can be applied to an embed, however this does not /// necessarily mean the embed will look according to that style. For instance, /// applying "bold" style to an image gives no effect, while adding a "link" to /// an image actually makes the image react to user's action. class EmbedNode extends LeafNode { static final kPlainTextPlaceholder = new String.fromCharCode(0x200b); EmbedNode() : super._(kPlainTextPlaceholder); }