├── .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 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Peritext
8 |
9 |
10 |
11 |
12 |
13 | 🔄 Sync
14 |
15 |
16 |
Editor
17 |
18 |
22 |
23 |
Micromerge Operations + Prosemirror Steps
24 |
25 |
26 |
27 |
28 |
Editor
29 |
30 |
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
--------------------------------------------------------------------------------