diff --git a/README.md b/README.md index 56ce5ef..09395f5 100644 --- a/README.md +++ b/README.md @@ -336,12 +336,17 @@ A total order on Positions, independent of any specific assignment of values. An Order manages metadata (bunches) for any number of Lists, Texts, Outlines, and AbsLists. You can also use an Order to create Positions independent of a List (`createPositions`), convert between Positions and AbsPositions (`abs` and `unabs`), and directly view the tree of bunches (`getBunch`, `getBunchFor`). -#### `Text` +#### `Text` A list of characters, represented as an ordered map with Position keys. Text is functionally equivalent to a `List` with single-char values, but it uses strings internally and in bulk methods, instead of arrays of single chars. This reduces memory usage and the size of saved states. +The list may also contain embedded objects of type `E`. +Each embed takes the place of a single character. You can use embeds to represent +non-text content, like images and videos, that may appear inline in a text document. +If you do not specify the generic type `E`, it defaults to `never`, i.e., no embeds are allowed. + #### `Outline` An `Outline` is like a List but without values. Instead, you tell the Outline which Positions are currently present, then use it to convert between Positions and their current indices. @@ -376,7 +381,7 @@ AbsList's API is a hybrid between `Array` and `Map`. Use `ins The library also comes with _unordered_ collections: - `PositionMap`: A map from Positions to values of type `T`, like `List` but without ordering info. -- `PositionCharMap`: A map from Positions to characters, like `Text` but without ordering info. +- `PositionCharMap`: A map from Positions to characters (or embeds), like `Text` but without ordering info. - `PositionSet`: A set of Positions, like `Outline` but without ordering info. These collections do not support in-order or indexed access, but they also do not require managing metadata, and they are slightly more efficient. @@ -401,7 +406,7 @@ Saved states: Each class lets you save and load its internal states in JSON form - `ListSavedState` - `OrderSavedState` -- `TextSavedState` +- `TextSavedState` - `OutlineSavedState` - `AbsListSavedState` @@ -482,16 +487,16 @@ Each benchmark applies the [automerge-perf](https://github.com/automerge/automer Results for an op-based/state-based text CRDT built on top of a Text + PositionSet, on my laptop: -- Sender time (ms): 655 +- Sender time (ms): 722 - Avg update size (bytes): 92.7 -- Receiver time (ms): 369 +- Receiver time (ms): 416 - Save time (ms): 11 -- Save size (bytes): 599817 -- Load time (ms): 10 -- Save time GZIP'd (ms): 42 -- Save size GZIP'd (bytes): 87006 +- Save size (bytes): 598917 +- Load time (ms): 11 +- Save time GZIP'd (ms): 40 +- Save size GZIP'd (bytes): 86969 - Load time GZIP'd (ms): 30 -- Mem used estimate (MB): 1.8 +- Mem used estimate (MB): 2.0 For more results, see [benchmark_results.md](./benchmark_results.md). diff --git a/benchmark_results.md b/benchmark_results.md index 82373ec..eb4ae59 100644 --- a/benchmark_results.md +++ b/benchmark_results.md @@ -13,15 +13,15 @@ For perspective on the save sizes: the final text (excluding deleted chars) is 1 Use `List` and send updates directly over a reliable link (e.g. WebSocket). Updates and saved states use JSON encoding, with optional GZIP for saved states. -- Sender time (ms): 623 +- Sender time (ms): 671 - Avg update size (bytes): 86.8 -- Receiver time (ms): 342 -- Save time (ms): 9 -- Save size (bytes): 804020 -- Load time (ms): 14 -- Save time GZIP'd (ms): 54 -- Save size GZIP'd (bytes): 89118 -- Load time GZIP'd (ms): 36 +- Receiver time (ms): 384 +- Save time (ms): 8 +- Save size (bytes): 803120 +- Load time (ms): 17 +- Save time GZIP'd (ms): 55 +- Save size GZIP'd (bytes): 89013 +- Load time GZIP'd (ms): 37 - Mem used estimate (MB): 2.2 ## AbsList Direct @@ -29,31 +29,31 @@ Updates and saved states use JSON encoding, with optional GZIP for saved states. Use `AbsList` and send updates directly over a reliable link (e.g. WebSocket). Updates and saved states use JSON encoding, with optional GZIP for saved states. -- Sender time (ms): 1504 +- Sender time (ms): 1576 - Avg update size (bytes): 216.2 - AbsPosition length stats: avg = 187.4, percentiles [25, 50, 75, 100] = 170,184,202,272 -- Receiver time (ms): 739 -- Save time (ms): 15 -- Save size (bytes): 868579 -- Load time (ms): 19 -- Save time GZIP'd (ms): 64 -- Save size GZIP'd (bytes): 87086 -- Load time GZIP'd (ms): 44 -- Mem used estimate (MB): 2.1 +- Receiver time (ms): 791 +- Save time (ms): 14 +- Save size (bytes): 867679 +- Load time (ms): 21 +- Save time GZIP'd (ms): 63 +- Save size GZIP'd (bytes): 87108 +- Load time GZIP'd (ms): 46 +- Mem used estimate (MB): 2.2 ## List Direct w/ Custom Encoding Use `List` and send updates directly over a reliable link (e.g. WebSocket). Updates use a custom string encoding; saved states use JSON with optional GZIP. -- Sender time (ms): 509 +- Sender time (ms): 556 - Avg update size (bytes): 31.2 -- Receiver time (ms): 299 -- Save time (ms): 8 -- Save size (bytes): 804020 +- Receiver time (ms): 357 +- Save time (ms): 9 +- Save size (bytes): 803120 - Load time (ms): 11 -- Save time GZIP'd (ms): 49 -- Save size GZIP'd (bytes): 89113 +- Save time GZIP'd (ms): 47 +- Save size GZIP'd (bytes): 89021 - Load time GZIP'd (ms): 36 - Mem used estimate (MB): 2.2 @@ -62,16 +62,16 @@ Updates use a custom string encoding; saved states use JSON with optional GZIP. Use `Text` and send updates directly over a reliable link (e.g. WebSocket). Updates and saved states use JSON encoding, with optional GZIP for saved states. -- Sender time (ms): 619 +- Sender time (ms): 693 - Avg update size (bytes): 86.8 -- Receiver time (ms): 389 +- Receiver time (ms): 444 - Save time (ms): 5 -- Save size (bytes): 493835 +- Save size (bytes): 492935 - Load time (ms): 8 -- Save time GZIP'd (ms): 36 -- Save size GZIP'd (bytes): 73737 -- Load time GZIP'd (ms): 22 -- Mem used estimate (MB): 1.3 +- Save time GZIP'd (ms): 35 +- Save size GZIP'd (bytes): 73709 +- Load time GZIP'd (ms): 24 +- Mem used estimate (MB): 1.4 ## Outline Direct @@ -79,16 +79,16 @@ Use `Outline` and send updates directly over a reliable link (e.g. WebSocket). Updates and saved states use JSON encoding, with optional GZIP for saved states. Neither updates nor saved states include values (chars). -- Sender time (ms): 587 +- Sender time (ms): 648 - Avg update size (bytes): 78.4 -- Receiver time (ms): 326 -- Save time (ms): 5 +- Receiver time (ms): 365 +- Save time (ms): 6 - Save size (bytes): 382419 - Load time (ms): 7 - Save time GZIP'd (ms): 24 -- Save size GZIP'd (bytes): 39367 -- Load time GZIP'd (ms): 14 -- Mem used estimate (MB): 1.2 +- Save size GZIP'd (bytes): 39364 +- Load time GZIP'd (ms): 13 +- Mem used estimate (MB): 1.1 ## TextCrdt @@ -96,16 +96,16 @@ Use a hybrid op-based/state-based CRDT implemented on top of the library's data This variant uses a Text + PositionSet to store the state and Positions in messages, manually managing BunchMetas. Updates and saved states use JSON encoding, with optional GZIP for saved states. -- Sender time (ms): 655 +- Sender time (ms): 722 - Avg update size (bytes): 92.7 -- Receiver time (ms): 369 +- Receiver time (ms): 416 - Save time (ms): 11 -- Save size (bytes): 599817 -- Load time (ms): 10 -- Save time GZIP'd (ms): 42 -- Save size GZIP'd (bytes): 87006 +- Save size (bytes): 598917 +- Load time (ms): 11 +- Save time GZIP'd (ms): 40 +- Save size GZIP'd (bytes): 86969 - Load time GZIP'd (ms): 30 -- Mem used estimate (MB): 1.8 +- Mem used estimate (MB): 2.0 ## ListCrdt @@ -113,13 +113,13 @@ Use a hybrid op-based/state-based CRDT implemented on top of the library's data This variant uses a List of characters + PositionSet to store the state and Positions in messages, manually managing BunchMetas. Updates and saved states use JSON encoding, with optional GZIP for saved states. -- Sender time (ms): 701 +- Sender time (ms): 762 - Avg update size (bytes): 94.8 -- Receiver time (ms): 472 +- Receiver time (ms): 507 - Save time (ms): 13 -- Save size (bytes): 910002 -- Load time (ms): 21 -- Save time GZIP'd (ms): 64 -- Save size GZIP'd (bytes): 102650 -- Load time GZIP'd (ms): 35 -- Mem used estimate (MB): 2.5 +- Save size (bytes): 909102 +- Load time (ms): 15 +- Save time GZIP'd (ms): 57 +- Save size GZIP'd (bytes): 102554 +- Load time GZIP'd (ms): 36 +- Mem used estimate (MB): 2.6 diff --git a/package-lock.json b/package-lock.json index 101d835..7c734c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "lex-sequence": "^2.0.0", "maybe-random-string": "^1.0.0", - "sparse-array-rled": "^1.0.0" + "sparse-array-rled": "^2.0.1" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", @@ -4362,9 +4362,9 @@ } }, "node_modules/sparse-array-rled": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/sparse-array-rled/-/sparse-array-rled-1.0.0.tgz", - "integrity": "sha512-qTcQ4jMSggbqMz+ySCdKcCNFXEKckkR/ZO6JzHJXZuV3ib8/djiEUkd+pV+zcSwKMRAKaQfqBN528LIbd0Wepg==" + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/sparse-array-rled/-/sparse-array-rled-2.0.1.tgz", + "integrity": "sha512-kfJ0KmfahwO4s+dClWfT3HoWou2fNJy+1xOPY9kNr91lum9FYq5Pza7Y1qDx/gmdSsQ3tl5CnFlIP4jnO6xgZQ==" }, "node_modules/spawn-wrap": { "version": "2.0.0", diff --git a/package.json b/package.json index 5f7d600..e039dd1 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "dependencies": { "lex-sequence": "^2.0.0", "maybe-random-string": "^1.0.0", - "sparse-array-rled": "^1.0.0" + "sparse-array-rled": "^2.0.1" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", diff --git a/src/index.ts b/src/index.ts index e7a98a7..1bbf162 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,13 @@ -export * from "./abs_list"; -export * from "./abs_position"; -export * from "./bunch"; -export * from "./bunch_ids"; -export * from "./lexicographic_string"; -export * from "./list"; -export * from "./order"; -export * from "./outline"; -export * from "./position"; -export * from "./text"; +export * from "./lists/abs_list"; +export * from "./lists/list"; +export * from "./lists/outline"; +export * from "./lists/text"; +export * from "./order/abs_position"; +export * from "./order/bunch"; +export * from "./order/bunch_ids"; +export * from "./order/lexicographic_string"; +export * from "./order/order"; +export * from "./order/position"; export * from "./unordered_collections/position_char_map"; export * from "./unordered_collections/position_map"; export * from "./unordered_collections/position_set"; diff --git a/src/internal/item_list.ts b/src/internal/item_list.ts index 82fd6fe..5d66db2 100644 --- a/src/internal/item_list.ts +++ b/src/internal/item_list.ts @@ -1,7 +1,7 @@ -import type { SparseItems } from "sparse-array-rled"; -import { BunchMeta, BunchNode } from "../bunch"; -import { Order } from "../order"; -import { MAX_POSITION, MIN_POSITION, Position } from "../position"; +import { SparseIndices, type SparseItems } from "sparse-array-rled"; +import { BunchMeta, BunchNode } from "../order/bunch"; +import { Order } from "../order/order"; +import { MAX_POSITION, MIN_POSITION, Position } from "../order/position"; export interface SparseItemsFactory> { "new"(): S; @@ -244,6 +244,8 @@ export class ItemList> { /** * Returns the [item, offset] at position, or null if it is not currently present. + * + * **Warning**: item is aliased internally! Use immediately and discard. */ getItem(pos: Position): [item: I, offset: number] | null { const data = this.state.get(this.order.getNodeFor(pos)); @@ -254,6 +256,8 @@ export class ItemList> { /** * Returns the [item, offset] currently at index. * + * **Warning**: item is aliased internally! Use immediately and discard. + * * @throws If index is not in `[0, this.length)`. * Note that this differs from an ordinary Array, * which would instead return undefined. @@ -646,11 +650,11 @@ export class ItemList> { const savedState: { [bunchID: string]: number[] } = {}; for (const [node, data] of this.state) { if (!data.values.isEmpty()) { - savedState[node.bunchID] = data.values - .serialize() - .map((item, i) => - i % 2 === 0 ? this.itemsFactory.length(item as I) : (item as number) - ); + const indices = SparseIndices.new(); + for (const [index, item] of data.values.items()) { + indices.set(index, this.itemsFactory.length(item)); + } + savedState[node.bunchID] = indices.serialize(); } } return savedState; diff --git a/src/abs_list.ts b/src/lists/abs_list.ts similarity index 98% rename from src/abs_list.ts rename to src/lists/abs_list.ts index 5c823f4..98bb565 100644 --- a/src/abs_list.ts +++ b/src/lists/abs_list.ts @@ -1,6 +1,6 @@ -import { AbsBunchMeta, AbsPosition, AbsPositions } from "./abs_position"; +import { AbsBunchMeta, AbsPosition, AbsPositions } from "../order/abs_position"; +import { Order } from "../order/order"; import { List, ListSavedState } from "./list"; -import { Order } from "./order"; /** * A JSON-serializable saved state for an `AbsList`. @@ -27,15 +27,14 @@ import { Order } from "./order"; * uses a compact JSON representation with run-length encoded deletions, identical to `SerializedSparseArray` from the * [sparse-array-rled](https://github.com/mweidner037/sparse-array-rled#readme) package. * It alternates between: - * - arrays of present values (even indices), and - * - numbers (odd indices), representing that number of deleted values. + * - arrays of present values, and + * - numbers, representing that number of deleted indices (empty slots). * * For example, the sparse array `["foo", "bar", , , , "X", "yy"]` serializes to * `[["foo", "bar"], 3, ["X", "yy"]]`. * - * Trivial entries (empty arrays, 0s, & trailing deletions) are always omitted, - * except that the 0th entry may be an empty array. - * For example, the sparse array `[, , "biz", "baz"]` serializes to `[[], 2, ["biz", "baz"]]`. + * Trivial entries (empty arrays, 0s, & trailing deletions) are always omitted. + * For example, the sparse array `[, , "biz", "baz"]` serializes to `[2, ["biz", "baz"]]`. */ export type AbsListSavedState = Array<{ bunchMeta: AbsBunchMeta; diff --git a/src/list.ts b/src/lists/list.ts similarity index 97% rename from src/list.ts rename to src/lists/list.ts index 48f631b..fac19e8 100644 --- a/src/list.ts +++ b/src/lists/list.ts @@ -1,9 +1,9 @@ import { SparseArray } from "sparse-array-rled"; -import { BunchMeta } from "./bunch"; -import { ItemList, SparseItemsFactory } from "./internal/item_list"; -import { normalizeSliceRange } from "./internal/util"; -import { Order } from "./order"; -import { Position } from "./position"; +import { ItemList, SparseItemsFactory } from "../internal/item_list"; +import { normalizeSliceRange } from "../internal/util"; +import { BunchMeta } from "../order/bunch"; +import { Order } from "../order/order"; +import { Position } from "../order/position"; import { Outline, OutlineSavedState } from "./outline"; const sparseArrayFactory: SparseItemsFactory< @@ -45,15 +45,14 @@ const sparseArrayFactory: SparseItemsFactory< * uses a compact JSON representation with run-length encoded deletions, identical to `SerializedSparseArray` from the * [sparse-array-rled](https://github.com/mweidner037/sparse-array-rled#readme) package. * It alternates between: - * - arrays of present values (even indices), and - * - numbers (odd indices), representing that number of deleted values. + * - arrays of present values, and + * - numbers, representing that number of deleted indices (empty slots). * * For example, the sparse array `["foo", "bar", , , , "X", "yy"]` serializes to * `[["foo", "bar"], 3, ["X", "yy"]]`. * - * Trivial entries (empty arrays, 0s, & trailing deletions) are always omitted, - * except that the 0th entry may be an empty array. - * For example, the sparse array `[, , "biz", "baz"]` serializes to `[[], 2, ["biz", "baz"]]`. + * Trivial entries (empty arrays, 0s, & trailing deletions) are always omitted. + * For example, the sparse array `[, , "biz", "baz"]` serializes to `[2, ["biz", "baz"]]`. */ export type ListSavedState = { [bunchID: string]: (T[] | number)[]; diff --git a/src/outline.ts b/src/lists/outline.ts similarity index 98% rename from src/outline.ts rename to src/lists/outline.ts index 17fd3a4..e13e5f7 100644 --- a/src/outline.ts +++ b/src/lists/outline.ts @@ -1,8 +1,8 @@ import { SparseIndices } from "sparse-array-rled"; -import { BunchMeta } from "./bunch"; -import { ItemList, SparseItemsFactory } from "./internal/item_list"; -import { Order } from "./order"; -import { Position } from "./position"; +import { ItemList, SparseItemsFactory } from "../internal/item_list"; +import { BunchMeta } from "../order/bunch"; +import { Order } from "../order/order"; +import { Position } from "../order/position"; const sparseIndicesFactory: SparseItemsFactory = { // eslint-disable-next-line @typescript-eslint/unbound-method diff --git a/src/text.ts b/src/lists/text.ts similarity index 62% rename from src/text.ts rename to src/lists/text.ts index 82104ad..04381cc 100644 --- a/src/text.ts +++ b/src/lists/text.ts @@ -1,27 +1,34 @@ import { SparseString } from "sparse-array-rled"; -import { BunchMeta } from "./bunch"; -import { ItemList, SparseItemsFactory } from "./internal/item_list"; -import { normalizeSliceRange } from "./internal/util"; -import { Order } from "./order"; -import { Position } from "./position"; -import { OutlineSavedState, Outline } from "./outline"; - -const sparseStringFactory: SparseItemsFactory = { +import { ItemList, SparseItemsFactory } from "../internal/item_list"; +import { normalizeSliceRange } from "../internal/util"; +import { BunchMeta } from "../order/bunch"; +import { Order } from "../order/order"; +import { Position } from "../order/position"; +import { Outline, OutlineSavedState } from "./outline"; + +const sparseStringFactory: SparseItemsFactory< + string | object, + SparseString +> = { // eslint-disable-next-line @typescript-eslint/unbound-method new: SparseString.new, // eslint-disable-next-line @typescript-eslint/unbound-method deserialize: SparseString.deserialize, length(item) { - return item.length; + if (typeof item === "string") return item.length; + else return 1; }, slice(item, start, end) { - return item.slice(start, end); + if (typeof item === "string") return item.slice(start, end); + else return item; }, } as const; -function checkChar(char: string): void { - if (char.length !== 1) { - throw new Error(`Values must be single chars, not "${char}"`); +function checkCharOrEmbed( + charOrEmbed: string | E +): void { + if (typeof charOrEmbed === "string" && charOrEmbed.length !== 1) { + throw new Error(`Values must be single chars, not "${charOrEmbed}"`); } } @@ -38,27 +45,30 @@ function checkChar(char: string): void { * with Positions present in the Text, map its bunchID to a serialized form of * the sparse string * ``` - * innerIndex -> (char at Position { bunchID, innerIndex }) + * innerIndex -> (char (or embed) at Position { bunchID, innerIndex }) * ``` * The bunches are in no particular order. * * ### Per-Bunch Format * - * Each bunch's serialized sparse string (type `(string | number)[]`) - * uses a compact JSON representation with run-length encoded deletions, identical to `SerializedSparseString` from the + * Each bunch's serialized sparse string (type `(string | E | number)[]`) + * uses a compact JSON representation with run-length encoded deletions, identical to `SerializedSparseString` from the * [sparse-array-rled](https://github.com/mweidner037/sparse-array-rled#readme) package. - * It alternates between: - * - strings of concatenated present chars (even indices), and - * - numbers (odd indices), representing that number of deleted values. + * It consists of: + * - strings of concatenated present chars, + * - embedded objects of type `E`, and + * - numbers, representing that number of deleted indices. * * For example, the sparse string `["a", "b", , , , "f", "g"]` serializes to `["ab", 3, "fg"]`. * - * Trivial entries (empty strings, 0s, & trailing deletions) are always omitted, - * except that the 0th entry may be an empty string. - * For example, the sparse string `[, , "x", "y"]` serializes to `["", 2, "xy"]`. + * As an example with an embed, the sparse string `["h", "i", " ", { type: "image", ... }, "!"]` + * serializes to `["hi ", { type: "image", ... }, "!"]`. + * + * Trivial entries (empty strings, 0s, & trailing deletions) are always omitted. + * For example, the sparse string `[, , "x", "y"]` serializes to `[2, "xy"]`. */ -export type TextSavedState = { - [bunchID: string]: (string | number)[]; +export type TextSavedState = { + [bunchID: string]: (string | E | number)[]; }; /** @@ -70,16 +80,24 @@ export type TextSavedState = { * but it uses strings internally and in bulk methods, instead of arrays * of single chars. This reduces memory usage and the size of saved states. * + * The list may also contain embedded objects of type `E`. + * Each embed takes the place of a single character. You can use embeds to represent + * non-text content, like images and videos, that may appear inline in a text document. + * If you do not specify the generic type `E`, it defaults to `never`, i.e., no embeds are allowed. + * * Technically, Text is a sequence of UTF-16 code units, like an ordinary JavaScript * string ([MDN reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#utf-16_characters_unicode_code_points_and_grapheme_clusters)). + * + * @typeParam E - The type of embeds, or `never` (no embeds allowed) if not specified. + * Embeds must be non-null objects. */ -export class Text { +export class Text { /** * The Order that manages this list's Positions and their metadata. * See [Managing Metadata](https://github.com/mweidner037/list-positions#managing-metadata). */ readonly order: Order; - private readonly itemList: ItemList; + private readonly itemList: ItemList>; /** * Constructs a Text, initially empty. @@ -92,7 +110,10 @@ export class Text { */ constructor(order?: Order) { this.order = order ?? new Order(); - this.itemList = new ItemList(this.order, sparseStringFactory); + this.itemList = new ItemList( + this.order, + sparseStringFactory as SparseItemsFactory> + ); } /** @@ -102,14 +123,14 @@ export class Text { * Like when loading a saved state, you must deliver all of the Positions' * dependent metadata to `order` before calling this method. */ - static fromEntries( - entries: Iterable<[pos: Position, char: string]>, + static fromEntries( + entries: Iterable<[pos: Position, charOrEmbed: string | E]>, order: Order - ): Text { - const text = new Text(order); - for (const [pos, char] of entries) { - checkChar(char); - text.set(pos, char); + ): Text { + const text = new Text(order); + for (const [pos, charOrEmbed] of entries) { + checkCharOrEmbed(charOrEmbed); + text.set(pos, charOrEmbed); } return text; } @@ -121,13 +142,13 @@ export class Text { * Like when loading a saved state, you must deliver all of the Positions' * dependent metadata to `order` before calling this method. */ - static fromItems( - items: Iterable<[startPos: Position, chars: string]>, + static fromItems( + items: Iterable<[startPos: Position, charsOrEmbed: string | E]>, order: Order - ): Text { - const text = new Text(order); - for (const [startPos, chars] of items) { - text.set(startPos, chars); + ): Text { + const text = new Text(order); + for (const [startPos, charsOrEmbed] of items) { + text.set(startPos, charsOrEmbed); } return text; } @@ -137,13 +158,13 @@ export class Text { // ---------- /** - * Sets the char at the given position. + * Sets the char (or embed) at the given position. * - * If the position is already present, its char is overwritten. - * Otherwise, later chars in the list shift right + * If the position is already present, its value is overwritten. + * Otherwise, later values in the list shift right * (increment their index). */ - set(pos: Position, char: string): void; + set(pos: Position, charOrEmbed: string | E): void; /** * Sets the chars at a sequence of Positions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). * @@ -154,25 +175,25 @@ export class Text { * @see {@link expandPositions} */ set(startPos: Position, chars: string): void; - set(startPos: Position, chars: string): void { - this.itemList.set(startPos, chars); + set(startPos: Position, charsOrEmbed: string | E): void { + this.itemList.set(startPos, charsOrEmbed); } /** - * Sets the char at the given index (equivalently, at Position `this.positionAt(index)`), - * overwriting the existing char. + * Sets the char (or embed) at the given index (equivalently, at Position `this.positionAt(index)`), + * overwriting the existing value. * * @throws If index is not in `[0, this.length)`. */ - setAt(index: number, char: string): void { - checkChar(char); - this.set(this.positionAt(index), char); + setAt(index: number, charOrEmbed: string | E): void { + checkCharOrEmbed(charOrEmbed); + this.set(this.positionAt(index), charOrEmbed); } /** - * Deletes the given position, making it and its char no longer present in the list. + * Deletes the given position, making it and its char (or embed) no longer present in the list. * - * If the position was indeed present, later chars in the list shift left (decrement their index). + * If the position was indeed present, later values in the list shift left (decrement their index). */ delete(pos: Position): void; /** @@ -190,7 +211,7 @@ export class Text { } /** - * Deletes `count` chars starting at `index`. + * Deletes `count` values starting at `index`. * * @throws If any of `index`, ..., `index + count - 1` are not in `[0, this.length)`. */ @@ -203,7 +224,7 @@ export class Text { } /** - * Deletes every char in the list, making it empty. + * Deletes every value in the list, making it empty. * * `this.order` is unaffected (retains all metadata). */ @@ -212,9 +233,9 @@ export class Text { } /** - * Inserts the given char just after prevPos, at a new Position. + * Inserts the given char (or embed) just after prevPos, at a new Position. - * Later chars in the list shift right + * Later values in the list shift right * (increment their index). * * In a collaborative setting, the new Position is *globally unique*, even @@ -225,7 +246,7 @@ export class Text { */ insert( prevPos: Position, - char: string + charOrEmbed: string | E ): [pos: Position, newMeta: BunchMeta | null]; /** * Inserts the given chars just after prevPos, at a series of new Positions. @@ -246,15 +267,15 @@ export class Text { ): [startPos: Position, newMeta: BunchMeta | null]; insert( prevPos: Position, - chars: string + charsOrEmbed: string | E ): [startPos: Position, newMeta: BunchMeta | null] { - return this.itemList.insert(prevPos, chars); + return this.itemList.insert(prevPos, charsOrEmbed); } /** - * Inserts the given char at `index` (i.e., between the chars at `index - 1` and `index`), at a new Position. + * Inserts the given char (or embed) at `index` (i.e., between the values at `index - 1` and `index`), at a new Position. * - * Later chars in the list shift right + * Later values in the list shift right * (increment their index). * * In a collaborative setting, the new Position is *globally unique*, even @@ -265,7 +286,7 @@ export class Text { */ insertAt( index: number, - char: string + charOrEmbed: string | E ): [pos: Position, newMeta: BunchMeta | null]; /** * Inserts the given chars at `index` (i.e., between the chars at `index - 1` and `index`), at a series of new Positions. @@ -286,9 +307,9 @@ export class Text { ): [startPos: Position, newMeta: BunchMeta | null]; insertAt( index: number, - chars: string + charsOrEmbed: string ): [startPos: Position, newMeta: BunchMeta | null] { - return this.itemList.insertAt(index, chars); + return this.itemList.insertAt(index, charsOrEmbed); } // ---------- @@ -296,23 +317,25 @@ export class Text { // ---------- /** - * Returns the char at the given position, or undefined if it is not currently present. + * Returns the char (or embed) at the given position, or undefined if it is not currently present. */ - get(pos: Position): string | undefined { + get(pos: Position): string | E | undefined { const located = this.itemList.getItem(pos); if (located === null) return undefined; const [item, offset] = located; - return item[offset]; + if (typeof item === "string") return item[offset]; + else return item; } /** - * Returns the char currently at index. + * Returns the char (or embed) currently at index. * * @throws If index is not in `[0, this.length)`. */ - getAt(index: number): string { + getAt(index: number): string | E { const [item, offset] = this.itemList.getItemAt(index); - return item[offset]; + if (typeof item === "string") return item[offset]; + else return item; } /** @@ -329,10 +352,10 @@ export class Text { * then the result depends on searchDir: * - "none" (default): Returns -1. * - "left": Returns the next index to the left of pos. - * If there are no chars to the left of pos, + * If there are no values to the left of pos, * returns -1. * - "right": Returns the next index to the right of pos. - * If there are no chars to the right of pos, + * If there are no values to the right of pos, * returns `this.length`. * * To find the index where a position would be if @@ -391,39 +414,75 @@ export class Text { // Iterators // ---------- - /** Iterates over chars in the list, in list order. */ - [Symbol.iterator](): IterableIterator { + /** Iterates over chars (and embeds) in the list, in list order. */ + [Symbol.iterator](): IterableIterator { return this.values(); } /** - * Iterates over chars in the list, in list order. + * Iterates over chars (and embeds) in the list, in list order. * * Optionally, you may specify a range of indices `[start, end)` instead of * iterating the entire list. * * @throws If `start < 0`, `end > this.length`, or `start > end`. */ - *values(start?: number, end?: number): IterableIterator { - for (const [, item] of this.itemList.items(start, end)) yield* item; + *values(start?: number, end?: number): IterableIterator { + for (const [, item] of this.itemList.items(start, end)) { + if (typeof item === "string") yield* item; + else yield item; + } } /** * Returns a copy of a section of this list, as a string. * + * If the section contains embeds, they are replaced with `\uFFFC`, the object + * replacement character. Text editors might render this as a box containing "OBJ". + * To preserve embeds, use {@link sliceWithEmbeds}. + * * Arguments are as in [Array.slice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice). */ slice(start?: number, end?: number): string { [start, end] = normalizeSliceRange(this.length, start, end); let ans = ""; - for (const [, chars] of this.itemList.items(start, end)) { - ans += chars; + for (const [, charsOrEmbed] of this.itemList.items(start, end)) { + if (typeof charsOrEmbed === "string") ans += charsOrEmbed; + else ans += "\uFFFC"; + } + return ans; + } + + /** + * Returns a copy of a section of this list, as an array of strings and embeds. + * + * The string sections are separated by embeds. + * For example, suppose `list` has char/embed values `["H", "i", " ", { type: "image", ... }, "!"]`. + * Then `list.sliceWithEmbeds()` returns `["Hi ", { type: "image", ... }, "!"]`. + * + * Arguments are as in [Array.slice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice). + */ + sliceWithEmbeds(start?: number, end?: number): (string | E)[] { + [start, end] = normalizeSliceRange(this.length, start, end); + const ans: (string | E)[] = []; + for (const [, charsOrEmbed] of this.itemList.items(start, end)) { + if ( + ans.length !== 0 && + typeof charsOrEmbed === "string" && + typeof ans[ans.length - 1] === "string" + ) { + ans[ans.length - 1] += charsOrEmbed; + } else ans.push(charsOrEmbed); } return ans; } /** * Returns the current text as a literal string. + * + * If the string contains embeds, they are replaced with `\uFFFC`, the object + * replacement character. Text editors might render this as a box containing "OBJ". + * To preserve embeds, use {@link sliceWithEmbeds}. */ toString(): string { return this.slice(); @@ -442,7 +501,7 @@ export class Text { } /** - * Iterates over [pos, char] pairs in the list, in list order. These are its entries as an ordered map. + * Iterates over [pos, char (or embed)] pairs in the list, in list order. These are its entries as an ordered map. * * Optionally, you may specify a range of indices `[start, end)` instead of * iterating the entire list. @@ -452,23 +511,27 @@ export class Text { *entries( start?: number, end?: number - ): IterableIterator<[pos: Position, char: string]> { + ): IterableIterator<[pos: Position, charOrEmbed: string | E]> { for (const [ { bunchID, innerIndex: startInnerIndex }, item, ] of this.itemList.items(start, end)) { - for (let i = 0; i < item.length; i++) { - yield [{ bunchID, innerIndex: startInnerIndex + i }, item[i]]; - } + if (typeof item === "string") { + for (let i = 0; i < item.length; i++) { + yield [{ bunchID, innerIndex: startInnerIndex + i }, item[i]]; + } + } else yield [{ bunchID, innerIndex: startInnerIndex }, item]; } } /** * Iterates over items, in list order. * - * Each *item* is a series of entries that have contiguous positions + * Each *item* [startPos, charsOrEmbed] is either an individual embed at startPos, + * or a series of characters that have contiguous positions * from the same [bunch](https://github.com/mweidner037/list-positions#bunches). - * Specifically, for an item [startPos, chars], the positions start at `startPos` + * Specifically, for a string-valued item [startPos, chars: string], + * the individual chars' positions start at `startPos` * and have the same `bunchID` but increasing `innerIndex`. * * You can use this method as an optimized version of other iterators, or as @@ -482,7 +545,7 @@ export class Text { items( start?: number, end?: number - ): IterableIterator<[startPos: Position, chars: string]> { + ): IterableIterator<[startPos: Position, charsOrEmbed: string | E]> { return this.itemList.items(start, end); } @@ -509,18 +572,18 @@ export class Text { /** * Returns a saved state for this Text. * - * The saved state describes our current (Position -> char) map in JSON-serializable form. + * The saved state describes our current (Position -> char/embed) map in JSON-serializable form. * You can load this state on another Text by calling `load(savedState)`, * possibly in a different session or on a different device. */ - save(): TextSavedState { + save(): TextSavedState { return this.itemList.save(); } /** * Loads a saved state returned by another Text's `save()` method. * - * Loading sets our (Position -> char) map to match the saved Text's, *overwriting* + * Loading sets our (Position -> char/embed) map to match the saved Text's, *overwriting* * our current state. * * **Before loading a saved state, you must deliver its dependent metadata @@ -529,19 +592,19 @@ export class Text { * See [Managing Metadata](https://github.com/mweidner037/list-positions#save-load) for an example * with List (Text is analogous). */ - load(savedState: TextSavedState): void { + load(savedState: TextSavedState): void { this.itemList.load(savedState); } /** * Returns a saved state for this Text's *positions*, independent of its values. * - * `saveOutline` and `loadOutline` let you save a Text's chars (values) as an ordinary string, - * separate from the list-positions info. That is useful for storing the string in a transparent + * `saveOutline` and `loadOutline` let you save a Text's values (chars and embeds) + * separately from the list-positions info. That is useful for storing the string in a transparent * format (e.g., to allow full-text searches) and for migrating data between List/Text/Outline. * * Specifically, this method returns a saved state for an {@link Outline} with the same Positions as this Text. - * You can load the state on another Text by calling `loadOutline(savedState, this.slice())`, + * You can load the state on another Text by calling `loadOutline(savedState, this.sliceWithEmbeds())`, * possibly in a different session or on a different device. * You can also load the state with `Outline.load` or `List.loadOutline`. */ @@ -553,9 +616,10 @@ export class Text { * Loads a saved state returned by another Text's `saveOutline()` method * or by an Outline's `save()` method. * - * Loading sets our (Position -> char) map so that: + * Loading sets our (Position -> char/embed) map so that: * - its keys are the saved state's set of Positions, and - * - its chars are the given `chars`, in list order. + * - its values are given by `charsWithEmbeds`, in list order. + * The `charsWithEmbeds` must use the same format as {@link sliceWithEmbeds}. * * **Before loading a saved state, you must deliver its dependent metadata * to this.order**. For example, you could save and load the Order's state @@ -563,23 +627,52 @@ export class Text { * See [Managing Metadata](https://github.com/mweidner037/list-positions#save-load) for an example * with List (Text is analogous). * - * @throws If the saved state's length does not match `chars.length`. + * @throws If the saved state's length does not match the total length of `charsWithEmbeds`. */ - loadOutline(savedState: OutlineSavedState, chars: string): void { + loadOutline( + savedState: OutlineSavedState, + charsWithEmbeds: (string | E)[] + ): void { const outline = new Outline(this.order); outline.load(savedState); - if (outline.length !== chars.length) { - throw new Error( - `Outline length (${outline.length}) does not match chars.length (${chars.length})` - ); + let index = 0; + for (const charsOrEmbed of charsWithEmbeds) { + if (typeof charsOrEmbed === "string") { + if (index + charsOrEmbed.length > outline.length) { + throw new Error( + `Outline length (${outline.length}) is less than charsWithEmbeds total length` + ); + } + + let charsIndex = 0; + for (const [startPos, count] of outline.items( + index, + index + charsOrEmbed.length + )) { + this.itemList.set( + startPos, + charsOrEmbed.slice(charsIndex, charsIndex + count) + ); + charsIndex += count; + } + index += charsOrEmbed.length; + } else { + if (index + 1 > outline.length) { + throw new Error( + `Outline length (${outline.length}) is less than charsWithEmbeds total length` + ); + } + + this.itemList.set(outline.positionAt(index), charsOrEmbed); + index++; + } } - // Here we rely on the fact that outline.items() is in list order. - let index = 0; - for (const [startPos, count] of outline.items()) { - this.itemList.set(startPos, chars.slice(index, index + count)); - index += count; + if (index !== outline.length) { + throw new Error( + `Outline length (${outline.length}) is greater than charsWithEmbeds length (${index})` + ); } } } diff --git a/src/abs_position.ts b/src/order/abs_position.ts similarity index 99% rename from src/abs_position.ts rename to src/order/abs_position.ts index a8ec580..dd62d42 100644 --- a/src/abs_position.ts +++ b/src/order/abs_position.ts @@ -1,10 +1,10 @@ -import { BunchMeta } from "./bunch"; -import { BunchIDs } from "./bunch_ids"; import { + arrayShallowEquals, parseMaybeDotID, stringifyMaybeDotID, - arrayShallowEquals, -} from "./internal/util"; +} from "../internal/util"; +import { BunchMeta } from "./bunch"; +import { BunchIDs } from "./bunch_ids"; /** * AbsPosition analog of a BunchMeta. diff --git a/src/bunch.ts b/src/order/bunch.ts similarity index 100% rename from src/bunch.ts rename to src/order/bunch.ts diff --git a/src/bunch_ids.ts b/src/order/bunch_ids.ts similarity index 96% rename from src/bunch_ids.ts rename to src/order/bunch_ids.ts index 9da24d3..dfa03ac 100644 --- a/src/bunch_ids.ts +++ b/src/order/bunch_ids.ts @@ -1,5 +1,5 @@ import { maybeRandomString } from "maybe-random-string"; -import { parseMaybeDotID, stringifyMaybeDotID } from "./internal/util"; +import { parseMaybeDotID, stringifyMaybeDotID } from "../internal/util"; /** * Utilities for generating `bunchIDs`. diff --git a/src/lexicographic_string.ts b/src/order/lexicographic_string.ts similarity index 98% rename from src/lexicographic_string.ts rename to src/order/lexicographic_string.ts index 02f11ed..e8d5d23 100644 --- a/src/lexicographic_string.ts +++ b/src/order/lexicographic_string.ts @@ -1,6 +1,6 @@ import { sequence } from "lex-sequence"; +import { stringifyMaybeDotID } from "../internal/util"; import { AbsPosition } from "./abs_position"; -import { stringifyMaybeDotID } from "./internal/util"; const OFFSET_BASE = 36; diff --git a/src/order.ts b/src/order/order.ts similarity index 100% rename from src/order.ts rename to src/order/order.ts diff --git a/src/position.ts b/src/order/position.ts similarity index 100% rename from src/position.ts rename to src/order/position.ts diff --git a/src/unordered_collections/position_char_map.ts b/src/unordered_collections/position_char_map.ts index 018876a..8dfd53f 100644 --- a/src/unordered_collections/position_char_map.ts +++ b/src/unordered_collections/position_char_map.ts @@ -1,6 +1,6 @@ import { SerializedSparseString, SparseString } from "sparse-array-rled"; -import { Position } from "../position"; -import { TextSavedState } from "../text"; +import { TextSavedState } from "../lists/text"; +import { Position } from "../order/position"; /** * A map from Positions to characters, **without ordering info**. @@ -11,8 +11,16 @@ import { TextSavedState } from "../text"; * * For example, you can use a PositionCharMap to accumulate changes to save in a batch later. * There the list order is unnecessary and managing metadata could be inconvenient. + * + * The map values may also be embedded objects of type `E`. + * Each embed takes the place of a single character. You can use embeds to represent + * non-text content, like images and videos, that may appear inline in a text document. + * If you do not specify the generic type `E`, it defaults to `never`, i.e., no embeds are allowed. + * + * @typeParam E - The type of embeds, or `never` (no embeds allowed) if not specified. + * Embeds must be non-null objects. */ -export class PositionCharMap { +export class PositionCharMap { /** * The internal state of this PositionCharMap: A map from bunchID * to the [SparseString](https://github.com/mweidner037/sparse-array-rled#readme) @@ -20,7 +28,7 @@ export class PositionCharMap { * * You are free to manipulate this state directly. */ - readonly state: Map; + readonly state: Map>; constructor() { this.state = new Map(); @@ -31,9 +39,9 @@ export class PositionCharMap { // ---------- /** - * Sets the char at the given position. + * Sets the char (or embed) at the given position. */ - set(pos: Position, char: string): void; + set(pos: Position, charOrEmbed: string | E): void; /** * Sets the chars at a sequence of Positions within the same [bunch](https://github.com/mweidner037/list-positions#bunches). * @@ -42,13 +50,13 @@ export class PositionCharMap { * @see {@link expandPositions} */ set(startPos: Position, chars: string): void; - set(startPos: Position, chars: string): void { + set(startPos: Position, charsOrEmbed: string | E): void { let arr = this.state.get(startPos.bunchID); if (arr === undefined) { arr = SparseString.new(); this.state.set(startPos.bunchID, arr); } - arr.set(startPos.innerIndex, chars); + arr.set(startPos.innerIndex, charsOrEmbed); } /** @@ -91,7 +99,7 @@ export class PositionCharMap { /** * Returns the char at the given position, or undefined if it is not currently present. */ - get(pos: Position): string | undefined { + get(pos: Position): string | E | undefined { return this.state.get(pos.bunchID)?.get(pos.innerIndex); } @@ -107,16 +115,18 @@ export class PositionCharMap { // ---------- /** - * Iterates over [pos, char] pairs in the map, **in no particular order**. + * Iterates over [pos, char (or embed)] pairs in the map, **in no particular order**. */ - [Symbol.iterator](): IterableIterator<[pos: Position, char: string]> { + [Symbol.iterator](): IterableIterator< + [pos: Position, charOrEmbed: string | E] + > { return this.entries(); } /** - * Iterates over [pos, char] pairs in the map, **in no particular order**. + * Iterates over [pos, char (or embed)] pairs in the map, **in no particular order**. */ - *entries(): IterableIterator<[pos: Position, char: string]> { + *entries(): IterableIterator<[pos: Position, charOrEmbed: string | E]> { for (const [bunchID, arr] of this.state) { for (const [innerIndex, value] of arr.entries()) { yield [{ bunchID, innerIndex }, value]; @@ -131,12 +141,12 @@ export class PositionCharMap { /** * Returns a saved state for this map, which is identical to its saved state as a Text. * - * The saved state describes our current (Position -> char) map in JSON-serializable form. + * The saved state describes our current (Position -> char/embed) map in JSON-serializable form. * You can load this state on another PositionCharMap (or Text) by calling `load(savedState)`, * possibly in a different session or on a different device. */ - save(): TextSavedState { - const savedState: { [bunchID: string]: SerializedSparseString } = {}; + save(): TextSavedState { + const savedState: { [bunchID: string]: SerializedSparseString } = {}; for (const [bunchID, arr] of this.state) { if (!arr.isEmpty()) { savedState[bunchID] = arr.serialize(); @@ -148,10 +158,10 @@ export class PositionCharMap { /** * Loads a saved state returned by another PositionCharMap's (or Text's) `save()` method. * - * Loading sets our (Position -> char) map to match the saved PositionCharMap, *overwriting* + * Loading sets our (Position -> char/embed) map to match the saved PositionCharMap, *overwriting* * our current state. */ - load(savedState: TextSavedState): void { + load(savedState: TextSavedState): void { this.clear(); for (const [bunchID, savedArr] of Object.entries(savedState)) { diff --git a/src/unordered_collections/position_map.ts b/src/unordered_collections/position_map.ts index 276cf12..2037705 100644 --- a/src/unordered_collections/position_map.ts +++ b/src/unordered_collections/position_map.ts @@ -1,6 +1,6 @@ import { SerializedSparseArray, SparseArray } from "sparse-array-rled"; -import { ListSavedState } from "../list"; -import { Position } from "../position"; +import { ListSavedState } from "../lists/list"; +import { Position } from "../order/position"; /** * A map from Positions to values of type `T`, **without ordering info**. diff --git a/src/unordered_collections/position_set.ts b/src/unordered_collections/position_set.ts index 0464460..0e06bb6 100644 --- a/src/unordered_collections/position_set.ts +++ b/src/unordered_collections/position_set.ts @@ -1,6 +1,6 @@ import { SerializedSparseIndices, SparseIndices } from "sparse-array-rled"; -import { OutlineSavedState } from "../outline"; -import { Position } from "../position"; +import { OutlineSavedState } from "../lists/outline"; +import { Position } from "../order/position"; /** * A set of Positions, **without ordering info**. diff --git a/test/lists/manual.test.ts b/test/lists/manual.test.ts index 54638ba..0919820 100644 --- a/test/lists/manual.test.ts +++ b/test/lists/manual.test.ts @@ -2,7 +2,7 @@ import { assert } from "chai"; import { maybeRandomString } from "maybe-random-string"; import { describe, test } from "mocha"; import seedrandom from "seedrandom"; -import { List, MAX_POSITION, MIN_POSITION, Order } from "../../src"; +import { List, MAX_POSITION, MIN_POSITION, Order, Text } from "../../src"; import { Checker } from "./util"; describe("lists - manual", () => { @@ -371,4 +371,91 @@ describe("lists - manual", () => { } }); }); + + describe("Text embeds", () => { + interface Embed { + a?: string; + b?: string; + } + + let text!: Text; + + beforeEach(() => { + const replicaID = maybeRandomString({ prng }); + text = new Text(new Order({ replicaID })); + + // Create mis-aligned bunches and string sections. + text.insertAt(0, "hello world"); + text.setAt(5, { a: "foo" }); + text.insertAt(8, "RLD WO"); + }); + + test("slice and sliceWithEmbeds", () => { + assert.strictEqual(text.slice(), "hello\uFFFCwoRLD WOrld"); + assert.deepStrictEqual(text.sliceWithEmbeds(), [ + "hello", + { a: "foo" }, + "woRLD WOrld", + ]); + assert.strictEqual(text.slice().length, text.length); + }); + + test("save and load", () => { + // Check the exact saved state. + const bunchId0 = text.positionAt(0).bunchID; + const bunchId1 = text.positionAt(8).bunchID; + assert.notStrictEqual(bunchId0, bunchId1); + assert.deepStrictEqual(text.save(), { + [bunchId0]: ["hello", { a: "foo" }, "world"], + [bunchId1]: ["RLD WO"], + }); + + // Load on another instance. + const text2 = new Text(text.order); + text2.load(text.save()); + + assert.deepStrictEqual([...text.entries()], [...text2.entries()]); + assert.deepStrictEqual(text.save(), text2.save()); + assert.deepStrictEqual(text.saveOutline(), text2.saveOutline()); + }); + + test("saveOutline and loadOutline", () => { + const text2 = new Text(text.order); + text2.loadOutline(text.saveOutline(), text.sliceWithEmbeds()); + + assert.deepStrictEqual([...text.entries()], [...text2.entries()]); + assert.deepStrictEqual(text.save(), text2.save()); + assert.deepStrictEqual(text.saveOutline(), text2.saveOutline()); + }); + + test("loadOutline errors", () => { + let text2 = new Text(text.order); + assert.throws(() => + text2.loadOutline(text.saveOutline(), [...text.sliceWithEmbeds(), "X"]) + ); + + text2 = new Text(text.order); + assert.throws(() => + text2.loadOutline(text.saveOutline(), [ + ...text.sliceWithEmbeds(), + { a: "wrong" }, + ]) + ); + + const short = text.sliceWithEmbeds(); + short.pop(); + text2 = new Text(text.order); + assert.throws(() => text2.loadOutline(text.saveOutline(), short)); + + const extraChars = text.sliceWithEmbeds(); + extraChars[extraChars.length - 1] += "X"; + text2 = new Text(text.order); + assert.throws(() => text2.loadOutline(text.saveOutline(), extraChars)); + + const missingChars = text.sliceWithEmbeds(); + missingChars[0] = (missingChars[0] as string).slice(1); + text2 = new Text(text.order); + assert.throws(() => text2.loadOutline(text.saveOutline(), missingChars)); + }); + }); }); diff --git a/test/lists/util.ts b/test/lists/util.ts index 2c5dc1d..6a4ce44 100644 --- a/test/lists/util.ts +++ b/test/lists/util.ts @@ -6,8 +6,8 @@ import { Order, Outline, Position, - lexicographicString, expandPositions, + lexicographicString, } from "../../src"; /** diff --git a/test/order/manual.test.ts b/test/order/manual.test.ts index 21e5055..bab1d45 100644 --- a/test/order/manual.test.ts +++ b/test/order/manual.test.ts @@ -6,8 +6,8 @@ import { MIN_POSITION, Order, Position, - lexicographicString, expandPositions, + lexicographicString, } from "../../src"; import { assertIsOrdered, testUniqueAfterDelete } from "./util";