Skip to content

Commit 5f6c6ef

Browse files
committed
use correct expand config when creating marks
Problem: the spans created by the plugin were hard coded to use "both" or to use the default provided by Automerge. This doesn't always match what a user might expect as ProseMirror allows configuring how marks should expand using the "inclusive" attribute on mark specifications. Solution: use the "inclusive" attribute to set the mark expand configuration when creating marks in Automerge (including as part of the updateSpans call).
1 parent 98ee841 commit 5f6c6ef

4 files changed

Lines changed: 278 additions & 17 deletions

File tree

src/pmToAm.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default function (
3232
}
3333

3434
for (const step of steps) {
35-
//console.log(step)
35+
// console.log(JSON.stringify(step))
3636
const stepId = step.toJSON()["stepType"]
3737
if (stepId === "addMark") {
3838
unappliedMarks.push(step as AddMarkStep)
@@ -119,7 +119,7 @@ function replaceStep(
119119
throw new Error("Could not apply step to document")
120120
}
121121
const newSpans = pmNodeToSpans(adapter, applied)
122-
automerge.updateSpans(doc, field, newSpans)
122+
automerge.updateSpans(doc, field, newSpans, adapter.updateSpansConfig())
123123
}
124124

125125
function replaceAroundStep(
@@ -135,7 +135,7 @@ function replaceAroundStep(
135135
throw new Error("Could not apply step to document")
136136
}
137137
const newSpans = pmNodeToSpans(adapter, applied)
138-
automerge.updateSpans(doc, field, newSpans)
138+
automerge.updateSpans(doc, field, newSpans, adapter.updateSpansConfig())
139139
}
140140

141141
function applyAddMarkSteps(
@@ -254,10 +254,14 @@ function reconcileMarks(
254254
!currentMarkNames.has(markName) ||
255255
newMarks[markName] !== currentMarks[markName]
256256
) {
257+
const expand =
258+
(marks.find(m => m.type.name === markName)?.type.spec.inclusive ?? true)
259+
? "both"
260+
: "none"
257261
automerge.mark(
258262
doc,
259263
path,
260-
{ start: index, end: index + length, expand: "both" },
264+
{ start: index, end: index + length, expand },
261265
markName,
262266
newMarks[markName],
263267
)

src/schema.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
import { next as am } from "@automerge/automerge/slim"
1212
import { BlockMarker } from "./types.js"
1313

14+
type ExpandConfig = "both" | "none"
15+
1416
export interface MappedSchemaSpec {
1517
nodes: { [key: string]: MappedNodeSpec }
1618
marks?: { [key: string]: MappedMarkSpec }
@@ -179,6 +181,28 @@ by setting the automerge.unknownBlock property to true`,
179181
this.unknownBlock = unknownBlock
180182
this.schema = schema
181183
}
184+
185+
expandConfig(markName: string): ExpandConfig {
186+
const mark = this.markMappings.find(
187+
m => m.prosemirrorMark.name === markName,
188+
)
189+
return (mark?.prosemirrorMark.spec.inclusive ?? true) ? "both" : "none"
190+
}
191+
192+
updateSpansConfig(): am.UpdateSpansConfig {
193+
const config: am.UpdateSpansConfig = {
194+
defaultExpand: "both",
195+
perMarkExpand: {},
196+
}
197+
198+
const perMark: { [key: string]: ExpandConfig } = {}
199+
for (const mark of this.markMappings) {
200+
perMark[mark.automergeMarkName] =
201+
(mark.prosemirrorMark.spec.inclusive ?? true) ? "both" : "none"
202+
}
203+
config.perMarkExpand = perMark
204+
return config
205+
}
182206
}
183207

184208
function shallowClone(spec: MappedSchemaSpec): MappedSchemaSpec {
@@ -236,7 +260,7 @@ function addAmgNodeStateAttrs(nodes: { [key: string]: MappedNodeSpec }): {
236260
export function amMarksFromPmMarks(
237261
adapter: SchemaAdapter,
238262
marks: readonly Mark[],
239-
): am.MarkSet {
263+
): { [key: string]: am.MarkValue } {
240264
// eslint-disable-next-line @typescript-eslint/no-explicit-any
241265
const result: { [key: string]: any } = {}
242266
marks.forEach(mark => {

test/pmToAm.spec.ts

Lines changed: 214 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,35 @@
11
import { Fragment, Slice, Node } from "prosemirror-model"
22
import { assertSplitBlock, makeDoc } from "./utils.js"
3-
import { AddMarkStep, ReplaceStep, Step } from "prosemirror-transform"
3+
import {
4+
AddMarkStep,
5+
ReplaceAroundStep,
6+
ReplaceStep,
7+
Step,
8+
} from "prosemirror-transform"
49
import { default as pmToAm } from "../src/pmToAm.js"
510
import { next as am } from "@automerge/automerge"
611
import { assert } from "chai"
712
import { pmDocFromSpans } from "../src/traversal.js"
813
import { EditorState } from "prosemirror-state"
914
import { basicSchemaAdapter } from "../src/basicSchema.js"
15+
import { ImmutableString } from "@automerge/automerge-repo"
1016

1117
const schema = basicSchemaAdapter.schema
1218

1319
function updateDoc(
1420
amDoc: am.Doc<unknown>,
1521
pmDoc: Node,
1622
steps: Step[],
17-
): am.Patch[] {
23+
): { diff: am.Patch[]; updatedDoc: am.Doc<unknown> } {
1824
const heads = am.getHeads(amDoc)
1925
const spans = am.spans(amDoc, ["text"])
2026
const updatedDoc = am.change(amDoc, d => {
2127
pmToAm(basicSchemaAdapter, spans, steps, d, pmDoc, ["text"])
2228
})
23-
return am.diff(amDoc, heads, am.getHeads(updatedDoc))
29+
return {
30+
diff: am.diff(amDoc, heads, am.getHeads(updatedDoc)),
31+
updatedDoc,
32+
}
2433
}
2534

2635
describe("when converting a ReplaceStep to a change", () => {
@@ -32,7 +41,7 @@ describe("when converting a ReplaceStep to a change", () => {
3241
// am: 0 1 2 3 4 5 6
3342
// <doc><ol> <li> <p> i t e m ' ' 1 </p></li></ol></doc>
3443
// pm:0 0 1 2 3 4 5 6 7 8 9 10 11 12 13
35-
const diff = updateDoc(doc, editor.doc, [
44+
const { diff } = updateDoc(doc, editor.doc, [
3645
new ReplaceStep(
3746
9,
3847
9,
@@ -63,7 +72,7 @@ describe("when converting a ReplaceStep to a change", () => {
6372
{ type: "ordered-list-item", parents: [], attrs: {} },
6473
"item 1",
6574
])
66-
const diff = updateDoc(doc, editor.doc, [
75+
const { diff } = updateDoc(doc, editor.doc, [
6776
new ReplaceStep(
6877
11,
6978
11,
@@ -91,7 +100,7 @@ describe("when converting a ReplaceStep to a change", () => {
91100
{ type: "paragraph", parents: ["ordered-list-item"], attrs: {} },
92101
"item 1",
93102
])
94-
const diff = updateDoc(doc, editor.doc, [
103+
const { diff } = updateDoc(doc, editor.doc, [
95104
new ReplaceStep(
96105
9,
97106
9,
@@ -121,7 +130,7 @@ describe("when converting a ReplaceStep to a change", () => {
121130
//am: 0 1 2 3 4 5 6
122131
// <doc> <ol> <li> <p> i t e m ' ' 1 </p> </li> </ol> </doc>
123132
//pm: 0 0 1 2 3 4 5 6 7 8 9 10 11 12 13
124-
const diff = updateDoc(doc, editor.doc, [
133+
const { diff } = updateDoc(doc, editor.doc, [
125134
new ReplaceStep(
126135
11,
127136
11,
@@ -229,6 +238,204 @@ describe("when converting a ReplaceStep to a change", () => {
229238
strong: [{ start: 5, end: 6, name: "strong", value: true }],
230239
})
231240
})
241+
242+
describe("expand configuration", () => {
243+
// These tests all make use of the fact that the basicSchemaAdapter is
244+
// configured with bold spans which are inclusive (in prosemirror terms)
245+
// and link spans which are not inclusive. These tests check that the
246+
// automerge expand configuration is correctly inferred from the schema.
247+
248+
it("should use the mark expand configuration from the schema", () => {
249+
// We use ProseMirror to insert a range which has both a bold and link
250+
// mark on it, then use Automerge to splice a character following this
251+
// range and check that only the bold mark expands
252+
const { editor, doc } = makeDoc(["text"])
253+
254+
// First make an insertion which adds marks
255+
let { updatedDoc } = updateDoc(doc, editor.doc, [
256+
new ReplaceStep(
257+
1,
258+
1,
259+
new Slice(
260+
Fragment.from(
261+
editor.schema.text("1", [
262+
editor.schema.marks.strong.create(),
263+
editor.schema.marks.link.create({
264+
href: "https://example.com",
265+
}),
266+
]),
267+
),
268+
0,
269+
0,
270+
),
271+
),
272+
])
273+
274+
// Now, insert into the automerge document after the initial character
275+
updatedDoc = am.change(updatedDoc, d =>
276+
am.splice(d as am.Doc<unknown>, ["text"], 1, 0, "2"),
277+
)
278+
279+
const spans = am.spans(updatedDoc, ["text"])
280+
// Here we expect the bold span to expand, because it's configured to do so
281+
// in the schema, and the link span to not expand as it's configured to not
282+
// expand
283+
assert.deepStrictEqual(spans, [
284+
{
285+
marks: {
286+
link: JSON.stringify({ href: "https://example.com", title: null }),
287+
strong: true,
288+
},
289+
type: "text",
290+
value: "1",
291+
},
292+
{
293+
marks: {
294+
strong: true,
295+
},
296+
type: "text",
297+
value: "2",
298+
},
299+
{
300+
type: "text",
301+
value: "text",
302+
},
303+
])
304+
})
305+
})
306+
307+
it("should use the correct mark config when performing more complex ReplaceSteps", () => {
308+
// In this test we make a slightly more complex change which replaces some existing content
309+
// with new blocks. This means that in the implementation we don't use splice but instead
310+
// use updateSpans.
311+
const { editor, doc } = makeDoc(["item 1"])
312+
313+
// Use ProseMirror to replace the entire document with a paragraph
314+
// containing the text "item one" with both strong and link marks
315+
let { updatedDoc } = updateDoc(doc, editor.doc, [
316+
new ReplaceStep(
317+
0,
318+
8,
319+
new Slice(
320+
Fragment.from(
321+
schema.node("ordered_list", null, [
322+
schema.node("list_item", null, [
323+
schema.node("paragraph", null, [
324+
schema.text("item one", [
325+
editor.schema.marks.strong.create(),
326+
editor.schema.marks.link.create({
327+
href: "https://example.com",
328+
}),
329+
]),
330+
]),
331+
]),
332+
]),
333+
),
334+
0,
335+
0,
336+
),
337+
),
338+
])
339+
340+
// Now use Automerge to splice the text 'two' following the marked text we
341+
// just created
342+
updatedDoc = am.change(updatedDoc, d => {
343+
am.splice(d as any, ["text"], 9, 0, "two")
344+
})
345+
const spans = am.spans(updatedDoc, ["text"])
346+
347+
// Here bold should expand as it is inclusive (in prosemirror terms) whilst
348+
// the link span should not expand as it is not inclusive
349+
assert.deepStrictEqual(spans, [
350+
{
351+
type: "block",
352+
value: {
353+
attrs: {},
354+
isEmbed: false,
355+
parents: [],
356+
type: new ImmutableString("ordered-list-item"),
357+
},
358+
},
359+
{
360+
marks: {
361+
link: '{"href":"https://example.com","title":null}',
362+
strong: true,
363+
},
364+
type: "text",
365+
value: "item one",
366+
},
367+
{
368+
marks: {
369+
strong: true,
370+
},
371+
type: "text",
372+
value: "two",
373+
},
374+
])
375+
})
376+
377+
it("should use the correct mark config when performing ReplaceAroundSteps", () => {
378+
// In this test we create a `ReplaceAroundStep` which again fires the updateSpans
379+
// logic in the plugin
380+
const { editor, doc } = makeDoc(["item one"])
381+
382+
// Insert <paragraph>intro</paragraph> into the document and move the
383+
// existing content to occur after this paragraph. The newly inserted
384+
// paragraph has a link and strong mark on it.
385+
let { updatedDoc } = updateDoc(doc, editor.doc, [
386+
new ReplaceAroundStep(
387+
0,
388+
10,
389+
1,
390+
9,
391+
new Slice(
392+
Fragment.from([
393+
schema.node("paragraph", null, [
394+
schema.text("intro", [
395+
editor.schema.marks.strong.create(),
396+
editor.schema.marks.link.create({
397+
href: "https://example.com",
398+
}),
399+
]),
400+
]),
401+
]),
402+
0,
403+
0,
404+
),
405+
6,
406+
),
407+
])
408+
409+
// Now use automerge to insert the text "middle" in the paragraph we just inserted
410+
updatedDoc = am.change(updatedDoc, d => {
411+
am.splice(d as any, ["text"], 5, 0, "middle")
412+
})
413+
const spans = am.spans(updatedDoc, ["text"])
414+
415+
// Here bold should expand as it is inclusive (in prosemirror terms) whilst
416+
// the link span should not expand as it is not inclusive
417+
assert.deepStrictEqual(spans, [
418+
{
419+
marks: {
420+
link: '{"href":"https://example.com","title":null}',
421+
strong: true,
422+
},
423+
type: "text",
424+
value: "intro",
425+
},
426+
{
427+
marks: {
428+
strong: true,
429+
},
430+
type: "text",
431+
value: "middle",
432+
},
433+
{
434+
type: "text",
435+
value: "item one",
436+
},
437+
])
438+
})
232439
})
233440

234441
describe("when converting addMark steps to a change", () => {

0 commit comments

Comments
 (0)