From 97bfec4067cfebf189d406e875c0f783bb1cce09 Mon Sep 17 00:00:00 2001 From: Dmytro Maretskyi Date: Tue, 19 Dec 2023 11:46:55 +0100 Subject: [PATCH 1/6] Add changed code --- src/PatchSemaphore.ts | 121 +++++++++++++++------------------ src/amToCodemirror.ts | 149 +++++++++++++++++++---------------------- src/codeMirrorToAm.ts | 57 ++++++---------- src/handle.ts | 10 +++ src/index.ts | 4 +- src/plugin.ts | 152 +++++++++++++++++++++++++++--------------- 6 files changed, 251 insertions(+), 242 deletions(-) create mode 100644 src/handle.ts diff --git a/src/PatchSemaphore.ts b/src/PatchSemaphore.ts index 56bb9fb..451f20b 100644 --- a/src/PatchSemaphore.ts +++ b/src/PatchSemaphore.ts @@ -1,86 +1,69 @@ -import { next as automerge, equals } from "@automerge/automerge" -import { DocHandle } from "@automerge/automerge-repo" -import { EditorView } from "@codemirror/view" -import codeMirrorToAm from "./codeMirrorToAm" -import amToCodemirror from "./amToCodemirror" -import { - Field, - isReconcileTx, - getPath, - reconcileAnnotationType, - updateHeads, - getLastHeads, -} from "./plugin" +import { next as automerge } from '@automerge/automerge'; -type Doc = automerge.Doc -type Heads = automerge.Heads +import amToCodemirror from './amToCodemirror'; +import codeMirrorToAm from './codeMirrorToAm'; +import { type IDocHandle } from './handle'; +import { type Field, isReconcileTx, getPath, reconcileAnnotationType, updateHeads, getLastHeads } from './plugin'; +import { type EditorView } from '@codemirror/view'; -type ChangeFn = ( - atHeads: Heads, - change: (doc: Doc) => void -) => Heads | undefined +type Doc = automerge.Doc; +type Heads = automerge.Heads; + +type ChangeFn = (atHeads: Heads, change: (doc: Doc) => void) => Heads | undefined; export class PatchSemaphore { - _field: Field - _inReconcile = false - _queue: Array = [] + _field!: Field; + _inReconcile = false; + _queue: Array = []; - constructor(field: Field) { - this._field = field + constructor(field?: Field) { + if (field !== undefined) { + this._field = field; + } } - reconcile = (handle: DocHandle, view: EditorView) => { + reconcile = (handle: IDocHandle, view: EditorView) => { if (this._inReconcile) { - return - } else { - this._inReconcile = true + return; + } + this._inReconcile = true; - const path = getPath(view.state, this._field) - const oldHeads = getLastHeads(view.state, this._field) - let selection = view.state.selection + const path = getPath(view.state, this._field); + const oldHeads = getLastHeads(view.state, this._field); + let selection = view.state.selection; - const transactions = view.state - .field(this._field) - .unreconciledTransactions.filter(tx => !isReconcileTx(tx)) + const transactions = view.state.field(this._field).unreconciledTransactions.filter((tx) => !isReconcileTx(tx)); - // First undo all the unreconciled transactions - const toInvert = transactions.slice().reverse() - for (const tx of toInvert) { - const inverted = tx.changes.invert(tx.startState.doc) - selection = selection.map(inverted) - view.dispatch({ - changes: inverted, - annotations: reconcileAnnotationType.of(true), - }) - } + // First undo all the unreconciled transactions + const toInvert = transactions.slice().reverse(); + for (const tx of toInvert) { + const inverted = tx.changes.invert(tx.startState.doc); + selection = selection.map(inverted); + view.dispatch({ + changes: inverted, + annotations: reconcileAnnotationType.of(true), + }); + } - // now apply the unreconciled transactions to the document - let newHeads = codeMirrorToAm( - this._field, - handle.changeAt.bind(handle), - transactions, - view.state - ) + // now apply the unreconciled transactions to the document + let newHeads = codeMirrorToAm(this._field, handle, transactions, view.state); - // NOTE: null and undefined each come from automerge and repo respectively - if (newHeads === null || newHeads === undefined) { - // TODO: @alexjg this is the call that's resetting the editor state on click - newHeads = automerge.getHeads(handle.docSync()) - } + // NOTE: null and undefined each come from automerge and repo respectively + if (newHeads === null || newHeads === undefined) { + // TODO: @alexjg this is the call that's resetting the editor state on click + newHeads = automerge.getHeads(handle.docSync()!); + } - // now get the diff between the updated state of the document and the heads - // and apply that to the codemirror doc - const diff = automerge.equals(oldHeads, newHeads) - ? [] - : automerge.diff(handle.docSync(), oldHeads, newHeads) - amToCodemirror(view, selection, path, diff) + // now get the diff between the updated state of the document and the heads + // and apply that to the codemirror doc + const diff = automerge.equals(oldHeads, newHeads) ? [] : automerge.diff(handle.docSync()!, oldHeads, newHeads); + amToCodemirror(view, selection, path, diff); - view.dispatch({ - effects: updateHeads(newHeads), - annotations: reconcileAnnotationType.of({}), - }) + view.dispatch({ + effects: updateHeads(newHeads), + annotations: reconcileAnnotationType.of({}), + }); - this._inReconcile = false - } - } + this._inReconcile = false; + }; } diff --git a/src/amToCodemirror.ts b/src/amToCodemirror.ts index fbc738f..03fdac9 100644 --- a/src/amToCodemirror.ts +++ b/src/amToCodemirror.ts @@ -1,114 +1,101 @@ +import { ChangeSet, type ChangeSpec, type EditorSelection, type EditorState } from '@codemirror/state'; +import { type EditorView } from '@codemirror/view'; + import { - DelPatch, - InsertPatch, - Patch, - Prop, - PutPatch, - SpliceTextPatch, -} from "@automerge/automerge" -import { - ChangeSet, - ChangeSpec, - EditorSelection, - EditorState, -} from "@codemirror/state" -import { EditorView } from "@codemirror/view" -import { reconcileAnnotationType } from "./plugin" + type DelPatch, + type InsertPatch, + type Patch, + type Prop, + type PutPatch, + type SpliceTextPatch, +} from '@automerge/automerge'; + +import { reconcileAnnotationType } from './plugin'; -export default function ( - view: EditorView, - selection: EditorSelection, - target: Prop[], - patches: Patch[] -) { +export default (view: EditorView, selection: EditorSelection, target: Prop[], patches: Patch[]) => { for (const patch of patches) { - const changeSpec = handlePatch(patch, target, view.state) + const changeSpec = handlePatch(patch, target, view.state); if (changeSpec != null) { - const changeSet = ChangeSet.of(changeSpec, view.state.doc.length, "\n") - selection = selection.map(changeSet, 1) + const changeSet = ChangeSet.of(changeSpec, view.state.doc.length, '\n'); + selection = selection.map(changeSet, 1); view.dispatch({ changes: changeSet, annotations: reconcileAnnotationType.of({}), - }) + }); } } view.dispatch({ selection, annotations: reconcileAnnotationType.of({}), - }) -} + }); +}; -function handlePatch( - patch: Patch, - target: Prop[], - state: EditorState -): ChangeSpec | null { - if (patch.action === "insert") { - return handleInsert(target, patch) - } else if (patch.action === "splice") { - return handleSplice(target, patch) - } else if (patch.action === "del") { - return handleDel(target, patch) - } else if (patch.action === "put") { - return handlePut(target, patch, state) +const handlePatch = (patch: Patch, target: Prop[], state: EditorState): ChangeSpec | null => { + if (patch.action === 'insert') { + return handleInsert(target, patch); + } else if (patch.action === 'splice') { + return handleSplice(target, patch); + } else if (patch.action === 'del') { + return handleDel(target, patch); + } else if (patch.action === 'put') { + return handlePut(target, patch, state); } else { - return null + return null; } -} +}; -function handleInsert(target: Prop[], patch: InsertPatch): Array { - const index = charPath(target, patch.path) +const handleInsert = (target: Prop[], patch: InsertPatch): Array => { + const index = charPath(target, patch.path); if (index == null) { - return [] + return []; } - const text = patch.values.map(v => (v ? v.toString() : "")).join("") - return [{ from: index, to: index, insert: text }] -} + const text = patch.values.map((v) => (v ? v.toString() : '')).join(''); + return [{ from: index, to: index, insert: text }]; +}; -function handleSplice( - target: Prop[], - patch: SpliceTextPatch -): Array { - const index = charPath(target, patch.path) +const handleSplice = (target: Prop[], patch: SpliceTextPatch): Array => { + const index = charPath(target, patch.path); if (index == null) { - return [] + return []; } - return [{ from: index, insert: patch.value }] -} + return [{ from: index, insert: patch.value }]; +}; -function handleDel(target: Prop[], patch: DelPatch): Array { - const index = charPath(target, patch.path) +const handleDel = (target: Prop[], patch: DelPatch): Array => { + const index = charPath(target, patch.path); if (index == null) { - return [] + return []; } - const length = patch.length || 1 - return [{ from: index, to: index + length }] -} + const length = patch.length || 1; + return [{ from: index, to: index + length }]; +}; -function handlePut( - target: Prop[], - patch: PutPatch, - state: EditorState -): Array { - const index = charPath(target, [...patch.path, 0]) +const handlePut = (target: Prop[], patch: PutPatch, state: EditorState): Array => { + const index = charPath(target, [...patch.path, 0]); if (index == null) { - return [] + return []; } - const length = state.doc.length - if (typeof patch.value !== "string") { - return [] // TODO(dmaretskyi): How to handle non string values? + const length = state.doc.length; + if (typeof patch.value !== 'string') { + return []; // TODO(dmaretskyi): How to handle non string values? } - return [{ from: 0, to: length, insert: patch.value as any }] -} + return [{ from: 0, to: length, insert: patch.value as any }]; +}; // If the path of the patch is of the form [path, ] then we know this is // a path to a character within the sequence given by path -function charPath(textPath: Prop[], candidatePath: Prop[]): number | null { - if (candidatePath.length !== textPath.length + 1) return null +const charPath = (textPath: Prop[], candidatePath: Prop[]): number | null => { + if (candidatePath.length !== textPath.length + 1) { + return null; + } for (let i = 0; i < textPath.length; i++) { - if (textPath[i] !== candidatePath[i]) return null + if (textPath[i] !== candidatePath[i]) { + return null; + } + } + const index = candidatePath[candidatePath.length - 1]; + if (typeof index === 'number') { + return index; } - const index = candidatePath[candidatePath.length - 1] - if (typeof index === "number") return index - return null -} + return null; +}; diff --git a/src/codeMirrorToAm.ts b/src/codeMirrorToAm.ts index cc10242..a558006 100644 --- a/src/codeMirrorToAm.ts +++ b/src/codeMirrorToAm.ts @@ -1,50 +1,37 @@ -import { next as am } from "@automerge/automerge" -import { Heads } from "@automerge/automerge" -import { EditorState, Text, Transaction } from "@codemirror/state" -import { type Field } from "./plugin" +import { type EditorState, type Text, type Transaction } from '@codemirror/state'; -type Update = ( - atHeads: Heads, - change: (doc: am.Doc) => void -) => Heads | undefined +import { next as am, type Heads } from '@automerge/automerge'; -export default function ( +import { type IDocHandle } from './handle'; +import { type Field } from './plugin'; + +export default ( field: Field, - update: Update, + handle: IDocHandle, transactions: Transaction[], - state: EditorState -): Heads | undefined { - const { lastHeads, path } = state.field(field) + state: EditorState, +): Heads | undefined => { + const { lastHeads, path } = state.field(field); // We don't want to call `automerge.updateAt` if there are no changes. // Otherwise later on `automerge.diff` will return empty patches that result in a no-op but still mess up the selection. - let hasChanges = false + let hasChanges = false; for (const tr of transactions) { - if (!tr.changes.empty) { - tr.changes.iterChanges(() => { - hasChanges = true - }) - } + tr.changes.iterChanges(() => { + hasChanges = true; + }); } if (!hasChanges) { - return undefined + return undefined; } - const newHeads = update(lastHeads, (doc: am.Doc) => { + const newHeads = handle.changeAt(lastHeads, (doc: am.Doc) => { for (const tr of transactions) { - tr.changes.iterChanges( - ( - fromA: number, - toA: number, - _fromB: number, - _toB: number, - inserted: Text - ) => { - am.splice(doc, path, fromA, toA - fromA, inserted.toString()) - } - ) + tr.changes.iterChanges((fromA: number, toA: number, _fromB: number, _toB: number, inserted: Text) => { + am.splice(doc, path, fromA, toA - fromA, inserted.toString()); + }); } - }) - return newHeads -} + }); + return newHeads ?? undefined; +}; diff --git a/src/handle.ts b/src/handle.ts new file mode 100644 index 0000000..83e18b5 --- /dev/null +++ b/src/handle.ts @@ -0,0 +1,10 @@ +import type * as A from '@automerge/automerge'; + +export type IDocHandle = { + docSync(): A.Doc | undefined; + change(callback: A.ChangeFn, options?: A.ChangeOptions): void; + changeAt(heads: A.Heads, callback: A.ChangeFn, options?: A.ChangeOptions): string[] | undefined; + + addListener(event: 'change', listener: () => void): void; + removeListener(event: 'change', listener: () => void): void; +}; diff --git a/src/index.ts b/src/index.ts index 1d01971..6b4a53b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export { plugin } from "./plugin" -export { PatchSemaphore } from "./PatchSemaphore" +export { automergePlugin, type AutomergePlugin } from './plugin'; +export { type IDocHandle } from './handle'; diff --git a/src/plugin.ts b/src/plugin.ts index 7d0cfac..f508afd 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,92 +1,134 @@ import { Annotation, - EditorState, + type EditorState, + type Extension, + Facet, StateEffect, StateField, - Transaction, - TransactionSpec, -} from "@codemirror/state" -import * as automerge from "@automerge/automerge" -import { Doc, Heads, Prop } from "@automerge/automerge" + type Transaction, + type TransactionSpec, +} from '@codemirror/state'; +import { ViewPlugin, type EditorView, type PluginValue, type ViewUpdate } from '@codemirror/view'; + +import * as automerge from '@automerge/automerge'; +import { type Heads, type Prop } from '@automerge/automerge'; + +import { PatchSemaphore } from './PatchSemaphore'; +import { type IDocHandle } from './handle'; export type Value = { - lastHeads: Heads - path: Prop[] - unreconciledTransactions: Transaction[] -} + lastHeads: Heads; + path: Prop[]; + unreconciledTransactions: Transaction[]; +}; type UpdateHeads = { - newHeads: Heads -} + newHeads: Heads; +}; -export const effectType = StateEffect.define({}) +export const effectType = StateEffect.define({}); -export function updateHeads(newHeads: Heads): StateEffect { - return effectType.of({ newHeads }) -} +export const updateHeads = (newHeads: Heads): StateEffect => effectType.of({ newHeads }); -export function getLastHeads(state: EditorState, field: Field): Heads { - return state.field(field).lastHeads -} +export const getLastHeads = (state: EditorState, field: Field): Heads => state.field(field).lastHeads; -export function getPath(state: EditorState, field: Field): Prop[] { - return state.field(field).path -} +export const getPath = (state: EditorState, field: Field): Prop[] => state.field(field).path; -export type Field = StateField +export type Field = StateField; -export function plugin(doc: Doc, path: Prop[]): StateField { - return StateField.define({ - create() { - return { - lastHeads: automerge.getHeads(doc), - unreconciledTransactions: [], - path: path.slice(), - } - }, - update(value: Value, tr: Transaction) { - const result = { +const semaphoreFacet = Facet.define({ + combine: (values) => values.at(-1)!, // Take last. +}); + +export type AutomergePlugin = { + extension: Extension; +}; + +export const automergePlugin = (handle: IDocHandle, path: Prop[]): AutomergePlugin => { + const stateField: StateField = StateField.define({ + create: () => ({ + lastHeads: automerge.getHeads(handle.docSync()!), + unreconciledTransactions: [], + path: path.slice(), + }), + update: (value: Value, tr: Transaction) => { + const result: Value = { lastHeads: value.lastHeads, unreconciledTransactions: value.unreconciledTransactions.slice(), path: path.slice(), - } - let clearUnreconciled = false + }; + let clearUnreconciled = false; for (const effect of tr.effects) { if (effect.is(effectType)) { - result.lastHeads = effect.value.newHeads - clearUnreconciled = true + result.lastHeads = effect.value.newHeads; + clearUnreconciled = true; } } if (clearUnreconciled) { - result.unreconciledTransactions = [] + result.unreconciledTransactions = []; } else { if (!isReconcileTx(tr)) { - result.unreconciledTransactions.push(tr) + result.unreconciledTransactions.push(tr); } } - return result + return result; }, - }) -} + }); + const semaphore = new PatchSemaphore(stateField); -export const reconcileAnnotationType = Annotation.define() + const viewPlugin = ViewPlugin.fromClass( + class AutomergeCodemirrorViewPlugin implements PluginValue { + private _view: EditorView; -export function isReconcileTx(tr: Transaction): boolean { - return !!tr.annotation(reconcileAnnotationType) + constructor(view: EditorView) { + this._view = view; + handle.addListener('change', this._handleChange); + } + + update(update: ViewUpdate) { + if (update.transactions.length > 0 && update.transactions.some((t) => !isReconcileTx(t))) { + queueMicrotask(() => { + reconcile(handle, this._view) + }); + } + } + + destroy() { + handle.addListener('change', this._handleChange); + } + + private _handleChange = () => { + reconcile(handle, this._view) + }; + }, + ); + + return { + extension: [stateField, semaphoreFacet.of(semaphore), viewPlugin], + }; +}; + +const reconcile = (handle: IDocHandle, view: EditorView) => { + const semaphore = view.state.facet(semaphoreFacet) + semaphore.reconcile(handle, view); } -export function makeReconcile(tr: TransactionSpec) { +export const reconcileAnnotationType = Annotation.define(); + +export const isReconcileTx = (tr: Transaction): boolean => !!tr.annotation(reconcileAnnotationType); + +export const makeReconcile = (tr: TransactionSpec) => { if (tr.annotations != null) { if (tr.annotations instanceof Array) { - tr.annotations = [...tr.annotations, reconcileAnnotationType.of({})] + tr.annotations = [...tr.annotations, reconcileAnnotationType.of({})]; } else { - tr.annotations = [tr.annotations, reconcileAnnotationType.of({})] + tr.annotations = [tr.annotations, reconcileAnnotationType.of({})]; } } else { - tr.annotations = [reconcileAnnotationType.of({})] + tr.annotations = [reconcileAnnotationType.of({})]; } - //return { - //...tr, - //annotations: reconcileAnnotationType.of({}) - //} -} + // return { + // ...tr, + // annotations: reconcileAnnotationType.of({}) + // } +}; From 4879cc4f32c1ae6f042c195e6ba4b006feb907ee Mon Sep 17 00:00:00 2001 From: Dmytro Maretskyi Date: Tue, 19 Dec 2023 11:48:04 +0100 Subject: [PATCH 2/6] Update test --- test/Editor.tsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/test/Editor.tsx b/test/Editor.tsx index ec3731c..d2f7d85 100644 --- a/test/Editor.tsx +++ b/test/Editor.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useRef } from "react" import { EditorView } from "@codemirror/view" import { basicSetup } from "codemirror" import { Prop } from "@automerge/automerge" -import { plugin as amgPlugin, PatchSemaphore } from "../src" +import { automergePlugin } from "../src" import { type DocHandle } from "@automerge/automerge-repo" export type EditorProps = { @@ -18,26 +18,13 @@ export function Editor({ handle, path }: EditorProps) { useEffect(() => { const doc = handle.docSync() const source = doc.text // this should use path - const plugin = amgPlugin(doc, path) - const semaphore = new PatchSemaphore(plugin) const view = (editorRoot.current = new EditorView({ doc: source, - extensions: [basicSetup, plugin], - dispatch(transaction) { - view.update([transaction]) - semaphore.reconcile(handle, view) - }, + extensions: [basicSetup, automergePlugin(handle, path)], parent: containerRef.current, })) - const handleChange = ({ doc, patchInfo }) => { - semaphore.reconcile(handle, view) - } - - handle.addListener("change", handleChange) - return () => { - handle.removeListener("change", handleChange) view.destroy() } }, []) From 51daa3d2322b0658851ddac211b9b76888687796 Mon Sep 17 00:00:00 2001 From: Dmytro Maretskyi Date: Tue, 19 Dec 2023 11:48:24 +0100 Subject: [PATCH 3/6] Format --- src/PatchSemaphore.ts | 74 ++++++++++++++---------- src/amToCodemirror.ts | 130 +++++++++++++++++++++++++----------------- src/codeMirrorToAm.ts | 44 ++++++++------ src/handle.ts | 18 +++--- src/index.ts | 4 +- src/plugin.ts | 113 ++++++++++++++++++++---------------- 6 files changed, 226 insertions(+), 157 deletions(-) diff --git a/src/PatchSemaphore.ts b/src/PatchSemaphore.ts index 451f20b..4ba4b8f 100644 --- a/src/PatchSemaphore.ts +++ b/src/PatchSemaphore.ts @@ -1,69 +1,83 @@ -import { next as automerge } from '@automerge/automerge'; +import { next as automerge } from "@automerge/automerge" -import amToCodemirror from './amToCodemirror'; -import codeMirrorToAm from './codeMirrorToAm'; -import { type IDocHandle } from './handle'; -import { type Field, isReconcileTx, getPath, reconcileAnnotationType, updateHeads, getLastHeads } from './plugin'; -import { type EditorView } from '@codemirror/view'; +import amToCodemirror from "./amToCodemirror" +import codeMirrorToAm from "./codeMirrorToAm" +import { type IDocHandle } from "./handle" +import { + type Field, + isReconcileTx, + getPath, + reconcileAnnotationType, + updateHeads, + getLastHeads, +} from "./plugin" +import { type EditorView } from "@codemirror/view" -type Doc = automerge.Doc; -type Heads = automerge.Heads; +type Doc = automerge.Doc +type Heads = automerge.Heads -type ChangeFn = (atHeads: Heads, change: (doc: Doc) => void) => Heads | undefined; +type ChangeFn = ( + atHeads: Heads, + change: (doc: Doc) => void +) => Heads | undefined export class PatchSemaphore { - _field!: Field; - _inReconcile = false; - _queue: Array = []; + _field!: Field + _inReconcile = false + _queue: Array = [] constructor(field?: Field) { if (field !== undefined) { - this._field = field; + this._field = field } } reconcile = (handle: IDocHandle, view: EditorView) => { if (this._inReconcile) { - return; + return } - this._inReconcile = true; + this._inReconcile = true - const path = getPath(view.state, this._field); - const oldHeads = getLastHeads(view.state, this._field); - let selection = view.state.selection; + const path = getPath(view.state, this._field) + const oldHeads = getLastHeads(view.state, this._field) + let selection = view.state.selection - const transactions = view.state.field(this._field).unreconciledTransactions.filter((tx) => !isReconcileTx(tx)); + const transactions = view.state + .field(this._field) + .unreconciledTransactions.filter(tx => !isReconcileTx(tx)) // First undo all the unreconciled transactions - const toInvert = transactions.slice().reverse(); + const toInvert = transactions.slice().reverse() for (const tx of toInvert) { - const inverted = tx.changes.invert(tx.startState.doc); - selection = selection.map(inverted); + const inverted = tx.changes.invert(tx.startState.doc) + selection = selection.map(inverted) view.dispatch({ changes: inverted, annotations: reconcileAnnotationType.of(true), - }); + }) } // now apply the unreconciled transactions to the document - let newHeads = codeMirrorToAm(this._field, handle, transactions, view.state); + let newHeads = codeMirrorToAm(this._field, handle, transactions, view.state) // NOTE: null and undefined each come from automerge and repo respectively if (newHeads === null || newHeads === undefined) { // TODO: @alexjg this is the call that's resetting the editor state on click - newHeads = automerge.getHeads(handle.docSync()!); + newHeads = automerge.getHeads(handle.docSync()!) } // now get the diff between the updated state of the document and the heads // and apply that to the codemirror doc - const diff = automerge.equals(oldHeads, newHeads) ? [] : automerge.diff(handle.docSync()!, oldHeads, newHeads); - amToCodemirror(view, selection, path, diff); + const diff = automerge.equals(oldHeads, newHeads) + ? [] + : automerge.diff(handle.docSync()!, oldHeads, newHeads) + amToCodemirror(view, selection, path, diff) view.dispatch({ effects: updateHeads(newHeads), annotations: reconcileAnnotationType.of({}), - }); + }) - this._inReconcile = false; - }; + this._inReconcile = false + } } diff --git a/src/amToCodemirror.ts b/src/amToCodemirror.ts index 03fdac9..b24ab0b 100644 --- a/src/amToCodemirror.ts +++ b/src/amToCodemirror.ts @@ -1,5 +1,10 @@ -import { ChangeSet, type ChangeSpec, type EditorSelection, type EditorState } from '@codemirror/state'; -import { type EditorView } from '@codemirror/view'; +import { + ChangeSet, + type ChangeSpec, + type EditorSelection, + type EditorState, +} from "@codemirror/state" +import { type EditorView } from "@codemirror/view" import { type DelPatch, @@ -8,94 +13,113 @@ import { type Prop, type PutPatch, type SpliceTextPatch, -} from '@automerge/automerge'; +} from "@automerge/automerge" -import { reconcileAnnotationType } from './plugin'; +import { reconcileAnnotationType } from "./plugin" -export default (view: EditorView, selection: EditorSelection, target: Prop[], patches: Patch[]) => { +export default ( + view: EditorView, + selection: EditorSelection, + target: Prop[], + patches: Patch[] +) => { for (const patch of patches) { - const changeSpec = handlePatch(patch, target, view.state); + const changeSpec = handlePatch(patch, target, view.state) if (changeSpec != null) { - const changeSet = ChangeSet.of(changeSpec, view.state.doc.length, '\n'); - selection = selection.map(changeSet, 1); + const changeSet = ChangeSet.of(changeSpec, view.state.doc.length, "\n") + selection = selection.map(changeSet, 1) view.dispatch({ changes: changeSet, annotations: reconcileAnnotationType.of({}), - }); + }) } } view.dispatch({ selection, annotations: reconcileAnnotationType.of({}), - }); -}; + }) +} -const handlePatch = (patch: Patch, target: Prop[], state: EditorState): ChangeSpec | null => { - if (patch.action === 'insert') { - return handleInsert(target, patch); - } else if (patch.action === 'splice') { - return handleSplice(target, patch); - } else if (patch.action === 'del') { - return handleDel(target, patch); - } else if (patch.action === 'put') { - return handlePut(target, patch, state); +const handlePatch = ( + patch: Patch, + target: Prop[], + state: EditorState +): ChangeSpec | null => { + if (patch.action === "insert") { + return handleInsert(target, patch) + } else if (patch.action === "splice") { + return handleSplice(target, patch) + } else if (patch.action === "del") { + return handleDel(target, patch) + } else if (patch.action === "put") { + return handlePut(target, patch, state) } else { - return null; + return null } -}; +} -const handleInsert = (target: Prop[], patch: InsertPatch): Array => { - const index = charPath(target, patch.path); +const handleInsert = ( + target: Prop[], + patch: InsertPatch +): Array => { + const index = charPath(target, patch.path) if (index == null) { - return []; + return [] } - const text = patch.values.map((v) => (v ? v.toString() : '')).join(''); - return [{ from: index, to: index, insert: text }]; -}; + const text = patch.values.map(v => (v ? v.toString() : "")).join("") + return [{ from: index, to: index, insert: text }] +} -const handleSplice = (target: Prop[], patch: SpliceTextPatch): Array => { - const index = charPath(target, patch.path); +const handleSplice = ( + target: Prop[], + patch: SpliceTextPatch +): Array => { + const index = charPath(target, patch.path) if (index == null) { - return []; + return [] } - return [{ from: index, insert: patch.value }]; -}; + return [{ from: index, insert: patch.value }] +} const handleDel = (target: Prop[], patch: DelPatch): Array => { - const index = charPath(target, patch.path); + const index = charPath(target, patch.path) if (index == null) { - return []; + return [] } - const length = patch.length || 1; - return [{ from: index, to: index + length }]; -}; + const length = patch.length || 1 + return [{ from: index, to: index + length }] +} -const handlePut = (target: Prop[], patch: PutPatch, state: EditorState): Array => { - const index = charPath(target, [...patch.path, 0]); +const handlePut = ( + target: Prop[], + patch: PutPatch, + state: EditorState +): Array => { + const index = charPath(target, [...patch.path, 0]) if (index == null) { - return []; + return [] } - const length = state.doc.length; - if (typeof patch.value !== 'string') { - return []; // TODO(dmaretskyi): How to handle non string values? + const length = state.doc.length + if (typeof patch.value !== "string") { + return [] // TODO(dmaretskyi): How to handle non string values? } - return [{ from: 0, to: length, insert: patch.value as any }]; -}; + return [{ from: 0, to: length, insert: patch.value as any }] +} // If the path of the patch is of the form [path, ] then we know this is // a path to a character within the sequence given by path const charPath = (textPath: Prop[], candidatePath: Prop[]): number | null => { if (candidatePath.length !== textPath.length + 1) { - return null; + return null } for (let i = 0; i < textPath.length; i++) { if (textPath[i] !== candidatePath[i]) { - return null; + return null } } - const index = candidatePath[candidatePath.length - 1]; - if (typeof index === 'number') { - return index; + const index = candidatePath[candidatePath.length - 1] + if (typeof index === "number") { + return index } - return null; -}; + return null +} diff --git a/src/codeMirrorToAm.ts b/src/codeMirrorToAm.ts index a558006..7741d9b 100644 --- a/src/codeMirrorToAm.ts +++ b/src/codeMirrorToAm.ts @@ -1,37 +1,49 @@ -import { type EditorState, type Text, type Transaction } from '@codemirror/state'; +import { + type EditorState, + type Text, + type Transaction, +} from "@codemirror/state" -import { next as am, type Heads } from '@automerge/automerge'; +import { next as am, type Heads } from "@automerge/automerge" -import { type IDocHandle } from './handle'; -import { type Field } from './plugin'; +import { type IDocHandle } from "./handle" +import { type Field } from "./plugin" export default ( field: Field, handle: IDocHandle, transactions: Transaction[], - state: EditorState, + state: EditorState ): Heads | undefined => { - const { lastHeads, path } = state.field(field); + const { lastHeads, path } = state.field(field) // We don't want to call `automerge.updateAt` if there are no changes. // Otherwise later on `automerge.diff` will return empty patches that result in a no-op but still mess up the selection. - let hasChanges = false; + let hasChanges = false for (const tr of transactions) { tr.changes.iterChanges(() => { - hasChanges = true; - }); + hasChanges = true + }) } if (!hasChanges) { - return undefined; + return undefined } const newHeads = handle.changeAt(lastHeads, (doc: am.Doc) => { for (const tr of transactions) { - tr.changes.iterChanges((fromA: number, toA: number, _fromB: number, _toB: number, inserted: Text) => { - am.splice(doc, path, fromA, toA - fromA, inserted.toString()); - }); + tr.changes.iterChanges( + ( + fromA: number, + toA: number, + _fromB: number, + _toB: number, + inserted: Text + ) => { + am.splice(doc, path, fromA, toA - fromA, inserted.toString()) + } + ) } - }); - return newHeads ?? undefined; -}; + }) + return newHeads ?? undefined +} diff --git a/src/handle.ts b/src/handle.ts index 83e18b5..23153dd 100644 --- a/src/handle.ts +++ b/src/handle.ts @@ -1,10 +1,14 @@ -import type * as A from '@automerge/automerge'; +import type * as A from "@automerge/automerge" export type IDocHandle = { - docSync(): A.Doc | undefined; - change(callback: A.ChangeFn, options?: A.ChangeOptions): void; - changeAt(heads: A.Heads, callback: A.ChangeFn, options?: A.ChangeOptions): string[] | undefined; + docSync(): A.Doc | undefined + change(callback: A.ChangeFn, options?: A.ChangeOptions): void + changeAt( + heads: A.Heads, + callback: A.ChangeFn, + options?: A.ChangeOptions + ): string[] | undefined - addListener(event: 'change', listener: () => void): void; - removeListener(event: 'change', listener: () => void): void; -}; + addListener(event: "change", listener: () => void): void + removeListener(event: "change", listener: () => void): void +} diff --git a/src/index.ts b/src/index.ts index 6b4a53b..ad27b9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export { automergePlugin, type AutomergePlugin } from './plugin'; -export { type IDocHandle } from './handle'; +export { automergePlugin, type AutomergePlugin } from "./plugin" +export { type IDocHandle } from "./handle" diff --git a/src/plugin.ts b/src/plugin.ts index f508afd..d3be098 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -7,44 +7,55 @@ import { StateField, type Transaction, type TransactionSpec, -} from '@codemirror/state'; -import { ViewPlugin, type EditorView, type PluginValue, type ViewUpdate } from '@codemirror/view'; +} from "@codemirror/state" +import { + ViewPlugin, + type EditorView, + type PluginValue, + type ViewUpdate, +} from "@codemirror/view" -import * as automerge from '@automerge/automerge'; -import { type Heads, type Prop } from '@automerge/automerge'; +import * as automerge from "@automerge/automerge" +import { type Heads, type Prop } from "@automerge/automerge" -import { PatchSemaphore } from './PatchSemaphore'; -import { type IDocHandle } from './handle'; +import { PatchSemaphore } from "./PatchSemaphore" +import { type IDocHandle } from "./handle" export type Value = { - lastHeads: Heads; - path: Prop[]; - unreconciledTransactions: Transaction[]; -}; + lastHeads: Heads + path: Prop[] + unreconciledTransactions: Transaction[] +} type UpdateHeads = { - newHeads: Heads; -}; + newHeads: Heads +} -export const effectType = StateEffect.define({}); +export const effectType = StateEffect.define({}) -export const updateHeads = (newHeads: Heads): StateEffect => effectType.of({ newHeads }); +export const updateHeads = (newHeads: Heads): StateEffect => + effectType.of({ newHeads }) -export const getLastHeads = (state: EditorState, field: Field): Heads => state.field(field).lastHeads; +export const getLastHeads = (state: EditorState, field: Field): Heads => + state.field(field).lastHeads -export const getPath = (state: EditorState, field: Field): Prop[] => state.field(field).path; +export const getPath = (state: EditorState, field: Field): Prop[] => + state.field(field).path -export type Field = StateField; +export type Field = StateField const semaphoreFacet = Facet.define({ - combine: (values) => values.at(-1)!, // Take last. -}); + combine: values => values.at(-1)!, // Take last. +}) export type AutomergePlugin = { - extension: Extension; -}; + extension: Extension +} -export const automergePlugin = (handle: IDocHandle, path: Prop[]): AutomergePlugin => { +export const automergePlugin = ( + handle: IDocHandle, + path: Prop[] +): AutomergePlugin => { const stateField: StateField = StateField.define({ create: () => ({ lastHeads: automerge.getHeads(handle.docSync()!), @@ -56,79 +67,83 @@ export const automergePlugin = (handle: IDocHandle, path: Prop[]): AutomergePlug lastHeads: value.lastHeads, unreconciledTransactions: value.unreconciledTransactions.slice(), path: path.slice(), - }; - let clearUnreconciled = false; + } + let clearUnreconciled = false for (const effect of tr.effects) { if (effect.is(effectType)) { - result.lastHeads = effect.value.newHeads; - clearUnreconciled = true; + result.lastHeads = effect.value.newHeads + clearUnreconciled = true } } if (clearUnreconciled) { - result.unreconciledTransactions = []; + result.unreconciledTransactions = [] } else { if (!isReconcileTx(tr)) { - result.unreconciledTransactions.push(tr); + result.unreconciledTransactions.push(tr) } } - return result; + return result }, - }); - const semaphore = new PatchSemaphore(stateField); + }) + const semaphore = new PatchSemaphore(stateField) const viewPlugin = ViewPlugin.fromClass( class AutomergeCodemirrorViewPlugin implements PluginValue { - private _view: EditorView; + private _view: EditorView constructor(view: EditorView) { - this._view = view; - handle.addListener('change', this._handleChange); + this._view = view + handle.addListener("change", this._handleChange) } update(update: ViewUpdate) { - if (update.transactions.length > 0 && update.transactions.some((t) => !isReconcileTx(t))) { + if ( + update.transactions.length > 0 && + update.transactions.some(t => !isReconcileTx(t)) + ) { queueMicrotask(() => { reconcile(handle, this._view) - }); + }) } } destroy() { - handle.addListener('change', this._handleChange); + handle.addListener("change", this._handleChange) } private _handleChange = () => { reconcile(handle, this._view) - }; - }, - ); + } + } + ) return { extension: [stateField, semaphoreFacet.of(semaphore), viewPlugin], - }; -}; + } +} const reconcile = (handle: IDocHandle, view: EditorView) => { const semaphore = view.state.facet(semaphoreFacet) - semaphore.reconcile(handle, view); + semaphore.reconcile(handle, view) } -export const reconcileAnnotationType = Annotation.define(); +export const reconcileAnnotationType = Annotation.define() -export const isReconcileTx = (tr: Transaction): boolean => !!tr.annotation(reconcileAnnotationType); +export const isReconcileTx = (tr: Transaction): boolean => + !!tr.annotation(reconcileAnnotationType) export const makeReconcile = (tr: TransactionSpec) => { if (tr.annotations != null) { if (tr.annotations instanceof Array) { - tr.annotations = [...tr.annotations, reconcileAnnotationType.of({})]; + tr.annotations = [...tr.annotations, reconcileAnnotationType.of({})] } else { - tr.annotations = [tr.annotations, reconcileAnnotationType.of({})]; + tr.annotations = [tr.annotations, reconcileAnnotationType.of({})] } } else { - tr.annotations = [reconcileAnnotationType.of({})]; + tr.annotations = [reconcileAnnotationType.of({})] } // return { // ...tr, // annotations: reconcileAnnotationType.of({}) // } -}; +} From b255197f2db01ccc2d3285acf5d82c67498bd666 Mon Sep 17 00:00:00 2001 From: Dmytro Maretskyi Date: Tue, 19 Dec 2023 12:03:39 +0100 Subject: [PATCH 4/6] Revert change --- src/codeMirrorToAm.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/codeMirrorToAm.ts b/src/codeMirrorToAm.ts index 7741d9b..7ca65d5 100644 --- a/src/codeMirrorToAm.ts +++ b/src/codeMirrorToAm.ts @@ -21,9 +21,11 @@ export default ( // Otherwise later on `automerge.diff` will return empty patches that result in a no-op but still mess up the selection. let hasChanges = false for (const tr of transactions) { - tr.changes.iterChanges(() => { - hasChanges = true - }) + if(!tr.changes.empty) { + tr.changes.iterChanges(() => { + hasChanges = true + }) + } } if (!hasChanges) { From 98239aee84c79851ac30e1fa07f9d2c0773d0883 Mon Sep 17 00:00:00 2001 From: Dmytro Maretskyi Date: Tue, 19 Dec 2023 12:03:55 +0100 Subject: [PATCH 5/6] Space --- src/codeMirrorToAm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/codeMirrorToAm.ts b/src/codeMirrorToAm.ts index 7ca65d5..33b8f08 100644 --- a/src/codeMirrorToAm.ts +++ b/src/codeMirrorToAm.ts @@ -21,7 +21,7 @@ export default ( // Otherwise later on `automerge.diff` will return empty patches that result in a no-op but still mess up the selection. let hasChanges = false for (const tr of transactions) { - if(!tr.changes.empty) { + if (!tr.changes.empty) { tr.changes.iterChanges(() => { hasChanges = true }) From f09b8cfcd2dfdbc88b84bac6c5fce67b92108899 Mon Sep 17 00:00:00 2001 From: Dmytro Maretskyi Date: Tue, 19 Dec 2023 12:08:39 +0100 Subject: [PATCH 6/6] Simplify code --- src/PatchSemaphore.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/PatchSemaphore.ts b/src/PatchSemaphore.ts index 4ba4b8f..17fc4fd 100644 --- a/src/PatchSemaphore.ts +++ b/src/PatchSemaphore.ts @@ -22,14 +22,12 @@ type ChangeFn = ( ) => Heads | undefined export class PatchSemaphore { - _field!: Field + _field: Field _inReconcile = false _queue: Array = [] - constructor(field?: Field) { - if (field !== undefined) { - this._field = field - } + constructor(field: Field) { + this._field = field } reconcile = (handle: IDocHandle, view: EditorView) => {