Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Make the plugin self-contained #6

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 43 additions & 48 deletions src/PatchSemaphore.ts
Original file line number Diff line number Diff line change
@@ -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<T> = automerge.Doc<T>
type Heads = automerge.Heads
Expand All @@ -29,58 +30,52 @@ export class PatchSemaphore {
this._field = field
}

reconcile = (handle: DocHandle<unknown>, 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
}
}
63 changes: 37 additions & 26 deletions src/amToCodemirror.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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") {
Expand All @@ -56,7 +58,10 @@ function handlePatch(
}
}

function handleInsert(target: Prop[], patch: InsertPatch): Array<ChangeSpec> {
const handleInsert = (
target: Prop[],
patch: InsertPatch
): Array<ChangeSpec> => {
const index = charPath(target, patch.path)
if (index == null) {
return []
Expand All @@ -65,18 +70,18 @@ function handleInsert(target: Prop[], patch: InsertPatch): Array<ChangeSpec> {
return [{ from: index, to: index, insert: text }]
}

function handleSplice(
const handleSplice = (
target: Prop[],
patch: SpliceTextPatch
): Array<ChangeSpec> {
): Array<ChangeSpec> => {
const index = charPath(target, patch.path)
if (index == null) {
return []
}
return [{ from: index, insert: patch.value }]
}

function handleDel(target: Prop[], patch: DelPatch): Array<ChangeSpec> {
const handleDel = (target: Prop[], patch: DelPatch): Array<ChangeSpec> => {
const index = charPath(target, patch.path)
if (index == null) {
return []
Expand All @@ -85,11 +90,11 @@ function handleDel(target: Prop[], patch: DelPatch): Array<ChangeSpec> {
return [{ from: index, to: index + length }]
}

function handlePut(
const handlePut = (
target: Prop[],
patch: PutPatch,
state: EditorState
): Array<ChangeSpec> {
): Array<ChangeSpec> => {
const index = charPath(target, [...patch.path, 0])
if (index == null) {
return []
Expand All @@ -103,12 +108,18 @@ function handlePut(

// If the path of the patch is of the form [path, <index>] 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
}
27 changes: 14 additions & 13 deletions src/codeMirrorToAm.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) => 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.
Expand All @@ -31,7 +32,7 @@ export default function (
return undefined
}

const newHeads = update(lastHeads, (doc: am.Doc<unknown>) => {
const newHeads = handle.changeAt(lastHeads, (doc: am.Doc<unknown>) => {
for (const tr of transactions) {
tr.changes.iterChanges(
(
Expand All @@ -46,5 +47,5 @@ export default function (
)
}
})
return newHeads
return newHeads ?? undefined
}
14 changes: 14 additions & 0 deletions src/handle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type * as A from "@automerge/automerge"

export type IDocHandle<T = any> = {
docSync(): A.Doc<T> | undefined
change(callback: A.ChangeFn<T>, options?: A.ChangeOptions<T>): void
changeAt(
heads: A.Heads,
callback: A.ChangeFn<T>,
options?: A.ChangeOptions<T>
): string[] | undefined

addListener(event: "change", listener: () => void): void
removeListener(event: "change", listener: () => void): void
}
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { plugin } from "./plugin"
export { PatchSemaphore } from "./PatchSemaphore"
export { automergePlugin, type AutomergePlugin } from "./plugin"
export { type IDocHandle } from "./handle"
Loading