diff --git a/src/PatchSemaphore.ts b/src/PatchSemaphore.ts index 56bb9fb..17fc4fd 100644 --- a/src/PatchSemaphore.ts +++ b/src/PatchSemaphore.ts @@ -1,16 +1,17 @@ -import { next as automerge, equals } from "@automerge/automerge" -import { DocHandle } from "@automerge/automerge-repo" -import { EditorView } from "@codemirror/view" -import codeMirrorToAm from "./codeMirrorToAm" +import { next as automerge } from "@automerge/automerge" + import amToCodemirror from "./amToCodemirror" +import codeMirrorToAm from "./codeMirrorToAm" +import { type IDocHandle } from "./handle" import { - Field, + type Field, isReconcileTx, getPath, reconcileAnnotationType, updateHeads, getLastHeads, } from "./plugin" +import { type EditorView } from "@codemirror/view" type Doc = automerge.Doc type Heads = automerge.Heads @@ -29,58 +30,52 @@ export class PatchSemaphore { this._field = field } - reconcile = (handle: DocHandle, view: EditorView) => { + reconcile = (handle: IDocHandle, view: EditorView) => { if (this._inReconcile) { return - } else { - 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() - 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..b24ab0b 100644 --- a/src/amToCodemirror.ts +++ b/src/amToCodemirror.ts @@ -1,26 +1,28 @@ -import { - DelPatch, - InsertPatch, - Patch, - Prop, - PutPatch, - SpliceTextPatch, -} from "@automerge/automerge" import { ChangeSet, - ChangeSpec, - EditorSelection, - EditorState, + type ChangeSpec, + type EditorSelection, + type EditorState, } from "@codemirror/state" -import { EditorView } from "@codemirror/view" +import { type EditorView } from "@codemirror/view" + +import { + type DelPatch, + type InsertPatch, + type Patch, + type Prop, + type PutPatch, + type SpliceTextPatch, +} from "@automerge/automerge" + import { reconcileAnnotationType } from "./plugin" -export default function ( +export default ( view: EditorView, selection: EditorSelection, target: Prop[], patches: Patch[] -) { +) => { for (const patch of patches) { const changeSpec = handlePatch(patch, target, view.state) if (changeSpec != null) { @@ -38,11 +40,11 @@ export default function ( }) } -function handlePatch( +const handlePatch = ( patch: Patch, target: Prop[], state: EditorState -): ChangeSpec | null { +): ChangeSpec | null => { if (patch.action === "insert") { return handleInsert(target, patch) } else if (patch.action === "splice") { @@ -56,7 +58,10 @@ function handlePatch( } } -function handleInsert(target: Prop[], patch: InsertPatch): Array { +const handleInsert = ( + target: Prop[], + patch: InsertPatch +): Array => { const index = charPath(target, patch.path) if (index == null) { return [] @@ -65,10 +70,10 @@ function handleInsert(target: Prop[], patch: InsertPatch): Array { return [{ from: index, to: index, insert: text }] } -function handleSplice( +const handleSplice = ( target: Prop[], patch: SpliceTextPatch -): Array { +): Array => { const index = charPath(target, patch.path) if (index == null) { return [] @@ -76,7 +81,7 @@ function handleSplice( return [{ from: index, insert: patch.value }] } -function handleDel(target: Prop[], patch: DelPatch): Array { +const handleDel = (target: Prop[], patch: DelPatch): Array => { const index = charPath(target, patch.path) if (index == null) { return [] @@ -85,11 +90,11 @@ function handleDel(target: Prop[], patch: DelPatch): Array { return [{ from: index, to: index + length }] } -function handlePut( +const handlePut = ( target: Prop[], patch: PutPatch, state: EditorState -): Array { +): Array => { const index = charPath(target, [...patch.path, 0]) if (index == null) { return [] @@ -103,12 +108,18 @@ function handlePut( // 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 + if (typeof index === "number") { + return index + } return null } diff --git a/src/codeMirrorToAm.ts b/src/codeMirrorToAm.ts index cc10242..33b8f08 100644 --- a/src/codeMirrorToAm.ts +++ b/src/codeMirrorToAm.ts @@ -1,19 +1,20 @@ -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" + +import { next as am, type Heads } from "@automerge/automerge" -type Update = ( - atHeads: Heads, - change: (doc: am.Doc) => void -) => Heads | undefined +import { type IDocHandle } from "./handle" +import { type Field } from "./plugin" -export default function ( +export default ( field: Field, - update: Update, + handle: IDocHandle, transactions: Transaction[], state: EditorState -): Heads | undefined { +): Heads | undefined => { const { lastHeads, path } = state.field(field) // We don't want to call `automerge.updateAt` if there are no changes. @@ -31,7 +32,7 @@ export default function ( 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( ( @@ -46,5 +47,5 @@ export default function ( ) } }) - return newHeads + return newHeads ?? undefined } diff --git a/src/handle.ts b/src/handle.ts new file mode 100644 index 0000000..23153dd --- /dev/null +++ b/src/handle.ts @@ -0,0 +1,14 @@ +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..ad27b9b 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..d3be098 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,13 +1,25 @@ import { Annotation, - EditorState, + type EditorState, + type Extension, + Facet, StateEffect, StateField, - Transaction, - TransactionSpec, + 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 { Doc, Heads, Prop } 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 @@ -21,31 +33,37 @@ type UpdateHeads = { 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 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(), @@ -67,15 +85,54 @@ export function plugin(doc: Doc, path: Prop[]): StateField { return result }, }) + const semaphore = new PatchSemaphore(stateField) + + const viewPlugin = ViewPlugin.fromClass( + class AutomergeCodemirrorViewPlugin implements PluginValue { + private _view: EditorView + + 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 const reconcileAnnotationType = Annotation.define() -export function isReconcileTx(tr: Transaction): boolean { - return !!tr.annotation(reconcileAnnotationType) -} +export const isReconcileTx = (tr: Transaction): boolean => + !!tr.annotation(reconcileAnnotationType) -export function makeReconcile(tr: TransactionSpec) { +export const makeReconcile = (tr: TransactionSpec) => { if (tr.annotations != null) { if (tr.annotations instanceof Array) { tr.annotations = [...tr.annotations, reconcileAnnotationType.of({})] @@ -85,8 +142,8 @@ export function makeReconcile(tr: TransactionSpec) { } else { tr.annotations = [reconcileAnnotationType.of({})] } - //return { - //...tr, - //annotations: reconcileAnnotationType.of({}) - //} + // return { + // ...tr, + // annotations: reconcileAnnotationType.of({}) + // } } 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() } }, [])