├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── essay-demo.html ├── index.html ├── package-lock.json ├── package.json ├── src ├── bridge.ts ├── changeQueue.ts ├── comment.ts ├── essay-demo-content.ts ├── essay-demo.ts ├── globals.d.ts ├── index.ts ├── micromerge.ts ├── peritext.ts ├── playback.ts ├── pubsub.ts └── schema.ts ├── static ├── essay-demo.css └── styles.css ├── test ├── accumulatePatches.ts ├── assertDocsEqual.ts ├── fuzz.ts ├── generateDocs.ts ├── merge.ts ├── micromerge.ts └── tsconfig.json ├── traces ├── link-trace.json ├── links-again.json ├── links-brief-2.json ├── links-brief.json ├── links-minimal-2.json ├── links-minimal.json ├── links-nice.json ├── notes.txt ├── trace-latest.json └── two-links.json ├── tsconfig.json └── v2-notes.txt /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "ecmaFeatures": { 10 | "impliedStrict": true 11 | }, 12 | "ecmaVersion": 10, 13 | "sourceType": "module" 14 | }, 15 | "plugins": ["@typescript-eslint"], 16 | "rules": { 17 | "@typescript-eslint/ban-ts-comment": "warn", 18 | "@typescript-eslint/no-inferrable-types": "off", 19 | "@typescript-eslint/no-unused-vars": [ 20 | "error", 21 | { 22 | "varsIgnorePattern": "(^_.+|^Assert)" 23 | } 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-node@v2 9 | with: 10 | node-version: '15' 11 | cache: 'npm' 12 | - run: npm install 13 | - run: npm run build 14 | typecheck: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-node@v2 19 | with: 20 | node-version: '15' 21 | cache: 'npm' 22 | - run: npm install 23 | - run: npx tsc --noEmit 24 | test: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-node@v2 29 | with: 30 | node-version: '15' 31 | cache: 'npm' 32 | - run: npm install 33 | - run: npm run test 34 | lint: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v2 38 | - uses: actions/setup-node@v2 39 | with: 40 | node-version: '15' 41 | cache: 'npm' 42 | - run: npm install 43 | - run: npm run lint 44 | prettier: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v2 48 | - uses: actions/setup-node@v2 49 | with: 50 | node-version: '15' 51 | cache: 'npm' 52 | - run: npm install 53 | - run: npm run check-prettier 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | dist/ 3 | node_modules 4 | .parcel-cache/ 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "useTabs": false, 4 | "tabWidth": 4, 5 | "semi": false, 6 | "arrowParens": "avoid", 7 | "trailingComma": "all" 8 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ink & Switch LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Peritext 2 | 3 | This is a prototype implementation of Peritext, a [CRDT](https://crdt.tech/) for rich text with inline formatting. The algorithm is described in the following publications: 4 | 5 | - Online essay, [Peritext: A CRDT for Rich Text Collaboration](https://www.inkandswitch.com/peritext/) 6 | - Geoffrey Litt, Sarah Lim, Martin Kleppmann, and Peter van Hardenberg. 7 | [Peritext: A CRDT for Collaborative Rich Text Editing](https://www.inkandswitch.com/peritext/static/cscw-publication.pdf). 8 | Proceedings of the ACM on Human-Computer Interaction (PACMHCI), Volume 6, Issue [CSCW2](https://cscw.acm.org/2022/), Article 531, November 2022. [doi:10.1145/3555644](https://doi.org/10.1145/3555644) 9 | 10 | This repo includes: 11 | 12 | - A Typescript implementation of the core Peritext CRDT algorithm 13 | - A prototype integration with the [Prosemirror](http://prosemirror.net/) editor library 14 | - An interactive demo UI where you can try out the editor 15 | - A test suite 16 | 17 | ## Try the editor demo 18 | 19 | To see a basic interactive demo where you can type rich text into two editors and periodically sync them: 20 | 21 | `npm install` 22 | 23 | `npm run start` 24 | 25 | ## Code tour 26 | 27 | **Algorithm code**: The main algorithm implementation is in `src/peritext.ts`. Because the goal of this work is to eventually implement a rich text type in [Automerge](https://github.com/automerge/automerge), we implemented Peritext as an extension to a codebase called `Micromerge`, which is a simplified implementation of Automerge that has mostly the same behavior but is less performance-optimized. 28 | 29 | The essay describes the algorithm in three main parts: 30 | 31 | - [Generating operations](https://www.inkandswitch.com/peritext/#generating-inline-formatting-operations): happens in `changeMark` 32 | - [Applying operations](https://www.inkandswitch.com/peritext/#applying-operations): happens in `applyAddRemoveMark` 33 | - [Producing a document](https://www.inkandswitch.com/peritext/#producing-a-final-document): there are two places this logic is defined. `getTextWithFormatting` is a "batch" approach which iterates over the internal document metadata and produces a Prosemirror document. There is also a codepath that produces incremental patches representing changes (which is actually what powers the editor demo); these patches get emitted directly while applying the op, within `applyAddRemoveMark`. 34 | 35 | **Prosemirror integration:** `src/bridge.ts` contains the code for the integration between the CRDT and the Prosemirror library. There are two main pieces to the integration: 36 | 37 | - Prosemirror to CRDT: when a change happens in the editor, Prosemirror emits a `Transaction`. We turn that transaction into a list of `InputOperation` commands for the CRDT, inside the `applyProsemirrorTransactionToMicromergeDoc` function. 38 | - CRDT to Prosemirror: when a change happens in the Micromerge CRDT, the CRDT emits a `Patch` object representing what changed. We turn this into a Prosemirror transaction with the `extendProsemirrorTransactionWithMicromergePatch` function. 39 | 40 | Each direction of this transformation is straightforward, because the external interface of `InputOperation`s and `Patch`es provided by the CRDT closesly matches the Prosemirror `Transaction` format. 41 | 42 | ## Tests 43 | 44 | `npm run test` will run the manual tests defined in `test/micromerge.ts`. These tests correspond to many of the specific examples explained in the essay. 45 | 46 | You can also run a generative fuzz tester using `npm run fuzz`. This will randomly generate edit traces and check for convergence. 47 | 48 | ## Build demo artifact for essay 49 | 50 | This repo also contains a UI that plays back a preset trace of edit actions, which is included in the Ink & Switch essay about Peritext. 51 | 52 | To see that UI, you can run `npm run start-essay-demo`. 53 | 54 | To build an artifact for including in the essay, run `npx parcel build src/essay-demo.ts`, and then copy the resulting `./dist/essay-demo.js` file to `content/peritext/static/peritext-demo.js` in the essays repo. Also copy over any CSS changes from `static/essay-demo.css` to `content/peritext/static/peritext-styles.css` if needed. 55 | -------------------------------------------------------------------------------- /essay-demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Peritext 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 |
B
18 |
i
19 |
U
20 | 21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
B
29 |
i
30 |
U
31 | 32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Peritext 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |

Editor

17 |
18 |
19 |

Marks

20 |
21 |
22 |
23 |

Micromerge Operations + Prosemirror Steps

24 |
25 |
26 |
27 |
28 |

Editor

29 |
30 |
31 |

Marks

32 |
33 |
34 |
35 |

Micromerge Operations + Prosemirror Steps

36 |
37 |
38 |
39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peritext", 3 | "version": "0.0.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "parcel ./index.html", 7 | "start-essay-demo": "parcel ./essay-demo.html", 8 | "build": "parcel build ./index.html --public-url=./", 9 | "typecheck": "tsc --noEmit --watch", 10 | "prettier": "prettier --write ./index.html *.json ./src/**/* ./test/**/*", 11 | "check-prettier": "prettier --check ./index.html *.json ./src/**/* ./test/**/*", 12 | "test": "ts-mocha ./test/micromerge.ts -p ./test/tsconfig.json", 13 | "fuzz": "ts-mocha ./test/fuzz.ts -p ./test/tsconfig.json", 14 | "lint": "eslint ./src/**/* ./test/**/*" 15 | }, 16 | "author": "", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@types/color-hash": "^1.0.1", 20 | "@types/expect": "^24.3.0", 21 | "@types/mocha": "^8.2.2", 22 | "@typescript-eslint/eslint-plugin": "^4.0.1", 23 | "@typescript-eslint/parser": "^4.0.1", 24 | "eslint": "^7.8.1", 25 | "parcel": "^2.0.1", 26 | "prettier": "^2.2.1", 27 | "typescript": "^4.3.2" 28 | }, 29 | "dependencies": { 30 | "@types/lodash": "^4.14.170", 31 | "@types/prosemirror-commands": "1.0.4", 32 | "@types/prosemirror-keymap": "1.0.4", 33 | "@types/prosemirror-model": "1.13.0", 34 | "@types/prosemirror-schema-list": "1.0.3", 35 | "@types/prosemirror-state": "1.2.6", 36 | "@types/prosemirror-view": "1.17.1", 37 | "@types/shuffle-seed": "^1.1.0", 38 | "@types/uuid": "^8.3.0", 39 | "color-hash": "^2.0.1", 40 | "lodash": "^4.17.21", 41 | "mocha": "^8.4.0", 42 | "prosemirror-commands": "1.1.8", 43 | "prosemirror-keymap": "1.1.4", 44 | "prosemirror-model": "1.14.1", 45 | "prosemirror-schema-list": "1.1.4", 46 | "prosemirror-state": "1.3.4", 47 | "prosemirror-transform": "^1.3.3", 48 | "prosemirror-view": "1.18.7", 49 | "shuffle-seed": "^1.1.6", 50 | "ts-mocha": "^8.0.0", 51 | "uuid": "^3.4.0" 52 | }, 53 | "browserslist": [ 54 | "last 1 Chrome version" 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /src/bridge.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logic for interfacing between ProseMirror and CRDT. 3 | */ 4 | 5 | import Micromerge, { OperationPath, Patch } from "./micromerge" 6 | import { EditorState, TextSelection, Transaction } from "prosemirror-state" 7 | import { EditorView } from "prosemirror-view" 8 | import { Schema, Slice, Node, Fragment, Mark } from "prosemirror-model" 9 | import { baseKeymap, Command, Keymap, toggleMark } from "prosemirror-commands" 10 | import { keymap } from "prosemirror-keymap" 11 | import { ALL_MARKS, isMarkType, MarkType, schemaSpec } from "./schema" 12 | import { ReplaceStep, AddMarkStep, RemoveMarkStep } from "prosemirror-transform" 13 | import { ChangeQueue } from "./changeQueue" 14 | import type { DocSchema } from "./schema" 15 | import type { Publisher } from "./pubsub" 16 | import type { ActorId, Char, Change, Operation as InternalOperation, InputOperation } from "./micromerge" 17 | import { MarkMap, FormatSpanWithText, MarkValue } from "./peritext" 18 | import type { Comment, CommentId } from "./comment" 19 | import { v4 as uuid } from "uuid" 20 | import { clamp } from "lodash" 21 | 22 | export const schema = new Schema(schemaSpec) 23 | 24 | export type RootDoc = { 25 | text: Array 26 | comments: Record 27 | } 28 | 29 | // This is a factory which returns a Prosemirror command. 30 | // The Prosemirror command adds a mark to the document. 31 | // The mark takes on the position of the current selection, 32 | // and has the given type and attributes. 33 | // (The structure/usage of this is similar to the toggleMark command factory 34 | // built in to prosemirror) 35 | function addMark(args: { markType: M; makeAttrs: () => Omit }) { 36 | const { markType, makeAttrs } = args 37 | const command: Command = ( 38 | state: EditorState, 39 | dispatch: ((t: Transaction) => void) | undefined, 40 | ) => { 41 | const tr = state.tr 42 | const { $from, $to } = state.selection.ranges[0] 43 | const from = $from.pos, 44 | to = $to.pos 45 | tr.addMark(from, to, schema.marks[markType].create(makeAttrs())) 46 | if (dispatch !== undefined) { 47 | dispatch(tr) 48 | } 49 | return true 50 | } 51 | return command 52 | } 53 | 54 | const richTextKeymap: Keymap = { 55 | ...baseKeymap, 56 | "Mod-b": toggleMark(schema.marks.strong), 57 | "Mod-i": toggleMark(schema.marks.em), 58 | "Mod-e": addMark({ 59 | markType: "comment", 60 | makeAttrs: () => ({ id: uuid() }), 61 | }), 62 | "Mod-k": addMark({ 63 | markType: "link", 64 | makeAttrs: () => ({ 65 | url: `https://www.google.com/search?q=${uuid()}`, 66 | }), 67 | }), 68 | } 69 | 70 | export type Editor = { 71 | doc: Micromerge 72 | view: EditorView 73 | queue: ChangeQueue 74 | outputDebugForChange: (change: Change) => void 75 | } 76 | 77 | const describeMarkType = (markType: string): string => { 78 | switch (markType) { 79 | case "em": 80 | return "italic" 81 | case "strong": 82 | return "bold" 83 | default: 84 | return markType 85 | } 86 | } 87 | 88 | // Returns a natural language description of an op in our CRDT. 89 | // Just for demo / debug purposes, doesn't cover all cases 90 | function describeOp(op: InternalOperation): string { 91 | if (op.action === "set" && op.elemId !== undefined) { 92 | return `${op.value}` 93 | } else if (op.action === "del" && op.elemId !== undefined) { 94 | return `❌ ${String(op.elemId)}` 95 | } else if (op.action === "addMark") { 96 | return `🖌 format ${describeMarkType(op.markType)}` 97 | } else if (op.action === "removeMark") { 98 | return `🖌 unformat ${op.markType}` 99 | } else if (op.action === "makeList") { 100 | return `🗑 reset` 101 | } else { 102 | return op.action 103 | } 104 | } 105 | 106 | /** Initialize multiple Micromerge docs to all have same base editor state. 107 | * The key is that all docs get initialized with a single change that originates 108 | * on one of the docs; this avoids weird issues where each doc independently 109 | * tries to initialize the basic structure of the document. 110 | */ 111 | export const initializeDocs = (docs: Micromerge[], initialInputOps?: InputOperation[]): void => { 112 | const inputOps: InputOperation[] = [{ path: [], action: "makeList", key: Micromerge.contentKey }] 113 | if (initialInputOps) { 114 | inputOps.push(...initialInputOps) 115 | } 116 | const { change: initialChange } = docs[0].change(inputOps) 117 | for (const doc of docs.slice(1)) { 118 | doc.applyChange(initialChange) 119 | } 120 | } 121 | 122 | /** Extends a Prosemirror Transaction with new steps incorporating 123 | * the effects of a Micromerge Patch. 124 | * 125 | * @param transaction - the original transaction to extend 126 | * @param patch - the Micromerge Patch to incorporate 127 | * @returns 128 | * transaction: a Transaction that includes additional steps representing the patch 129 | * startPos: the Prosemirror position where the patch's effects start 130 | * endPos: the Prosemirror position where the patch's effects end 131 | * */ 132 | export const extendProsemirrorTransactionWithMicromergePatch = ( 133 | transaction: Transaction, 134 | patch: Patch, 135 | ): { transaction: Transaction; startPos: number; endPos: number } => { 136 | // console.log("applying patch", patch) 137 | switch (patch.action) { 138 | case "insert": { 139 | const index = prosemirrorPosFromContentPos(patch.index) 140 | return { 141 | transaction: transaction.replace( 142 | index, 143 | index, 144 | new Slice( 145 | Fragment.from(schema.text(patch.values[0], getProsemirrorMarksForMarkMap(patch.marks))), 146 | 0, 147 | 0, 148 | ), 149 | ), 150 | startPos: index, 151 | endPos: index + 1, 152 | } 153 | } 154 | 155 | case "delete": { 156 | const index = prosemirrorPosFromContentPos(patch.index) 157 | return { 158 | transaction: transaction.replace(index, index + patch.count, Slice.empty), 159 | startPos: index, 160 | endPos: index, 161 | } 162 | } 163 | 164 | case "addMark": { 165 | return { 166 | transaction: transaction.addMark( 167 | prosemirrorPosFromContentPos(patch.startIndex), 168 | prosemirrorPosFromContentPos(patch.endIndex), 169 | schema.mark(patch.markType, patch.attrs), 170 | ), 171 | startPos: prosemirrorPosFromContentPos(patch.startIndex), 172 | endPos: prosemirrorPosFromContentPos(patch.endIndex), 173 | } 174 | } 175 | case "removeMark": { 176 | return { 177 | transaction: transaction.removeMark( 178 | prosemirrorPosFromContentPos(patch.startIndex), 179 | prosemirrorPosFromContentPos(patch.endIndex), 180 | schema.mark(patch.markType, patch.attrs), 181 | ), 182 | startPos: prosemirrorPosFromContentPos(patch.startIndex), 183 | endPos: prosemirrorPosFromContentPos(patch.endIndex), 184 | } 185 | } 186 | case "makeList": { 187 | return { 188 | transaction: transaction.delete(0, transaction.doc.content.size), 189 | startPos: 0, 190 | endPos: 0, 191 | } 192 | } 193 | } 194 | unreachable(patch) 195 | } 196 | 197 | /** Construct a Prosemirror editor instance on a DOM node, and bind it to a Micromerge doc */ 198 | export function createEditor(args: { 199 | actorId: ActorId 200 | editorNode: Element 201 | changesNode: Element 202 | doc: Micromerge 203 | publisher: Publisher> 204 | editable: boolean 205 | handleClickOn?: ( 206 | this: unknown, 207 | view: EditorView, 208 | pos: number, 209 | node: Node, 210 | nodePos: number, 211 | event: MouseEvent, 212 | direct: boolean, 213 | ) => boolean 214 | onRemotePatchApplied?: (args: { 215 | transaction: Transaction 216 | view: EditorView 217 | startPos: number 218 | endPos: number 219 | }) => Transaction 220 | }): Editor { 221 | const { actorId, editorNode, changesNode, doc, publisher, handleClickOn, onRemotePatchApplied, editable } = args 222 | const queue = new ChangeQueue({ 223 | handleFlush: (changes: Array) => { 224 | publisher.publish(actorId, changes) 225 | }, 226 | }) 227 | queue.start() 228 | 229 | const outputDebugForChange = (change: Change) => { 230 | const opsDivs = change.ops.map((op: InternalOperation) => `
${describeOp(op)}
`) 231 | 232 | for (const divHtml of opsDivs) { 233 | changesNode.insertAdjacentHTML("beforeend", divHtml) 234 | } 235 | changesNode.scrollTop = changesNode.scrollHeight 236 | } 237 | 238 | publisher.subscribe(actorId, incomingChanges => { 239 | if (incomingChanges.length === 0) { 240 | return 241 | } 242 | 243 | let state = view.state 244 | 245 | // For each incoming change, we: 246 | // - retrieve Patches from Micromerge describing the effect of applying the change 247 | // - construct a Prosemirror Transaction representing those effecst 248 | // - apply that Prosemirror Transaction to the document 249 | for (const change of incomingChanges) { 250 | // Create a transaction that will accumulate the effects of our patches 251 | let transaction = state.tr 252 | 253 | const patches = doc.applyChange(change) 254 | for (const patch of patches) { 255 | // Get a new Prosemirror transaction containing the effects of the Micromerge patch 256 | const result = extendProsemirrorTransactionWithMicromergePatch(transaction, patch) 257 | let { transaction: newTransaction } = result 258 | const { startPos, endPos } = result 259 | 260 | // If this editor has a callback function defined for handling a remote patch being applied, 261 | // apply that callback and give it the chance to extend the transaction. 262 | // (e.g. this can be used to visualize changes by adding new marks.) 263 | if (onRemotePatchApplied) { 264 | newTransaction = onRemotePatchApplied({ 265 | transaction: newTransaction, 266 | view, 267 | startPos, 268 | endPos, 269 | }) 270 | } 271 | 272 | // Assign the newly modified transaction 273 | transaction = newTransaction 274 | } 275 | state = state.apply(transaction) 276 | } 277 | 278 | view.updateState(state) 279 | }) 280 | 281 | // Generate an empty document conforming to the schema, 282 | // and a default selection at the start of the document. 283 | const state = EditorState.create({ 284 | schema, 285 | plugins: [keymap(richTextKeymap)], 286 | doc: prosemirrorDocFromCRDT({ 287 | schema, 288 | spans: doc.getTextWithFormatting([Micromerge.contentKey]), 289 | }), 290 | }) 291 | 292 | // Create a view for the state and generate transactions when the user types. 293 | const view = new EditorView(editorNode, { 294 | // state.doc is a read-only data structure using a node hierarchy 295 | // A node contains a fragment with zero or more child nodes. 296 | // Text is modeled as a flat sequence of tokens. 297 | // Each document has a unique valid representation. 298 | // Order of marks specified by schema. 299 | state, 300 | handleClickOn, 301 | editable: () => editable, 302 | // We intercept local Prosemirror transactions and derive Micromerge changes from them 303 | dispatchTransaction: (txn: Transaction) => { 304 | let state = view.state 305 | 306 | // Apply a corresponding change to the Micromerge document. 307 | // We observe a Micromerge Patch from applying the change, and 308 | // apply its effects to our local Prosemirror doc. 309 | const { change, patches } = applyProsemirrorTransactionToMicromergeDoc({ doc, txn }) 310 | if (change) { 311 | let transaction = state.tr 312 | for (const patch of patches) { 313 | const { transaction: newTxn } = extendProsemirrorTransactionWithMicromergePatch(transaction, patch) 314 | transaction = newTxn 315 | } 316 | state = state.apply(transaction) 317 | outputDebugForChange(change) 318 | 319 | // Broadcast the change to remote peers 320 | queue.enqueue(change) 321 | } 322 | 323 | // If this transaction updated the local selection, we need to 324 | // make sure that's reflected in the editor state. 325 | // (Roundtripping through Micromerge won't do that for us, since 326 | // selection state is not part of the document state.) 327 | if (txn.selectionSet) { 328 | state = state.apply( 329 | state.tr.setSelection( 330 | new TextSelection( 331 | state.doc.resolve(txn.selection.anchor), 332 | state.doc.resolve(txn.selection.head), 333 | ), 334 | ), 335 | ) 336 | } 337 | 338 | view.updateState(state) 339 | console.groupEnd() 340 | }, 341 | }) 342 | 343 | return { doc, view, queue, outputDebugForChange } 344 | } 345 | 346 | /** 347 | * Converts a position in the Prosemirror doc to an offset in the CRDT content string. 348 | * For now we only have a single node so this is relatively trivial. 349 | * In the future when things get more complicated with multiple block nodes, 350 | * we can probably take advantage 351 | * of the additional metadata that Prosemirror can provide by "resolving" the position. 352 | * @param position : an unresolved Prosemirror position in the doc; 353 | * @param doc : the Prosemirror document containing the position 354 | */ 355 | function contentPosFromProsemirrorPos(position: number, doc: Node): number { 356 | // The -1 accounts for the extra character at the beginning of the PM doc 357 | // containing the beginning of the paragraph. 358 | // In some rare cases we can end up with incoming positions outside of the single 359 | // paragraph node (e.g., when the user does cmd-A to select all), 360 | // so we need to be sure to clamp the resulting position to inside the paragraph node. 361 | return clamp(position - 1, 0, doc.textContent.length) 362 | } 363 | 364 | /** Given an index in the text CRDT, convert to an index in the Prosemirror editor. 365 | * The Prosemirror editor has a paragraph node which we ignore because we only handle inline; 366 | * the beginning of the paragraph takes up one position in the Prosemirror indexing scheme. 367 | * This means we have to add 1 to CRDT indexes to get correct Prosemirror indexes. 368 | */ 369 | function prosemirrorPosFromContentPos(position: number) { 370 | return position + 1 371 | } 372 | 373 | function getProsemirrorMarksForMarkMap(markMap: T): Mark[] { 374 | const marks = [] 375 | for (const markType of ALL_MARKS) { 376 | const markValue = markMap[markType] 377 | if (markValue === undefined) { 378 | continue 379 | } 380 | if (Array.isArray(markValue)) { 381 | for (const value of markValue) { 382 | marks.push(schema.mark(markType, value)) 383 | } 384 | } else { 385 | if (markValue) { 386 | marks.push(schema.mark(markType, markValue)) 387 | } 388 | } 389 | } 390 | return marks 391 | } 392 | 393 | // Given a micromerge doc representation, produce a prosemirror doc. 394 | export function prosemirrorDocFromCRDT(args: { schema: DocSchema; spans: FormatSpanWithText[] }): Node { 395 | const { schema, spans } = args 396 | 397 | // Prosemirror doesn't allow for empty text nodes; 398 | // if our doc is empty, we short-circuit and don't add any text nodes. 399 | if (spans.length === 1 && spans[0].text === "") { 400 | return schema.node("doc", undefined, [schema.node("paragraph", [])]) 401 | } 402 | 403 | const result = schema.node("doc", undefined, [ 404 | schema.node( 405 | "paragraph", 406 | undefined, 407 | spans.map(span => { 408 | return schema.text(span.text, getProsemirrorMarksForMarkMap(span.marks)) 409 | }), 410 | ), 411 | ]) 412 | 413 | return result 414 | } 415 | 416 | // Given a CRDT Doc and a Prosemirror Transaction, update the micromerge doc. 417 | export function applyProsemirrorTransactionToMicromergeDoc(args: { doc: Micromerge; txn: Transaction }): { 418 | change: Change | null 419 | patches: Patch[] 420 | } { 421 | const { doc, txn } = args 422 | const operations: Array = [] 423 | 424 | for (const step of txn.steps) { 425 | if (step instanceof ReplaceStep) { 426 | if (step.slice) { 427 | // handle insertion 428 | if (step.from !== step.to) { 429 | operations.push({ 430 | path: [Micromerge.contentKey], 431 | action: "delete", 432 | index: contentPosFromProsemirrorPos(step.from, txn.before), 433 | count: 434 | contentPosFromProsemirrorPos(step.to, txn.before) - 435 | contentPosFromProsemirrorPos(step.from, txn.before), 436 | }) 437 | } 438 | 439 | const insertedContent = step.slice.content.textBetween(0, step.slice.content.size) 440 | 441 | operations.push({ 442 | path: [Micromerge.contentKey], 443 | action: "insert", 444 | index: contentPosFromProsemirrorPos(step.from, txn.before), 445 | values: insertedContent.split(""), 446 | }) 447 | } else { 448 | // handle deletion 449 | operations.push({ 450 | path: [Micromerge.contentKey], 451 | action: "delete", 452 | index: contentPosFromProsemirrorPos(step.from, txn.before), 453 | count: 454 | contentPosFromProsemirrorPos(step.to, txn.before) - 455 | contentPosFromProsemirrorPos(step.from, txn.before), 456 | }) 457 | } 458 | } else if (step instanceof AddMarkStep) { 459 | if (!isMarkType(step.mark.type.name)) { 460 | throw new Error(`Invalid mark type: ${step.mark.type.name}`) 461 | } 462 | 463 | const partialOp: { 464 | action: "addMark" 465 | path: OperationPath 466 | startIndex: number 467 | endIndex: number 468 | } = { 469 | action: "addMark", 470 | path: [Micromerge.contentKey], 471 | startIndex: contentPosFromProsemirrorPos(step.from, txn.before), 472 | endIndex: contentPosFromProsemirrorPos(step.to, txn.before), 473 | } 474 | 475 | if (step.mark.type.name === "comment") { 476 | if (!step.mark.attrs || typeof step.mark.attrs.id !== "string") { 477 | throw new Error("Expected comment mark to have id attrs") 478 | } 479 | operations.push({ 480 | ...partialOp, 481 | markType: step.mark.type.name, 482 | attrs: step.mark.attrs as { id: string }, 483 | }) 484 | } else if (step.mark.type.name === "link") { 485 | if (!step.mark.attrs || typeof step.mark.attrs.url !== "string") { 486 | throw new Error("Expected link mark to have url attrs") 487 | } 488 | operations.push({ 489 | ...partialOp, 490 | markType: step.mark.type.name, 491 | attrs: step.mark.attrs as { url: string }, 492 | }) 493 | } else { 494 | operations.push({ 495 | ...partialOp, 496 | markType: step.mark.type.name, 497 | }) 498 | } 499 | } else if (step instanceof RemoveMarkStep) { 500 | if (!isMarkType(step.mark.type.name)) { 501 | throw new Error(`Invalid mark type: ${step.mark.type.name}`) 502 | } 503 | 504 | const partialOp: { 505 | action: "removeMark" 506 | path: OperationPath 507 | startIndex: number 508 | endIndex: number 509 | } = { 510 | action: "removeMark", 511 | path: [Micromerge.contentKey], 512 | startIndex: contentPosFromProsemirrorPos(step.from, txn.before), 513 | endIndex: contentPosFromProsemirrorPos(step.to, txn.before), 514 | } 515 | 516 | if (step.mark.type.name === "comment") { 517 | if (!step.mark.attrs || typeof step.mark.attrs.id !== "string") { 518 | throw new Error("Expected comment mark to have id attrs") 519 | } 520 | operations.push({ 521 | ...partialOp, 522 | markType: step.mark.type.name, 523 | attrs: step.mark.attrs as { id: string }, 524 | }) 525 | } else { 526 | operations.push({ 527 | ...partialOp, 528 | markType: step.mark.type.name, 529 | }) 530 | } 531 | } 532 | } 533 | 534 | if (operations.length > 0) { 535 | return doc.change(operations) 536 | } else { 537 | return { change: null, patches: [] } 538 | } 539 | } 540 | -------------------------------------------------------------------------------- /src/changeQueue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Queue for storing editor changes, flushed at a given interval. 3 | */ 4 | import type { Change } from "./micromerge" 5 | 6 | export class ChangeQueue { 7 | private changes: Array = [] 8 | private timer: number | undefined = undefined 9 | 10 | /** Milliseconds between flushes. */ 11 | private interval: number 12 | 13 | /** Flush action. */ 14 | private handleFlush: (changes: Array) => void 15 | 16 | constructor({ 17 | // Can tune this sync interval to simulate network latency, 18 | // make it easier to observe sync behavior, etc. 19 | interval = 10, 20 | handleFlush, 21 | }: { 22 | interval?: number 23 | /** Flush action. */ 24 | handleFlush: (changes: Array) => void 25 | }) { 26 | this.interval = interval 27 | this.handleFlush = handleFlush 28 | } 29 | 30 | public enqueue(...changes: Array): void { 31 | this.changes.push(...changes) 32 | } 33 | 34 | /** 35 | * Flush all changes to the publisher. Runs on a timer. 36 | */ 37 | flush = (): void => { 38 | // TODO: Add retry logic to capture failures. 39 | this.handleFlush(this.changes) 40 | this.changes = [] 41 | } 42 | 43 | public start(): void { 44 | this.timer = window.setInterval(this.flush, this.interval) 45 | } 46 | 47 | public drop(): void { 48 | if (this.timer !== undefined) { 49 | window.clearInterval(this.timer) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/comment.ts: -------------------------------------------------------------------------------- 1 | import type { ActorId } from "./micromerge" 2 | 3 | export type CommentId = string 4 | 5 | export type Comment = { 6 | id: CommentId 7 | /** Author of the comment. */ 8 | actor: ActorId 9 | /** Content. */ 10 | // TODO: Should eventually be an Array 11 | content: string 12 | } 13 | -------------------------------------------------------------------------------- /src/essay-demo-content.ts: -------------------------------------------------------------------------------- 1 | import { Trace, TraceEvent } from "./playback" 2 | 3 | export const simulateTypingForInputOp = (o: TraceEvent): TraceEvent[] => { 4 | if (o.action === "insert") { 5 | return o.values.map((v, i) => ({ 6 | ...o, 7 | delay: 55 + Math.random() * 20, 8 | values: [v], 9 | index: o.index + i, 10 | })) 11 | } 12 | 13 | return [o] 14 | } 15 | 16 | const clearEditors: Trace = [ 17 | { editorId: "alice", path: [], action: "makeList", key: "text", delay: 0 }, 18 | { action: "sync", delay: 0 }, 19 | ] 20 | 21 | const initialDemo: Trace = [ 22 | ...simulateTypingForInputOp({ 23 | editorId: "alice", 24 | path: ["text"], 25 | action: "insert", 26 | index: 0, 27 | values: "Peritext is a rich-text CRDT.".split(""), 28 | }), 29 | { action: "sync", delay: 0 }, 30 | { 31 | editorId: "alice", 32 | action: "addMark", 33 | path: ["text"], 34 | startIndex: 14, 35 | endIndex: 23, 36 | markType: "em", 37 | }, 38 | { 39 | editorId: "bob", 40 | action: "addMark", 41 | path: ["text"], 42 | startIndex: 24, 43 | endIndex: 28, 44 | markType: "strong", 45 | }, 46 | { action: "sync", delay: 1000 }, 47 | ] 48 | 49 | // 1 2 3 50 | // 0123456789012345678901234578901234567 51 | const formatting = [ 52 | "Bold formatting can overlap with italic.\n", 53 | "Links conflict when they overlap.\n", 54 | "Comments can co-exist.", 55 | ] 56 | const formattingDemo: Trace = [ 57 | // 1 2 3 58 | // 0123456789012345678901234578901234567890 59 | // 'Bold formatting can overlap with italic.\n', 60 | ...simulateTypingForInputOp({ 61 | editorId: "alice", 62 | path: ["text"], 63 | action: "insert", 64 | index: 0, 65 | values: formatting[0].split(""), 66 | }), 67 | { action: "sync", delay: 0 }, 68 | { 69 | editorId: "alice", 70 | action: "addMark", 71 | path: ["text"], 72 | startIndex: 0, 73 | endIndex: 27, 74 | markType: "strong", 75 | }, 76 | { 77 | editorId: "bob", 78 | action: "addMark", 79 | path: ["text"], 80 | startIndex: 5, 81 | endIndex: 40, 82 | markType: "em", 83 | }, 84 | { action: "sync" }, 85 | 86 | // 1 2 3 87 | // 0123456789012345678901234578901234567 88 | // 'Links conflict when they overlap.\n' 89 | ...simulateTypingForInputOp({ 90 | editorId: "alice", 91 | path: ["text"], 92 | action: "insert", 93 | index: formatting[0].length, 94 | values: formatting[1].split(""), 95 | }), 96 | { action: "sync", delay: 0 }, 97 | { 98 | editorId: "alice", 99 | action: "addMark", 100 | path: ["text"], 101 | startIndex: formatting[0].length + 0, 102 | endIndex: formatting[0].length + 19, 103 | markType: "link", 104 | attrs: { url: "http://inkandswitch.com" }, 105 | }, 106 | { 107 | editorId: "bob", 108 | action: "addMark", 109 | path: ["text"], 110 | startIndex: formatting[0].length + 15, 111 | endIndex: formatting[0].length + 34, 112 | markType: "link", 113 | attrs: { url: "http://notion.so" }, 114 | }, 115 | { action: "sync", delay: 0 }, 116 | 117 | // 1 2 3 118 | // 0123456789012345678901234578901234567 119 | // 'Comments can co-exist.\n' 120 | ...simulateTypingForInputOp({ 121 | editorId: "alice", 122 | path: ["text"], 123 | action: "insert", 124 | index: formatting[0].length + formatting[1].length, 125 | values: formatting[2].split(""), 126 | }), 127 | { action: "sync", delay: 0 }, 128 | { 129 | editorId: "alice", 130 | action: "addMark", 131 | path: ["text"], 132 | startIndex: formatting[0].length + formatting[1].length + 0, 133 | endIndex: formatting[0].length + formatting[1].length + 20, 134 | markType: "comment", 135 | attrs: { id: "comment-1" }, 136 | }, 137 | { 138 | editorId: "bob", 139 | action: "addMark", 140 | path: ["text"], 141 | startIndex: formatting[0].length + formatting[1].length + 9, 142 | endIndex: formatting[0].length + formatting[1].length + 21, 143 | markType: "comment", 144 | attrs: { id: "comment-2" }, 145 | }, 146 | { 147 | editorId: "bob", 148 | action: "addMark", 149 | path: ["text"], 150 | startIndex: formatting[0].length + formatting[1].length + 9, 151 | endIndex: formatting[0].length + formatting[1].length + 11, 152 | markType: "comment", 153 | attrs: { id: "comment-3" }, 154 | }, 155 | { action: "sync", delay: 0 }, 156 | ] 157 | 158 | // 1 2 3 159 | // 0123456789012345678901234578901234567 160 | const expansion = ["Bold formatting expands for new text.\n", "But links retain their size when text comes later."] 161 | const expansionDemo: Trace = [ 162 | // 1 2 3 163 | // 0123456789012345678901234578901234567 164 | // 'Bold formatting expands for new text.\n', 165 | // 'But links retain their size when text comes later.' 166 | ...simulateTypingForInputOp({ 167 | editorId: "alice", 168 | path: ["text"], 169 | action: "insert", 170 | index: 0, 171 | values: expansion[0].split("").slice(0, 15).concat([".", "\n"]), 172 | }), 173 | { action: "sync", delay: 0 }, 174 | { 175 | editorId: "alice", 176 | action: "addMark", 177 | path: ["text"], 178 | startIndex: 0, 179 | endIndex: 15, 180 | markType: "strong", 181 | }, 182 | ...simulateTypingForInputOp({ 183 | editorId: "bob", 184 | path: ["text"], 185 | action: "insert", 186 | index: 15, 187 | values: expansion[0].split("").slice(15, 36), 188 | }), 189 | { action: "sync", delay: 0 }, 190 | ...simulateTypingForInputOp({ 191 | editorId: "bob", 192 | path: ["text"], 193 | action: "insert", 194 | index: 38, 195 | values: "But links...".split(""), 196 | }), 197 | { action: "sync", delay: 0 }, 198 | { 199 | editorId: "alice", 200 | action: "addMark", 201 | path: ["text"], 202 | startIndex: 38 + 4, 203 | endIndex: 38 + 4 + 5, 204 | markType: "link", 205 | attrs: { url: "https://inkandswitch.com" }, 206 | }, 207 | ...simulateTypingForInputOp({ 208 | editorId: "bob", 209 | path: ["text"], 210 | action: "insert", 211 | index: 38 + 9, 212 | values: " retain their size".split(""), 213 | }), 214 | { action: "sync", delay: 0 }, 215 | ] 216 | 217 | export const trace: Trace = [ 218 | { editorId: "alice", path: [], action: "makeList", key: "text", delay: 0 }, 219 | ...initialDemo, 220 | ...clearEditors, 221 | ...formattingDemo, 222 | ...clearEditors, 223 | ...expansionDemo, 224 | ] 225 | -------------------------------------------------------------------------------- /src/essay-demo.ts: -------------------------------------------------------------------------------- 1 | // This file is meant to go together w/ the markup in essay-demo.html, 2 | // and be embedded into the Peritext essay. 3 | 4 | import { createEditor, schema } from "./bridge" 5 | import { Publisher } from "./pubsub" 6 | import type { Change } from "./micromerge" 7 | import Micromerge from "./micromerge" 8 | import { executeTraceEvent, Trace, Editors } from "./playback" 9 | import { trace } from "./essay-demo-content" 10 | import { Transaction } from "prosemirror-state" 11 | import { EditorView } from "prosemirror-view" 12 | 13 | const publisher = new Publisher>() 14 | 15 | const initializeEditor = (name: string) => { 16 | const node = document.querySelector(`#${name}`) 17 | const editorNode = node?.querySelector(".editor") 18 | const changesNode = node?.querySelector(".changes") 19 | const marks = node?.querySelector(".marks") 20 | 21 | if (!(node && editorNode && changesNode && marks)) { 22 | throw new Error(`Didn't find expected node in the DOM`) 23 | } 24 | 25 | const doc = new Micromerge(name) 26 | // note: technically this could cause problems because we're recreating 27 | // the document on each side with no shared history and not syncing, so 28 | // it might just be luck / compensating bugs that makes this work 29 | const { change } = doc.change([{ path: [], action: "makeList", key: Micromerge.contentKey }]) 30 | 31 | const editor = createEditor({ 32 | actorId: name, 33 | editorNode, 34 | doc, 35 | publisher, 36 | handleClickOn: () => false, 37 | changesNode, 38 | editable: false, 39 | onRemotePatchApplied: highlightRemoteChanges, 40 | }) 41 | 42 | editor.queue.enqueue(change) 43 | 44 | return editor 45 | } 46 | 47 | const highlightRemoteChanges = ({ 48 | transaction, 49 | view, 50 | startPos, 51 | endPos, 52 | }: { 53 | transaction: Transaction 54 | view: EditorView 55 | startPos: number 56 | endPos: number 57 | }): Transaction => { 58 | const newTransaction = transaction.addMark(startPos, endPos, schema.mark("highlightChange")) 59 | 60 | setTimeout(() => { 61 | view.state = view.state.apply( 62 | view.state.tr 63 | .removeMark(startPos, endPos, schema.mark("highlightChange")) 64 | .addMark(startPos, endPos, schema.mark("unhighlightChange")), 65 | ) 66 | view.updateState(view.state) 67 | 68 | setTimeout(() => { 69 | view.state = view.state.apply(view.state.tr.removeMark(startPos, endPos, schema.mark("unhighlightChange"))) 70 | view.updateState(view.state) 71 | }, 1000) 72 | }, 10) 73 | 74 | return newTransaction 75 | } 76 | 77 | // This handler gets called 500ms before the sync happens. 78 | // If we keep the sync icon visible for ~1000ms it feels good. 79 | const displaySyncEvent = () => { 80 | for (const changesNode of document.querySelectorAll(".changes")) { 81 | changesNode.classList.add("syncing") 82 | setTimeout(() => { 83 | changesNode.classList.remove("syncing") 84 | changesNode.innerHTML = "" 85 | }, 900) 86 | } 87 | } 88 | 89 | export function* endlessLoop(t: T[]): Generator { 90 | while (true) { 91 | for (const e of t) { 92 | yield e 93 | } 94 | } 95 | } 96 | 97 | const initializeDemo = () => { 98 | const names = ["alice", "bob"] 99 | const editors = names.reduce( 100 | (editors: Editors, name: string) => ({ ...editors, [name]: initializeEditor(name) }), 101 | {}, 102 | ) 103 | 104 | // disable live sync & use manual calls to flush() 105 | for (const editor of Object.values(editors)) { 106 | editor.queue.drop() 107 | } 108 | 109 | let playing = false 110 | const traceGen = endlessLoop(trace) 111 | 112 | const playTrace = async (trace: Trace, editors: Editors, handleSyncEvent: () => void): Promise => { 113 | if (!playing) { 114 | return 115 | } 116 | 117 | const event = traceGen.next().value 118 | await executeTraceEvent(event, editors, handleSyncEvent) 119 | const delay = event.delay || 1000 120 | setTimeout(() => playTrace(trace, editors, handleSyncEvent), delay) 121 | } 122 | const playPause = (e: MouseEvent) => { 123 | playing = !playing 124 | ;(e.target as HTMLElement).classList.toggle("paused") 125 | ;(e.target as HTMLElement).innerHTML = playing ? "||" : "▶" 126 | if (playing) { 127 | playTrace(trace, editors, displaySyncEvent) 128 | } 129 | } 130 | 131 | document.querySelector(".play-pause")?.addEventListener("click", playPause as (e: Event) => void) 132 | } 133 | 134 | initializeDemo() 135 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from "prosemirror-view" 2 | import type { Schema, Slice, Mark } from "prosemirror-model" 3 | 4 | declare global { 5 | type Assert = T1 6 | type Values> = T[keyof T] 7 | type Inner = T extends Array ? U : never 8 | type DistributiveOmit = O extends unknown ? Omit : never 9 | 10 | function unreachable(x: never): never 11 | 12 | interface Window { 13 | view: EditorView 14 | } 15 | } 16 | 17 | /** 18 | * Add cursor types to automerge. 19 | */ 20 | declare module "automerge" { 21 | interface Text { 22 | getCursorAt(index: number): Cursor 23 | } 24 | 25 | class Cursor { 26 | index: number 27 | constructor(object: Text, index: number) 28 | constructor(object: string, index: number, elemId: string) 29 | } 30 | } 31 | 32 | declare module "prosemirror-transform" { 33 | /** https://prosemirror.net/docs/ref/#transform.ReplaceStep */ 34 | interface ReplaceStep extends Step { 35 | from: number 36 | to: number 37 | slice: Slice 38 | } 39 | 40 | /** https://prosemirror.net/docs/ref/#transform.AddMarkStep */ 41 | interface AddMarkStep { 42 | from: number 43 | to: number 44 | mark: Mark 45 | } 46 | 47 | /** https://prosemirror.net/docs/ref/#transform.RemoveMarkStep */ 48 | interface RemoveMarkStep extends Step { 49 | from: number 50 | to: number 51 | mark: Mark 52 | } 53 | } 54 | 55 | declare module "prosemirror-model" { 56 | // Need to disable these rules to extend the module definition. 57 | // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any 58 | interface Fragment { 59 | /** https://prosemirror.net/docs/ref/#model.Fragment.textBetween */ 60 | textBetween(from: number, to: number, blockSeparator?: string, leafText?: string): string 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createEditor, initializeDocs } from "./bridge" 2 | import { Publisher } from "./pubsub" 3 | import type { Change } from "./micromerge" 4 | import type { Editor } from "./bridge" 5 | import { Mark } from "prosemirror-model" 6 | import Micromerge from "./micromerge" 7 | 8 | const publisher = new Publisher>() 9 | 10 | const editors: { [key: string]: Editor } = {} 11 | 12 | const renderMarks = (domNode: Element, marks: Mark[]): void => { 13 | domNode.innerHTML = marks 14 | .map(m => `• ${m.type.name} ${Object.keys(m.attrs).length !== 0 ? JSON.stringify(m.attrs) : ""}`) 15 | .join("
") 16 | } 17 | 18 | const aliceDoc = new Micromerge("alice") 19 | const bobDoc = new Micromerge("bob") 20 | 21 | initializeDocs( 22 | [aliceDoc, bobDoc], 23 | [ 24 | { 25 | path: [Micromerge.contentKey], 26 | action: "insert", 27 | index: 0, 28 | values: "This is the Peritext editor demo. Press sync to synchronize the editors. Ctrl-B for bold, Ctrl-i for italic, Ctrl-k for link, Ctrl-e for comment".split( 29 | "", 30 | ), 31 | }, 32 | { 33 | path: [Micromerge.contentKey], 34 | action: "addMark", 35 | markType: "strong", 36 | startIndex: 84, 37 | endIndex: 88, 38 | }, 39 | { 40 | path: [Micromerge.contentKey], 41 | action: "addMark", 42 | markType: "em", 43 | startIndex: 100, 44 | endIndex: 107, 45 | }, 46 | { 47 | path: [Micromerge.contentKey], 48 | action: "addMark", 49 | markType: "link", 50 | attrs: { url: "http://inkandswitch.com" }, 51 | startIndex: 120, 52 | endIndex: 124, 53 | }, 54 | { 55 | path: [Micromerge.contentKey], 56 | action: "addMark", 57 | markType: "comment", 58 | attrs: { id: "1" }, 59 | startIndex: 137, 60 | endIndex: 144, 61 | }, 62 | ], 63 | ) 64 | 65 | const aliceNode = document.querySelector("#alice") 66 | const aliceEditor = aliceNode?.querySelector(".editor") 67 | const aliceChanges = aliceNode?.querySelector(".changes") 68 | const aliceMarks = aliceNode?.querySelector(".marks") 69 | 70 | if (aliceNode && aliceEditor && aliceChanges && aliceMarks) { 71 | editors["alice"] = createEditor({ 72 | actorId: "alice", 73 | editorNode: aliceEditor, 74 | changesNode: aliceChanges, 75 | doc: aliceDoc, 76 | publisher, 77 | editable: true, 78 | handleClickOn: (view, pos, node, nodePos, event, direct) => { 79 | // Prosemirror calls this once per node that overlaps w/ the clicked pos. 80 | // We only want to run our callback once, on the innermost clicked node. 81 | if (!direct) return false 82 | 83 | const marksAtPosition = view.state.doc.resolve(pos).marks() 84 | renderMarks(aliceMarks, marksAtPosition) 85 | return false 86 | }, 87 | }) 88 | } else { 89 | throw new Error(`Didn't find expected node in the DOM`) 90 | } 91 | 92 | const bobNode = document.querySelector("#bob") 93 | const bobEditor = bobNode?.querySelector(".editor") 94 | const bobChanges = bobNode?.querySelector(".changes") 95 | const bobMarks = bobNode?.querySelector(".marks") 96 | 97 | if (bobNode && bobEditor && bobChanges && bobMarks) { 98 | editors["bob"] = createEditor({ 99 | actorId: "bob", 100 | editorNode: bobEditor, 101 | changesNode: bobChanges, 102 | doc: bobDoc, 103 | publisher, 104 | editable: true, 105 | handleClickOn: (view, pos, node, nodePos, event, direct) => { 106 | // Prosemirror calls this once per node that overlaps w/ the clicked pos. 107 | // We only want to run our callback once, on the innermost clicked node. 108 | if (!direct) return false 109 | 110 | const marksAtPosition = view.state.doc.resolve(pos).marks() 111 | renderMarks(bobMarks, marksAtPosition) 112 | return false 113 | }, 114 | }) 115 | } else { 116 | throw new Error(`Didn't find expected node in the DOM`) 117 | } 118 | 119 | for (const editor of Object.values(editors)) { 120 | editor.queue.drop() 121 | } 122 | 123 | // Add a button for syncing the two editors 124 | document.querySelector("#sync")?.addEventListener("click", () => { 125 | for (const editor of Object.values(editors)) { 126 | editor.queue.flush() 127 | } 128 | }) 129 | -------------------------------------------------------------------------------- /src/peritext.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { isEqual, sortBy } from "lodash" 3 | import Micromerge, { 4 | Json, ObjectId, OperationId, OperationPath, 5 | BaseOperation, Patch, 6 | ListItemMetadata, ListMetadata, 7 | compareOpIds, getListElementId 8 | } from "./micromerge" 9 | import { Marks, markSpec, MarkType } from "./schema" 10 | 11 | export type MarkOperation = AddMarkOperation | RemoveMarkOperation 12 | 13 | /** A position at which a mark operation can start or end. 14 | * In a text string with n characters, there are 2n+2 boundary positions: 15 | * one to the left or right of each character, plus the start and end of the string. 16 | */ 17 | export type BoundaryPosition = 18 | | { type: "before"; elemId: OperationId } 19 | | { type: "after"; elemId: OperationId } 20 | | { type: "startOfText" } 21 | | { type: "endOfText" } 22 | 23 | type MarkOpsPosition = "markOpsBefore" | "markOpsAfter" 24 | 25 | interface AddMarkOperationBase extends BaseOperation { 26 | action: "addMark" 27 | /** List element to apply the mark start. */ 28 | start: BoundaryPosition 29 | /** List element to apply the mark end, inclusive. */ 30 | end: BoundaryPosition 31 | /** Mark to add. */ 32 | markType: M 33 | } 34 | 35 | export interface FormatSpanWithText { 36 | text: string 37 | marks: MarkMap 38 | } 39 | 40 | export type AddMarkOperation = Values<{ 41 | [M in MarkType]: keyof Omit extends never 42 | ? AddMarkOperationBase & { attrs?: undefined } 43 | : AddMarkOperationBase & { 44 | attrs: Required> 45 | } 46 | }> 47 | 48 | interface RemoveMarkOperationBase extends BaseOperation { 49 | action: "removeMark" 50 | /** List element to apply the mark start. */ 51 | start: BoundaryPosition 52 | /** List element to apply the mark end, inclusive. */ 53 | end: BoundaryPosition 54 | /** Mark to add. */ 55 | markType: M 56 | } 57 | 58 | export type RemoveMarkOperation = 59 | | RemoveMarkOperationBase<"strong"> 60 | | RemoveMarkOperationBase<"em"> 61 | | (RemoveMarkOperationBase<"comment"> & { 62 | /** Data attributes for the mark. */ 63 | attrs: MarkValue["comment"] 64 | }) 65 | | RemoveMarkOperationBase<"link"> 66 | 67 | interface AddMarkOperationInputBase { 68 | action: "addMark" 69 | /** Path to a list object. */ 70 | path: OperationPath 71 | /** Index in the list to apply the mark start, inclusive. */ 72 | startIndex: number 73 | /** Index in the list to end the mark, exclusive. */ 74 | endIndex: number 75 | /** Mark to add. */ 76 | markType: M 77 | } 78 | 79 | // TODO: automatically populate attrs type w/o manual enumeration 80 | export type AddMarkOperationInput = Values<{ 81 | [M in MarkType]: keyof Omit extends never 82 | ? AddMarkOperationInputBase & { attrs?: undefined } 83 | : AddMarkOperationInputBase & { 84 | attrs: Required> 85 | } 86 | }> 87 | 88 | // TODO: What happens if the mark isn't active at all of the given indices? 89 | // TODO: What happens if the indices are out of bounds? 90 | interface RemoveMarkOperationInputBase { 91 | action: "removeMark" 92 | /** Path to a list object. */ 93 | path: OperationPath 94 | /** Index in the list to remove the mark, inclusive. */ 95 | startIndex: number 96 | /** Index in the list to end the mark removal, exclusive. */ 97 | endIndex: number 98 | /** Mark to remove. */ 99 | markType: M 100 | } 101 | 102 | export type RemoveMarkOperationInput = 103 | | (RemoveMarkOperationInputBase<"strong"> & { 104 | attrs?: undefined 105 | }) 106 | | (RemoveMarkOperationInputBase<"em"> & { 107 | attrs?: undefined 108 | }) 109 | | (RemoveMarkOperationInputBase<"comment"> & { 110 | /** Data attributes for the mark. */ 111 | attrs: MarkValue["comment"] 112 | }) 113 | | (RemoveMarkOperationInputBase<"link"> & { 114 | /** Data attributes for the mark. */ 115 | attrs?: undefined 116 | }) 117 | 118 | type CommentMarkValue = { 119 | id: string 120 | } 121 | 122 | type BooleanMarkValue = { active: boolean } 123 | type LinkMarkValue = { url: string } 124 | 125 | export type MarkValue = Assert< 126 | { 127 | strong: BooleanMarkValue 128 | em: BooleanMarkValue 129 | comment: CommentMarkValue 130 | link: LinkMarkValue 131 | }, 132 | { [K in MarkType]: Record } 133 | > 134 | 135 | export type MarkMap = { 136 | [K in MarkType]?: Marks[K]["allowMultiple"] extends true ? Array : MarkValue[K] 137 | } 138 | 139 | export type FormatSpan = { 140 | marks: MarkMap 141 | start: number 142 | } 143 | 144 | /** 145 | * As we walk through the document applying the operation, we keep track of whether we've reached the right area. 146 | */ 147 | type MarkOpState = "BEFORE" | "DURING" | "AFTER" 148 | 149 | /** A patch which only has a start index and not an end index yet. 150 | * Used when we're iterating thru metadata sequence and constructing a patch to emit. 151 | */ 152 | type PartialPatch = Omit | Omit 153 | 154 | export function applyAddRemoveMark(op: MarkOperation, object: Json, metadata: ListMetadata): Patch[] { 155 | if (!(metadata instanceof Array)) { 156 | throw new Error(`Expected list metadata for a list`) 157 | } 158 | 159 | if (!(object instanceof Array)) { 160 | throw new Error(`Expected list metadata for a list`) 161 | } 162 | 163 | // we shall build a list of patches to return 164 | const patches: Patch[] = [] 165 | 166 | // Make an ordered list of all the document positions, walking from left to right 167 | type Positions = [number, MarkOpsPosition, ListItemMetadata][] 168 | const positions = Array.from(metadata.entries(), ([i, elMeta]) => [ 169 | [i, "markOpsBefore", elMeta], 170 | [i, "markOpsAfter", elMeta] 171 | ]).flat() as Positions; 172 | 173 | // set up some initial counters which will keep track of the state of the document. 174 | // these are explained where they're used. 175 | let visibleIndex = 0 176 | let currentOps = new Set() 177 | let opState: MarkOpState = "BEFORE" 178 | let partialPatch: PartialPatch | undefined 179 | const objLength = object.length as number // pvh wonders: why does this not account for deleted items? 180 | 181 | for (const [, side, elMeta] of positions) { 182 | // First we update the currently known formatting operations affecting this position 183 | currentOps = elMeta[side] || currentOps 184 | let changedOps 185 | [opState, changedOps] = calculateOpsForPosition(op, currentOps, side, elMeta, opState) 186 | if (changedOps) { elMeta[side] = changedOps } 187 | 188 | // Next we need to do patch maintenance. 189 | // Once we are DURING the operation, we'll start a patch, emitting an intermediate patch 190 | // any time the formatting changes during that range, and eventually emitting one last patch 191 | // at the end of the range (or document.) 192 | if (side === "markOpsAfter" && !elMeta.deleted) { 193 | // We need to keep track of the "visible" index, since the outside world won't know about 194 | // deleted characters. 195 | visibleIndex += 1 196 | } 197 | 198 | if (changedOps) { 199 | // First see if we need to emit a new patch, which occurs when formatting changes 200 | // within the range of characters the formatting operation targets. 201 | if (partialPatch) { 202 | const patch = finishPartialPatch(partialPatch, visibleIndex, objLength) 203 | if (patch) { patches.push(patch) } 204 | partialPatch = undefined 205 | } 206 | 207 | // Now begin a new patch since we have new formatting to send out. 208 | if (opState == "DURING" && !isEqual(opsToMarks(currentOps), opsToMarks(changedOps))) { 209 | partialPatch = beginPartialPatch(op, visibleIndex) 210 | } 211 | } 212 | 213 | if (opState == "AFTER") { break } 214 | } 215 | 216 | // If we have a partial patch leftover at the end, emit it 217 | if (partialPatch) { 218 | const patch = finishPartialPatch(partialPatch, visibleIndex, objLength) 219 | if (patch) { patches.push(patch) } 220 | } 221 | 222 | return patches 223 | } 224 | 225 | function calculateOpsForPosition( 226 | op: MarkOperation, currentOps: Set, 227 | side: MarkOpsPosition, 228 | elMeta: ListItemMetadata, 229 | opState: MarkOpState): [opState: MarkOpState, newOps?: Set] { 230 | // Compute an index in the visible characters which will be used for patches. 231 | // If this character is visible and we're on the "after slot", then the relevant 232 | // index is one to the right of the current visible index. 233 | // Otherwise, just use the current visible index. 234 | const opSide = side === "markOpsAfter" ? "after" : "before" 235 | 236 | if (op.start.type === opSide && op.start.elemId === elMeta.elemId) { 237 | // we've reached the start of the operation 238 | return ["DURING", new Set([...currentOps, op])] 239 | } else if (op.end.type === opSide && op.end.elemId === elMeta.elemId) { 240 | // and here's the end of the operation 241 | return ["AFTER", new Set([...currentOps].filter(opInSet => opInSet !== op))] 242 | } else if (opState == "DURING" && elMeta[side] !== undefined) { 243 | // we've hit some kind of change in formatting mid-operation 244 | return ["DURING", new Set([...currentOps, op])] 245 | } 246 | 247 | // No change... 248 | return [opState, undefined] 249 | } 250 | 251 | function beginPartialPatch( 252 | op: MarkOperation, 253 | startIndex: number 254 | ): PartialPatch { 255 | const partialPatch: PartialPatch = { 256 | action: op.action, 257 | markType: op.markType, 258 | path: [Micromerge.contentKey], 259 | startIndex, 260 | } 261 | 262 | if (op.action === "addMark" && (op.markType === "link" || op.markType === "comment")) { 263 | partialPatch.attrs = op.attrs 264 | } 265 | 266 | return partialPatch 267 | } 268 | 269 | function finishPartialPatch(partialPatch: PartialPatch, endIndex: number, length: number): Patch | undefined { 270 | // Exclude certain patches which make sense from an internal metadata perspective, 271 | // but wouldn't make sense to an external caller: 272 | // - Any patch where the start or end is after the end of the currently visible text 273 | // - Any patch that is zero width, affecting no visible characters 274 | const patch = { ...partialPatch, endIndex: Math.min(endIndex, length) } as AddMarkOperationInput | RemoveMarkOperationInput 275 | const patchIsNotZeroLength = endIndex > partialPatch.startIndex 276 | const patchAffectsVisibleDocument = partialPatch.startIndex < length 277 | if (patchIsNotZeroLength && patchAffectsVisibleDocument) { 278 | return patch 279 | } 280 | return undefined 281 | } 282 | 283 | 284 | /** Given a set of mark operations for a span, produce a 285 | * mark map reflecting the effects of those operations. 286 | * (The ops can be in arbitrary order and the result is always 287 | * the same, because we do op ID comparisons.) 288 | */ 289 | 290 | // PVH code comment 291 | // we could radically simplify this by storing opId separately, 292 | // giving em/strong a boolean attrs and treating equality as key/attr equality 293 | // might be worth doing for the AM implementation 294 | export function opsToMarks(ops: Set): MarkMap { 295 | const markMap: MarkMap = {} 296 | const opIdMap: Record = {} 297 | 298 | // Construct a mark map which stores op IDs 299 | for (const op of ops) { 300 | const existingOpId = opIdMap[op.markType] 301 | // To ensure convergence, we don't always apply the operation to the mark map. 302 | // It only gets applied if its opID is greater than the previous op that 303 | // affected that value 304 | if (!markSpec[op.markType].allowMultiple) { 305 | if (existingOpId === undefined || compareOpIds(op.opId, existingOpId) === 1) { 306 | opIdMap[op.markType] = op.opId 307 | if (op.action === "addMark") { 308 | markMap[op.markType] = op.attrs || { active: true } 309 | } 310 | else { 311 | delete markMap[op.markType] 312 | } 313 | } 314 | } else { 315 | if (op.action === "addMark" && !markMap[op.markType]?.find(c => c.id === op.attrs.id)) { 316 | // Keeping the comments in ID-sorted order helps make equality checks easier later 317 | // because we can just check mark maps for deep equality 318 | markMap[op.markType] = sortBy([...(markMap[op.markType] || []), op.attrs], c => c.id) 319 | } else if (op.action === "removeMark") { 320 | markMap[op.markType] = (markMap[op.markType] || []).filter(c => c.id !== op.attrs.id) 321 | } 322 | } 323 | } 324 | 325 | return markMap 326 | } 327 | 328 | export function getActiveMarksAtIndex(metadata: ListMetadata, index: number): MarkMap { 329 | return opsToMarks(findClosestMarkOpsToLeft({ metadata, index, side: "before" })) 330 | } 331 | 332 | /** Given a path to somewhere in the document, return a list of format spans w/ text. 333 | * Each span specifies the formatting marks as well as the text within the span. 334 | * (This function avoids the need for a caller to manually stitch together 335 | * format spans with a text string.) 336 | */ 337 | export function getTextWithFormatting(text: Json, metadata: ListMetadata): Array { 338 | // Conveniently print out the metadata array, useful for debugging 339 | // console.log( 340 | // inspect( 341 | // { 342 | // actorId: this.actorId, 343 | // metadata: metadata?.map((item: ListItemMetadata, index: number) => ({ 344 | // char: text[index], 345 | // before: item.markOpsBefore, 346 | // after: item.markOpsAfter, 347 | // })), 348 | // }, 349 | // false, 350 | // 4, 351 | // ), 352 | // ) 353 | // XXX: should i pass in the objectId for this? 354 | if (text === undefined || !(text instanceof Array)) { 355 | throw new Error(`Expected a list at object ID ${"objectId".toString()}`) 356 | } 357 | if (metadata === undefined || !(metadata instanceof Array)) { 358 | throw new Error(`Expected list metadata for object ID ${"objectId".toString()}`) 359 | } 360 | 361 | const spans: FormatSpanWithText[] = [] 362 | let characters: string[] = [] 363 | let marks: MarkMap = {} 364 | let visible = 0 365 | 366 | for (const [index, elMeta] of metadata.entries()) { 367 | let newMarks: MarkMap | undefined 368 | 369 | // Figure out if new formatting became active in the gap before this character: 370 | // either on the "before" set of this character, or the "after" of previous character. 371 | // The "before" of this character takes precedence because it's later in the sequence. 372 | if (elMeta.markOpsBefore) { 373 | newMarks = opsToMarks(elMeta.markOpsBefore) 374 | } else if (index > 0 && metadata[index - 1].markOpsAfter) { 375 | newMarks = opsToMarks(metadata[index - 1].markOpsAfter!) 376 | } 377 | 378 | if (newMarks !== undefined) { 379 | // If we have some characters to emit, need to add to formatted spans 380 | addCharactersToSpans({ characters, spans, marks }) 381 | characters = [] 382 | marks = newMarks 383 | } 384 | 385 | if (!elMeta.deleted) { 386 | // todo: what happens if the char isn't a string? 387 | characters.push(text[visible] as string) 388 | visible += 1 389 | } 390 | } 391 | 392 | addCharactersToSpans({ characters, spans, marks }) 393 | 394 | return spans 395 | } 396 | 397 | 398 | // Given a position before or after a character in a list, returns a set of mark operations 399 | // which represent the closest set of mark ops to the left in the metadata. 400 | // - The search excludes the passed-in position itself, so if there is metadata at that position 401 | // it will not be returned. 402 | // - Returns a new Set object that clones the existing one to avoid problems with sharing references. 403 | // - If no mark operations are found between the beginning of the sequence and this position, 404 | // 405 | function findClosestMarkOpsToLeft(args: { 406 | index: number 407 | side: "before" | "after" 408 | metadata: ListMetadata 409 | }): Set { 410 | const { index, side, metadata } = args 411 | 412 | let ops = new Set() 413 | 414 | // First, if our initial position is after a character, look before that character 415 | if (side === "after" && metadata[index].markOpsBefore !== undefined) { 416 | return new Set(metadata[index].markOpsBefore!) 417 | } 418 | 419 | // Iterate through all characters to the left of the initial one; 420 | // first look after each character, then before it. 421 | for (let i = index - 1; i >= 0; i--) { 422 | const metadataAfter = metadata[i].markOpsAfter 423 | if (metadataAfter !== undefined) { 424 | ops = new Set(metadataAfter) 425 | break 426 | } 427 | 428 | const metadataBefore = metadata[i].markOpsBefore 429 | if (metadataBefore !== undefined) { 430 | ops = new Set(metadataBefore) 431 | break 432 | } 433 | } 434 | 435 | return ops 436 | } 437 | /** Add some characters with given marks to the end of a list of spans */ 438 | export function addCharactersToSpans(args: { 439 | characters: string[] 440 | marks: MarkMap 441 | spans: FormatSpanWithText[] 442 | }): void { 443 | const { characters, marks, spans } = args 444 | if (characters.length === 0) { 445 | return 446 | } 447 | // If the new marks are same as the previous span, we can just 448 | // add the new characters to the last span 449 | if (spans.length > 0 && isEqual(spans.slice(-1)[0].marks, marks)) { 450 | spans.slice(-1)[0].text = spans.slice(-1)[0].text.concat(characters.join("")) 451 | } else { 452 | // Otherwise we create a new span with the characters 453 | spans.push({ text: characters.join(""), marks }) 454 | } 455 | } 456 | 457 | // TODO: what's up with these return types? 458 | export function changeMark( 459 | inputOp: AddMarkOperationInput | RemoveMarkOperationInput, 460 | objId: ObjectId, 461 | meta: ListMetadata, 462 | obj: Json[] | (Json[] & Record)): DistributiveOmit { 463 | const { action, startIndex, endIndex, markType, attrs } = inputOp 464 | 465 | // TODO: factor this out to a proper per-mark-type config object somewhere 466 | const startGrows = false 467 | const endGrows = markSpec[inputOp.markType].inclusive 468 | 469 | let start: BoundaryPosition 470 | let end: BoundaryPosition 471 | 472 | /** 473 | * [start]---["H"]---["e"]---["y"]---[end] 474 | * | | | | | | | | 475 | * SA 0B 0A 1B 1A 2B 2A EB 476 | * 477 | * Spans that grow attach to the next/preceding position, sometimes 478 | * on a different character, so if a span ends on character 1 "e" but should 479 | * expand if new text is inserted, we actually attach the end of the span to 480 | * character 2's "before" slot. 481 | */ 482 | 483 | if (startGrows && inputOp.startIndex == 0) { 484 | start = { type: "startOfText" } 485 | } else if (startGrows) { 486 | start = { type: "after", elemId: getListElementId(meta, startIndex - 1) } 487 | } else { 488 | start = { type: "before", elemId: getListElementId(meta, startIndex) } 489 | } 490 | 491 | if (endGrows && inputOp.endIndex >= obj.length) { 492 | end = { type: "endOfText" } 493 | } else if (endGrows) { 494 | end = { type: "before", elemId: getListElementId(meta, endIndex) } 495 | } else { 496 | end = { type: "after", elemId: getListElementId(meta, endIndex - 1) } 497 | } 498 | 499 | const partialOp: DistributiveOmit = { action, obj: objId, start, end, markType, ...(attrs) && { attrs } } 500 | return partialOp 501 | } -------------------------------------------------------------------------------- /src/playback.ts: -------------------------------------------------------------------------------- 1 | import { TraceSpec, PathlessInputOperation } from "../test/micromerge" 2 | import { extendProsemirrorTransactionWithMicromergePatch, Editor } from "./bridge" 3 | import { InputOperation } from "./micromerge" 4 | 5 | export type Editors = { [key: string]: Editor } 6 | 7 | export type TraceEvent = ((InputOperation & { editorId: string }) | { action: "sync" } | { action: "restart" }) & { 8 | delay?: number 9 | } 10 | export type Trace = TraceEvent[] 11 | 12 | /** Specify concurrent edits on two editors, which sync at the end */ 13 | const testToTrace = (traceSpec: TraceSpec): Trace => { 14 | if (!traceSpec.initialText || !traceSpec.inputOps1 || !traceSpec.inputOps2) { 15 | throw new Error(`Expected full trace spec`) 16 | } 17 | 18 | const trace: Trace = [] 19 | 20 | trace.push({ editorId: "alice", path: [], action: "makeList", key: "text", delay: 0 }) 21 | trace.push({ action: "sync", delay: 0 }) 22 | trace.push({ 23 | editorId: "alice", 24 | path: ["text"], 25 | action: "insert", 26 | index: 0, 27 | values: traceSpec.initialText.split(""), 28 | }) 29 | trace.push({ action: "sync" }) 30 | 31 | traceSpec.inputOps1.forEach(o => trace.push(...simulateTypingForInputOp("alice", o))) 32 | traceSpec.inputOps2.forEach(o => trace.push(...simulateTypingForInputOp("bob", o))) 33 | trace.push({ action: "sync" }) 34 | 35 | return trace 36 | } 37 | 38 | export const simulateTypingForInputOp = (name: string, o: PathlessInputOperation): TraceEvent[] => { 39 | if (o.action === "insert") { 40 | return o.values.map((v, i) => ({ 41 | ...o, 42 | editorId: name, 43 | path: ["text"], 44 | delay: 50, 45 | values: [v], 46 | index: o.index + i, 47 | })) 48 | } 49 | 50 | return [{ ...o, editorId: name, path: ["text"] }] 51 | } 52 | 53 | export const trace = testToTrace({ 54 | initialText: "The Peritext editor", 55 | inputOps1: [ 56 | { 57 | action: "addMark", 58 | startIndex: 0, 59 | endIndex: 12, 60 | markType: "strong", 61 | }, 62 | ], 63 | inputOps2: [ 64 | { 65 | action: "addMark", 66 | startIndex: 4, 67 | endIndex: 19, 68 | markType: "em", 69 | }, 70 | ], 71 | expectedResult: [ 72 | { marks: { strong: { active: true } }, text: "The " }, 73 | { 74 | marks: { strong: { active: true }, em: { active: true } }, 75 | text: "Peritext", 76 | }, 77 | { marks: { em: { active: true } }, text: " editor" }, 78 | ], 79 | }) 80 | 81 | const SYNC_ANIMATION_SPEED = 1000 82 | export const executeTraceEvent = async ( 83 | event: TraceEvent, 84 | editors: Editors, 85 | handleSyncEvent: () => void, 86 | ): Promise => { 87 | switch (event.action) { 88 | case "sync": { 89 | // Call the sync event handler, then wait before actually syncing. 90 | // This makes the sync indicator seem more realistic because it 91 | // starts activating before the sync completes. 92 | handleSyncEvent() 93 | await new Promise(resolve => setTimeout(resolve, SYNC_ANIMATION_SPEED)) 94 | Object.values(editors).forEach(e => e.queue.flush()) 95 | 96 | // Wait after the sync happens, to let the user see the results 97 | await new Promise(resolve => setTimeout(resolve, event.delay || 1000)) 98 | break 99 | } 100 | case "restart": { 101 | break 102 | } 103 | default: { 104 | const editor = editors[event.editorId] 105 | if (!editor) { 106 | throw new Error("Encountered a trace event for a missing editor") 107 | } 108 | 109 | const { change, patches } = editor.doc.change([event]) 110 | let transaction = editor.view.state.tr 111 | for (const patch of patches) { 112 | const { transaction: newTxn } = extendProsemirrorTransactionWithMicromergePatch(transaction, patch) 113 | transaction = newTxn 114 | } 115 | editor.view.state = editor.view.state.apply(transaction) 116 | editor.view.updateState(editor.view.state) 117 | editor.queue.enqueue(change) 118 | editor.outputDebugForChange(change) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/pubsub.ts: -------------------------------------------------------------------------------- 1 | export class Publisher { 2 | private subscribers: Record void> = {} 3 | 4 | public subscribe(key: string, callback: (update: T) => void): void { 5 | if (this.subscribers[key]) { 6 | throw new Error(`Subscriber already exists: ${key}`) 7 | } 8 | this.subscribers[key] = callback 9 | } 10 | 11 | public unsubscribe(key: string): void { 12 | if (!this.subscribers[key]) { 13 | throw new Error(`Subscriber not found: ${key}`) 14 | } 15 | delete this.subscribers[key] 16 | } 17 | 18 | public publish(sender: string, update: T): void { 19 | for (const [id, callback] of Object.entries(this.subscribers)) { 20 | if (id === sender) { 21 | continue 22 | } 23 | callback(update) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import { MarkSpec, Node, Schema, SchemaSpec, DOMOutputSpec, DOMOutputSpecArray, Mark } from "prosemirror-model" 2 | 3 | import ColorHash from "color-hash" 4 | const colorHash = new ColorHash() 5 | 6 | /*********************************************** 7 | * Nodes. 8 | ***********************************************/ 9 | 10 | const nodeSpec = { 11 | doc: { 12 | content: "block+", 13 | }, 14 | paragraph: { 15 | content: "text*", 16 | group: "block", 17 | toDOM: (): DOMOutputSpecArray => ["p", 0], 18 | }, 19 | text: {}, 20 | } as const 21 | 22 | type Nodes = typeof nodeSpec 23 | 24 | export type NodeType = keyof Nodes 25 | export type GroupType = { 26 | [T in NodeType]: Nodes[T] extends { group: string } ? Nodes[T]["group"] : never 27 | }[NodeType] 28 | 29 | type Quantifier = "+" | "*" | "?" 30 | 31 | export type ContentDescription = NodeType | GroupType | `${NodeType | GroupType}${Quantifier}` 32 | 33 | interface NodeSpec { 34 | content?: ContentDescription 35 | group?: GroupType 36 | toDOM?: (node: Node) => DOMOutputSpec | DOMOutputSpecArray 37 | } 38 | 39 | type AssertNodesMatchSpec = Assert 40 | 41 | /*********************************************** 42 | * Marks. 43 | ***********************************************/ 44 | 45 | export const markSpec = { 46 | strong: { 47 | toDOM() { 48 | return ["strong"] as const 49 | }, 50 | allowMultiple: false, 51 | inclusive: true, 52 | }, 53 | em: { 54 | toDOM() { 55 | return ["em"] as const 56 | }, 57 | allowMultiple: false, 58 | inclusive: true, 59 | }, 60 | comment: { 61 | attrs: { 62 | id: {}, 63 | }, 64 | inclusive: false, 65 | toDOM(mark: Mark) { 66 | return [ 67 | "span", 68 | { 69 | "data-mark": "comment", 70 | "data-comment-id": mark.attrs.id, 71 | }, 72 | ] as const 73 | }, 74 | /** TODO: We should not be spamming this config with our own attributes. 75 | However, in the real world we would define a custom config structure 76 | that compiled down to a ProseMirror schema spec, so I will allow it. */ 77 | allowMultiple: true, 78 | excludes: "" as const, // Allow overlapping with other marks of the same type. 79 | }, 80 | link: { 81 | attrs: { 82 | url: {}, 83 | }, 84 | inclusive: false, 85 | allowMultiple: false, 86 | toDOM(mark: Mark) { 87 | return [ 88 | "a", 89 | { 90 | href: mark.attrs.url, 91 | style: `color: ${colorHash.hex(mark.attrs.url)};`, 92 | }, 93 | ] as const 94 | }, 95 | }, 96 | } as const 97 | 98 | // We add additional mark types only used for displaying changes in the demo. 99 | export const demoMarkSpec = { 100 | ...markSpec, 101 | highlightChange: { 102 | toDOM() { 103 | return [ 104 | "span", 105 | { 106 | class: "highlight-flash", 107 | }, 108 | ] as const 109 | }, 110 | }, 111 | unhighlightChange: { 112 | toDOM() { 113 | return [ 114 | "span", 115 | { 116 | class: "unhighlight", 117 | }, 118 | ] 119 | }, 120 | }, 121 | } 122 | 123 | export type Marks = typeof markSpec 124 | 125 | export const ALL_MARKS = ["strong" as const, "em" as const, "comment" as const, "link" as const] 126 | 127 | type AssertAllListedAreMarks = Assert, MarkType> 128 | type AssertAllMarksAreListed = Assert> 129 | 130 | export type MarkType = keyof typeof markSpec 131 | type AssertMarksMatchSpec = Assert 132 | 133 | export function isMarkType(s: string): s is MarkType { 134 | if (s === "strong" || s === "em" || s === "comment" || s === "link") { 135 | type AssertSound = Assert 136 | type AssertComplete = Assert 137 | return true 138 | } 139 | return false 140 | } 141 | 142 | /*********************************************** 143 | * Schema. 144 | ***********************************************/ 145 | 146 | export const schemaSpec: SchemaSpec = { 147 | nodes: nodeSpec, 148 | marks: demoMarkSpec, 149 | } 150 | 151 | export type DocSchema = Schema 152 | -------------------------------------------------------------------------------- /static/essay-demo.css: -------------------------------------------------------------------------------- 1 | /** Mobile layout */ 2 | 3 | @media (max-width: 720px) { 4 | body > article { 5 | width: calc(100% - var(--column-gap)); 6 | } 7 | 8 | article aside { 9 | margin-top: 8px; 10 | margin-bottom: 16px; 11 | padding: var(--padding); /* Override the padding-right: 0; assignment */ 12 | } 13 | } 14 | 15 | /** article.css overrides */ 16 | code span { 17 | font-style: normal; 18 | } 19 | 20 | /** Peritext-specific */ 21 | 22 | code { 23 | hyphens: none; 24 | } 25 | 26 | :root { 27 | --alice-color: #e08b8b; 28 | --bob-color: #00c2ff; 29 | } 30 | 31 | blockquote > p { 32 | margin: 0; 33 | } 34 | 35 | .actors { 36 | display: flex; 37 | flex-direction: column; 38 | justify-content: space-between; 39 | } 40 | 41 | @media (min-width: 720px) { 42 | .actors { 43 | flex-direction: row; 44 | } 45 | 46 | .actors > div:first-child { 47 | margin-right: 15px; 48 | margin-bottom: 0; 49 | } 50 | 51 | .alice, 52 | .bob { 53 | flex: 1; 54 | display: flex; 55 | flex-direction: column; 56 | } 57 | } 58 | 59 | .alice > p, 60 | .bob > p { 61 | padding-left: 5px; 62 | margin-bottom: 0.1em; 63 | } 64 | 65 | .alice > blockquote, 66 | .bob > blockquote { 67 | border: solid thin #ddd; 68 | border-radius: 5px; 69 | padding: var(--padding); /* Otherwise too small on mobile. */ 70 | } 71 | 72 | .alice > blockquote { 73 | border-left: solid 3px var(--alice-color); 74 | } 75 | 76 | .bob > blockquote { 77 | border-left: solid 3px var(--bob-color); 78 | } 79 | 80 | /* Style shamelessly copied from Notion */ 81 | .comment { 82 | background: rgba(255, 212, 0, 0.14); 83 | border-bottom: 2px solid rgb(255, 212, 0); 84 | padding-bottom: 2px; 85 | position: relative; 86 | } 87 | 88 | .overlap { 89 | background: rgba(255, 212, 0, 0.56); 90 | } 91 | 92 | .comment .popup { 93 | font-size: 80%; 94 | position: absolute; 95 | background: rgba(255, 212, 0, 0.14); 96 | top: 2rem; 97 | left: 2rem; 98 | white-space: nowrap; 99 | padding: 2px 6px; 100 | } 101 | 102 | .comment .line { 103 | position: absolute; 104 | top: 1.4rem; 105 | width: 2rem; 106 | height: 2rem; 107 | border-left: 2px solid rgb(255, 212, 0); 108 | border-bottom: 2px solid rgb(255, 212, 0); 109 | } 110 | 111 | /* Live demo ------------- */ 112 | 113 | .peritext-demo { 114 | width: 100%; 115 | display: flex; 116 | flex-wrap: wrap; 117 | gap: 20px; 118 | padding: 5px 0; 119 | position: relative; 120 | } 121 | 122 | .peritext-demo .editor-container { 123 | background-color: #ddd; 124 | flex: 1 0 0; 125 | min-width: 300px; 126 | height: 250px; 127 | background: #f2f2f2; 128 | border: 1px solid #c4c4c4; 129 | box-sizing: border-box; 130 | border-radius: 5px; 131 | padding: 20px 10px; 132 | margin: 20px 5px; 133 | } 134 | 135 | .peritext-demo .editor { 136 | border: solid thin #000; 137 | border-radius: 5px; 138 | height: 76px; 139 | background: #fff; 140 | padding: 5px; 141 | } 142 | 143 | .peritext-demo .buttons { 144 | height: 30px; 145 | margin-bottom: 5px; 146 | display: flex; 147 | } 148 | 149 | .peritext-demo .button { 150 | width: 20px; 151 | margin: 5px; 152 | padding-right: 5px; 153 | border-right: solid thin #bbb; 154 | user-select: none; 155 | } 156 | 157 | .peritext-demo .button:last-child { 158 | border-right: none; 159 | } 160 | 161 | .peritext-demo .button.link { 162 | margin-top: 1px; 163 | } 164 | 165 | .peritext-demo #alice { 166 | border-top: solid 5px var(--alice-color); 167 | } 168 | 169 | .peritext-demo #bob { 170 | border-top: solid 5px var(--bob-color); 171 | } 172 | 173 | .peritext-demo .changes { 174 | display: flex; 175 | flex-wrap: wrap; 176 | height: 64px; 177 | overflow: hidden; 178 | margin-top: 10px; 179 | position: relative; 180 | left: 0px; 181 | } 182 | 183 | @keyframes slideLeft { 184 | from { 185 | left: 0px; 186 | opacity: 100%; 187 | } 188 | 189 | to { 190 | left: 450px; 191 | opacity: 30%; 192 | } 193 | } 194 | 195 | @keyframes slideRight { 196 | from { 197 | left: 0px; 198 | opacity: 100%; 199 | } 200 | 201 | to { 202 | left: -450px; 203 | opacity: 30%; 204 | } 205 | } 206 | 207 | .peritext-demo #alice .syncing { 208 | animation-duration: 1s; 209 | animation-name: slideLeft; 210 | } 211 | 212 | .peritext-demo #bob .syncing { 213 | animation-duration: 1s; 214 | animation-name: slideRight; 215 | } 216 | 217 | .peritext-demo .changes .op { 218 | height: 20px; 219 | padding: 0 5px 5px 5px; 220 | border-radius: 5px; 221 | line-height: 1.5em; 222 | min-width: 0.6em; 223 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", 224 | "Helvetica Neue", sans-serif; 225 | font-weight: 500; 226 | margin: 2px; 227 | background: white; 228 | animation: fadeIn 200ms; 229 | } 230 | 231 | @keyframes fadeIn { 232 | 0% { 233 | opacity: 0; 234 | } 235 | 100% { 236 | opacity: 1; 237 | } 238 | } 239 | 240 | .peritext-demo #alice .changes .op { 241 | border-bottom: solid 2px var(--alice-color); 242 | } 243 | .peritext-demo #bob .changes .op { 244 | border-bottom: solid 2px var(--bob-color); 245 | } 246 | 247 | span[data-mark="comment"] { 248 | background: rgba(255, 227, 86, 0.3); 249 | border-bottom: solid 2px rgba(255, 212, 0, 0.5); 250 | } 251 | 252 | span.highlight-flash { 253 | background: rgb(255, 250, 185); 254 | } 255 | 256 | @keyframes unhighlightGradual { 257 | 0% { 258 | background: rgba(255, 250, 185, 1); 259 | } 260 | 100% { 261 | background: rgba(255, 250, 185, 0); 262 | } 263 | } 264 | 265 | span.unhighlight { 266 | animation: unhighlightGradual 1000ms ease-out; 267 | } 268 | 269 | .peritext-demo .play-pause { 270 | position: absolute; 271 | width: 100%; 272 | height: 100%; 273 | background-color: #000; 274 | color: white; 275 | font-size: 10em; 276 | text-align: center; 277 | transition: opacity 200ms ease-out; 278 | cursor: pointer; 279 | display: flex; 280 | justify-content: center; 281 | align-items: center; 282 | } 283 | 284 | .peritext-demo .play-pause.paused { 285 | opacity: 0.2; 286 | } 287 | 288 | .peritext-demo .play-pause:not(.paused) { 289 | opacity: 0; 290 | } 291 | 292 | /* On devices with actual hover support, show a hover state 293 | https://stackoverflow.com/a/28058919 */ 294 | @media (hover: hover) { 295 | .peritext-demo .play-pause:hover { 296 | opacity: 0.1; 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | .editors-container { 6 | width: 100%; 7 | display: flex; 8 | justify-content: space-between; 9 | } 10 | 11 | .editor-container { 12 | flex-basis: 50%; 13 | margin-bottom: 23px; 14 | } 15 | 16 | .editor-container:first-child { 17 | margin-right: 10px; 18 | } 19 | 20 | .editor span[data-mark="comment"] { 21 | background-color: rgba(255, 241, 50, 0.25); 22 | } 23 | 24 | /* If two comments overlap, render an extra-intense yellow */ 25 | .editor span[data-mark="comment"] span[data-mark="comment"] { 26 | background-color: rgba(255, 241, 50, 0.6); 27 | } 28 | 29 | #toggle-connect { 30 | margin-bottom: 20px; 31 | } 32 | 33 | .marks { 34 | height: 40px; 35 | padding: 5px; 36 | font-size: 12px; 37 | } 38 | 39 | .changes { 40 | height: 300px; 41 | overflow-y: scroll; 42 | scroll-snap-type: y proximity; 43 | } 44 | 45 | .change { 46 | margin: 5px; 47 | padding: 5px; 48 | font-size: 12px; 49 | } 50 | 51 | .change.from-alice { 52 | background-color: rgba(200, 200, 0, 0.1); 53 | } 54 | 55 | .change.from-bob { 56 | background-color: rgba(0, 200, 200, 0.1); 57 | } 58 | 59 | span.de-emphasize { 60 | color: #aaa; 61 | } 62 | 63 | h3 { 64 | font-size: 12px; 65 | color: #999; 66 | margin-bottom: 5px; 67 | } 68 | -------------------------------------------------------------------------------- /test/accumulatePatches.ts: -------------------------------------------------------------------------------- 1 | import { Patch } from "../src/micromerge" 2 | import { FormatSpanWithText, addCharactersToSpans } from "../src/peritext" 3 | import { isEqual, sortBy } from "lodash" 4 | import { TextWithMetadata, range } from "./micromerge" 5 | 6 | /** Accumulates effects of patches into the same structure returned by our batch codepath; 7 | * this lets us test that the result of applying a bunch of patches is what we expect. 8 | */ 9 | export const accumulatePatches = (patches: Patch[]): FormatSpanWithText[] => { 10 | const metadata: TextWithMetadata = [] 11 | for (const patch of patches) { 12 | if (!isEqual(patch.path, ["text"])) { 13 | throw new Error("This implementation only supports a single path: 'text'") 14 | } 15 | 16 | switch (patch.action) { 17 | case "insert": { 18 | patch.values.forEach((character: string, valueIndex: number) => { 19 | metadata.splice(patch.index + valueIndex, 0, { 20 | character, 21 | marks: { ...patch.marks }, 22 | }) 23 | }) 24 | 25 | break 26 | } 27 | 28 | case "delete": { 29 | metadata.splice(patch.index, patch.count) 30 | break 31 | } 32 | 33 | case "addMark": { 34 | for (const index of range(patch.startIndex, patch.endIndex - 1)) { 35 | if (patch.markType !== "comment") { 36 | metadata[index].marks[patch.markType] = { 37 | ...(patch.attrs || { active: true }), 38 | } 39 | } else { 40 | const commentsArray = metadata[index].marks[patch.markType] 41 | if (commentsArray === undefined) { 42 | metadata[index].marks[patch.markType] = [{ ...patch.attrs }] 43 | } else if (!commentsArray.find(c => c.id === patch.attrs.id)) { 44 | metadata[index].marks[patch.markType] = sortBy( 45 | [...commentsArray, { ...patch.attrs }], 46 | c => c.id, 47 | ) 48 | } 49 | } 50 | } 51 | break 52 | } 53 | 54 | case "removeMark": { 55 | for (const index of range(patch.startIndex, patch.endIndex - 1)) { 56 | delete metadata[index].marks[patch.markType] 57 | } 58 | break 59 | } 60 | 61 | case "makeList": { 62 | break 63 | } 64 | 65 | default: { 66 | unreachable(patch) 67 | } 68 | } 69 | } 70 | 71 | // Accumulate the per-character metadata into a normal spans structure 72 | // as returned by our batch codepath 73 | const spans: FormatSpanWithText[] = [] 74 | 75 | for (const meta of metadata) { 76 | addCharactersToSpans({ characters: [meta.character], marks: meta.marks, spans }) 77 | } 78 | 79 | return spans 80 | } 81 | -------------------------------------------------------------------------------- /test/assertDocsEqual.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/peritext/89c162d3c1ae02c426c9002419aef0814e779ed8/test/assertDocsEqual.ts -------------------------------------------------------------------------------- /test/fuzz.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert" 2 | import crypto from "crypto" 3 | import { isEqual } from "lodash" 4 | import fs from "fs" 5 | import path from "path" 6 | import { v4 as uuid } from "uuid" 7 | import { getMissingChanges, applyChanges } from "./merge" 8 | import Micromerge, { ActorId, Change, Patch } from "../src/micromerge" 9 | import { generateDocs } from "./generateDocs" 10 | import { accumulatePatches } from "./accumulatePatches" 11 | 12 | function assertUnreachable(x: never): never { 13 | throw new Error("Didn't expect to get here" + x) 14 | } 15 | 16 | const saveFailedTrace = (data: unknown) => { 17 | const filename = `../traces/fail-${uuid()}.json` 18 | fs.writeFileSync(path.resolve(__dirname, filename), JSON.stringify(data)) 19 | console.log(`wrote failed trace to ${filename}`) 20 | } 21 | 22 | type OpTypes = "insert" | "remove" | "addMark" | "removeMark" 23 | const opTypes: OpTypes[] = ["insert", "remove", "addMark", "removeMark"] 24 | 25 | type MarkTypes = "strong" | "em" | "link" | "comment" 26 | const markTypes: MarkTypes[] = ["strong", "em", "link", "comment"] 27 | 28 | const exampleURLs = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").map(letter => `${letter}.com`) 29 | 30 | const commentHistory: string[] = [] 31 | 32 | function addMarkChange(doc: Micromerge) { 33 | const length = (doc.root.text as string[]).length 34 | const startIndex = Math.floor(Math.random() * length) 35 | const endIndex = startIndex + Math.floor(Math.random() * (length - startIndex)) + 1 36 | const markType = markTypes[Math.floor(Math.random() * markTypes.length)] 37 | 38 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 39 | const sharedStuff: any = { 40 | path: ["text"], 41 | action: "addMark", 42 | startIndex, 43 | endIndex, 44 | markType, 45 | } 46 | 47 | if (markType === "link") { 48 | // pick one of the four urls we use to encourage adjacent matching spans 49 | const url = exampleURLs[Math.floor(Math.random() * exampleURLs.length)] 50 | return doc.change([ 51 | { 52 | ...sharedStuff, 53 | attrs: { url }, 54 | }, 55 | ]) 56 | } else if (markType === "comment") { 57 | // make a new comment ID and remember it so we can try removing it later 58 | const id = "comment-" + crypto.randomBytes(2).toString("hex") 59 | commentHistory.push(id) 60 | return doc.change([ 61 | { 62 | ...sharedStuff, 63 | attrs: { id }, 64 | }, 65 | ]) 66 | } else { 67 | return doc.change([sharedStuff]) 68 | } 69 | } 70 | 71 | function removeMarkChange(doc: Micromerge) { 72 | const length = (doc.root.text as unknown[]).length 73 | const startIndex = Math.floor(Math.random() * length) 74 | const endIndex = startIndex + Math.floor(Math.random() * (length - startIndex)) + 1 75 | const markType = markTypes[Math.floor(Math.random() * markTypes.length)] 76 | 77 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 78 | const sharedStuff: any = { 79 | path: ["text"], 80 | action: "addMark", 81 | startIndex, 82 | endIndex, 83 | markType, 84 | } 85 | 86 | if (markType === "link") { 87 | const url = exampleURLs[Math.floor(Math.random() * exampleURLs.length)] 88 | return doc.change([ 89 | { 90 | ...sharedStuff, 91 | attrs: { url }, // do we need a URL? 92 | }, 93 | ]) 94 | } else if (markType === "comment") { 95 | // note to gklitt: we should probably enumerate the existing comments, right now it just grows 96 | const id = commentHistory[Math.floor(Math.random() * commentHistory.length)] 97 | return doc.change([ 98 | { 99 | ...sharedStuff, 100 | attrs: { id }, 101 | }, 102 | ]) 103 | } else { 104 | return doc.change([sharedStuff]) 105 | } 106 | } 107 | 108 | const MAX_CHARS = 2 109 | function insertChange(doc: Micromerge) { 110 | const length = (doc.root.text as unknown[]).length 111 | const index = Math.floor(Math.random() * length) 112 | const numChars = Math.floor(Math.random() * MAX_CHARS) 113 | const values = crypto.randomBytes(numChars).toString("hex").split("") 114 | 115 | return doc.change([ 116 | { 117 | path: ["text"], 118 | action: "insert", 119 | index, 120 | values, 121 | }, 122 | ]) 123 | } 124 | 125 | function removeChange(doc: Micromerge) { 126 | const length = (doc.root.text as unknown[]).length 127 | // gklitt: this appears to be a real bug! if you delete everything things go wonky 128 | const index = Math.floor(Math.random() * length) + 1 129 | const count = Math.ceil(Math.random() * (length - index)) 130 | 131 | const { change, patches } = doc.change([ 132 | { 133 | path: ["text"], 134 | action: "delete", 135 | index, 136 | count, 137 | }, 138 | ]) 139 | return { change, patches } 140 | } 141 | 142 | function handleOp(op: OpTypes, doc: Micromerge): { change: Change; patches: Patch[] } { 143 | switch (op) { 144 | case "insert": 145 | return insertChange(doc) 146 | case "remove": 147 | return removeChange(doc) 148 | case "addMark": 149 | return addMarkChange(doc) 150 | case "removeMark": 151 | return removeMarkChange(doc) 152 | default: 153 | assertUnreachable(op) 154 | } 155 | } 156 | 157 | const { docs, patches: allPatches, initialChange } = generateDocs("ABCDE", 3) 158 | const docIds = docs.map(d => d.actorId) 159 | 160 | type SharedHistory = Record 161 | export const queues: SharedHistory = {} 162 | docIds.forEach(id => (queues[id] = [])) 163 | queues["doc1"].push(initialChange) 164 | 165 | const syncs = [] 166 | // eslint-disable-next-line no-constant-condition 167 | while (true) { 168 | const randomTarget = Math.floor(Math.random() * docs.length) 169 | const doc = docs[randomTarget] 170 | const queue = queues[docIds[randomTarget]] 171 | const patchList = allPatches[randomTarget] 172 | 173 | const op = opTypes[Math.floor(Math.random() * opTypes.length)] 174 | 175 | const { change, patches } = handleOp(op, doc) 176 | queue.push(change) 177 | patchList.push(...patches) 178 | 179 | const shouldSync = true // (Math.random() < 0.2) 180 | if (shouldSync) { 181 | const left = Math.floor(Math.random() * docs.length) 182 | 183 | let right: number 184 | do { 185 | right = Math.floor(Math.random() * docs.length) 186 | } while (left == right) 187 | 188 | // console.log("merging", docs[left].actorId, docs[right].actorId) 189 | syncs.push({ 190 | left: docs[left].actorId, 191 | right: docs[right].actorId, 192 | missingLeft: getMissingChanges(docs[left], docs[right]), 193 | missingRight: getMissingChanges(docs[right], docs[left]), 194 | }) 195 | // console.log(util.inspect(getMissingChanges(docs[left], docs[right]), true, 10)) 196 | // console.log(util.inspect(getMissingChanges(docs[right], docs[left]), true, 10)) 197 | 198 | const rightPatches = applyChanges(docs[right], getMissingChanges(docs[left], docs[right])) 199 | const leftPatches = applyChanges(docs[left], getMissingChanges(docs[right], docs[left])) 200 | 201 | allPatches[right].push(...rightPatches) 202 | allPatches[left].push(...leftPatches) 203 | 204 | const leftText = docs[left].getTextWithFormatting(["text"]) 205 | const rightText = docs[right].getTextWithFormatting(["text"]) 206 | 207 | if (!isEqual(accumulatePatches(allPatches[left]), leftText)) { 208 | console.log(`de-sync with ${allPatches[left].length} patches`) 209 | saveFailedTrace({ 210 | docId: docs[left].actorId, 211 | patchDoc: accumulatePatches(allPatches[left]), 212 | batchDoc: leftText, 213 | // @ts-ignore -- reach into private metadata, it's fine for this purpose 214 | meta: docs[left].metadata["1@doc1"].map(item => ({ 215 | ...item, 216 | // show mark op sets as arrays in JSON 217 | markOpsBefore: item.markOpsBefore && [...item.markOpsBefore], 218 | markOpsAfter: item.markOpsAfter && [...item.markOpsAfter], 219 | })), 220 | patches: allPatches[left], 221 | queues, 222 | syncs, 223 | }) 224 | } 225 | 226 | if (!isEqual(accumulatePatches(allPatches[right]), rightText)) { 227 | console.log(`de-sync with ${allPatches[right].length} patches`) 228 | saveFailedTrace({ 229 | docId: docs[right].actorId, 230 | patchDoc: accumulatePatches(allPatches[right]), 231 | batchDoc: rightText, 232 | // @ts-ignore -- reach into private metadata, it's fine for this purpose 233 | meta: docs[right].metadata["1@doc1"].map(item => ({ 234 | ...item, 235 | // show mark op sets as arrays in JSON 236 | markOpsBefore: item.markOpsBefore && [...item.markOpsBefore], 237 | markOpsAfter: item.markOpsAfter && [...item.markOpsAfter], 238 | })), 239 | patches: allPatches[right], 240 | queues, 241 | syncs, 242 | }) 243 | } 244 | 245 | assert.deepStrictEqual(accumulatePatches(allPatches[right]), rightText) 246 | assert.deepStrictEqual(accumulatePatches(allPatches[left]), leftText) 247 | 248 | if (!isEqual(leftText, rightText)) { 249 | saveFailedTrace({ 250 | queues, 251 | left: { 252 | doc: docs[left].actorId, 253 | text: leftText, 254 | // @ts-ignore -- reach into private metadata, it's fine for this purpose 255 | meta: docs[left].metadata["1@doc1"].map(item => ({ 256 | ...item, 257 | // show mark op sets as arrays in JSON 258 | markOpsBefore: item.markOpsBefore && [...item.markOpsBefore], 259 | markOpsAfter: item.markOpsAfter && [...item.markOpsAfter], 260 | })), 261 | }, 262 | right: { 263 | doc: docs[right].actorId, 264 | text: rightText, 265 | // @ts-ignore -- reach into private metadata, it's fine 266 | meta: docs[right].metadata["1@doc1"].map(item => ({ 267 | ...item, 268 | // show mark op sets as arrays in JSON 269 | markOpsBefore: item.markOpsBefore && [...item.markOpsBefore], 270 | markOpsAfter: item.markOpsAfter && [...item.markOpsAfter], 271 | })), 272 | }, 273 | syncs, 274 | }) 275 | } 276 | 277 | assert.deepStrictEqual(docs[left].clock, docs[right].clock) 278 | assert.deepStrictEqual(leftText, rightText) 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /test/generateDocs.ts: -------------------------------------------------------------------------------- 1 | import Micromerge, { Change, Patch } from "../src/micromerge" 2 | 3 | /** Create and return two Micromerge documents with the same text content. 4 | * Useful for creating a baseline upon which to play further changes 5 | */ 6 | const defaultText = "The Peritext editor" 7 | 8 | /** Create and return two Micromerge documents with the same text content. 9 | * Useful for creating a baseline upon which to play further changes 10 | */ 11 | export const generateDocs = ( 12 | text: string = defaultText, 13 | count: number = 2, 14 | ): { 15 | docs: Micromerge[] 16 | patches: Patch[][] 17 | initialChange: Change 18 | } => { 19 | const docs = new Array(count).fill(null).map((n, i) => { 20 | return new Micromerge(`doc${i + 1}`) 21 | }) 22 | const patches: Patch[][] = new Array(count).fill(null).map(() => []) 23 | const textChars = text.split("") 24 | 25 | // Generate a change on doc1 26 | const { change: initialChange, patches: initialPatches } = docs[0].change([ 27 | { path: [], action: "makeList", key: "text" }, 28 | { 29 | path: ["text"], 30 | action: "insert", 31 | index: 0, 32 | values: textChars, 33 | }, 34 | ]) 35 | patches[0] = initialPatches 36 | 37 | for (const [index, doc] of docs.entries()) { 38 | if (index === 0) continue 39 | patches[index] = doc.applyChange(initialChange) 40 | } 41 | return { docs, patches, initialChange } 42 | } 43 | -------------------------------------------------------------------------------- /test/merge.ts: -------------------------------------------------------------------------------- 1 | import Micromerge, { Change, Patch } from "../src/micromerge" 2 | import { queues } from "./fuzz" 3 | 4 | export function applyChanges(document: Micromerge, changes: Change[]): Patch[] { 5 | let iterations = 0 6 | const patches = [] 7 | while (changes.length > 0) { 8 | const change = changes.shift() 9 | if (!change) { 10 | return patches 11 | } 12 | try { 13 | const newPatches = document.applyChange(change) 14 | patches.push(...newPatches) 15 | } catch { 16 | changes.push(change) 17 | } 18 | if (iterations++ > 10000) { 19 | throw "applyChanges did not converge" 20 | } 21 | } 22 | return patches 23 | } 24 | 25 | export function getMissingChanges(source: Micromerge, target: Micromerge): Change[] { 26 | const sourceClock = source.clock 27 | const targetClock = target.clock 28 | const changes = [] 29 | for (const [actor, number] of Object.entries(sourceClock)) { 30 | if (targetClock[actor] === undefined) { 31 | changes.push(...queues[actor].slice(0, number)) 32 | } 33 | if (targetClock[actor] < number) { 34 | changes.push(...queues[actor].slice(targetClock[actor], number)) 35 | } 36 | } 37 | return changes 38 | } 39 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es2015", 6 | "moduleResolution": "node" 7 | }, 8 | "include": ["../src/globals.d.ts", "./**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /traces/link-trace.json: -------------------------------------------------------------------------------- 1 | { 2 | "patchDoc": [ 3 | { 4 | "text": "A", 5 | "marks": { 6 | "link": { 7 | "active": true, 8 | "url": "C.com" 9 | } 10 | } 11 | }, 12 | { 13 | "text": "28", 14 | "marks": {} 15 | } 16 | ], 17 | "batchDoc": [ 18 | { 19 | "text": "A", 20 | "marks": { 21 | "link": { 22 | "active": true, 23 | "url": "C.com" 24 | } 25 | } 26 | }, 27 | { 28 | "text": "2", 29 | "marks": {} 30 | }, 31 | { 32 | "text": "8", 33 | "marks": { 34 | "link": { 35 | "active": true, 36 | "url": "A.com" 37 | } 38 | } 39 | } 40 | ], 41 | "meta": [ 42 | { 43 | "elemId": "2@doc1", 44 | "valueId": "2@doc1", 45 | "deleted": false, 46 | "markOpsBefore": [ 47 | { 48 | "opId": "11@doc2", 49 | "action": "addMark", 50 | "obj": "1@doc1", 51 | "start": { 52 | "type": "before", 53 | "elemId": "2@doc1" 54 | }, 55 | "end": { 56 | "type": "after", 57 | "elemId": "2@doc1" 58 | }, 59 | "markType": "link", 60 | "attrs": { 61 | "url": "C.com" 62 | } 63 | } 64 | ], 65 | "markOpsAfter": [] 66 | }, 67 | { 68 | "elemId": "3@doc1", 69 | "valueId": "3@doc1", 70 | "deleted": true 71 | }, 72 | { 73 | "elemId": "7@doc3", 74 | "valueId": "7@doc3", 75 | "deleted": false 76 | }, 77 | { 78 | "elemId": "8@doc3", 79 | "valueId": "8@doc3", 80 | "deleted": false, 81 | "markOpsBefore": [ 82 | { 83 | "opId": "9@doc1", 84 | "action": "addMark", 85 | "obj": "1@doc1", 86 | "start": { 87 | "type": "before", 88 | "elemId": "8@doc3" 89 | }, 90 | "end": { 91 | "type": "after", 92 | "elemId": "4@doc1" 93 | }, 94 | "markType": "link", 95 | "attrs": { 96 | "url": "A.com" 97 | } 98 | } 99 | ] 100 | }, 101 | { 102 | "elemId": "4@doc1", 103 | "valueId": "4@doc1", 104 | "deleted": true, 105 | "markOpsAfter": [] 106 | }, 107 | { 108 | "elemId": "5@doc1", 109 | "valueId": "5@doc1", 110 | "deleted": true 111 | }, 112 | { 113 | "elemId": "6@doc1", 114 | "valueId": "6@doc1", 115 | "deleted": true 116 | } 117 | ], 118 | "patches": [ 119 | { 120 | "opId": "1@doc1", 121 | "action": "makeList", 122 | "key": "text", 123 | "path": [ 124 | "text" 125 | ] 126 | }, 127 | { 128 | "path": [ 129 | "text" 130 | ], 131 | "action": "insert", 132 | "index": 0, 133 | "values": [ 134 | "A" 135 | ], 136 | "marks": {} 137 | }, 138 | { 139 | "path": [ 140 | "text" 141 | ], 142 | "action": "insert", 143 | "index": 1, 144 | "values": [ 145 | "B" 146 | ], 147 | "marks": {} 148 | }, 149 | { 150 | "path": [ 151 | "text" 152 | ], 153 | "action": "insert", 154 | "index": 2, 155 | "values": [ 156 | "C" 157 | ], 158 | "marks": {} 159 | }, 160 | { 161 | "path": [ 162 | "text" 163 | ], 164 | "action": "insert", 165 | "index": 3, 166 | "values": [ 167 | "D" 168 | ], 169 | "marks": {} 170 | }, 171 | { 172 | "path": [ 173 | "text" 174 | ], 175 | "action": "insert", 176 | "index": 4, 177 | "values": [ 178 | "E" 179 | ], 180 | "marks": {} 181 | }, 182 | { 183 | "path": [ 184 | "text" 185 | ], 186 | "action": "delete", 187 | "index": 1, 188 | "count": 1 189 | }, 190 | { 191 | "path": [ 192 | "text" 193 | ], 194 | "action": "delete", 195 | "index": 1, 196 | "count": 1 197 | }, 198 | { 199 | "path": [ 200 | "text" 201 | ], 202 | "action": "delete", 203 | "index": 1, 204 | "count": 1 205 | }, 206 | { 207 | "path": [ 208 | "text" 209 | ], 210 | "action": "delete", 211 | "index": 1, 212 | "count": 1 213 | }, 214 | { 215 | "action": "addMark", 216 | "markType": "link", 217 | "path": [ 218 | "text" 219 | ], 220 | "startIndex": 0, 221 | "attrs": { 222 | "url": "C.com" 223 | }, 224 | "endIndex": 1 225 | }, 226 | { 227 | "path": [ 228 | "text" 229 | ], 230 | "action": "insert", 231 | "index": 1, 232 | "values": [ 233 | "2" 234 | ], 235 | "marks": {} 236 | }, 237 | { 238 | "path": [ 239 | "text" 240 | ], 241 | "action": "insert", 242 | "index": 2, 243 | "values": [ 244 | "8" 245 | ], 246 | "marks": {} 247 | } 248 | ], 249 | "queues": { 250 | "doc1": [ 251 | { 252 | "actor": "doc1", 253 | "seq": 1, 254 | "deps": {}, 255 | "startOp": 1, 256 | "ops": [ 257 | { 258 | "opId": "1@doc1", 259 | "action": "makeList", 260 | "key": "text" 261 | }, 262 | { 263 | "opId": "2@doc1", 264 | "action": "set", 265 | "obj": "1@doc1", 266 | "insert": true, 267 | "value": "A" 268 | }, 269 | { 270 | "opId": "3@doc1", 271 | "action": "set", 272 | "obj": "1@doc1", 273 | "elemId": "2@doc1", 274 | "insert": true, 275 | "value": "B" 276 | }, 277 | { 278 | "opId": "4@doc1", 279 | "action": "set", 280 | "obj": "1@doc1", 281 | "elemId": "3@doc1", 282 | "insert": true, 283 | "value": "C" 284 | }, 285 | { 286 | "opId": "5@doc1", 287 | "action": "set", 288 | "obj": "1@doc1", 289 | "elemId": "4@doc1", 290 | "insert": true, 291 | "value": "D" 292 | }, 293 | { 294 | "opId": "6@doc1", 295 | "action": "set", 296 | "obj": "1@doc1", 297 | "elemId": "5@doc1", 298 | "insert": true, 299 | "value": "E" 300 | } 301 | ] 302 | }, 303 | { 304 | "actor": "doc1", 305 | "seq": 2, 306 | "deps": { 307 | "doc1": 1, 308 | "doc3": 1 309 | }, 310 | "startOp": 9, 311 | "ops": [ 312 | { 313 | "opId": "9@doc1", 314 | "action": "addMark", 315 | "obj": "1@doc1", 316 | "start": { 317 | "type": "before", 318 | "elemId": "8@doc3" 319 | }, 320 | "end": { 321 | "type": "after", 322 | "elemId": "4@doc1" 323 | }, 324 | "markType": "link", 325 | "attrs": { 326 | "url": "A.com" 327 | } 328 | } 329 | ] 330 | } 331 | ], 332 | "doc2": [ 333 | { 334 | "actor": "doc2", 335 | "seq": 1, 336 | "deps": { 337 | "doc1": 1 338 | }, 339 | "startOp": 7, 340 | "ops": [ 341 | { 342 | "opId": "7@doc2", 343 | "action": "del", 344 | "obj": "1@doc1", 345 | "elemId": "3@doc1" 346 | }, 347 | { 348 | "opId": "8@doc2", 349 | "action": "del", 350 | "obj": "1@doc1", 351 | "elemId": "4@doc1" 352 | }, 353 | { 354 | "opId": "9@doc2", 355 | "action": "del", 356 | "obj": "1@doc1", 357 | "elemId": "5@doc1" 358 | } 359 | ] 360 | }, 361 | { 362 | "actor": "doc2", 363 | "seq": 2, 364 | "deps": { 365 | "doc1": 1, 366 | "doc2": 1 367 | }, 368 | "startOp": 10, 369 | "ops": [ 370 | { 371 | "opId": "10@doc2", 372 | "action": "del", 373 | "obj": "1@doc1", 374 | "elemId": "6@doc1" 375 | } 376 | ] 377 | }, 378 | { 379 | "actor": "doc2", 380 | "seq": 3, 381 | "deps": { 382 | "doc1": 1, 383 | "doc2": 2 384 | }, 385 | "startOp": 11, 386 | "ops": [ 387 | { 388 | "opId": "11@doc2", 389 | "action": "addMark", 390 | "obj": "1@doc1", 391 | "start": { 392 | "type": "before", 393 | "elemId": "2@doc1" 394 | }, 395 | "end": { 396 | "type": "after", 397 | "elemId": "2@doc1" 398 | }, 399 | "markType": "link", 400 | "attrs": { 401 | "url": "C.com" 402 | } 403 | } 404 | ] 405 | } 406 | ], 407 | "doc3": [ 408 | { 409 | "actor": "doc3", 410 | "seq": 1, 411 | "deps": { 412 | "doc1": 1 413 | }, 414 | "startOp": 7, 415 | "ops": [ 416 | { 417 | "opId": "7@doc3", 418 | "action": "set", 419 | "obj": "1@doc1", 420 | "elemId": "3@doc1", 421 | "insert": true, 422 | "value": "2" 423 | }, 424 | { 425 | "opId": "8@doc3", 426 | "action": "set", 427 | "obj": "1@doc1", 428 | "elemId": "7@doc3", 429 | "insert": true, 430 | "value": "8" 431 | } 432 | ] 433 | } 434 | ] 435 | } 436 | } -------------------------------------------------------------------------------- /traces/links-again.json: -------------------------------------------------------------------------------- 1 | { 2 | "queues": { 3 | "doc1": [ 4 | { 5 | "actor": "doc1", 6 | "seq": 1, 7 | "deps": {}, 8 | "startOp": 1, 9 | "ops": [ 10 | { 11 | "opId": "1@doc1", 12 | "action": "makeList", 13 | "key": "text" 14 | }, 15 | { 16 | "opId": "2@doc1", 17 | "action": "set", 18 | "obj": "1@doc1", 19 | "insert": true, 20 | "value": "A" 21 | }, 22 | { 23 | "opId": "3@doc1", 24 | "action": "set", 25 | "obj": "1@doc1", 26 | "elemId": "2@doc1", 27 | "insert": true, 28 | "value": "B" 29 | }, 30 | { 31 | "opId": "4@doc1", 32 | "action": "set", 33 | "obj": "1@doc1", 34 | "elemId": "3@doc1", 35 | "insert": true, 36 | "value": "C" 37 | }, 38 | { 39 | "opId": "5@doc1", 40 | "action": "set", 41 | "obj": "1@doc1", 42 | "elemId": "4@doc1", 43 | "insert": true, 44 | "value": "D" 45 | }, 46 | { 47 | "opId": "6@doc1", 48 | "action": "set", 49 | "obj": "1@doc1", 50 | "elemId": "5@doc1", 51 | "insert": true, 52 | "value": "E" 53 | } 54 | ] 55 | }, 56 | { 57 | "actor": "doc1", 58 | "seq": 2, 59 | "deps": { 60 | "doc1": 1 61 | }, 62 | "startOp": 7, 63 | "ops": [ 64 | { 65 | "opId": "7@doc1", 66 | "action": "addMark", 67 | "obj": "1@doc1", 68 | "start": { 69 | "type": "before", 70 | "elemId": "2@doc1" 71 | }, 72 | "end": { 73 | "type": "after", 74 | "elemId": "5@doc1" 75 | }, 76 | "markType": "link", 77 | "attrs": { 78 | "url": "https://inkandswitch.com/peritext/" 79 | } 80 | } 81 | ] 82 | }, 83 | { 84 | "actor": "doc1", 85 | "seq": 3, 86 | "deps": { 87 | "doc1": 2 88 | }, 89 | "startOp": 8, 90 | "ops": [] 91 | }, 92 | { 93 | "actor": "doc1", 94 | "seq": 4, 95 | "deps": { 96 | "doc1": 3 97 | }, 98 | "startOp": 8, 99 | "ops": [ 100 | { 101 | "opId": "8@doc1", 102 | "action": "addMark", 103 | "obj": "1@doc1", 104 | "start": { 105 | "type": "before", 106 | "elemId": "3@doc1" 107 | }, 108 | "end": { 109 | "type": "after", 110 | "elemId": "3@doc1" 111 | }, 112 | "markType": "link", 113 | "attrs": { 114 | "url": "https://inkandswitch.com/pushpin" 115 | } 116 | } 117 | ] 118 | }, 119 | { 120 | "actor": "doc1", 121 | "seq": 5, 122 | "deps": { 123 | "doc1": 4 124 | }, 125 | "startOp": 9, 126 | "ops": [ 127 | { 128 | "opId": "9@doc1", 129 | "action": "addMark", 130 | "obj": "1@doc1", 131 | "start": { 132 | "type": "before", 133 | "elemId": "5@doc1" 134 | }, 135 | "end": { 136 | "type": "after", 137 | "elemId": "5@doc1" 138 | }, 139 | "markType": "link", 140 | "attrs": { 141 | "url": "https://inkandswitch.com" 142 | } 143 | } 144 | ] 145 | } 146 | ], 147 | "doc2": [ 148 | { 149 | "actor": "doc2", 150 | "seq": 1, 151 | "deps": { 152 | "doc1": 5 153 | }, 154 | "startOp": 10, 155 | "ops": [] 156 | }, 157 | { 158 | "actor": "doc2", 159 | "seq": 2, 160 | "deps": { 161 | "doc1": 5, 162 | "doc2": 1 163 | }, 164 | "startOp": 10, 165 | "ops": [ 166 | { 167 | "opId": "10@doc2", 168 | "action": "set", 169 | "obj": "1@doc1", 170 | "elemId": "3@doc1", 171 | "insert": true, 172 | "value": "d" 173 | }, 174 | { 175 | "opId": "11@doc2", 176 | "action": "set", 177 | "obj": "1@doc1", 178 | "elemId": "10@doc2", 179 | "insert": true, 180 | "value": "4" 181 | }, 182 | { 183 | "opId": "12@doc2", 184 | "action": "set", 185 | "obj": "1@doc1", 186 | "elemId": "11@doc2", 187 | "insert": true, 188 | "value": "5" 189 | }, 190 | { 191 | "opId": "13@doc2", 192 | "action": "set", 193 | "obj": "1@doc1", 194 | "elemId": "12@doc2", 195 | "insert": true, 196 | "value": "4" 197 | }, 198 | { 199 | "opId": "14@doc2", 200 | "action": "set", 201 | "obj": "1@doc1", 202 | "elemId": "13@doc2", 203 | "insert": true, 204 | "value": "5" 205 | }, 206 | { 207 | "opId": "15@doc2", 208 | "action": "set", 209 | "obj": "1@doc1", 210 | "elemId": "14@doc2", 211 | "insert": true, 212 | "value": "b" 213 | }, 214 | { 215 | "opId": "16@doc2", 216 | "action": "set", 217 | "obj": "1@doc1", 218 | "elemId": "15@doc2", 219 | "insert": true, 220 | "value": "9" 221 | }, 222 | { 223 | "opId": "17@doc2", 224 | "action": "set", 225 | "obj": "1@doc1", 226 | "elemId": "16@doc2", 227 | "insert": true, 228 | "value": "2" 229 | } 230 | ] 231 | } 232 | ], 233 | "doc3": [ 234 | { 235 | "actor": "doc3", 236 | "seq": 1, 237 | "deps": { 238 | "doc1": 4 239 | }, 240 | "startOp": 9, 241 | "ops": [ 242 | { 243 | "opId": "9@doc3", 244 | "action": "del", 245 | "obj": "1@doc1", 246 | "elemId": "3@doc1" 247 | }, 248 | { 249 | "opId": "10@doc3", 250 | "action": "del", 251 | "obj": "1@doc1", 252 | "elemId": "4@doc1" 253 | }, 254 | { 255 | "opId": "11@doc3", 256 | "action": "del", 257 | "obj": "1@doc1", 258 | "elemId": "5@doc1" 259 | }, 260 | { 261 | "opId": "12@doc3", 262 | "action": "del", 263 | "obj": "1@doc1", 264 | "elemId": "6@doc1" 265 | } 266 | ] 267 | } 268 | ] 269 | }, 270 | "left": { 271 | "doc": "doc3", 272 | "text": [ 273 | { 274 | "text": "A", 275 | "marks": { 276 | "link": { 277 | "active": true, 278 | "url": "https://inkandswitch.com/peritext/" 279 | } 280 | } 281 | }, 282 | { 283 | "text": "d4545b92", 284 | "marks": {} 285 | } 286 | ], 287 | "meta": { 288 | "1@doc1": [ 289 | { 290 | "elemId": "2@doc1", 291 | "valueId": "2@doc1", 292 | "deleted": false, 293 | "markOpsBefore": {} 294 | }, 295 | { 296 | "elemId": "3@doc1", 297 | "valueId": "3@doc1", 298 | "deleted": true, 299 | "markOpsBefore": {}, 300 | "markOpsAfter": {} 301 | }, 302 | { 303 | "elemId": "4@doc1", 304 | "valueId": "4@doc1", 305 | "deleted": true 306 | }, 307 | { 308 | "elemId": "5@doc1", 309 | "valueId": "5@doc1", 310 | "deleted": true, 311 | "markOpsAfter": {}, 312 | "markOpsBefore": {} 313 | }, 314 | { 315 | "elemId": "10@doc2", 316 | "valueId": "10@doc2", 317 | "deleted": false 318 | }, 319 | { 320 | "elemId": "11@doc2", 321 | "valueId": "11@doc2", 322 | "deleted": false 323 | }, 324 | { 325 | "elemId": "12@doc2", 326 | "valueId": "12@doc2", 327 | "deleted": false 328 | }, 329 | { 330 | "elemId": "13@doc2", 331 | "valueId": "13@doc2", 332 | "deleted": false 333 | }, 334 | { 335 | "elemId": "14@doc2", 336 | "valueId": "14@doc2", 337 | "deleted": false 338 | }, 339 | { 340 | "elemId": "15@doc2", 341 | "valueId": "15@doc2", 342 | "deleted": false 343 | }, 344 | { 345 | "elemId": "16@doc2", 346 | "valueId": "16@doc2", 347 | "deleted": false 348 | }, 349 | { 350 | "elemId": "17@doc2", 351 | "valueId": "17@doc2", 352 | "deleted": false 353 | }, 354 | { 355 | "elemId": "6@doc1", 356 | "valueId": "6@doc1", 357 | "deleted": true 358 | } 359 | ] 360 | } 361 | }, 362 | "right": { 363 | "doc": "doc2", 364 | "text": [ 365 | { 366 | "text": "Ad4545b92", 367 | "marks": { 368 | "link": { 369 | "active": true, 370 | "url": "https://inkandswitch.com/peritext/" 371 | } 372 | } 373 | } 374 | ], 375 | "meta": { 376 | "1@doc1": [ 377 | { 378 | "elemId": "2@doc1", 379 | "valueId": "2@doc1", 380 | "deleted": false, 381 | "markOpsBefore": {} 382 | }, 383 | { 384 | "elemId": "3@doc1", 385 | "valueId": "3@doc1", 386 | "deleted": true, 387 | "markOpsBefore": {}, 388 | "markOpsAfter": {} 389 | }, 390 | { 391 | "elemId": "10@doc2", 392 | "valueId": "10@doc2", 393 | "deleted": false 394 | }, 395 | { 396 | "elemId": "11@doc2", 397 | "valueId": "11@doc2", 398 | "deleted": false 399 | }, 400 | { 401 | "elemId": "12@doc2", 402 | "valueId": "12@doc2", 403 | "deleted": false 404 | }, 405 | { 406 | "elemId": "13@doc2", 407 | "valueId": "13@doc2", 408 | "deleted": false 409 | }, 410 | { 411 | "elemId": "14@doc2", 412 | "valueId": "14@doc2", 413 | "deleted": false 414 | }, 415 | { 416 | "elemId": "15@doc2", 417 | "valueId": "15@doc2", 418 | "deleted": false 419 | }, 420 | { 421 | "elemId": "16@doc2", 422 | "valueId": "16@doc2", 423 | "deleted": false 424 | }, 425 | { 426 | "elemId": "17@doc2", 427 | "valueId": "17@doc2", 428 | "deleted": false 429 | }, 430 | { 431 | "elemId": "4@doc1", 432 | "valueId": "4@doc1", 433 | "deleted": true 434 | }, 435 | { 436 | "elemId": "5@doc1", 437 | "valueId": "5@doc1", 438 | "deleted": true, 439 | "markOpsAfter": {}, 440 | "markOpsBefore": {} 441 | }, 442 | { 443 | "elemId": "6@doc1", 444 | "valueId": "6@doc1", 445 | "deleted": true 446 | } 447 | ] 448 | } 449 | }, 450 | "syncs": [ 451 | { 452 | "left": "doc2", 453 | "right": "doc1", 454 | "missingLeft": [], 455 | "missingRight": [ 456 | { 457 | "actor": "doc1", 458 | "seq": 2, 459 | "deps": { 460 | "doc1": 1 461 | }, 462 | "startOp": 7, 463 | "ops": [ 464 | { 465 | "opId": "7@doc1", 466 | "action": "addMark", 467 | "obj": "1@doc1", 468 | "start": { 469 | "type": "before", 470 | "elemId": "2@doc1" 471 | }, 472 | "end": { 473 | "type": "after", 474 | "elemId": "5@doc1" 475 | }, 476 | "markType": "link", 477 | "attrs": { 478 | "url": "https://inkandswitch.com/peritext/" 479 | } 480 | } 481 | ] 482 | } 483 | ] 484 | }, 485 | { 486 | "left": "doc1", 487 | "right": "doc3", 488 | "missingLeft": [ 489 | { 490 | "actor": "doc1", 491 | "seq": 2, 492 | "deps": { 493 | "doc1": 1 494 | }, 495 | "startOp": 7, 496 | "ops": [ 497 | { 498 | "opId": "7@doc1", 499 | "action": "addMark", 500 | "obj": "1@doc1", 501 | "start": { 502 | "type": "before", 503 | "elemId": "2@doc1" 504 | }, 505 | "end": { 506 | "type": "after", 507 | "elemId": "5@doc1" 508 | }, 509 | "markType": "link", 510 | "attrs": { 511 | "url": "https://inkandswitch.com/peritext/" 512 | } 513 | } 514 | ] 515 | }, 516 | { 517 | "actor": "doc1", 518 | "seq": 3, 519 | "deps": { 520 | "doc1": 2 521 | }, 522 | "startOp": 8, 523 | "ops": [] 524 | } 525 | ], 526 | "missingRight": [] 527 | }, 528 | { 529 | "left": "doc1", 530 | "right": "doc3", 531 | "missingLeft": [ 532 | { 533 | "actor": "doc1", 534 | "seq": 4, 535 | "deps": { 536 | "doc1": 3 537 | }, 538 | "startOp": 8, 539 | "ops": [ 540 | { 541 | "opId": "8@doc1", 542 | "action": "addMark", 543 | "obj": "1@doc1", 544 | "start": { 545 | "type": "before", 546 | "elemId": "3@doc1" 547 | }, 548 | "end": { 549 | "type": "after", 550 | "elemId": "3@doc1" 551 | }, 552 | "markType": "link", 553 | "attrs": { 554 | "url": "https://inkandswitch.com/pushpin" 555 | } 556 | } 557 | ] 558 | } 559 | ], 560 | "missingRight": [] 561 | }, 562 | { 563 | "left": "doc2", 564 | "right": "doc3", 565 | "missingLeft": [], 566 | "missingRight": [ 567 | { 568 | "actor": "doc1", 569 | "seq": 3, 570 | "deps": { 571 | "doc1": 2 572 | }, 573 | "startOp": 8, 574 | "ops": [] 575 | }, 576 | { 577 | "actor": "doc1", 578 | "seq": 4, 579 | "deps": { 580 | "doc1": 3 581 | }, 582 | "startOp": 8, 583 | "ops": [ 584 | { 585 | "opId": "8@doc1", 586 | "action": "addMark", 587 | "obj": "1@doc1", 588 | "start": { 589 | "type": "before", 590 | "elemId": "3@doc1" 591 | }, 592 | "end": { 593 | "type": "after", 594 | "elemId": "3@doc1" 595 | }, 596 | "markType": "link", 597 | "attrs": { 598 | "url": "https://inkandswitch.com/pushpin" 599 | } 600 | } 601 | ] 602 | } 603 | ] 604 | }, 605 | { 606 | "left": "doc1", 607 | "right": "doc2", 608 | "missingLeft": [ 609 | { 610 | "actor": "doc1", 611 | "seq": 5, 612 | "deps": { 613 | "doc1": 4 614 | }, 615 | "startOp": 9, 616 | "ops": [ 617 | { 618 | "opId": "9@doc1", 619 | "action": "addMark", 620 | "obj": "1@doc1", 621 | "start": { 622 | "type": "before", 623 | "elemId": "5@doc1" 624 | }, 625 | "end": { 626 | "type": "after", 627 | "elemId": "5@doc1" 628 | }, 629 | "markType": "link", 630 | "attrs": { 631 | "url": "https://inkandswitch.com" 632 | } 633 | } 634 | ] 635 | } 636 | ], 637 | "missingRight": [] 638 | }, 639 | { 640 | "left": "doc3", 641 | "right": "doc1", 642 | "missingLeft": [ 643 | { 644 | "actor": "doc3", 645 | "seq": 1, 646 | "deps": { 647 | "doc1": 4 648 | }, 649 | "startOp": 9, 650 | "ops": [ 651 | { 652 | "opId": "9@doc3", 653 | "action": "del", 654 | "obj": "1@doc1", 655 | "elemId": "3@doc1" 656 | }, 657 | { 658 | "opId": "10@doc3", 659 | "action": "del", 660 | "obj": "1@doc1", 661 | "elemId": "4@doc1" 662 | }, 663 | { 664 | "opId": "11@doc3", 665 | "action": "del", 666 | "obj": "1@doc1", 667 | "elemId": "5@doc1" 668 | }, 669 | { 670 | "opId": "12@doc3", 671 | "action": "del", 672 | "obj": "1@doc1", 673 | "elemId": "6@doc1" 674 | } 675 | ] 676 | } 677 | ], 678 | "missingRight": [ 679 | { 680 | "actor": "doc1", 681 | "seq": 5, 682 | "deps": { 683 | "doc1": 4 684 | }, 685 | "startOp": 9, 686 | "ops": [ 687 | { 688 | "opId": "9@doc1", 689 | "action": "addMark", 690 | "obj": "1@doc1", 691 | "start": { 692 | "type": "before", 693 | "elemId": "5@doc1" 694 | }, 695 | "end": { 696 | "type": "after", 697 | "elemId": "5@doc1" 698 | }, 699 | "markType": "link", 700 | "attrs": { 701 | "url": "https://inkandswitch.com" 702 | } 703 | } 704 | ] 705 | } 706 | ] 707 | }, 708 | { 709 | "left": "doc3", 710 | "right": "doc2", 711 | "missingLeft": [ 712 | { 713 | "actor": "doc3", 714 | "seq": 1, 715 | "deps": { 716 | "doc1": 4 717 | }, 718 | "startOp": 9, 719 | "ops": [ 720 | { 721 | "opId": "9@doc3", 722 | "action": "del", 723 | "obj": "1@doc1", 724 | "elemId": "3@doc1" 725 | }, 726 | { 727 | "opId": "10@doc3", 728 | "action": "del", 729 | "obj": "1@doc1", 730 | "elemId": "4@doc1" 731 | }, 732 | { 733 | "opId": "11@doc3", 734 | "action": "del", 735 | "obj": "1@doc1", 736 | "elemId": "5@doc1" 737 | }, 738 | { 739 | "opId": "12@doc3", 740 | "action": "del", 741 | "obj": "1@doc1", 742 | "elemId": "6@doc1" 743 | } 744 | ] 745 | } 746 | ], 747 | "missingRight": [ 748 | { 749 | "actor": "doc2", 750 | "seq": 1, 751 | "deps": { 752 | "doc1": 5 753 | }, 754 | "startOp": 10, 755 | "ops": [] 756 | }, 757 | { 758 | "actor": "doc2", 759 | "seq": 2, 760 | "deps": { 761 | "doc1": 5, 762 | "doc2": 1 763 | }, 764 | "startOp": 10, 765 | "ops": [ 766 | { 767 | "opId": "10@doc2", 768 | "action": "set", 769 | "obj": "1@doc1", 770 | "elemId": "3@doc1", 771 | "insert": true, 772 | "value": "d" 773 | }, 774 | { 775 | "opId": "11@doc2", 776 | "action": "set", 777 | "obj": "1@doc1", 778 | "elemId": "10@doc2", 779 | "insert": true, 780 | "value": "4" 781 | }, 782 | { 783 | "opId": "12@doc2", 784 | "action": "set", 785 | "obj": "1@doc1", 786 | "elemId": "11@doc2", 787 | "insert": true, 788 | "value": "5" 789 | }, 790 | { 791 | "opId": "13@doc2", 792 | "action": "set", 793 | "obj": "1@doc1", 794 | "elemId": "12@doc2", 795 | "insert": true, 796 | "value": "4" 797 | }, 798 | { 799 | "opId": "14@doc2", 800 | "action": "set", 801 | "obj": "1@doc1", 802 | "elemId": "13@doc2", 803 | "insert": true, 804 | "value": "5" 805 | }, 806 | { 807 | "opId": "15@doc2", 808 | "action": "set", 809 | "obj": "1@doc1", 810 | "elemId": "14@doc2", 811 | "insert": true, 812 | "value": "b" 813 | }, 814 | { 815 | "opId": "16@doc2", 816 | "action": "set", 817 | "obj": "1@doc1", 818 | "elemId": "15@doc2", 819 | "insert": true, 820 | "value": "9" 821 | }, 822 | { 823 | "opId": "17@doc2", 824 | "action": "set", 825 | "obj": "1@doc1", 826 | "elemId": "16@doc2", 827 | "insert": true, 828 | "value": "2" 829 | } 830 | ] 831 | } 832 | ] 833 | } 834 | ] 835 | } -------------------------------------------------------------------------------- /traces/links-brief.json: -------------------------------------------------------------------------------- 1 | { 2 | "docId": "doc2", 3 | "patchDoc": [ 4 | { 5 | "text": "A", 6 | "marks": {} 7 | } 8 | ], 9 | "batchDoc": [ 10 | { 11 | "text": "A", 12 | "marks": { 13 | "link": { 14 | "active": true, 15 | "url": "C.com" 16 | } 17 | } 18 | } 19 | ], 20 | "meta": [ 21 | { 22 | "elemId": "2@doc1", 23 | "valueId": "2@doc1", 24 | "deleted": false, 25 | "markOpsBefore": [ 26 | { 27 | "opId": "13@doc3", 28 | "action": "addMark", 29 | "obj": "1@doc1", 30 | "start": { 31 | "type": "before", 32 | "elemId": "2@doc1" 33 | }, 34 | "end": { 35 | "type": "after", 36 | "elemId": "3@doc1" 37 | }, 38 | "markType": "link", 39 | "attrs": { 40 | "url": "C.com" 41 | } 42 | } 43 | ] 44 | }, 45 | { 46 | "elemId": "3@doc1", 47 | "valueId": "3@doc1", 48 | "deleted": true, 49 | "markOpsAfter": [] 50 | }, 51 | { 52 | "elemId": "4@doc1", 53 | "valueId": "4@doc1", 54 | "deleted": true 55 | }, 56 | { 57 | "elemId": "5@doc1", 58 | "valueId": "5@doc1", 59 | "deleted": true 60 | }, 61 | { 62 | "elemId": "7@doc3", 63 | "valueId": "7@doc3", 64 | "deleted": true 65 | }, 66 | { 67 | "elemId": "8@doc3", 68 | "valueId": "8@doc3", 69 | "deleted": true 70 | }, 71 | { 72 | "elemId": "6@doc1", 73 | "valueId": "6@doc1", 74 | "deleted": true 75 | } 76 | ], 77 | "patches": [ 78 | { 79 | "opId": "1@doc1", 80 | "action": "makeList", 81 | "key": "text", 82 | "path": [ 83 | "text" 84 | ] 85 | }, 86 | { 87 | "path": [ 88 | "text" 89 | ], 90 | "action": "insert", 91 | "index": 0, 92 | "values": [ 93 | "A" 94 | ], 95 | "marks": {} 96 | }, 97 | { 98 | "path": [ 99 | "text" 100 | ], 101 | "action": "insert", 102 | "index": 1, 103 | "values": [ 104 | "B" 105 | ], 106 | "marks": {} 107 | }, 108 | { 109 | "path": [ 110 | "text" 111 | ], 112 | "action": "insert", 113 | "index": 2, 114 | "values": [ 115 | "C" 116 | ], 117 | "marks": {} 118 | }, 119 | { 120 | "path": [ 121 | "text" 122 | ], 123 | "action": "insert", 124 | "index": 3, 125 | "values": [ 126 | "D" 127 | ], 128 | "marks": {} 129 | }, 130 | { 131 | "path": [ 132 | "text" 133 | ], 134 | "action": "insert", 135 | "index": 4, 136 | "values": [ 137 | "E" 138 | ], 139 | "marks": {} 140 | }, 141 | { 142 | "path": [ 143 | "text" 144 | ], 145 | "action": "delete", 146 | "index": 2, 147 | "count": 1 148 | }, 149 | { 150 | "path": [ 151 | "text" 152 | ], 153 | "action": "delete", 154 | "index": 2, 155 | "count": 1 156 | }, 157 | { 158 | "path": [ 159 | "text" 160 | ], 161 | "action": "delete", 162 | "index": 2, 163 | "count": 1 164 | }, 165 | { 166 | "path": [ 167 | "text" 168 | ], 169 | "action": "delete", 170 | "index": 1, 171 | "count": 1 172 | }, 173 | { 174 | "path": [ 175 | "text" 176 | ], 177 | "action": "insert", 178 | "index": 1, 179 | "values": [ 180 | "d" 181 | ], 182 | "marks": {} 183 | }, 184 | { 185 | "path": [ 186 | "text" 187 | ], 188 | "action": "insert", 189 | "index": 2, 190 | "values": [ 191 | "4" 192 | ], 193 | "marks": {} 194 | }, 195 | { 196 | "path": [ 197 | "text" 198 | ], 199 | "action": "delete", 200 | "index": 1, 201 | "count": 1 202 | }, 203 | { 204 | "path": [ 205 | "text" 206 | ], 207 | "action": "delete", 208 | "index": 1, 209 | "count": 1 210 | } 211 | ], 212 | "queues": { 213 | "doc1": [ 214 | { 215 | "actor": "doc1", 216 | "seq": 1, 217 | "deps": {}, 218 | "startOp": 1, 219 | "ops": [ 220 | { 221 | "opId": "1@doc1", 222 | "action": "makeList", 223 | "key": "text" 224 | }, 225 | { 226 | "opId": "2@doc1", 227 | "action": "set", 228 | "obj": "1@doc1", 229 | "insert": true, 230 | "value": "A" 231 | }, 232 | { 233 | "opId": "3@doc1", 234 | "action": "set", 235 | "obj": "1@doc1", 236 | "elemId": "2@doc1", 237 | "insert": true, 238 | "value": "B" 239 | }, 240 | { 241 | "opId": "4@doc1", 242 | "action": "set", 243 | "obj": "1@doc1", 244 | "elemId": "3@doc1", 245 | "insert": true, 246 | "value": "C" 247 | }, 248 | { 249 | "opId": "5@doc1", 250 | "action": "set", 251 | "obj": "1@doc1", 252 | "elemId": "4@doc1", 253 | "insert": true, 254 | "value": "D" 255 | }, 256 | { 257 | "opId": "6@doc1", 258 | "action": "set", 259 | "obj": "1@doc1", 260 | "elemId": "5@doc1", 261 | "insert": true, 262 | "value": "E" 263 | } 264 | ] 265 | }, 266 | { 267 | "actor": "doc1", 268 | "seq": 2, 269 | "deps": { 270 | "doc1": 1, 271 | "doc3": 1, 272 | "doc2": 3 273 | }, 274 | "startOp": 11, 275 | "ops": [ 276 | { 277 | "opId": "11@doc1", 278 | "action": "addMark", 279 | "obj": "1@doc1", 280 | "start": { 281 | "type": "before", 282 | "elemId": "2@doc1" 283 | }, 284 | "end": { 285 | "type": "after", 286 | "elemId": "7@doc3" 287 | }, 288 | "markType": "link", 289 | "attrs": { 290 | "url": "C.com" 291 | } 292 | } 293 | ] 294 | } 295 | ], 296 | "doc2": [ 297 | { 298 | "actor": "doc2", 299 | "seq": 1, 300 | "deps": { 301 | "doc1": 1 302 | }, 303 | "startOp": 7, 304 | "ops": [ 305 | { 306 | "opId": "7@doc2", 307 | "action": "del", 308 | "obj": "1@doc1", 309 | "elemId": "4@doc1" 310 | }, 311 | { 312 | "opId": "8@doc2", 313 | "action": "del", 314 | "obj": "1@doc1", 315 | "elemId": "5@doc1" 316 | }, 317 | { 318 | "opId": "9@doc2", 319 | "action": "del", 320 | "obj": "1@doc1", 321 | "elemId": "6@doc1" 322 | } 323 | ] 324 | }, 325 | { 326 | "actor": "doc2", 327 | "seq": 2, 328 | "deps": { 329 | "doc1": 1, 330 | "doc2": 1 331 | }, 332 | "startOp": 10, 333 | "ops": [ 334 | { 335 | "opId": "10@doc2", 336 | "action": "del", 337 | "obj": "1@doc1", 338 | "elemId": "3@doc1" 339 | } 340 | ] 341 | }, 342 | { 343 | "actor": "doc2", 344 | "seq": 3, 345 | "deps": { 346 | "doc1": 1, 347 | "doc2": 2 348 | }, 349 | "startOp": 11, 350 | "ops": [] 351 | } 352 | ], 353 | "doc3": [ 354 | { 355 | "actor": "doc3", 356 | "seq": 1, 357 | "deps": { 358 | "doc1": 1 359 | }, 360 | "startOp": 7, 361 | "ops": [ 362 | { 363 | "opId": "7@doc3", 364 | "action": "set", 365 | "obj": "1@doc1", 366 | "elemId": "5@doc1", 367 | "insert": true, 368 | "value": "d" 369 | }, 370 | { 371 | "opId": "8@doc3", 372 | "action": "set", 373 | "obj": "1@doc1", 374 | "elemId": "7@doc3", 375 | "insert": true, 376 | "value": "4" 377 | } 378 | ] 379 | }, 380 | { 381 | "actor": "doc3", 382 | "seq": 2, 383 | "deps": { 384 | "doc1": 1, 385 | "doc3": 1 386 | }, 387 | "startOp": 9, 388 | "ops": [ 389 | { 390 | "opId": "9@doc3", 391 | "action": "del", 392 | "obj": "1@doc1", 393 | "elemId": "5@doc1" 394 | }, 395 | { 396 | "opId": "10@doc3", 397 | "action": "del", 398 | "obj": "1@doc1", 399 | "elemId": "7@doc3" 400 | }, 401 | { 402 | "opId": "11@doc3", 403 | "action": "del", 404 | "obj": "1@doc1", 405 | "elemId": "8@doc3" 406 | }, 407 | { 408 | "opId": "12@doc3", 409 | "action": "del", 410 | "obj": "1@doc1", 411 | "elemId": "6@doc1" 412 | } 413 | ] 414 | }, 415 | { 416 | "actor": "doc3", 417 | "seq": 3, 418 | "deps": { 419 | "doc1": 1, 420 | "doc3": 2 421 | }, 422 | "startOp": 13, 423 | "ops": [ 424 | { 425 | "opId": "13@doc3", 426 | "action": "addMark", 427 | "obj": "1@doc1", 428 | "start": { 429 | "type": "before", 430 | "elemId": "2@doc1" 431 | }, 432 | "end": { 433 | "type": "after", 434 | "elemId": "3@doc1" 435 | }, 436 | "markType": "link", 437 | "attrs": { 438 | "url": "C.com" 439 | } 440 | } 441 | ] 442 | } 443 | ] 444 | }, 445 | "syncs": [ 446 | { 447 | "left": "doc3", 448 | "right": "doc1", 449 | "missingLeft": [], 450 | "missingRight": [] 451 | }, 452 | { 453 | "left": "doc3", 454 | "right": "doc1", 455 | "missingLeft": [ 456 | { 457 | "actor": "doc3", 458 | "seq": 1, 459 | "deps": { 460 | "doc1": 1 461 | }, 462 | "startOp": 7, 463 | "ops": [ 464 | { 465 | "opId": "7@doc3", 466 | "action": "set", 467 | "obj": "1@doc1", 468 | "elemId": "5@doc1", 469 | "insert": true, 470 | "value": "d" 471 | }, 472 | { 473 | "opId": "8@doc3", 474 | "action": "set", 475 | "obj": "1@doc1", 476 | "elemId": "7@doc3", 477 | "insert": true, 478 | "value": "4" 479 | } 480 | ] 481 | } 482 | ], 483 | "missingRight": [] 484 | }, 485 | { 486 | "left": "doc1", 487 | "right": "doc3", 488 | "missingLeft": [], 489 | "missingRight": [] 490 | }, 491 | { 492 | "left": "doc3", 493 | "right": "doc1", 494 | "missingLeft": [], 495 | "missingRight": [] 496 | }, 497 | { 498 | "left": "doc2", 499 | "right": "doc1", 500 | "missingLeft": [ 501 | { 502 | "actor": "doc2", 503 | "seq": 1, 504 | "deps": { 505 | "doc1": 1 506 | }, 507 | "startOp": 7, 508 | "ops": [ 509 | { 510 | "opId": "7@doc2", 511 | "action": "del", 512 | "obj": "1@doc1", 513 | "elemId": "4@doc1" 514 | }, 515 | { 516 | "opId": "8@doc2", 517 | "action": "del", 518 | "obj": "1@doc1", 519 | "elemId": "5@doc1" 520 | }, 521 | { 522 | "opId": "9@doc2", 523 | "action": "del", 524 | "obj": "1@doc1", 525 | "elemId": "6@doc1" 526 | } 527 | ] 528 | }, 529 | { 530 | "actor": "doc2", 531 | "seq": 2, 532 | "deps": { 533 | "doc1": 1, 534 | "doc2": 1 535 | }, 536 | "startOp": 10, 537 | "ops": [ 538 | { 539 | "opId": "10@doc2", 540 | "action": "del", 541 | "obj": "1@doc1", 542 | "elemId": "3@doc1" 543 | } 544 | ] 545 | }, 546 | { 547 | "actor": "doc2", 548 | "seq": 3, 549 | "deps": { 550 | "doc1": 1, 551 | "doc2": 2 552 | }, 553 | "startOp": 11, 554 | "ops": [] 555 | } 556 | ], 557 | "missingRight": [ 558 | { 559 | "actor": "doc3", 560 | "seq": 1, 561 | "deps": { 562 | "doc1": 1 563 | }, 564 | "startOp": 7, 565 | "ops": [ 566 | { 567 | "opId": "7@doc3", 568 | "action": "set", 569 | "obj": "1@doc1", 570 | "elemId": "5@doc1", 571 | "insert": true, 572 | "value": "d" 573 | }, 574 | { 575 | "opId": "8@doc3", 576 | "action": "set", 577 | "obj": "1@doc1", 578 | "elemId": "7@doc3", 579 | "insert": true, 580 | "value": "4" 581 | } 582 | ] 583 | } 584 | ] 585 | }, 586 | { 587 | "left": "doc2", 588 | "right": "doc1", 589 | "missingLeft": [], 590 | "missingRight": [] 591 | }, 592 | { 593 | "left": "doc3", 594 | "right": "doc2", 595 | "missingLeft": [ 596 | { 597 | "actor": "doc3", 598 | "seq": 2, 599 | "deps": { 600 | "doc1": 1, 601 | "doc3": 1 602 | }, 603 | "startOp": 9, 604 | "ops": [ 605 | { 606 | "opId": "9@doc3", 607 | "action": "del", 608 | "obj": "1@doc1", 609 | "elemId": "5@doc1" 610 | }, 611 | { 612 | "opId": "10@doc3", 613 | "action": "del", 614 | "obj": "1@doc1", 615 | "elemId": "7@doc3" 616 | }, 617 | { 618 | "opId": "11@doc3", 619 | "action": "del", 620 | "obj": "1@doc1", 621 | "elemId": "8@doc3" 622 | }, 623 | { 624 | "opId": "12@doc3", 625 | "action": "del", 626 | "obj": "1@doc1", 627 | "elemId": "6@doc1" 628 | } 629 | ] 630 | }, 631 | { 632 | "actor": "doc3", 633 | "seq": 3, 634 | "deps": { 635 | "doc1": 1, 636 | "doc3": 2 637 | }, 638 | "startOp": 13, 639 | "ops": [ 640 | { 641 | "opId": "13@doc3", 642 | "action": "addMark", 643 | "obj": "1@doc1", 644 | "start": { 645 | "type": "before", 646 | "elemId": "2@doc1" 647 | }, 648 | "end": { 649 | "type": "after", 650 | "elemId": "3@doc1" 651 | }, 652 | "markType": "link", 653 | "attrs": { 654 | "url": "C.com" 655 | } 656 | } 657 | ] 658 | } 659 | ], 660 | "missingRight": [ 661 | { 662 | "actor": "doc2", 663 | "seq": 1, 664 | "deps": { 665 | "doc1": 1 666 | }, 667 | "startOp": 7, 668 | "ops": [ 669 | { 670 | "opId": "7@doc2", 671 | "action": "del", 672 | "obj": "1@doc1", 673 | "elemId": "4@doc1" 674 | }, 675 | { 676 | "opId": "8@doc2", 677 | "action": "del", 678 | "obj": "1@doc1", 679 | "elemId": "5@doc1" 680 | }, 681 | { 682 | "opId": "9@doc2", 683 | "action": "del", 684 | "obj": "1@doc1", 685 | "elemId": "6@doc1" 686 | } 687 | ] 688 | }, 689 | { 690 | "actor": "doc2", 691 | "seq": 2, 692 | "deps": { 693 | "doc1": 1, 694 | "doc2": 1 695 | }, 696 | "startOp": 10, 697 | "ops": [ 698 | { 699 | "opId": "10@doc2", 700 | "action": "del", 701 | "obj": "1@doc1", 702 | "elemId": "3@doc1" 703 | } 704 | ] 705 | }, 706 | { 707 | "actor": "doc2", 708 | "seq": 3, 709 | "deps": { 710 | "doc1": 1, 711 | "doc2": 2 712 | }, 713 | "startOp": 11, 714 | "ops": [] 715 | } 716 | ] 717 | } 718 | ] 719 | } -------------------------------------------------------------------------------- /traces/links-minimal-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "queues": { 3 | "doc0": [ 4 | { 5 | "actor": "doc0", 6 | "seq": 1, 7 | "deps": {}, 8 | "startOp": 1, 9 | "ops": [ 10 | { 11 | "opId": "1@doc0", 12 | "action": "makeList", 13 | "key": "text" 14 | }, 15 | { 16 | "opId": "2@doc0", 17 | "action": "set", 18 | "obj": "1@doc0", 19 | "insert": true, 20 | "value": "A" 21 | }, 22 | { 23 | "opId": "3@doc0", 24 | "action": "set", 25 | "obj": "1@doc0", 26 | "elemId": "2@doc0", 27 | "insert": true, 28 | "value": "B" 29 | }, 30 | { 31 | "opId": "4@doc0", 32 | "action": "set", 33 | "obj": "1@doc0", 34 | "elemId": "3@doc0", 35 | "insert": true, 36 | "value": "C" 37 | }, 38 | { 39 | "opId": "5@doc0", 40 | "action": "set", 41 | "obj": "1@doc0", 42 | "elemId": "4@doc0", 43 | "insert": true, 44 | "value": "D" 45 | }, 46 | { 47 | "opId": "6@doc0", 48 | "action": "set", 49 | "obj": "1@doc0", 50 | "elemId": "5@doc0", 51 | "insert": true, 52 | "value": "E" 53 | } 54 | ] 55 | } 56 | ], 57 | "doc1": [ 58 | { 59 | "actor": "doc1", 60 | "seq": 1, 61 | "deps": { 62 | "doc0": 1 63 | }, 64 | "startOp": 7, 65 | "ops": [ 66 | { 67 | "opId": "7@doc1", 68 | "action": "addMark", 69 | "obj": "1@doc0", 70 | "start": { 71 | "type": "before", 72 | "elemId": "6@doc0" 73 | }, 74 | "end": { 75 | "type": "after", 76 | "elemId": "6@doc0" 77 | }, 78 | "markType": "link", 79 | "attrs": { 80 | "url": "https://inkandswitch.com/pushpin" 81 | } 82 | } 83 | ] 84 | } 85 | ], 86 | "doc2": [ 87 | { 88 | "actor": "doc2", 89 | "seq": 1, 90 | "deps": { 91 | "doc0": 1 92 | }, 93 | "startOp": 7, 94 | "ops": [ 95 | { 96 | "opId": "7@doc2", 97 | "action": "addMark", 98 | "obj": "1@doc0", 99 | "start": { 100 | "type": "before", 101 | "elemId": "5@doc0" 102 | }, 103 | "end": { 104 | "type": "after", 105 | "elemId": "6@doc0" 106 | }, 107 | "markType": "link", 108 | "attrs": { 109 | "url": "https://inkandswitch.com/cambria/" 110 | } 111 | } 112 | ] 113 | }, 114 | { 115 | "actor": "doc2", 116 | "seq": 2, 117 | "deps": { 118 | "doc0": 1, 119 | "doc2": 1 120 | }, 121 | "startOp": 8, 122 | "ops": [ 123 | { 124 | "opId": "8@doc2", 125 | "action": "addMark", 126 | "obj": "1@doc0", 127 | "start": { 128 | "type": "before", 129 | "elemId": "3@doc0" 130 | }, 131 | "end": { 132 | "type": "after", 133 | "elemId": "4@doc0" 134 | }, 135 | "markType": "link", 136 | "attrs": { 137 | "url": "https://inkandswitch.com/pushpin" 138 | } 139 | } 140 | ] 141 | } 142 | ] 143 | }, 144 | "leftDoc": "doc1", 145 | "rightDoc": "doc0", 146 | "leftText": [ 147 | { 148 | "text": "ABC", 149 | "marks": {} 150 | }, 151 | { 152 | "text": "DE", 153 | "marks": { 154 | "link": { 155 | "active": true, 156 | "url": "https://inkandswitch.com/cambria/" 157 | } 158 | } 159 | } 160 | ], 161 | "rightText": [ 162 | { 163 | "text": "ABC", 164 | "marks": {} 165 | }, 166 | { 167 | "text": "D", 168 | "marks": { 169 | "link": { 170 | "active": true, 171 | "url": "https://inkandswitch.com/cambria/" 172 | } 173 | } 174 | }, 175 | { 176 | "text": "E", 177 | "marks": { 178 | "link": { 179 | "active": true, 180 | "url": "https://inkandswitch.com/pushpin" 181 | } 182 | } 183 | } 184 | ], 185 | "syncs": [ 186 | { 187 | "left": "doc0", 188 | "right": "doc1", 189 | "missingLeft": [], 190 | "missingRight": [] 191 | }, 192 | { 193 | "left": "doc0", 194 | "right": "doc2", 195 | "missingLeft": [], 196 | "missingRight": [ 197 | { 198 | "actor": "doc2", 199 | "seq": 1, 200 | "deps": { 201 | "doc0": 1 202 | }, 203 | "startOp": 7, 204 | "ops": [ 205 | { 206 | "opId": "7@doc2", 207 | "action": "addMark", 208 | "obj": "1@doc0", 209 | "start": { 210 | "type": "before", 211 | "elemId": "5@doc0" 212 | }, 213 | "end": { 214 | "type": "after", 215 | "elemId": "6@doc0" 216 | }, 217 | "markType": "link", 218 | "attrs": { 219 | "url": "https://inkandswitch.com/cambria/" 220 | } 221 | } 222 | ] 223 | } 224 | ] 225 | }, 226 | { 227 | "left": "doc1", 228 | "right": "doc0", 229 | "missingLeft": [ 230 | { 231 | "actor": "doc1", 232 | "seq": 1, 233 | "deps": { 234 | "doc0": 1 235 | }, 236 | "startOp": 7, 237 | "ops": [ 238 | { 239 | "opId": "7@doc1", 240 | "action": "addMark", 241 | "obj": "1@doc0", 242 | "start": { 243 | "type": "before", 244 | "elemId": "6@doc0" 245 | }, 246 | "end": { 247 | "type": "after", 248 | "elemId": "6@doc0" 249 | }, 250 | "markType": "link", 251 | "attrs": { 252 | "url": "https://inkandswitch.com/pushpin" 253 | } 254 | } 255 | ] 256 | } 257 | ], 258 | "missingRight": [ 259 | { 260 | "actor": "doc2", 261 | "seq": 1, 262 | "deps": { 263 | "doc0": 1 264 | }, 265 | "startOp": 7, 266 | "ops": [ 267 | { 268 | "opId": "7@doc2", 269 | "action": "addMark", 270 | "obj": "1@doc0", 271 | "start": { 272 | "type": "before", 273 | "elemId": "5@doc0" 274 | }, 275 | "end": { 276 | "type": "after", 277 | "elemId": "6@doc0" 278 | }, 279 | "markType": "link", 280 | "attrs": { 281 | "url": "https://inkandswitch.com/cambria/" 282 | } 283 | } 284 | ] 285 | } 286 | ] 287 | } 288 | ] 289 | } -------------------------------------------------------------------------------- /traces/links-minimal.json: -------------------------------------------------------------------------------- 1 | { 2 | "queues": { 3 | "doc0": [ 4 | { 5 | "actor": "doc0", 6 | "seq": 1, 7 | "deps": {}, 8 | "startOp": 1, 9 | "ops": [ 10 | { 11 | "opId": "1@doc0", 12 | "action": "makeList", 13 | "key": "text" 14 | }, 15 | { 16 | "opId": "2@doc0", 17 | "action": "set", 18 | "obj": "1@doc0", 19 | "insert": true, 20 | "value": "A" 21 | }, 22 | { 23 | "opId": "3@doc0", 24 | "action": "set", 25 | "obj": "1@doc0", 26 | "elemId": "2@doc0", 27 | "insert": true, 28 | "value": "B" 29 | }, 30 | { 31 | "opId": "4@doc0", 32 | "action": "set", 33 | "obj": "1@doc0", 34 | "elemId": "3@doc0", 35 | "insert": true, 36 | "value": "C" 37 | }, 38 | { 39 | "opId": "5@doc0", 40 | "action": "set", 41 | "obj": "1@doc0", 42 | "elemId": "4@doc0", 43 | "insert": true, 44 | "value": "D" 45 | }, 46 | { 47 | "opId": "6@doc0", 48 | "action": "set", 49 | "obj": "1@doc0", 50 | "elemId": "5@doc0", 51 | "insert": true, 52 | "value": "E" 53 | } 54 | ] 55 | }, 56 | { 57 | "actor": "doc0", 58 | "seq": 2, 59 | "deps": { 60 | "doc0": 1 61 | }, 62 | "startOp": 7, 63 | "ops": [ 64 | { 65 | "opId": "7@doc0", 66 | "action": "set", 67 | "obj": "1@doc0", 68 | "elemId": "4@doc0", 69 | "insert": true, 70 | "value": "9" 71 | }, 72 | { 73 | "opId": "8@doc0", 74 | "action": "set", 75 | "obj": "1@doc0", 76 | "elemId": "7@doc0", 77 | "insert": true, 78 | "value": "e" 79 | }, 80 | { 81 | "opId": "9@doc0", 82 | "action": "set", 83 | "obj": "1@doc0", 84 | "elemId": "8@doc0", 85 | "insert": true, 86 | "value": "e" 87 | }, 88 | { 89 | "opId": "10@doc0", 90 | "action": "set", 91 | "obj": "1@doc0", 92 | "elemId": "9@doc0", 93 | "insert": true, 94 | "value": "0" 95 | }, 96 | { 97 | "opId": "11@doc0", 98 | "action": "set", 99 | "obj": "1@doc0", 100 | "elemId": "10@doc0", 101 | "insert": true, 102 | "value": "9" 103 | }, 104 | { 105 | "opId": "12@doc0", 106 | "action": "set", 107 | "obj": "1@doc0", 108 | "elemId": "11@doc0", 109 | "insert": true, 110 | "value": "1" 111 | }, 112 | { 113 | "opId": "13@doc0", 114 | "action": "set", 115 | "obj": "1@doc0", 116 | "elemId": "12@doc0", 117 | "insert": true, 118 | "value": "5" 119 | }, 120 | { 121 | "opId": "14@doc0", 122 | "action": "set", 123 | "obj": "1@doc0", 124 | "elemId": "13@doc0", 125 | "insert": true, 126 | "value": "0" 127 | } 128 | ] 129 | } 130 | ], 131 | "doc1": [ 132 | { 133 | "actor": "doc1", 134 | "seq": 1, 135 | "deps": { 136 | "doc0": 1 137 | }, 138 | "startOp": 7, 139 | "ops": [ 140 | { 141 | "opId": "7@doc1", 142 | "action": "addMark", 143 | "obj": "1@doc0", 144 | "start": { 145 | "type": "before", 146 | "elemId": "6@doc0" 147 | }, 148 | "end": { 149 | "type": "after", 150 | "elemId": "6@doc0" 151 | }, 152 | "markType": "link", 153 | "attrs": { 154 | "url": "https://inkandswitch.com/cambria/" 155 | } 156 | } 157 | ] 158 | } 159 | ], 160 | "doc2": [ 161 | { 162 | "actor": "doc2", 163 | "seq": 1, 164 | "deps": { 165 | "doc0": 1 166 | }, 167 | "startOp": 7, 168 | "ops": [ 169 | { 170 | "opId": "7@doc2", 171 | "action": "addMark", 172 | "obj": "1@doc0", 173 | "start": { 174 | "type": "before", 175 | "elemId": "2@doc0" 176 | }, 177 | "end": { 178 | "type": "after", 179 | "elemId": "6@doc0" 180 | }, 181 | "markType": "link", 182 | "attrs": { 183 | "url": "https://inkandswitch.com/pushpin" 184 | } 185 | } 186 | ] 187 | } 188 | ] 189 | }, 190 | "left": { 191 | "doc": "doc2", 192 | "text": [ 193 | { 194 | "text": "ABC9ee09150D", 195 | "marks": { 196 | "link": { 197 | "active": true, 198 | "url": "https://inkandswitch.com/pushpin" 199 | } 200 | } 201 | }, 202 | { 203 | "text": "E", 204 | "marks": { 205 | "link": { 206 | "active": true, 207 | "url": "https://inkandswitch.com/cambria/" 208 | } 209 | } 210 | } 211 | ], 212 | "meta": { 213 | "1@doc0": [ 214 | { 215 | "elemId": "2@doc0", 216 | "valueId": "2@doc0", 217 | "deleted": false, 218 | "markOpsBefore": {} 219 | }, 220 | { 221 | "elemId": "3@doc0", 222 | "valueId": "3@doc0", 223 | "deleted": false 224 | }, 225 | { 226 | "elemId": "4@doc0", 227 | "valueId": "4@doc0", 228 | "deleted": false 229 | }, 230 | { 231 | "elemId": "7@doc0", 232 | "valueId": "7@doc0", 233 | "deleted": false 234 | }, 235 | { 236 | "elemId": "8@doc0", 237 | "valueId": "8@doc0", 238 | "deleted": false 239 | }, 240 | { 241 | "elemId": "9@doc0", 242 | "valueId": "9@doc0", 243 | "deleted": false 244 | }, 245 | { 246 | "elemId": "10@doc0", 247 | "valueId": "10@doc0", 248 | "deleted": false 249 | }, 250 | { 251 | "elemId": "11@doc0", 252 | "valueId": "11@doc0", 253 | "deleted": false 254 | }, 255 | { 256 | "elemId": "12@doc0", 257 | "valueId": "12@doc0", 258 | "deleted": false 259 | }, 260 | { 261 | "elemId": "13@doc0", 262 | "valueId": "13@doc0", 263 | "deleted": false 264 | }, 265 | { 266 | "elemId": "14@doc0", 267 | "valueId": "14@doc0", 268 | "deleted": false 269 | }, 270 | { 271 | "elemId": "5@doc0", 272 | "valueId": "5@doc0", 273 | "deleted": false 274 | }, 275 | { 276 | "elemId": "6@doc0", 277 | "valueId": "6@doc0", 278 | "deleted": false, 279 | "markOpsAfter": {}, 280 | "markOpsBefore": {} 281 | } 282 | ] 283 | } 284 | }, 285 | "right": { 286 | "doc": "doc1", 287 | "text": [ 288 | { 289 | "text": "ABC9ee09150DE", 290 | "marks": { 291 | "link": { 292 | "active": true, 293 | "url": "https://inkandswitch.com/pushpin" 294 | } 295 | } 296 | } 297 | ], 298 | "meta": { 299 | "1@doc0": [ 300 | { 301 | "elemId": "2@doc0", 302 | "valueId": "2@doc0", 303 | "deleted": false, 304 | "markOpsBefore": {} 305 | }, 306 | { 307 | "elemId": "3@doc0", 308 | "valueId": "3@doc0", 309 | "deleted": false 310 | }, 311 | { 312 | "elemId": "4@doc0", 313 | "valueId": "4@doc0", 314 | "deleted": false 315 | }, 316 | { 317 | "elemId": "7@doc0", 318 | "valueId": "7@doc0", 319 | "deleted": false 320 | }, 321 | { 322 | "elemId": "8@doc0", 323 | "valueId": "8@doc0", 324 | "deleted": false 325 | }, 326 | { 327 | "elemId": "9@doc0", 328 | "valueId": "9@doc0", 329 | "deleted": false 330 | }, 331 | { 332 | "elemId": "10@doc0", 333 | "valueId": "10@doc0", 334 | "deleted": false 335 | }, 336 | { 337 | "elemId": "11@doc0", 338 | "valueId": "11@doc0", 339 | "deleted": false 340 | }, 341 | { 342 | "elemId": "12@doc0", 343 | "valueId": "12@doc0", 344 | "deleted": false 345 | }, 346 | { 347 | "elemId": "13@doc0", 348 | "valueId": "13@doc0", 349 | "deleted": false 350 | }, 351 | { 352 | "elemId": "14@doc0", 353 | "valueId": "14@doc0", 354 | "deleted": false 355 | }, 356 | { 357 | "elemId": "5@doc0", 358 | "valueId": "5@doc0", 359 | "deleted": false 360 | }, 361 | { 362 | "elemId": "6@doc0", 363 | "valueId": "6@doc0", 364 | "deleted": false, 365 | "markOpsBefore": {}, 366 | "markOpsAfter": {} 367 | } 368 | ] 369 | } 370 | }, 371 | "syncs": [ 372 | { 373 | "left": "doc1", 374 | "right": "doc2", 375 | "missingLeft": [], 376 | "missingRight": [] 377 | }, 378 | { 379 | "left": "doc2", 380 | "right": "doc0", 381 | "missingLeft": [ 382 | { 383 | "actor": "doc2", 384 | "seq": 1, 385 | "deps": { 386 | "doc0": 1 387 | }, 388 | "startOp": 7, 389 | "ops": [ 390 | { 391 | "opId": "7@doc2", 392 | "action": "addMark", 393 | "obj": "1@doc0", 394 | "start": { 395 | "type": "before", 396 | "elemId": "2@doc0" 397 | }, 398 | "end": { 399 | "type": "after", 400 | "elemId": "6@doc0" 401 | }, 402 | "markType": "link", 403 | "attrs": { 404 | "url": "https://inkandswitch.com/pushpin" 405 | } 406 | } 407 | ] 408 | } 409 | ], 410 | "missingRight": [ 411 | { 412 | "actor": "doc0", 413 | "seq": 2, 414 | "deps": { 415 | "doc0": 1 416 | }, 417 | "startOp": 7, 418 | "ops": [ 419 | { 420 | "opId": "7@doc0", 421 | "action": "set", 422 | "obj": "1@doc0", 423 | "elemId": "4@doc0", 424 | "insert": true, 425 | "value": "9" 426 | }, 427 | { 428 | "opId": "8@doc0", 429 | "action": "set", 430 | "obj": "1@doc0", 431 | "elemId": "7@doc0", 432 | "insert": true, 433 | "value": "e" 434 | }, 435 | { 436 | "opId": "9@doc0", 437 | "action": "set", 438 | "obj": "1@doc0", 439 | "elemId": "8@doc0", 440 | "insert": true, 441 | "value": "e" 442 | }, 443 | { 444 | "opId": "10@doc0", 445 | "action": "set", 446 | "obj": "1@doc0", 447 | "elemId": "9@doc0", 448 | "insert": true, 449 | "value": "0" 450 | }, 451 | { 452 | "opId": "11@doc0", 453 | "action": "set", 454 | "obj": "1@doc0", 455 | "elemId": "10@doc0", 456 | "insert": true, 457 | "value": "9" 458 | }, 459 | { 460 | "opId": "12@doc0", 461 | "action": "set", 462 | "obj": "1@doc0", 463 | "elemId": "11@doc0", 464 | "insert": true, 465 | "value": "1" 466 | }, 467 | { 468 | "opId": "13@doc0", 469 | "action": "set", 470 | "obj": "1@doc0", 471 | "elemId": "12@doc0", 472 | "insert": true, 473 | "value": "5" 474 | }, 475 | { 476 | "opId": "14@doc0", 477 | "action": "set", 478 | "obj": "1@doc0", 479 | "elemId": "13@doc0", 480 | "insert": true, 481 | "value": "0" 482 | } 483 | ] 484 | } 485 | ] 486 | }, 487 | { 488 | "left": "doc2", 489 | "right": "doc1", 490 | "missingLeft": [ 491 | { 492 | "actor": "doc0", 493 | "seq": 2, 494 | "deps": { 495 | "doc0": 1 496 | }, 497 | "startOp": 7, 498 | "ops": [ 499 | { 500 | "opId": "7@doc0", 501 | "action": "set", 502 | "obj": "1@doc0", 503 | "elemId": "4@doc0", 504 | "insert": true, 505 | "value": "9" 506 | }, 507 | { 508 | "opId": "8@doc0", 509 | "action": "set", 510 | "obj": "1@doc0", 511 | "elemId": "7@doc0", 512 | "insert": true, 513 | "value": "e" 514 | }, 515 | { 516 | "opId": "9@doc0", 517 | "action": "set", 518 | "obj": "1@doc0", 519 | "elemId": "8@doc0", 520 | "insert": true, 521 | "value": "e" 522 | }, 523 | { 524 | "opId": "10@doc0", 525 | "action": "set", 526 | "obj": "1@doc0", 527 | "elemId": "9@doc0", 528 | "insert": true, 529 | "value": "0" 530 | }, 531 | { 532 | "opId": "11@doc0", 533 | "action": "set", 534 | "obj": "1@doc0", 535 | "elemId": "10@doc0", 536 | "insert": true, 537 | "value": "9" 538 | }, 539 | { 540 | "opId": "12@doc0", 541 | "action": "set", 542 | "obj": "1@doc0", 543 | "elemId": "11@doc0", 544 | "insert": true, 545 | "value": "1" 546 | }, 547 | { 548 | "opId": "13@doc0", 549 | "action": "set", 550 | "obj": "1@doc0", 551 | "elemId": "12@doc0", 552 | "insert": true, 553 | "value": "5" 554 | }, 555 | { 556 | "opId": "14@doc0", 557 | "action": "set", 558 | "obj": "1@doc0", 559 | "elemId": "13@doc0", 560 | "insert": true, 561 | "value": "0" 562 | } 563 | ] 564 | }, 565 | { 566 | "actor": "doc2", 567 | "seq": 1, 568 | "deps": { 569 | "doc0": 1 570 | }, 571 | "startOp": 7, 572 | "ops": [ 573 | { 574 | "opId": "7@doc2", 575 | "action": "addMark", 576 | "obj": "1@doc0", 577 | "start": { 578 | "type": "before", 579 | "elemId": "2@doc0" 580 | }, 581 | "end": { 582 | "type": "after", 583 | "elemId": "6@doc0" 584 | }, 585 | "markType": "link", 586 | "attrs": { 587 | "url": "https://inkandswitch.com/pushpin" 588 | } 589 | } 590 | ] 591 | } 592 | ], 593 | "missingRight": [ 594 | { 595 | "actor": "doc1", 596 | "seq": 1, 597 | "deps": { 598 | "doc0": 1 599 | }, 600 | "startOp": 7, 601 | "ops": [ 602 | { 603 | "opId": "7@doc1", 604 | "action": "addMark", 605 | "obj": "1@doc0", 606 | "start": { 607 | "type": "before", 608 | "elemId": "6@doc0" 609 | }, 610 | "end": { 611 | "type": "after", 612 | "elemId": "6@doc0" 613 | }, 614 | "markType": "link", 615 | "attrs": { 616 | "url": "https://inkandswitch.com/cambria/" 617 | } 618 | } 619 | ] 620 | } 621 | ] 622 | } 623 | ] 624 | } -------------------------------------------------------------------------------- /traces/links-nice.json: -------------------------------------------------------------------------------- 1 | { 2 | "queues": { 3 | "doc1": [ 4 | { 5 | "actor": "doc1", 6 | "seq": 1, 7 | "deps": {}, 8 | "startOp": 1, 9 | "ops": [ 10 | { 11 | "opId": "1@doc1", 12 | "action": "makeList", 13 | "key": "text" 14 | }, 15 | { 16 | "opId": "2@doc1", 17 | "action": "set", 18 | "obj": "1@doc1", 19 | "insert": true, 20 | "value": "A" 21 | }, 22 | { 23 | "opId": "3@doc1", 24 | "action": "set", 25 | "obj": "1@doc1", 26 | "elemId": "2@doc1", 27 | "insert": true, 28 | "value": "B" 29 | }, 30 | { 31 | "opId": "4@doc1", 32 | "action": "set", 33 | "obj": "1@doc1", 34 | "elemId": "3@doc1", 35 | "insert": true, 36 | "value": "C" 37 | }, 38 | { 39 | "opId": "5@doc1", 40 | "action": "set", 41 | "obj": "1@doc1", 42 | "elemId": "4@doc1", 43 | "insert": true, 44 | "value": "D" 45 | }, 46 | { 47 | "opId": "6@doc1", 48 | "action": "set", 49 | "obj": "1@doc1", 50 | "elemId": "5@doc1", 51 | "insert": true, 52 | "value": "E" 53 | } 54 | ] 55 | }, 56 | { 57 | "actor": "doc1", 58 | "seq": 2, 59 | "deps": { 60 | "doc1": 1, 61 | "doc2": 1, 62 | "doc3": 1 63 | }, 64 | "startOp": 8, 65 | "ops": [ 66 | { 67 | "opId": "8@doc1", 68 | "action": "set", 69 | "obj": "1@doc1", 70 | "elemId": "2@doc1", 71 | "insert": true, 72 | "value": "9" 73 | }, 74 | { 75 | "opId": "9@doc1", 76 | "action": "set", 77 | "obj": "1@doc1", 78 | "elemId": "8@doc1", 79 | "insert": true, 80 | "value": "6" 81 | } 82 | ] 83 | } 84 | ], 85 | "doc2": [ 86 | { 87 | "actor": "doc2", 88 | "seq": 1, 89 | "deps": { 90 | "doc1": 1 91 | }, 92 | "startOp": 7, 93 | "ops": [ 94 | { 95 | "opId": "7@doc2", 96 | "action": "addMark", 97 | "obj": "1@doc1", 98 | "start": { 99 | "type": "before", 100 | "elemId": "2@doc1" 101 | }, 102 | "end": { 103 | "type": "after", 104 | "elemId": "4@doc1" 105 | }, 106 | "markType": "link", 107 | "attrs": { 108 | "url": "P.com" 109 | } 110 | } 111 | ] 112 | }, 113 | { 114 | "actor": "doc2", 115 | "seq": 2, 116 | "deps": { 117 | "doc1": 1, 118 | "doc2": 1 119 | }, 120 | "startOp": 8, 121 | "ops": [ 122 | { 123 | "opId": "8@doc2", 124 | "action": "set", 125 | "obj": "1@doc1", 126 | "elemId": "2@doc1", 127 | "insert": true, 128 | "value": "3" 129 | }, 130 | { 131 | "opId": "9@doc2", 132 | "action": "set", 133 | "obj": "1@doc1", 134 | "elemId": "8@doc2", 135 | "insert": true, 136 | "value": "3" 137 | } 138 | ] 139 | }, 140 | { 141 | "actor": "doc2", 142 | "seq": 3, 143 | "deps": { 144 | "doc1": 1, 145 | "doc2": 2, 146 | "doc3": 3 147 | }, 148 | "startOp": 14, 149 | "ops": [] 150 | } 151 | ], 152 | "doc3": [ 153 | { 154 | "actor": "doc3", 155 | "seq": 1, 156 | "deps": { 157 | "doc1": 1 158 | }, 159 | "startOp": 7, 160 | "ops": [ 161 | { 162 | "opId": "7@doc3", 163 | "action": "del", 164 | "obj": "1@doc1", 165 | "elemId": "6@doc1" 166 | } 167 | ] 168 | }, 169 | { 170 | "actor": "doc3", 171 | "seq": 2, 172 | "deps": { 173 | "doc1": 1, 174 | "doc3": 1, 175 | "doc2": 2 176 | }, 177 | "startOp": 10, 178 | "ops": [ 179 | { 180 | "opId": "10@doc3", 181 | "action": "del", 182 | "obj": "1@doc1", 183 | "elemId": "4@doc1" 184 | }, 185 | { 186 | "opId": "11@doc3", 187 | "action": "del", 188 | "obj": "1@doc1", 189 | "elemId": "5@doc1" 190 | } 191 | ] 192 | }, 193 | { 194 | "actor": "doc3", 195 | "seq": 3, 196 | "deps": { 197 | "doc1": 1, 198 | "doc3": 2, 199 | "doc2": 2 200 | }, 201 | "startOp": 12, 202 | "ops": [ 203 | { 204 | "opId": "12@doc3", 205 | "action": "del", 206 | "obj": "1@doc1", 207 | "elemId": "9@doc2" 208 | }, 209 | { 210 | "opId": "13@doc3", 211 | "action": "del", 212 | "obj": "1@doc1", 213 | "elemId": "3@doc1" 214 | } 215 | ] 216 | } 217 | ] 218 | }, 219 | "left": { 220 | "doc": "doc2", 221 | "text": [ 222 | { 223 | "text": "A3", 224 | "marks": { 225 | "link": { 226 | "active": true, 227 | "url": "P.com" 228 | } 229 | } 230 | }, 231 | { 232 | "text": "96", 233 | "marks": {} 234 | } 235 | ], 236 | "meta": [ 237 | { 238 | "elemId": "2@doc1", 239 | "valueId": "2@doc1", 240 | "deleted": false, 241 | "markOpsBefore": [ 242 | { 243 | "opId": "7@doc2", 244 | "action": "addMark", 245 | "obj": "1@doc1", 246 | "start": { 247 | "type": "before", 248 | "elemId": "2@doc1" 249 | }, 250 | "end": { 251 | "type": "after", 252 | "elemId": "4@doc1" 253 | }, 254 | "markType": "link", 255 | "attrs": { 256 | "url": "P.com" 257 | } 258 | } 259 | ] 260 | }, 261 | { 262 | "elemId": "8@doc2", 263 | "valueId": "8@doc2", 264 | "deleted": false 265 | }, 266 | { 267 | "elemId": "9@doc2", 268 | "valueId": "9@doc2", 269 | "deleted": true 270 | }, 271 | { 272 | "elemId": "3@doc1", 273 | "valueId": "3@doc1", 274 | "deleted": true 275 | }, 276 | { 277 | "elemId": "4@doc1", 278 | "valueId": "4@doc1", 279 | "deleted": true, 280 | "markOpsAfter": [] 281 | }, 282 | { 283 | "elemId": "8@doc1", 284 | "valueId": "8@doc1", 285 | "deleted": false 286 | }, 287 | { 288 | "elemId": "9@doc1", 289 | "valueId": "9@doc1", 290 | "deleted": false 291 | }, 292 | { 293 | "elemId": "5@doc1", 294 | "valueId": "5@doc1", 295 | "deleted": true 296 | }, 297 | { 298 | "elemId": "6@doc1", 299 | "valueId": "6@doc1", 300 | "deleted": true 301 | } 302 | ] 303 | }, 304 | "right": { 305 | "doc": "doc1", 306 | "text": [ 307 | { 308 | "text": "A396", 309 | "marks": { 310 | "link": { 311 | "active": true, 312 | "url": "P.com" 313 | } 314 | } 315 | } 316 | ], 317 | "meta": [ 318 | { 319 | "elemId": "2@doc1", 320 | "valueId": "2@doc1", 321 | "deleted": false, 322 | "markOpsBefore": [ 323 | { 324 | "opId": "7@doc2", 325 | "action": "addMark", 326 | "obj": "1@doc1", 327 | "start": { 328 | "type": "before", 329 | "elemId": "2@doc1" 330 | }, 331 | "end": { 332 | "type": "after", 333 | "elemId": "4@doc1" 334 | }, 335 | "markType": "link", 336 | "attrs": { 337 | "url": "P.com" 338 | } 339 | } 340 | ] 341 | }, 342 | { 343 | "elemId": "8@doc2", 344 | "valueId": "8@doc2", 345 | "deleted": false 346 | }, 347 | { 348 | "elemId": "9@doc2", 349 | "valueId": "9@doc2", 350 | "deleted": true 351 | }, 352 | { 353 | "elemId": "8@doc1", 354 | "valueId": "8@doc1", 355 | "deleted": false 356 | }, 357 | { 358 | "elemId": "9@doc1", 359 | "valueId": "9@doc1", 360 | "deleted": false 361 | }, 362 | { 363 | "elemId": "3@doc1", 364 | "valueId": "3@doc1", 365 | "deleted": true 366 | }, 367 | { 368 | "elemId": "4@doc1", 369 | "valueId": "4@doc1", 370 | "deleted": true, 371 | "markOpsAfter": [] 372 | }, 373 | { 374 | "elemId": "5@doc1", 375 | "valueId": "5@doc1", 376 | "deleted": true 377 | }, 378 | { 379 | "elemId": "6@doc1", 380 | "valueId": "6@doc1", 381 | "deleted": true 382 | } 383 | ] 384 | }, 385 | "syncs": [ 386 | { 387 | "left": "doc1", 388 | "right": "doc2", 389 | "missingLeft": [], 390 | "missingRight": [ 391 | { 392 | "actor": "doc2", 393 | "seq": 1, 394 | "deps": { 395 | "doc1": 1 396 | }, 397 | "startOp": 7, 398 | "ops": [ 399 | { 400 | "opId": "7@doc2", 401 | "action": "addMark", 402 | "obj": "1@doc1", 403 | "start": { 404 | "type": "before", 405 | "elemId": "2@doc1" 406 | }, 407 | "end": { 408 | "type": "after", 409 | "elemId": "4@doc1" 410 | }, 411 | "markType": "link", 412 | "attrs": { 413 | "url": "P.com" 414 | } 415 | } 416 | ] 417 | } 418 | ] 419 | }, 420 | { 421 | "left": "doc3", 422 | "right": "doc1", 423 | "missingLeft": [ 424 | { 425 | "actor": "doc3", 426 | "seq": 1, 427 | "deps": { 428 | "doc1": 1 429 | }, 430 | "startOp": 7, 431 | "ops": [ 432 | { 433 | "opId": "7@doc3", 434 | "action": "del", 435 | "obj": "1@doc1", 436 | "elemId": "6@doc1" 437 | } 438 | ] 439 | } 440 | ], 441 | "missingRight": [ 442 | { 443 | "actor": "doc2", 444 | "seq": 1, 445 | "deps": { 446 | "doc1": 1 447 | }, 448 | "startOp": 7, 449 | "ops": [ 450 | { 451 | "opId": "7@doc2", 452 | "action": "addMark", 453 | "obj": "1@doc1", 454 | "start": { 455 | "type": "before", 456 | "elemId": "2@doc1" 457 | }, 458 | "end": { 459 | "type": "after", 460 | "elemId": "4@doc1" 461 | }, 462 | "markType": "link", 463 | "attrs": { 464 | "url": "P.com" 465 | } 466 | } 467 | ] 468 | } 469 | ] 470 | }, 471 | { 472 | "left": "doc3", 473 | "right": "doc2", 474 | "missingLeft": [ 475 | { 476 | "actor": "doc3", 477 | "seq": 1, 478 | "deps": { 479 | "doc1": 1 480 | }, 481 | "startOp": 7, 482 | "ops": [ 483 | { 484 | "opId": "7@doc3", 485 | "action": "del", 486 | "obj": "1@doc1", 487 | "elemId": "6@doc1" 488 | } 489 | ] 490 | } 491 | ], 492 | "missingRight": [ 493 | { 494 | "actor": "doc2", 495 | "seq": 2, 496 | "deps": { 497 | "doc1": 1, 498 | "doc2": 1 499 | }, 500 | "startOp": 8, 501 | "ops": [ 502 | { 503 | "opId": "8@doc2", 504 | "action": "set", 505 | "obj": "1@doc1", 506 | "elemId": "2@doc1", 507 | "insert": true, 508 | "value": "3" 509 | }, 510 | { 511 | "opId": "9@doc2", 512 | "action": "set", 513 | "obj": "1@doc1", 514 | "elemId": "8@doc2", 515 | "insert": true, 516 | "value": "3" 517 | } 518 | ] 519 | } 520 | ] 521 | }, 522 | { 523 | "left": "doc2", 524 | "right": "doc3", 525 | "missingLeft": [], 526 | "missingRight": [ 527 | { 528 | "actor": "doc3", 529 | "seq": 2, 530 | "deps": { 531 | "doc1": 1, 532 | "doc3": 1, 533 | "doc2": 2 534 | }, 535 | "startOp": 10, 536 | "ops": [ 537 | { 538 | "opId": "10@doc3", 539 | "action": "del", 540 | "obj": "1@doc1", 541 | "elemId": "4@doc1" 542 | }, 543 | { 544 | "opId": "11@doc3", 545 | "action": "del", 546 | "obj": "1@doc1", 547 | "elemId": "5@doc1" 548 | } 549 | ] 550 | } 551 | ] 552 | }, 553 | { 554 | "left": "doc2", 555 | "right": "doc3", 556 | "missingLeft": [], 557 | "missingRight": [ 558 | { 559 | "actor": "doc3", 560 | "seq": 3, 561 | "deps": { 562 | "doc1": 1, 563 | "doc3": 2, 564 | "doc2": 2 565 | }, 566 | "startOp": 12, 567 | "ops": [ 568 | { 569 | "opId": "12@doc3", 570 | "action": "del", 571 | "obj": "1@doc1", 572 | "elemId": "9@doc2" 573 | }, 574 | { 575 | "opId": "13@doc3", 576 | "action": "del", 577 | "obj": "1@doc1", 578 | "elemId": "3@doc1" 579 | } 580 | ] 581 | } 582 | ] 583 | }, 584 | { 585 | "left": "doc3", 586 | "right": "doc2", 587 | "missingLeft": [], 588 | "missingRight": [ 589 | { 590 | "actor": "doc2", 591 | "seq": 3, 592 | "deps": { 593 | "doc1": 1, 594 | "doc2": 2, 595 | "doc3": 3 596 | }, 597 | "startOp": 14, 598 | "ops": [] 599 | } 600 | ] 601 | }, 602 | { 603 | "left": "doc2", 604 | "right": "doc1", 605 | "missingLeft": [ 606 | { 607 | "actor": "doc2", 608 | "seq": 2, 609 | "deps": { 610 | "doc1": 1, 611 | "doc2": 1 612 | }, 613 | "startOp": 8, 614 | "ops": [ 615 | { 616 | "opId": "8@doc2", 617 | "action": "set", 618 | "obj": "1@doc1", 619 | "elemId": "2@doc1", 620 | "insert": true, 621 | "value": "3" 622 | }, 623 | { 624 | "opId": "9@doc2", 625 | "action": "set", 626 | "obj": "1@doc1", 627 | "elemId": "8@doc2", 628 | "insert": true, 629 | "value": "3" 630 | } 631 | ] 632 | }, 633 | { 634 | "actor": "doc2", 635 | "seq": 3, 636 | "deps": { 637 | "doc1": 1, 638 | "doc2": 2, 639 | "doc3": 3 640 | }, 641 | "startOp": 14, 642 | "ops": [] 643 | }, 644 | { 645 | "actor": "doc3", 646 | "seq": 2, 647 | "deps": { 648 | "doc1": 1, 649 | "doc3": 1, 650 | "doc2": 2 651 | }, 652 | "startOp": 10, 653 | "ops": [ 654 | { 655 | "opId": "10@doc3", 656 | "action": "del", 657 | "obj": "1@doc1", 658 | "elemId": "4@doc1" 659 | }, 660 | { 661 | "opId": "11@doc3", 662 | "action": "del", 663 | "obj": "1@doc1", 664 | "elemId": "5@doc1" 665 | } 666 | ] 667 | }, 668 | { 669 | "actor": "doc3", 670 | "seq": 3, 671 | "deps": { 672 | "doc1": 1, 673 | "doc3": 2, 674 | "doc2": 2 675 | }, 676 | "startOp": 12, 677 | "ops": [ 678 | { 679 | "opId": "12@doc3", 680 | "action": "del", 681 | "obj": "1@doc1", 682 | "elemId": "9@doc2" 683 | }, 684 | { 685 | "opId": "13@doc3", 686 | "action": "del", 687 | "obj": "1@doc1", 688 | "elemId": "3@doc1" 689 | } 690 | ] 691 | } 692 | ], 693 | "missingRight": [ 694 | { 695 | "actor": "doc1", 696 | "seq": 2, 697 | "deps": { 698 | "doc1": 1, 699 | "doc2": 1, 700 | "doc3": 1 701 | }, 702 | "startOp": 8, 703 | "ops": [ 704 | { 705 | "opId": "8@doc1", 706 | "action": "set", 707 | "obj": "1@doc1", 708 | "elemId": "2@doc1", 709 | "insert": true, 710 | "value": "9" 711 | }, 712 | { 713 | "opId": "9@doc1", 714 | "action": "set", 715 | "obj": "1@doc1", 716 | "elemId": "8@doc1", 717 | "insert": true, 718 | "value": "6" 719 | } 720 | ] 721 | } 722 | ] 723 | } 724 | ] 725 | } -------------------------------------------------------------------------------- /traces/notes.txt: -------------------------------------------------------------------------------- 1 | It seems like the errors with links happen when two docs concurrently apply overlapping links. the problm -------------------------------------------------------------------------------- /traces/two-links.json: -------------------------------------------------------------------------------- 1 | { 2 | "queues": { 3 | "doc0": [ 4 | { 5 | "actor": "doc0", 6 | "seq": 1, 7 | "deps": {}, 8 | "startOp": 1, 9 | "ops": [ 10 | { 11 | "opId": "1@doc0", 12 | "action": "makeList", 13 | "key": "text" 14 | }, 15 | { 16 | "opId": "2@doc0", 17 | "action": "set", 18 | "obj": "1@doc0", 19 | "insert": true, 20 | "value": "A" 21 | }, 22 | { 23 | "opId": "3@doc0", 24 | "action": "set", 25 | "obj": "1@doc0", 26 | "elemId": "2@doc0", 27 | "insert": true, 28 | "value": "B" 29 | }, 30 | { 31 | "opId": "4@doc0", 32 | "action": "set", 33 | "obj": "1@doc0", 34 | "elemId": "3@doc0", 35 | "insert": true, 36 | "value": "C" 37 | }, 38 | { 39 | "opId": "5@doc0", 40 | "action": "set", 41 | "obj": "1@doc0", 42 | "elemId": "4@doc0", 43 | "insert": true, 44 | "value": "D" 45 | }, 46 | { 47 | "opId": "6@doc0", 48 | "action": "set", 49 | "obj": "1@doc0", 50 | "elemId": "5@doc0", 51 | "insert": true, 52 | "value": "E" 53 | } 54 | ] 55 | }, 56 | { 57 | "actor": "doc0", 58 | "seq": 2, 59 | "deps": { 60 | "doc0": 1 61 | }, 62 | "startOp": 7, 63 | "ops": [ 64 | { 65 | "opId": "7@doc0", 66 | "action": "addMark", 67 | "obj": "1@doc0", 68 | "start": { 69 | "type": "before", 70 | "elemId": "6@doc0" 71 | }, 72 | "end": { 73 | "type": "after", 74 | "elemId": "6@doc0" 75 | }, 76 | "markType": "link", 77 | "attrs": { 78 | "url": "https://inkandswitch.com/pushpin" 79 | } 80 | } 81 | ] 82 | } 83 | ], 84 | "doc1": [ 85 | { 86 | "actor": "doc1", 87 | "seq": 1, 88 | "deps": { 89 | "doc0": 1 90 | }, 91 | "startOp": 7, 92 | "ops": [ 93 | { 94 | "opId": "7@doc1", 95 | "action": "set", 96 | "obj": "1@doc0", 97 | "elemId": "2@doc0", 98 | "insert": true, 99 | "value": "d" 100 | }, 101 | { 102 | "opId": "8@doc1", 103 | "action": "set", 104 | "obj": "1@doc0", 105 | "elemId": "7@doc1", 106 | "insert": true, 107 | "value": "c" 108 | }, 109 | { 110 | "opId": "9@doc1", 111 | "action": "set", 112 | "obj": "1@doc0", 113 | "elemId": "8@doc1", 114 | "insert": true, 115 | "value": "d" 116 | }, 117 | { 118 | "opId": "10@doc1", 119 | "action": "set", 120 | "obj": "1@doc0", 121 | "elemId": "9@doc1", 122 | "insert": true, 123 | "value": "7" 124 | }, 125 | { 126 | "opId": "11@doc1", 127 | "action": "set", 128 | "obj": "1@doc0", 129 | "elemId": "10@doc1", 130 | "insert": true, 131 | "value": "5" 132 | }, 133 | { 134 | "opId": "12@doc1", 135 | "action": "set", 136 | "obj": "1@doc0", 137 | "elemId": "11@doc1", 138 | "insert": true, 139 | "value": "3" 140 | }, 141 | { 142 | "opId": "13@doc1", 143 | "action": "set", 144 | "obj": "1@doc0", 145 | "elemId": "12@doc1", 146 | "insert": true, 147 | "value": "e" 148 | }, 149 | { 150 | "opId": "14@doc1", 151 | "action": "set", 152 | "obj": "1@doc0", 153 | "elemId": "13@doc1", 154 | "insert": true, 155 | "value": "a" 156 | } 157 | ] 158 | }, 159 | { 160 | "actor": "doc1", 161 | "seq": 2, 162 | "deps": { 163 | "doc0": 1, 164 | "doc1": 1 165 | }, 166 | "startOp": 15, 167 | "ops": [ 168 | { 169 | "opId": "15@doc1", 170 | "action": "addMark", 171 | "obj": "1@doc0", 172 | "start": { 173 | "type": "before", 174 | "elemId": "8@doc1" 175 | }, 176 | "end": { 177 | "type": "after", 178 | "elemId": "6@doc0" 179 | }, 180 | "markType": "link", 181 | "attrs": { 182 | "url": "https://inkandswitch.com" 183 | } 184 | } 185 | ] 186 | } 187 | ], 188 | "doc2": [] 189 | }, 190 | "leftDoc": "doc0", 191 | "rightDoc": "doc2", 192 | "leftText": [ 193 | { 194 | "text": "Ad", 195 | "marks": {} 196 | }, 197 | { 198 | "text": "cd753eaBCDE", 199 | "marks": { 200 | "link": { 201 | "active": true, 202 | "url": "https://inkandswitch.com" 203 | } 204 | } 205 | } 206 | ], 207 | "rightText": [ 208 | { 209 | "text": "Ad", 210 | "marks": {} 211 | }, 212 | { 213 | "text": "cd753eaBCD", 214 | "marks": { 215 | "link": { 216 | "active": true, 217 | "url": "https://inkandswitch.com" 218 | } 219 | } 220 | }, 221 | { 222 | "text": "E", 223 | "marks": { 224 | "link": { 225 | "active": true, 226 | "url": "https://inkandswitch.com/pushpin" 227 | } 228 | } 229 | } 230 | ] 231 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Recommended", 4 | "compilerOptions": { 5 | "target": "esnext", 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "jsx": "react" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /v2-notes.txt: -------------------------------------------------------------------------------- 1 | Maybe add to RGA something like: 2 | 3 | active: Operation[] // all the ops active at this place 4 | ends: Operation[] // ops that end here --------------------------------------------------------------------------------