diff --git a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx index 7373487f19..2cb7e11d9f 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx +++ b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx @@ -5,6 +5,9 @@ import "./style.css"; import type { GalleryEditor } from "./gallerySchema"; import { createYVersionView, + type ExperimentalVersionDiffs, + type VersionDiffFixes, + versionDiffFixesIncluded, SuggestionsExtension, withCollaboration, } from "@blocknote/core/y"; @@ -22,10 +25,64 @@ import { createVersionMerge, docFromBlocks, } from "./scenarioDocs"; -import { scenarios, SuggestionScenario } from "./scenarios"; +import { Feedback, scenarios, SuggestionScenario } from "./scenarios"; type Mode = "suggestions" | "versioning"; +// The experimental version diff fixes the Diff can show, none by default as +// in the editor. Kept in the URL, so a link opens the same view. +const FIXES: { value: VersionDiffFixes | undefined; label: string }[] = [ + { value: undefined, label: "Default" }, + { value: "implicitDeleteAttribution", label: "Implicit delete attribution" }, + { + value: "implicitDeleteAttributionAndRecreatedBlocks", + label: "+ re-created blocks", + }, +]; +const ALL_FIXES: ExperimentalVersionDiffs = { + versionDiffFixes: FIXES[FIXES.length - 1].value, +}; + +function readFixes(): ExperimentalVersionDiffs { + const value = new URLSearchParams(window.location.search).get( + "versionDiffFixes", + ); + return { + versionDiffFixes: FIXES.find((fixes) => fixes.value === value)?.value, + }; +} + +function writeFixes(experimental: ExperimentalVersionDiffs) { + const url = new URL(window.location.href); + if (experimental.versionDiffFixes) { + url.searchParams.set("versionDiffFixes", experimental.versionDiffFixes); + } else { + url.searchParams.delete("versionDiffFixes"); + } + window.history.replaceState(null, "", url); +} + +// A note with `when` describes the Diff with or without those fixes, so it +// only shows in Versioning mode when they match. +function applies( + f: Feedback, + mode: Mode, + experimental: ExperimentalVersionDiffs, +): boolean { + if (!f.when) { + return true; + } + const included = experimental.versionDiffFixes + ? versionDiffFixesIncluded[experimental.versionDiffFixes] + : []; + return ( + mode === "versioning" && + Object.entries(f.when).every( + ([fix, on]) => included.some((each) => each === fix) === on, + ) + ); +} + function makeAwareness(doc: Y.Doc, name: string, color: string): Awareness { const awareness = new Awareness(doc); awareness.setLocalStateField("user", { name, color }); @@ -333,7 +390,13 @@ function versioningUsers(scenario: SuggestionScenario): VersioningUser[] { * editing any user re-merges (and re-diffs); editing Version 1 resets every user * back to a fresh clone (via the `nonce` remount). */ -function VersioningView({ scenario }: { scenario: SuggestionScenario }) { +function VersioningView({ + scenario, + experimental, +}: { + scenario: SuggestionScenario; + experimental: ExperimentalVersionDiffs; +}) { const [setup] = useState(() => { const beforeDoc = docFromBlocks(scenario.initial); return { @@ -375,6 +438,7 @@ function VersioningView({ scenario }: { scenario: SuggestionScenario }) { beforeDoc={setup.beforeDoc} users={setup.users} applyInitial={nonce === 0} + experimental={experimental} /> ); @@ -390,10 +454,12 @@ function VersionMerge({ beforeDoc, users, applyInitial, + experimental, }: { beforeDoc: Y.Doc; users: VersioningUser[]; applyInitial: boolean; + experimental: ExperimentalVersionDiffs; }) { const [setup] = useState(() => { // Records which user authored each merged change, so the Diff can color @@ -415,6 +481,7 @@ function VersionMerge({ fragment: setup.afterDoc.get("doc"), provider: { awareness: setup.diffAwareness }, user: USER_A, + experimental, }, }), ); @@ -531,8 +598,10 @@ const SEVERITY = { // The most-severe note across a scenario's feedback — a known crash counts as // high — or null if it has none. Drives the sidebar indicator. -function topSeverity(s: SuggestionScenario): "high" | "low" | "info" | null { - const fb = s.feedback ?? []; +function topSeverity( + s: SuggestionScenario, + fb: Feedback[], +): "high" | "low" | "info" | null { if (s.knownCrash || fb.some((f) => f.severity === "high")) { return "high"; } @@ -542,15 +611,38 @@ function topSeverity(s: SuggestionScenario): "high" | "low" | "info" | null { return fb.some((f) => f.severity === "info") ? "info" : null; } -function severityBadge(s: SuggestionScenario): string { - const sev = topSeverity(s); - return sev ? SEVERITY[sev].icon + " " : ""; +function notesFor( + s: SuggestionScenario, + mode: Mode, + experimental: ExperimentalVersionDiffs, +): Feedback[] { + return (s.feedback ?? []).filter((f) => applies(f, mode, experimental)); +} + +// The severity without fixes (the default) and, for a scenario the fixes +// affect, in parentheses the severity with all of them (green: no issue left). +// The chosen fixes don't change it. +function severityBadge(s: SuggestionScenario, mode: Mode): string { + const sev = topSeverity(s, notesFor(s, mode, {})); + let badge = sev ? SEVERITY[sev].icon + " " : ""; + if (mode === "versioning" && s.feedback?.some((f) => f.when)) { + const best = topSeverity(s, notesFor(s, mode, ALL_FIXES)); + badge += `(${best === "high" || best === "low" ? SEVERITY[best].icon : "🟢"}) `; + } + return badge; } export default function App() { const [selectedId, setSelectedId] = useState(scenarios[0].id); const [mode, setMode] = useState("versioning"); + const [experimental, setExperimental] = useState(readFixes); const selected = scenarios.find((s) => s.id === selectedId)!; + const feedback = notesFor(selected, mode, experimental); + + function choose(next: ExperimentalVersionDiffs) { + writeFixes(next); + setExperimental(next); + } const categories = [...new Set(scenarios.map((s) => s.category))]; @@ -573,7 +665,7 @@ export default function App() { } onClick={() => setSelectedId(s.id)} > - {severityBadge(s)} + {severityBadge(s, mode)} {s.kind === "concurrent" ? "👥 " : ""} {s.title} @@ -605,14 +697,31 @@ export default function App() { - {selected.feedback && selected.feedback.length > 0 && ( + {mode === "versioning" && ( +
+ Experimental fixes: + {FIXES.map(({ value, label }) => ( + + ))} +
+ )} + + {feedback.length > 0 && (
- {selected.feedback.some((f) => f.severity !== "info") + {feedback.some((f) => f.severity !== "info") ? "Known issues" : "Notes"}
- {[...selected.feedback] + {[...feedback] .sort( (a, b) => SEVERITY[a.severity].rank - SEVERITY[b.severity].rank, ) @@ -630,9 +739,11 @@ export default function App() {
)} - + {mode === "versioning" ? ( - + ) : ( )} diff --git a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts index 123b72f860..79f54da6f5 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts @@ -1,3 +1,4 @@ +import type { VersionDiffFix } from "@blocknote/core/y"; import { testDocumentBlocks } from "@shared/testDocumentBlocks.js"; import type { GalleryEditor, GalleryPartialBlock } from "./gallerySchema"; @@ -26,6 +27,8 @@ import type { GalleryEditor, GalleryPartialBlock } from "./gallerySchema"; export type Feedback = { severity: "info" | "low" | "high"; note: string; + /** Show the note only in Versioning mode, with or without these fixes. */ + when?: Partial>; }; export type SingleScenario = { @@ -114,6 +117,26 @@ function posBeforeText(editor: GalleryEditor, text: string): number { return pos; } +// Versioning notes for a block copy: a type change or a move stores a copy of +// the block, which the `recreatedBlocks` fix shows once. +const typeChangeNote: Feedback = { + severity: "info", + when: { recreatedBlocks: true }, + note: "Versioning shows the type change as a formatting change.", +}; +const moveNote: Feedback = { + severity: "info", + when: { recreatedBlocks: true }, + note: "Versioning shows the block as moved: at its new place, and struck through at its old place unless it was only indented or outdented.", +}; +function lostEditNote(user: string): Feedback { + return { + severity: "info", + when: { recreatedBlocks: true }, + note: `Versioning shows only the type change: the result doesn't have ${user}'s edit, so the diff doesn't show it.`, + }; +} + export const scenarios: SuggestionScenario[] = [ { kind: "single", @@ -238,12 +261,14 @@ export const scenarios: SuggestionScenario[] = [ kind: "single", id: "nest-bullet-existing", feedback: [ + moveNote, { severity: "low", note: "Nested bullets all render as • instead of •/◦/▪ — the suggestion-mark wrappers (display: contents) break the depth-detecting CSS chains. Fix: compute each bullet's nesting level in JS and expose it as data-bullet-level, then pick the glyph with a wrapper-independent attribute selector (as numbered lists do with data-index).", }, { severity: "high", + when: { recreatedBlocks: false }, note: "Indenting re-creates Parent and Child as new blocks (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows both as deleted and inserted again, all credited to whoever indented.", }, ], @@ -410,8 +435,10 @@ export const scenarios: SuggestionScenario[] = [ kind: "single", id: "type-list-to-paragraph", feedback: [ + typeChangeNote, { severity: "high", + when: { recreatedBlocks: false }, note: "Changing the type re-creates the block as a new one (with the schema fix, a block's type can't change in place). The diff therefore shows it as deleted and inserted again, all credited to whoever changed the type.", }, ], @@ -432,8 +459,10 @@ export const scenarios: SuggestionScenario[] = [ kind: "single", id: "type-paragraph-to-heading", feedback: [ + typeChangeNote, { severity: "high", + when: { recreatedBlocks: false }, note: "Changing the type re-creates the block as a new one (with the schema fix, a block's type can't change in place). The diff therefore shows it as deleted and inserted again, all credited to whoever changed the type.", }, ], @@ -596,8 +625,10 @@ export const scenarios: SuggestionScenario[] = [ kind: "single", id: "move-paragraph-up", feedback: [ + moveNote, { severity: "high", + when: { recreatedBlocks: false }, note: "Moving re-creates the block as a new one at its new place. The diff therefore shows it as deleted at its old place and inserted at its new one, all credited to the mover.", }, ], @@ -617,8 +648,10 @@ export const scenarios: SuggestionScenario[] = [ kind: "single", id: "move-paragraph-with-children", feedback: [ + moveNote, { severity: "high", + when: { recreatedBlocks: false }, note: "Moving re-creates the block, with its child, as a new one at its new place. The diff therefore shows it as deleted at its old place and inserted at its new one, all credited to the mover.", }, ], @@ -643,8 +676,10 @@ export const scenarios: SuggestionScenario[] = [ kind: "single", id: "nesting-indent", feedback: [ + moveNote, { severity: "high", + when: { recreatedBlocks: false }, note: "Indenting re-creates N0 and N1 as new blocks (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows both as deleted and inserted again, all credited to whoever indented.", }, ], @@ -666,8 +701,10 @@ export const scenarios: SuggestionScenario[] = [ kind: "single", id: "nesting-unindent", feedback: [ + moveNote, { severity: "high", + when: { recreatedBlocks: false }, note: "Outdenting re-creates N0 and N1 as new blocks (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows N0 and N1 as deleted and inserted again, all credited to whoever outdented.", }, ], @@ -691,8 +728,10 @@ export const scenarios: SuggestionScenario[] = [ kind: "single", id: "nesting-change-parent-type", feedback: [ + typeChangeNote, { severity: "high", + when: { recreatedBlocks: false }, note: "Changing the type re-creates N0, children included, as a new block (with the schema fix, a block's type can't change in place). The diff therefore shows it as deleted and inserted again, all credited to whoever changed the type.", }, ], @@ -1044,6 +1083,12 @@ export const scenarios: SuggestionScenario[] = [ feedback: [ { severity: "high", + when: { recreatedBlocks: true }, + note: "Versioning shows N0 unchanged and N2 as moved, but N1's two copies as inserted by A and by B.", + }, + { + severity: "high", + when: { recreatedBlocks: false }, note: "The diff also shows N0 and N2 as deleted and inserted again: indenting re-creates blocks (see Indent a block).", }, { @@ -1072,12 +1117,18 @@ export const scenarios: SuggestionScenario[] = [ kind: "concurrent", id: "concurrent-indent-vs-edit", feedback: [ + { + severity: "info", + when: { recreatedBlocks: true }, + note: "Versioning shows N0 unchanged and N1 as moved. The result doesn't have B's edit, so the diff doesn't show it.", + }, { severity: "low", note: "B's edit is lost: A's indent re-creates N1 as a new block, which doesn't have B's concurrent edit.", }, { severity: "high", + when: { recreatedBlocks: false }, note: "The diff also shows N0 and N1 as deleted and inserted again, all credited to A: indenting re-creates blocks (see Indent a block).", }, ], @@ -1103,6 +1154,12 @@ export const scenarios: SuggestionScenario[] = [ feedback: [ { severity: "high", + when: { recreatedBlocks: true }, + note: "Versioning shows R unchanged and B1–B3 as moved, but Q's two copies as inserted by B and by A.", + }, + { + severity: "high", + when: { recreatedBlocks: false }, note: "The diff also shows R and B1–B3 as deleted and inserted again: indenting and moving re-create blocks.", }, { @@ -1133,8 +1190,10 @@ export const scenarios: SuggestionScenario[] = [ kind: "concurrent", id: "concurrent-parent-type-vs-child-edit", feedback: [ + lostEditNote("B"), { severity: "high", + when: { recreatedBlocks: false }, note: "The diff also shows Parent and Child as deleted and inserted again, all credited to A: changing the type re-creates blocks (see Change type of a parent block).", }, { @@ -1172,7 +1231,13 @@ export const scenarios: SuggestionScenario[] = [ }, { severity: "high", - note: "Versioning shows X as deleted by B, though B only moved it. To be fixed by #3166.", + when: { implicitDeleteAttribution: false }, + note: "Versioning shows X as deleted by B, though B only moved it.", + }, + { + severity: "info", + when: { implicitDeleteAttribution: true }, + note: "Versioning shows X deleted without an author: B only moved it, and A never saw it there.", }, ], title: "Move a block into a block that is deleted", @@ -1345,10 +1410,17 @@ export const scenarios: SuggestionScenario[] = [ kind: "concurrent", id: "concurrent-text-vs-heading", feedback: [ + lostEditNote("A"), { severity: "high", + when: { recreatedBlocks: false }, note: "The diff shows the block as deleted and inserted again, all credited to B: changing the type re-creates it.", }, + { + severity: "info", + when: { implicitDeleteAttribution: true }, + note: "Versioning also shows the letters A deleted inside the replaced paragraph, as deleted by A and B.", + }, { severity: "low", note: "User A's content edit is lost — it's overwritten by B's simultaneous block-type change. This is a consequence of the schema fix.", @@ -1721,8 +1793,14 @@ export const scenarios: SuggestionScenario[] = [ kind: "single", id: "remove-1-column", feedback: [ + { + severity: "info", + when: { recreatedBlocks: true }, + note: "Versioning shows Left column as moved out of the columns, and the columns as deleted.", + }, { severity: "high", + when: { recreatedBlocks: false }, note: "Removing the column re-creates Left column as a new block outside the columns. The diff therefore shows it as inserted, as if it were new, credited to whoever removed the column.", }, ], diff --git a/examples/07-collaboration/14-suggestion-gallery/src/style.css b/examples/07-collaboration/14-suggestion-gallery/src/style.css index 034b54a5fd..4898bdec6c 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/style.css +++ b/examples/07-collaboration/14-suggestion-gallery/src/style.css @@ -105,6 +105,23 @@ font-weight: 600; } +.bn-gallery-experiments { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 16px; + margin-bottom: 16px; + font-size: 13px; + color: var(--gallery-muted); +} + +.bn-gallery-experiments label { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + .bn-gallery-title { font-size: 20px; margin: 0 0 4px; diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 401ed05a03..0a6612fcc1 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -1122,15 +1122,30 @@ content span instead, using the `--user-color-*` properties that cascade down from the wrapper. The `.bn-root ins, .bn-root del` rules above still style serialized/static output, where the wrapper is a real, painted box. */ -.bn-suggestion-mark { +/* A moved block's text keeps its authors, so it has no mark of its own: its + text element gets the inserted-text highlight instead. It shrinks to the text + (a flex item), or breaks per line in code blocks (an inline ). */ +.bn-suggestion-mark, +ins[data-moved] > .bn-suggestion-node .bn-inline-content { --bn-suggestion-token-color: currentColor; background-color: color-mix(in srgb, var(--user-color-light) 50%, white); color: var(--user-color-dark); border-radius: 4px; } +ins[data-moved] > .bn-suggestion-node .bn-inline-content { + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + .dark.bn-root .bn-suggestion-mark, -.bn-block-content[data-content-type="codeBlock"] > pre .bn-suggestion-mark { +.dark.bn-root ins[data-moved] > .bn-suggestion-node .bn-inline-content, +.bn-block-content[data-content-type="codeBlock"] > pre .bn-suggestion-mark, +ins[data-moved] + > .bn-suggestion-node + .bn-block-content[data-content-type="codeBlock"] + > pre + .bn-inline-content { background-color: color-mix( in srgb, var(--user-color-light) 25%, diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index 9bc6ba0de7..1e6601c828 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -399,6 +399,8 @@ export const ar: Dictionary = { deleted_by: (users: string) => `حُذف بواسطة: ${users}`, changed: "مُعدَّل", changed_by: (users: string) => `عُدّل بواسطة: ${users}`, + moved: "تم النقل", + moved_by: (users: string) => `نقله: ${users}`, formatting_change_by: (formats: string, users: string) => `تغيير التنسيق (${formats}) بواسطة: ${users}`, }, diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index 537bc5acd1..38d8d38bea 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -433,6 +433,8 @@ export const de: Dictionary = { deleted_by: (users: string) => `Gelöscht von: ${users}`, changed: "Geändert", changed_by: (users: string) => `Geändert von: ${users}`, + moved: "Verschoben", + moved_by: (users: string) => `Verschoben von: ${users}`, formatting_change_by: (formats: string, users: string) => `Formatierungsänderung (${formats}) von: ${users}`, }, diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index c87731fcf1..e73c2d53a4 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -414,6 +414,8 @@ export const en = { deleted_by: (users: string) => `Deleted by: ${users}`, changed: "Changed", changed_by: (users: string) => `Changed by: ${users}`, + moved: "Moved", + moved_by: (users: string) => `Moved by: ${users}`, formatting_change_by: (formats: string, users: string) => `Formatting change (${formats}) by: ${users}`, }, diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index 96fa83e013..094f6b9bd3 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -412,6 +412,8 @@ export const es: Dictionary = { deleted_by: (users: string) => `Eliminado por: ${users}`, changed: "Modificado", changed_by: (users: string) => `Modificado por: ${users}`, + moved: "Movido", + moved_by: (users: string) => `Movido por: ${users}`, formatting_change_by: (formats: string, users: string) => `Cambio de formato (${formats}) por: ${users}`, }, diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts index 35b907ac13..7c3d043249 100644 --- a/packages/core/src/i18n/locales/fa.ts +++ b/packages/core/src/i18n/locales/fa.ts @@ -383,6 +383,8 @@ export const fa = { deleted_by: (users: string) => `حذف‌شده توسط: ${users}`, changed: "تغییر\u200cیافته", changed_by: (users: string) => `تغییر\u200cیافته توسط: ${users}`, + moved: "منتقل شد", + moved_by: (users: string) => `منتقل شده توسط: ${users}`, formatting_change_by: (formats: string, users: string) => `تغییر قالب‌بندی (${formats}) توسط: ${users}`, }, diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index 653fd9581b..b184550d71 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -460,6 +460,8 @@ export const fr: Dictionary = { deleted_by: (users: string) => `Supprimé par : ${users}`, changed: "Modifié", changed_by: (users: string) => `Modifié par : ${users}`, + moved: "Déplacé", + moved_by: (users: string) => `Déplacé par : ${users}`, formatting_change_by: (formats: string, users: string) => `Modification de mise en forme (${formats}) par : ${users}`, }, diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts index 0e51f20987..46e0792ead 100644 --- a/packages/core/src/i18n/locales/he.ts +++ b/packages/core/src/i18n/locales/he.ts @@ -414,6 +414,8 @@ export const he: Dictionary = { deleted_by: (users: string) => `נמחק על ידי: ${users}`, changed: "שונה", changed_by: (users: string) => `שונה על ידי: ${users}`, + moved: "הועבר", + moved_by: (users: string) => `הועבר על ידי: ${users}`, formatting_change_by: (formats: string, users: string) => `שינוי עיצוב (${formats}) על ידי: ${users}`, }, diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index e7e1dc1425..27b64b09e3 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -428,6 +428,8 @@ export const hr: Dictionary = { deleted_by: (users: string) => `Izbrisao/la: ${users}`, changed: "Promijenjeno", changed_by: (users: string) => `Promijenio/la: ${users}`, + moved: "Premješteno", + moved_by: (users: string) => `Premjestio: ${users}`, formatting_change_by: (formats: string, users: string) => `Promjena oblikovanja (${formats}) od: ${users}`, }, diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index 55d3a860aa..416507a257 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -428,6 +428,8 @@ export const is: Dictionary = { deleted_by: (users: string) => `Eytt af: ${users}`, changed: "Breytt", changed_by: (users: string) => `Breytt af: ${users}`, + moved: "Fært", + moved_by: (users: string) => `Fært af: ${users}`, formatting_change_by: (formats: string, users: string) => `Sniðbreyting (${formats}) af: ${users}`, }, diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts index 005ceb6f7a..ceb9e98283 100644 --- a/packages/core/src/i18n/locales/it.ts +++ b/packages/core/src/i18n/locales/it.ts @@ -436,6 +436,8 @@ export const it: Dictionary = { deleted_by: (users: string) => `Eliminato da: ${users}`, changed: "Modificato", changed_by: (users: string) => `Modificato da: ${users}`, + moved: "Spostato", + moved_by: (users: string) => `Spostato da: ${users}`, formatting_change_by: (formats: string, users: string) => `Modifica formattazione (${formats}) da: ${users}`, }, diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index 93aa440e43..4dee0de0a4 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -454,6 +454,8 @@ export const ja: Dictionary = { deleted_by: (users: string) => `削除者: ${users}`, changed: "変更済み", changed_by: (users: string) => `変更者: ${users}`, + moved: "移動済み", + moved_by: (users: string) => `移動者:${users}`, formatting_change_by: (formats: string, users: string) => `書式の変更 (${formats}) 変更者: ${users}`, }, diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index 85c4addb75..cb98342c44 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -427,6 +427,8 @@ export const ko: Dictionary = { deleted_by: (users: string) => `삭제한 사람: ${users}`, changed: "변경됨", changed_by: (users: string) => `변경한 사람: ${users}`, + moved: "이동됨", + moved_by: (users: string) => `이동한 사용자: ${users}`, formatting_change_by: (formats: string, users: string) => `서식 변경 (${formats}) 변경한 사람: ${users}`, }, diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index 15a65c98b2..daac1b45fb 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -415,6 +415,8 @@ export const nl: Dictionary = { deleted_by: (users: string) => `Verwijderd door: ${users}`, changed: "Gewijzigd", changed_by: (users: string) => `Gewijzigd door: ${users}`, + moved: "Verplaatst", + moved_by: (users: string) => `Verplaatst door: ${users}`, formatting_change_by: (formats: string, users: string) => `Opmaakwijziging (${formats}) door: ${users}`, }, diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts index d67066c260..d31a2206d8 100644 --- a/packages/core/src/i18n/locales/no.ts +++ b/packages/core/src/i18n/locales/no.ts @@ -432,6 +432,8 @@ export const no: Dictionary = { deleted_by: (users: string) => `Slettet av: ${users}`, changed: "Endret", changed_by: (users: string) => `Endret av: ${users}`, + moved: "Flyttet", + moved_by: (users: string) => `Flyttet av: ${users}`, formatting_change_by: (formats: string, users: string) => `Formateringsendring (${formats}) av: ${users}`, }, diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index f9da477084..5c348fbb26 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -405,6 +405,8 @@ export const pl: Dictionary = { deleted_by: (users: string) => `Usunięte przez: ${users}`, changed: "Zmieniono", changed_by: (users: string) => `Zmienione przez: ${users}`, + moved: "Przeniesiono", + moved_by: (users: string) => `Przeniesione przez: ${users}`, formatting_change_by: (formats: string, users: string) => `Zmiana formatowania (${formats}) przez: ${users}`, }, diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index cb5d10361e..46367cfc75 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -407,6 +407,8 @@ export const pt: Dictionary = { deleted_by: (users: string) => `Excluído por: ${users}`, changed: "Alterado", changed_by: (users: string) => `Alterado por: ${users}`, + moved: "Movido", + moved_by: (users: string) => `Movido por: ${users}`, formatting_change_by: (formats: string, users: string) => `Alteração de formatação (${formats}) por: ${users}`, }, diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index 1e942be3c8..15534a94cd 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -458,6 +458,8 @@ export const ru: Dictionary = { deleted_by: (users: string) => `Удалено: ${users}`, changed: "Изменено", changed_by: (users: string) => `Изменено: ${users}`, + moved: "Перемещено", + moved_by: (users: string) => `Перемещено пользователем: ${users}`, formatting_change_by: (formats: string, users: string) => `Изменение форматирования (${formats}): ${users}`, }, diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts index d3b3e3ce03..b65eb1da4f 100644 --- a/packages/core/src/i18n/locales/sk.ts +++ b/packages/core/src/i18n/locales/sk.ts @@ -412,6 +412,8 @@ export const sk = { deleted_by: (users: string) => `Odstránil: ${users}`, changed: "Zmenené", changed_by: (users: string) => `Zmenil: ${users}`, + moved: "Presunuté", + moved_by: (users: string) => `Presunul: ${users}`, formatting_change_by: (formats: string, users: string) => `Zmena formátovania (${formats}) od: ${users}`, }, diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts index b71784b1fd..93dbb930a2 100644 --- a/packages/core/src/i18n/locales/uk.ts +++ b/packages/core/src/i18n/locales/uk.ts @@ -438,6 +438,8 @@ export const uk: Dictionary = { deleted_by: (users: string) => `Видалено користувачем: ${users}`, changed: "Змінено", changed_by: (users: string) => `Змінено користувачем: ${users}`, + moved: "Переміщено", + moved_by: (users: string) => `Переміщено користувачем: ${users}`, formatting_change_by: (formats: string, users: string) => `Зміна форматування (${formats}) користувачем: ${users}`, }, diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts index f057f88ca9..26146c843e 100644 --- a/packages/core/src/i18n/locales/uz.ts +++ b/packages/core/src/i18n/locales/uz.ts @@ -448,6 +448,8 @@ export const uz: Dictionary = { deleted_by: (users: string) => `O'chirgan: ${users}`, changed: "O'zgartirildi", changed_by: (users: string) => `O'zgartirgan: ${users}`, + moved: "Ko'chirildi", + moved_by: (users: string) => `Ko'chirgan: ${users}`, formatting_change_by: (formats: string, users: string) => `Formatlash o'zgarishi (${formats}), o'zgartirgan: ${users}`, }, diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index 801ce1bdf6..218632b2fc 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -413,6 +413,8 @@ export const vi: Dictionary = { deleted_by: (users: string) => `Được xóa bởi: ${users}`, changed: "Đã thay đổi", changed_by: (users: string) => `Được thay đổi bởi: ${users}`, + moved: "Đã di chuyển", + moved_by: (users: string) => `Được di chuyển bởi: ${users}`, formatting_change_by: (formats: string, users: string) => `Thay đổi định dạng (${formats}) bởi: ${users}`, }, diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts index 7f3626c55c..aa7946da15 100644 --- a/packages/core/src/i18n/locales/zh-tw.ts +++ b/packages/core/src/i18n/locales/zh-tw.ts @@ -455,6 +455,8 @@ export const zhTW: Dictionary = { deleted_by: (users: string) => `刪除者:${users}`, changed: "已變更", changed_by: (users: string) => `變更者:${users}`, + moved: "已移動", + moved_by: (users: string) => `移動者:${users}`, formatting_change_by: (formats: string, users: string) => `格式變更(${formats}),變更者:${users}`, }, diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index e485eb943b..4416142c46 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -455,6 +455,8 @@ export const zh: Dictionary = { deleted_by: (users: string) => `删除者:${users}`, changed: "已更改", changed_by: (users: string) => `更改者:${users}`, + moved: "已移动", + moved_by: (users: string) => `移动者:${users}`, formatting_change_by: (formats: string, users: string) => `格式更改(${formats}),更改者:${users}`, }, diff --git a/packages/core/src/y/extensions/AttributionExtension.ts b/packages/core/src/y/extensions/AttributionExtension.ts index 4f47e96a8b..4c1a3a5b39 100644 --- a/packages/core/src/y/extensions/AttributionExtension.ts +++ b/packages/core/src/y/extensions/AttributionExtension.ts @@ -80,7 +80,8 @@ export type AttributionChange = | { // `change`: a preview (e.g. a diagram) can't show what was inserted or // deleted in its hidden source, only that it changed. - modificationType: "insert" | "delete" | "change"; + // `move`: a moved block at its new place, or its struck-through original. + modificationType: "insert" | "delete" | "change" | "move"; format?: never; attributes?: never; } @@ -239,7 +240,8 @@ export const AttributionExtension = createExtension( const attributionIdentity = (wrapper: HTMLElement) => { const ids = parseUserIds(wrapper.dataset["userIds"]); const format = parseFormatKeys(wrapper.dataset["format"]); - return `${wrapper.tagName}:${wrapper.dataset["attributes"] ?? ""}:${format.join(",")}:${ids.join(",")}`; + const moved = wrapper.dataset["moved"] !== undefined ? "moved" : ""; + return `${wrapper.tagName}${moved}:${wrapper.dataset["attributes"] ?? ""}:${format.join(",")}:${ids.join(",")}`; }; // Build the tooltip state from a wrapper's `data-*` attributes. A @@ -249,7 +251,9 @@ export const AttributionExtension = createExtension( preview?: Element, ): AttributionTooltipState => { const markChange: AttributionChange & { - modificationType: AttributionMarkStyleInfo["modificationType"]; + modificationType: + | AttributionMarkStyleInfo["modificationType"] + | "move"; } = anchor.dataset["attributes"] !== undefined ? { @@ -263,9 +267,20 @@ export const AttributionExtension = createExtension( } : { modificationType: - anchor.tagName === "INS" ? "insert" : "delete", + anchor.dataset["moved"] !== undefined + ? "move" + : anchor.tagName === "INS" + ? "insert" + : "delete", }; - const { modificationType } = markChange; + // For styling, a moved block is inserted at its new place and + // deleted at its old one. + const modificationType = + markChange.modificationType === "move" + ? anchor.tagName === "INS" + ? "insert" + : "delete" + : markChange.modificationType; const change: AttributionChange = preview ? { modificationType: "change" } : markChange; diff --git a/packages/core/src/y/extensions/Versioning.ts b/packages/core/src/y/extensions/Versioning.ts index 3f14359cec..5f8e1a8f1c 100644 --- a/packages/core/src/y/extensions/Versioning.ts +++ b/packages/core/src/y/extensions/Versioning.ts @@ -8,6 +8,7 @@ import type { StyleSchema, } from "../../schema/index.js"; import { ForkYDocExtension } from "./ForkYDoc.js"; +import { CollaborationExtension } from "./index.js"; import { serializeFragment } from "./snapshotCodec.js"; import { showSnapshotPreview } from "./snapshotPreview.js"; @@ -59,6 +60,7 @@ export function createYVersionView< content, comparison?.content, comparison?.attributions, + editor.getExtension(CollaborationExtension)?.experimental, ); }, close() { diff --git a/packages/core/src/y/extensions/YAttributionMarks.ts b/packages/core/src/y/extensions/YAttributionMarks.ts index 6cb849cac2..16511a9e27 100644 --- a/packages/core/src/y/extensions/YAttributionMarks.ts +++ b/packages/core/src/y/extensions/YAttributionMarks.ts @@ -149,6 +149,9 @@ const createAttributionMarkView = userIds: JSON.stringify(userIds), inline: String(inline), }); + if (mark.attrs["moved"]) { + dom.dataset["moved"] = ""; + } if (type === "attrs") { dom.dataset["type"] = "attributes"; dom.dataset["attributes"] = JSON.stringify(changes); @@ -263,6 +266,8 @@ export const YAttributedInsertion = Mark.create<{ addAttributes() { return { userIds: { default: null }, + // A moved block, at its new place (see `showSnapshotPreview`). + moved: { default: null }, }; }, addMarkView() { @@ -290,6 +295,8 @@ export const YAttributedDeletion = Mark.create<{ addAttributes() { return { userIds: { default: null }, + // The original of a moved block (see `showSnapshotPreview`). + moved: { default: null }, }; }, addMarkView() { diff --git a/packages/core/src/y/extensions/YSync.ts b/packages/core/src/y/extensions/YSync.ts index 9e2f13d06b..3fac5bb557 100644 --- a/packages/core/src/y/extensions/YSync.ts +++ b/packages/core/src/y/extensions/YSync.ts @@ -68,16 +68,25 @@ export const mapAttributionToMark = ( insertAt?: number; deleteAt?: number; formatAt?: number; + /** Set on a moved block and its original, see `showSnapshotPreview`. */ + moved?: boolean; }, ): Record => { const out: Record = { ...format }; if (attribution.insert) { - out["y-attributed-insert"] = { userIds: attribution.insert }; + // Every mark attribute is listed, so the mark equals this value. + out["y-attributed-insert"] = { + userIds: attribution.insert, + moved: attribution.moved === true ? true : null, + }; } if (attribution.delete) { - out["y-attributed-delete"] = { userIds: attribution.delete }; + out["y-attributed-delete"] = { + userIds: attribution.delete, + moved: attribution.moved === true ? true : null, + }; } if (attribution.format) { diff --git a/packages/core/src/y/extensions/__snapshots__/versionDiffFlags.test.ts.snap b/packages/core/src/y/extensions/__snapshots__/versionDiffFlags.test.ts.snap new file mode 100644 index 0000000000..ad8547f567 --- /dev/null +++ b/packages/core/src/y/extensions/__snapshots__/versionDiffFlags.test.ts.snap @@ -0,0 +1,107 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`experimental diffs { versionDiffFixes: 'implicitDeleteAttribution' } > indent 1`] = ` +[ + "insert block X: bob", + "insert X: bob", + "delete block X: bob", +] +`; + +exports[`experimental diffs { versionDiffFixes: 'implicitDeleteAttribution' } > move into a concurrently deleted block 1`] = ` +[ + "delete block Parent: alice", + "delete block X: ", +] +`; + +exports[`experimental diffs { versionDiffFixes: 'implicitDeleteAttribution' } > move up 1`] = ` +[ + "insert block Next: bob", + "insert Next: bob", + "delete block Next: bob", +] +`; + +exports[`experimental diffs { versionDiffFixes: 'implicitDeleteAttribution' } > text edit 1`] = ` +[ + "insert !: bob", +] +`; + +exports[`experimental diffs { versionDiffFixes: 'implicitDeleteAttribution' } > type change 1`] = ` +[ + "delete block X: bob", + "insert block X: bob", + "insert X: bob", +] +`; + +exports[`experimental diffs { versionDiffFixes: 'implicitDeleteAttributionAndRecreatedBlocks' } > indent 1`] = ` +[ + "moved block X: bob", +] +`; + +exports[`experimental diffs { versionDiffFixes: 'implicitDeleteAttributionAndRecreatedBlocks' } > move into a concurrently deleted block 1`] = ` +[ + "delete block Parent: alice", + "delete block X: ", +] +`; + +exports[`experimental diffs { versionDiffFixes: 'implicitDeleteAttributionAndRecreatedBlocks' } > move up 1`] = ` +[ + "moved block Next: bob", + "moved from block Next: bob", +] +`; + +exports[`experimental diffs { versionDiffFixes: 'implicitDeleteAttributionAndRecreatedBlocks' } > text edit 1`] = ` +[ + "insert !: bob", +] +`; + +exports[`experimental diffs { versionDiffFixes: 'implicitDeleteAttributionAndRecreatedBlocks' } > type change 1`] = ` +[ + "formatting X", +] +`; + +exports[`experimental diffs {} > indent 1`] = ` +[ + "insert block X: bob", + "insert X: bob", + "delete block X: bob", +] +`; + +exports[`experimental diffs {} > move into a concurrently deleted block 1`] = ` +[ + "delete block Parent: alice", + "delete block X: bob", +] +`; + +exports[`experimental diffs {} > move up 1`] = ` +[ + "insert block Next: bob", + "insert Next: bob", + "delete block Next: bob", +] +`; + +exports[`experimental diffs {} > text edit 1`] = ` +[ + "insert !: bob", +] +`; + +exports[`experimental diffs {} > type change 1`] = ` +[ + "delete block X: bob", + "insert block X: bob", + "insert X: bob", +] +`; diff --git a/packages/core/src/y/extensions/index.ts b/packages/core/src/y/extensions/index.ts index 64d376c478..e76bb1beb3 100644 --- a/packages/core/src/y/extensions/index.ts +++ b/packages/core/src/y/extensions/index.ts @@ -9,6 +9,7 @@ import { normalizeToUserStore, UserStoreOrResolver } from "../../user/index.js"; import { AttributionExtension } from "./AttributionExtension.js"; import { ForkYDocExtension } from "./ForkYDoc.js"; import { RelativePositionMappingExtension } from "./RelativePositionMapping.js"; +import type { ExperimentalVersionDiffs } from "./snapshotPreview.js"; import { SuggestionsExtension } from "./Suggestions.js"; import { CollaborationUser, YCursorExtension } from "./YCursorPlugin.js"; import type { GetAttributionMarkClassName } from "./YAttributionMarks.js"; @@ -66,6 +67,12 @@ export type CollaborationOptions = { * dropped for that mark. See {@link GetAttributionMarkClassName}. */ getAttributionMarkClassName?: GetAttributionMarkClassName; + + /** + * Experimental refinements of how version history diffs are shown. They only + * change what a diff shows, never what is stored. + */ + experimental?: ExperimentalVersionDiffs; }; export const CollaborationExtension = createExtension( @@ -81,6 +88,7 @@ export const CollaborationExtension = createExtension( return { key: "collaboration", fragment: options.fragment, + experimental: options.experimental ?? {}, userStore, blockNoteExtensions: [ options.suggestionDoc @@ -129,6 +137,12 @@ export function withCollaboration< }; } +export type { + ExperimentalVersionDiffs, + VersionDiffFix, + VersionDiffFixes, +} from "./snapshotPreview.js"; +export { versionDiffFixesIncluded } from "./snapshotPreview.js"; export * from "./AttributionExtension.js"; export * from "./DiffVersioningExtension.js"; export * from "./ForkYDoc.js"; diff --git a/packages/core/src/y/extensions/nestingChanges.test.ts b/packages/core/src/y/extensions/nestingChanges.test.ts index a94f40df0a..d0b779be01 100644 --- a/packages/core/src/y/extensions/nestingChanges.test.ts +++ b/packages/core/src/y/extensions/nestingChanges.test.ts @@ -24,6 +24,9 @@ function collaborativeEditor(doc: Y.Doc): Editor { collaboration: { fragment: doc.get("doc"), user: { name: "Test", color: "#ff0000" }, + experimental: { + versionDiffFixes: "implicitDeleteAttributionAndRecreatedBlocks", + }, }, }), ); @@ -220,8 +223,7 @@ describe("version diff of a nesting change", () => { return changed; } - // To be fixed by #3166. - it.fails("shows an indent as a moved block, leaving the new parent unchanged", () => { + it("shows an indent as a moved block, leaving the new parent unchanged", () => { expect( diffOf( (editor) => { @@ -236,8 +238,7 @@ describe("version diff of a nesting change", () => { ).toEqual([">X"]); }); - // To be fixed by #3166. - it.fails("shows an unindent as a moved block, leaving the old parent unchanged", () => { + it("shows an unindent as a moved block, leaving the old parent unchanged", () => { expect( diffOf( (editor) => { diff --git a/packages/core/src/y/extensions/snapshotCodec.ts b/packages/core/src/y/extensions/snapshotCodec.ts index 985c68502f..c47ac5941e 100644 --- a/packages/core/src/y/extensions/snapshotCodec.ts +++ b/packages/core/src/y/extensions/snapshotCodec.ts @@ -16,11 +16,12 @@ export type DecodedFragment = { export function decodeFragmentUpdate( fragment: Y.Node, content: Uint8Array, - opts?: { suggestionDoc?: boolean }, + opts?: { suggestionDoc?: boolean; keepDeleted?: boolean }, ): DecodedFragment { - const doc = new Y.Doc( - opts?.suggestionDoc ? { isSuggestionDoc: true } : undefined, - ); + const doc = new Y.Doc({ + ...(opts?.suggestionDoc ? { isSuggestionDoc: true } : {}), + ...(opts?.keepDeleted ? { gc: false } : {}), + }); try { Y.applyUpdateV2(doc, content); return { doc, fragment: findTypeInOtherYdoc(fragment, doc) }; diff --git a/packages/core/src/y/extensions/snapshotPreview.ts b/packages/core/src/y/extensions/snapshotPreview.ts index 7f801cf2e0..120e4590b8 100644 --- a/packages/core/src/y/extensions/snapshotPreview.ts +++ b/packages/core/src/y/extensions/snapshotPreview.ts @@ -1,5 +1,6 @@ import { configureYProsemirror } from "@y/prosemirror"; import * as Y from "@y/y"; +import { diff } from "lib0/diff/patience"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { findTypeInOtherYdoc } from "../utils.js"; @@ -9,6 +10,657 @@ import { destroyDecodedFragment, } from "./snapshotCodec.js"; +/** A fix of how a diff between two versions is shown. */ +export type VersionDiffFix = + /** + * Content deleted without anyone deleting it (with a concurrently deleted + * block, or with a moved block's lost copy) isn't credited to the user whose + * change removed it. + */ + | "implicitDeleteAttribution" + /** + * A block that a type change or a move re-created shows once: a type change + * between text blocks as a formatting change, a move (including indenting) + * as a move, and a copy in the same place (its children changed) as + * unchanged. Its content keeps its authors. + */ + | "recreatedBlocks"; + +/** + * Experimental fixes of how a diff between two versions is shown, each + * including the ones before it. They only change what a diff shows, never + * what is stored, so they can be turned on or off at any time. + */ +export type VersionDiffFixes = + | "implicitDeleteAttribution" + | "implicitDeleteAttributionAndRecreatedBlocks"; + +/** The fixes each option turns on. */ +export const versionDiffFixesIncluded: Record< + VersionDiffFixes, + VersionDiffFix[] +> = { + implicitDeleteAttribution: ["implicitDeleteAttribution"], + implicitDeleteAttributionAndRecreatedBlocks: [ + "implicitDeleteAttribution", + "recreatedBlocks", + ], +}; + +export type ExperimentalVersionDiffs = { versionDiffFixes?: VersionDiffFixes }; + +function hasFix( + experimental: ExperimentalVersionDiffs, + fix: VersionDiffFix, +): boolean { + return ( + experimental.versionDiffFixes !== undefined && + versionDiffFixesIncluded[experimental.versionDiffFixes].includes(fix) + ); +} + +/** + * Snapshots are decoded with `gc: false`, so content inside a deleted block keeps + * its own attribution: content swept away with a concurrently deleted block shows + * no author instead of inheriting the deleter. The stock renderer hides content + * that was inserted and deleted between the two versions only once it has been + * garbage collected; hide it here too. + */ +class SnapshotDiffRenderer extends Y.DiffRenderer { + override readContent( + ...[contents, client, clock, deleted, content, shouldRender]: Parameters< + Y.DiffRenderer["readContent"] + > + ) { + const start = contents.length; + super.readContent(contents, client, clock, deleted, content, shouldRender); + if (deleted) { + for (let i = contents.length - 1; i >= start; i--) { + if (this.inserts.has(client, contents[i].clock)) { + contents.splice(i, 1); + } else if (this.deletedWithParent(client, contents[i])) { + // Shown inside the parent's deletion, as without `gc: false`. + contents[i].deleted = false; + contents[i].attrs = null; + } + } + } + } + + /** Whether the content's deletion is the same as its deleted parent's. */ + private deletedWithParent( + client: number, + { + clock, + attrs, + }: { clock: number; attrs: Y.ContentAttribute[] | null }, + ): boolean { + const structs = this._nextDoc.store.clients.get(client); + const item = structs?.[Y.findIndexSS(structs, clock)]; + const parent = item instanceof Y.Item ? item.parent?._item : null; + if (!parent?.deleted) { + return false; + } + const [own] = this.deletes.slice(parent.id.client, parent.id.clock, 1); + return sameAttributes(own?.attrs ?? null, attrs); + } + + /** + * Show `unchanged` content as unchanged (or not at all, if deleted), credit + * each copied item to the authors that inserted its original, and mark the + * `moved` blocks' insertion as a move. + */ + adjust( + unchanged: Y.IdSet, + credits: Array<[original: Y.ID, copy: Y.ID]>, + moved: Y.ID[], + movedFrom: Y.Item[], + ) { + this.inserts = Y.diffIdMap(this.inserts, unchanged); + this.deletes = Y.diffIdMap(this.deletes, unchanged); + this.attributed = Y.diffIdSet(this.attributed, unchanged); + const credited = Y.createIdSet(); + const credit: Y.IdMap = Y.createIdMap(); + for (const [original, copy] of credits) { + const attrs = this.inserts.slice(original.client, original.clock, 1)[0] + ?.attrs; + const ids = Y.createIdSet(); + ids.add(copy.client, copy.clock, 1); + credited.add(copy.client, copy.clock, 1); + Y.insertIntoIdMap(credit, Y.createIdMapFromIdSet(ids, attrs ?? [])); + } + for (const id of moved) { + const attrs = this.inserts.slice(id.client, id.clock, 1)[0]?.attrs ?? []; + const ids = Y.createIdSet(); + ids.add(id.client, id.clock, 1); + credited.add(id.client, id.clock, 1); + Y.insertIntoIdMap( + credit, + Y.createIdMapFromIdSet(ids, [ + ...attrs, + Y.createContentAttribute("moved", true), + ]), + ); + } + this.inserts = Y.mergeIdMaps([Y.diffIdMap(this.inserts, credited), credit]); + // The struck-through originals of moved blocks: their deletion is a move. + const relabeled = Y.createIdSet(); + const relabel: Y.IdMap = Y.createIdMap(); + for (const item of movedFrom) { + const { client } = item.id; + for (const range of this.deletes.slice( + client, + item.id.clock, + item.length, + )) { + if (range.attrs) { + const ids = Y.createIdSet(); + ids.add(client, range.clock, range.len); + relabeled.add(client, range.clock, range.len); + Y.insertIntoIdMap( + relabel, + Y.createIdMapFromIdSet(ids, [ + ...range.attrs, + Y.createContentAttribute("moved", true), + ]), + ); + } + } + } + this.deletes = Y.mergeIdMaps([ + Y.diffIdMap(this.deletes, relabeled), + relabel, + ]); + } +} + +/** Whether two attributions show the same, ignoring timestamps (`deleteAt`, ...). */ +function sameAttributes( + a: Y.ContentAttribute[] | null, + b: Y.ContentAttribute[] | null, +): boolean { + function shown(attrs: Y.ContentAttribute[] | null) { + return (attrs ?? []).filter((attr) => !attr.name.endsWith("At")); + } + const [x, y] = [shown(a), shown(b)]; + return ( + (a === null) === (b === null) && + x.length === y.length && + x.every((p) => y.some((q) => p.name === q.name && p.val === q.val)) + ); +} + +/** + * The delete attributions of moved blocks that are gone, with their content, + * without the movers. + * + * A move (indenting, dragging, a type change, ...) deletes a block and inserts + * a copy with the same id. While a copy is shown, the diff shows the move as + * is. Once every copy is gone, only the original's deletion is shown, and + * that names the mover, who may not have deleted it: the copy can be lost + * with a concurrently deleted parent, or deleted later by someone else. So the + * movers are left out; whoever else deleted the original (e.g. with its + * parent) still is. With nobody left, it's shown without an author. + */ +function withoutLostMovers( + doc: Y.Doc, + baseline: Y.Doc, + attributions: Y.ContentMap, + deletes: Y.IdMap, +): Y.IdMap { + const { inserted, deleted } = changesSince(doc, baseline); + const copies = new Map(); + for (const item of itemsIn(doc, inserted)) { + if (isBlock(item)) { + const id = blockId(item.content.type); + copies.set(id, [...(copies.get(id) ?? []), item]); + } + } + // A copy that landed in content gc'd before the earlier version is only a + // gc stub, known by its insertion alone. + const gcInserts = new Set(); + doc.transact((tr) => + Y.iterateStructsByIdSet(tr, inserted, (struct) => { + const change = + struct instanceof Y.GC && + changeOf(struct.id, attributions.inserts, "insert"); + if (change) { + gcInserts.add(change); + } + }), + ); + // Deleted content that was in the earlier version. + const removed = itemsIn(doc, Y.diffIdSet(deleted, inserted)); + const lost = removed.filter(isBlock).flatMap((item) => { + const id = blockId(item.content.type); + const moved = id == null ? undefined : copies.get(id); + const change = changeOf(item.id, attributions.deletes, "delete"); + let movers: Set; + if (moved) { + if (!moved.every((copy) => copy.deleted)) { + return []; + } + movers = new Set( + moved.flatMap((copy) => + usersOf(copy.id, attributions.inserts, "insert"), + ), + ); + } else if (change && gcInserts.has(change)) { + // A gc'd copy, inserted with the original's deletion: by its mover. + movers = new Set(usersOf(item.id, attributions.deletes, "delete")); + } else { + return []; + } + return [{ block: item.content.type, movers }]; + }); + const replaced = Y.createIdSet(); + const replacement: Y.IdMap = Y.createIdMap(); + for (const item of removed) { + const { movers } = + lost.find( + ({ block }) => item === block._item || Y.isParentOf(block, item), + ) ?? {}; + if (!movers) { + continue; + } + const { client } = item.id; + for (const range of deletes.slice(client, item.id.clock, item.length)) { + if (!range.attrs) { + continue; + } + const ids = Y.createIdSet(); + ids.add(client, range.clock, range.len); + replaced.add(client, range.clock, range.len); + const kept = range.attrs.filter( + (attr) => !(attr.name === "delete" && movers.has(attr.val)), + ); + if (kept.some((attr) => attr.name === "delete")) { + Y.insertIntoIdMap(replacement, Y.createIdMapFromIdSet(ids, kept)); + } + } + } + return Y.mergeIdMaps([Y.diffIdMap(deletes, replaced), replacement]); +} + +/** The ids inserted, and the ids deleted, in `doc` since `baseline`. */ +function changesSince(doc: Y.Doc, baseline: Y.Doc) { + return { + inserted: Y.diffIdSet( + Y.createInsertSetFromStructStore(doc.store, false), + Y.createInsertSetFromStructStore(baseline.store, false), + ), + deleted: Y.diffIdSet( + Y.createDeleteSetFromStructStore(doc.store), + Y.createDeleteSetFromStructStore(baseline.store), + ), + }; +} + +/** The items of `doc` that `ids` covers. */ +function itemsIn(doc: Y.Doc, ids: Y.IdSet): Y.Item[] { + const items: Y.Item[] = []; + // A transaction only because the iteration may split items; content is unchanged. + doc.transact((tr) => + Y.iterateStructsByIdSet(tr, ids, (struct) => { + if (struct instanceof Y.Item) { + items.push(struct); + } + }), + ); + return items; +} + +function isBlock(item: Y.Item): item is Y.Item & { content: Y.ContentType } { + return ( + item.content instanceof Y.ContentType && + item.content.type.name === "blockContainer" + ); +} + +/** A block's id, also when the block is deleted (its attributes read as unset). */ +function blockId(block: Y.Node): unknown { + return block._map.get("id")?.content.getContent().at(-1); +} + +/** + * The items of a block's content, one unit per character, mark or node. Text + * the old binding wrapped in anonymous nodes is included. + */ +function units( + node: Y.Node, + out: Array<{ id: Y.ID; key: string }> = [], +): Array<{ id: Y.ID; key: string }> { + for (let item = node._start; item !== null; item = item.right) { + const content = item.content; + if (content instanceof Y.ContentType && content.type.name == null) { + out.push({ id: item.id, key: "n" }); + units(content.type, out); + continue; + } + for (let i = 0; i < item.length; i++) { + out.push({ + id: Y.createID(item.id.client, item.id.clock + i), + key: + content instanceof Y.ContentString + ? `s${content.str[i]}` + : content instanceof Y.ContentFormat + ? `f${content.key}=${JSON.stringify(content.value)}` + : content instanceof Y.ContentType + ? `n${content.type.name}` + : `e${JSON.stringify(content.getContent()[i] ?? null)}`, + }); + } + } + return out; +} + +/** Pairs of equal units, in order. */ +function matchUnits( + a: Array<{ id: Y.ID; key: string }>, + b: Array<{ id: Y.ID; key: string }>, + pairs: Array<[Y.ID, Y.ID]>, +) { + let i = 0; + let j = 0; + const pairUntil = (end: number) => { + for (; i < end; i++, j++) { + pairs.push([a[i].id, b[j].id]); + } + }; + for (const change of diff( + a.map((unit) => unit.key), + b.map((unit) => unit.key), + )) { + pairUntil(change.index); + i += change.remove.length; + j += change.insert.length; + } + pairUntil(a.length); +} + +/** The attribute items of `a` and `b` that hold equal values. */ +function matchAttributes(a: Y.Node, b: Y.Node, pairs: Array<[Y.ID, Y.ID]>) { + for (const [key, copy] of b._map) { + const original = a._map.get(key); + if ( + original && + JSON.stringify(original.content.getContent()) === + JSON.stringify(copy.content.getContent()) + ) { + pairs.push([original.id, copy.id]); + } + } +} + +/** A node's child nodes, deleted ones included. */ +function childNodes(node: Y.Node): Y.Node[] { + const out: Y.Node[] = []; + for (let item = node._start; item !== null; item = item.right) { + if (item.content instanceof Y.ContentType) { + out.push(item.content.type); + } + } + return out; +} + +/** + * A block's content node (paragraph, heading, ...). A block from the old + * binding can hold several after a type change: prefer the one in `baseline`. + */ +function contentOf(block: Y.Node, baseline: Y.Doc): Y.Node | undefined { + const contents = childNodes(block).filter((n) => n.name !== "blockGroup"); + return ( + contents.find((n) => inBaseline(baseline, n._item!.id)) ?? + contents.find((n) => !n._item!.deleted) ?? + contents[0] + ); +} + +/** + * Pair a block's copy with its original: the blocks, their content, and their + * children (by id). A reformatted block's own content attributes are left + * unpaired, so they show as the change. + */ +function matchCopy( + original: Y.Node, + copy: Y.Node, + baseline: Y.Doc, + pairs: Array<[Y.ID, Y.ID]>, + reformatted: boolean, +) { + pairs.push([original._item!.id, copy._item!.id]); + matchAttributes(original, copy, pairs); + const originalContent = contentOf(original, baseline); + const copyContent = contentOf(copy, baseline); + if (originalContent && copyContent) { + pairs.push([originalContent._item!.id, copyContent._item!.id]); + if (!reformatted) { + matchAttributes(originalContent, copyContent, pairs); + } + matchUnits(units(originalContent), units(copyContent), pairs); + } + const originalGroup = childNodes(original).find( + (n) => n.name === "blockGroup", + ); + const copyGroup = childNodes(copy).find((n) => n.name === "blockGroup"); + if (originalGroup && copyGroup) { + pairs.push([originalGroup._item!.id, copyGroup._item!.id]); + const originals = new Map( + childNodes(originalGroup).map((child) => [blockId(child), child]), + ); + for (const child of childNodes(copyGroup)) { + const match = originals.get(blockId(child)); + if (match) { + matchCopy(match, child, baseline, pairs, false); + } + } + } +} + +/** + * Blocks that were copied since `baseline`, with the original they copy. + * + * Changing a block's type or moving it (including indenting it) deletes the + * block and inserts a copy with the same id: a Yjs node can't change its name + * or position. The copy's content is then credited to whoever made the change, + * and the diff shows the block twice. The pairs let the diff show the block + * once, as a formatting change or a move, and credit copied content to its + * authors. + */ +function copiedBlocks( + doc: Y.Doc, + baseline: Y.Doc, + attributions: Y.ContentMap, + holdsText: (type: string | undefined) => boolean, +): Array<{ + original: Y.Node; + intermediates: Y.Node[]; + copy: Y.Node; + typeChanged: boolean; +}> { + const { inserted, deleted } = changesSince(doc, baseline); + // A block copied several times since `baseline` (e.g. indented, then + // outdented) leaves intermediate copies, deleted too. + const candidates = new Map(); + for (const item of itemsIn(doc, deleted)) { + if (isBlock(item)) { + const id = blockId(item.content.type); + candidates.set(id, [...(candidates.get(id) ?? []), item.content.type]); + } + } + const copies = itemsIn(doc, inserted).flatMap((item) => + isBlock(item) && !item.deleted ? [item.content.type] : [], + ); + return copies.flatMap((copy) => { + const blocks = candidates.get(blockId(copy)) ?? []; + // Concurrent changes can leave several copies: showing each as the + // original would hide that the block is now there more than once. + if (copies.filter((other) => blockId(other) === blockId(copy)).length > 1) { + return []; + } + // The copies before this one, oldest first. The block that was in + // `baseline` is the original; without one, the oldest copy is, if the + // order is known. + const chain = copyChain(copy, blocks, attributions); + const inEarlier = blocks.find((block) => + inBaseline(baseline, block._item!.id), + ); + const original = + inEarlier ?? chain[0] ?? (blocks.length === 1 ? blocks[0] : undefined); + if (!original) { + return []; + } + // Without a known order back to the original, intermediates aren't used. + const intermediates = chain[0] === original ? chain.slice(1) : []; + const from = contentOf(original, baseline)?.name; + const to = contentOf(copy, baseline)?.name; + // Only text blocks change type in place: an image turned into a paragraph + // has lost the image. + if (from !== to && !(holdsText(from) && holdsText(to))) { + return []; + } + return [{ original, intermediates, copy, typeChanged: from !== to }]; + }); +} + +/** + * The deleted copies `copy` was made from, oldest first. A change that copies + * a block inserts the copy and deletes the block it copies, in one update: so + * the copy before is the one whose deletion has the same users and times as + * this copy's insertion. Stops where that isn't exactly one block. + */ +function copyChain( + copy: Y.Node, + blocks: Y.Node[], + attributions: Y.ContentMap, +): Y.Node[] { + const chain: Y.Node[] = []; + const left = new Set(blocks); + for (let current = copy; ;) { + const change = changeOf(current._item!.id, attributions.inserts, "insert"); + let previous = [...left].filter( + (block) => + change !== undefined && + changeOf(block._item!.id, attributions.deletes, "delete") === change, + ); + // One update can copy a block more than once (e.g. retype, then indent): + // of its deleted copies, the newer one was also made by that update. + if (previous.length > 1) { + previous = previous.filter( + (block) => + changeOf(block._item!.id, attributions.inserts, "insert") === change, + ); + } + if (previous.length !== 1) { + return chain; + } + chain.unshift(previous[0]); + left.delete(previous[0]); + current = previous[0]; + } +} + +/** + * Pair the copy's content that the original doesn't have with the + * intermediate copy it was typed into: the oldest one holding it. Only + * content copied with the copy can come from an intermediate copy. Text typed + * into the copy itself has its own insert, and keeps it. + */ +function matchTypedInCopies( + intermediates: Y.Node[], + copy: Y.Node, + baseline: Y.Doc, + inserts: Y.IdMap, + pairs: Array<[Y.ID, Y.ID]>, +) { + const copyContent = contentOf(copy, baseline); + if (!copyContent) { + return; + } + const paired = Y.createIdSet(); + for (const [, b] of pairs) { + paired.add(b.client, b.clock, 1); + } + const copied = changeOf(copy._item!.id, inserts, "insert"); + const unpaired = units(copyContent).filter( + ({ id }) => + !paired.has(id.client, id.clock) && + changeOf(id, inserts, "insert") === copied, + ); + for (const intermediate of intermediates) { + const content = contentOf(intermediate, baseline); + if (!content) { + continue; + } + const found: Array<[Y.ID, Y.ID]> = []; + matchUnits(units(content), unpaired, found); + for (const [a, b] of found) { + if (!paired.has(b.client, b.clock)) { + pairs.push([a, b]); + paired.add(b.client, b.clock, 1); + } + } + } +} + +/** The users who inserted or deleted a unit. */ +function usersOf( + id: Y.ID, + map: Y.IdMap, + kind: "insert" | "delete", +): unknown[] { + return (map.slice(id.client, id.clock, 1)[0]?.attrs ?? []) + .filter((attr) => attr.name === kind) + .map((attr) => attr.val); +} + +/** The users and times of a unit's insertion or deletion, as a key. */ +function changeOf( + id: Y.ID, + map: Y.IdMap, + kind: "insert" | "delete", +): string | undefined { + const attrs = map.slice(id.client, id.clock, 1)[0]?.attrs ?? []; + function values(name: string) { + return attrs + .filter((attr) => attr.name === name) + .map((attr) => String(attr.val)) + .sort() + .join(","); + } + const users = values(kind); + return users ? `${users}@${values(`${kind}At`)}` : undefined; +} + +/** Whether some unit of `item` is in `lost` but not in `kept`. */ +function losesUnit(item: Y.Item, lost: Y.IdSet, kept: Y.IdSet): boolean { + for (let i = 0; i < item.length; i++) { + const clock = item.id.clock + i; + if (lost.has(item.id.client, clock) && !kept.has(item.id.client, clock)) { + return true; + } + } + return false; +} + +/** + * Whether `copy` replaced `original` where it stood: in the same list, with + * only deleted items between them. Changing a block's children does that. + */ +function inPlace(original: Y.Node, copy: Y.Node): boolean { + for (const side of ["left", "right"] as const) { + for (let item = copy._item?.[side]; item; item = item[side]) { + if (item === original._item) { + return true; + } + if (!item.deleted) { + break; + } + } + } + return false; +} + /** Whether the item with this id was already in `baseline`. */ function inBaseline(baseline: Y.Doc, id: Y.ID): boolean { const last = baseline.store.clients.get(id.client)?.at(-1); @@ -210,6 +862,194 @@ function splitChangedBlocks( } } +/** + * Show each copied block (see {@link copiedBlocks}) once: when the earlier + * version has the original, render the copy as the original plus the change + * (formatting change or move); otherwise credit the copied content to the + * original's authors. + */ +function showCopiesOnce( + editor: BlockNoteEditor, + snapshot: { doc: Y.Doc; fragment: Y.Node }, + earlier: { doc: Y.Doc; fragment: Y.Node }, + renderer: SnapshotDiffRenderer, +) { + const doc = snapshot.doc; + const baseline = earlier.doc; + // Deleted content that was in the earlier version. + const { inserted, deleted } = changesSince(doc, baseline); + const removed = Y.diffIdSet(deleted, inserted); + const removedContent = itemsIn(doc, removed).filter( + // Content only: not attributes, nor structure (child groups, whose + // children are checked themselves, and the old binding's anonymous text + // wrappers). + (item) => + item.parentSub === null && + !( + item.content instanceof Y.ContentType && + (item.content.type.name == null || + item.content.type.name === "blockGroup") + ), + ); + const matches = copiedBlocks( + doc, + baseline, + Y.createContentMap(renderer.inserts, renderer.deletes), + (type) => + type !== undefined && + editor.schema.blockSpecs[type]?.config.content === "inline", + ).map(({ original, intermediates, copy, typeChanged }) => { + const pairs: Array<[Y.ID, Y.ID]> = []; + matchCopy(original, copy, baseline, pairs, typeChanged); + matchTypedInCopies(intermediates, copy, baseline, renderer.inserts, pairs); + return { original, copy, typeChanged, pairs }; + }); + + // An original in the earlier version is shown as its copy, so only the + // change shows. Unless content it had made it into no copy (it was lost + // with the original): then show both, so the loss shows too. Content that + // moved out (into another shown copy) isn't lost. + let shown = matches.filter((match) => + inBaseline(baseline, match.original._item!.id), + ); + const removedFrom = new Map(); + const originals = new Set(shown.map(({ original }) => original)); + for (const item of removedContent) { + for (const block of within(item, originals)) { + removedFrom.set(block, [...(removedFrom.get(block) ?? []), item]); + } + } + for (let changed = true; changed;) { + const kept = Y.createIdSet(); + for (const { pairs } of shown) { + for (const [a] of pairs) { + kept.add(a.client, a.clock, 1); + } + } + const next = shown.filter( + ({ original }) => + // Per unit: a deleted item can span content deleted before. + !removedFrom + .get(original) + ?.some((item) => losesUnit(item, removed, kept)), + ); + changed = next.length !== shown.length; + shown = next; + } + const shownMatches = new Set(shown); + + // A block that moved elsewhere also shows its original, struck through, at + // the old place. One indented or outdented keeps its place in reading + // order, so it only shows at the new place. Blocks moved with a parent are + // struck through with it. + const before = readingOrder(earlier.fragment); + const after = readingOrder(snapshot.fragment); + const above = blocksAbove(before, new Set(after)); + const aboveNow = blocksAbove(after, new Set(before)); + function isMove({ original, copy, typeChanged }: (typeof matches)[number]) { + return !typeChanged && !inPlace(original, copy); + } + // Another block above it than before: not only indented or outdented. + const reorderedOriginals = new Set( + shown + .filter( + (match) => + isMove(match) && + above.get(blockId(match.copy)) !== aboveNow.get(blockId(match.copy)), + ) + .map(({ original }) => original), + ); + function struck(original: Y.Node) { + return within(original._item!, reorderedOriginals).length > 0; + } + + const unchanged = Y.createIdSet(); + const credits: Array<[Y.ID, Y.ID]> = []; + const moved: Y.ID[] = []; + for (const match of matches) { + const { original, copy, pairs } = match; + if (!inBaseline(baseline, original._item!.id)) { + credits.push(...pairs); + continue; + } + if (!shownMatches.has(match)) { + continue; + } + // A block that moved stays marked, as a move. One copied in place (its + // children changed) shows unchanged. + const move = isMove(match); + for (const [a, b] of pairs) { + // Content added to the original after the earlier version isn't + // unchanged: the copy is credited to whoever added it. + if (!inBaseline(baseline, a)) { + credits.push([a, b]); + continue; + } + if (!(move && struck(original))) { + unchanged.add(a.client, a.clock, 1); + } + if (!move || b !== copy._item!.id) { + unchanged.add(b.client, b.clock, 1); + } + } + if (move) { + moved.push(copy._item!.id); + } + } + const movedFrom = itemsIn(doc, removed).filter( + (item) => within(item, reorderedOriginals).length > 0, + ); + renderer.adjust(unchanged, credits, moved, movedFrom); +} + +/** The blocks among `blocks` that are `item`'s node or hold it. */ +function within(item: Y.Item, blocks: Set): Y.Node[] { + const found: Y.Node[] = []; + let node: Y.Node | null = + item.content instanceof Y.ContentType + ? item.content.type + : (item.parent as Y.Node); + while (node) { + if (blocks.has(node)) { + found.push(node); + } + node = (node._item?.parent as Y.Node | undefined) ?? null; + } + return found; +} + +/** The ids of the blocks under `node`, top to bottom, nesting ignored. */ +function readingOrder(node: Y.Node, out: unknown[] = []): unknown[] { + for (let item = node._start; item !== null; item = item.right) { + if (!item.deleted && item.content instanceof Y.ContentType) { + if (isBlock(item)) { + out.push(blockId(item.content.type)); + } + readingOrder(item.content.type, out); + } + } + return out; +} + +/** + * For each block id in `order`, the block above it, counting only blocks in + * `other` too. An indent or outdent keeps it. + */ +function blocksAbove( + order: unknown[], + other: Set, +): Map { + const above = new Map(); + let previous: unknown = undefined; + for (const id of order) { + if (other.has(id)) { + above.set(id, previous); + previous = id; + } + } + return above; +} + /** * Decode a snapshot, diff it against a baseline if given, and render it. * @@ -227,14 +1067,21 @@ export function showSnapshotPreview( snapshotContent: Uint8Array, compareToContent?: Uint8Array, attributions?: Y.ContentMap, + experimental: ExperimentalVersionDiffs = {}, ): void { + // Deleted content is needed to tell what a user deleted from what was lost + // with something else. + const keepDeleted = hasFix(experimental, "implicitDeleteAttribution"); const baseline = compareToContent ? decodeFragmentUpdate(fragment, compareToContent, { suggestionDoc: true, + keepDeleted, }) : undefined; try { - const snapshot = decodeFragmentUpdate(fragment, snapshotContent); + const snapshot = decodeFragmentUpdate(fragment, snapshotContent, { + keepDeleted, + }); try { let renderAttributions = attributions; if (baseline) { @@ -246,25 +1093,39 @@ export function showSnapshotPreview( added, ); if (attributions) { + const deletes = Y.mergeIdMaps([attributions.deletes, added.deletes]); renderAttributions = Y.createContentMap( Y.mergeIdMaps([attributions.inserts, added.inserts]), - Y.mergeIdMaps([attributions.deletes, added.deletes]), + hasFix(experimental, "implicitDeleteAttribution") + ? withoutLostMovers( + snapshot.doc, + baseline.doc, + attributions, + deletes, + ) + : deletes, ); } } + const options = renderAttributions + ? { attributions: renderAttributions } + : undefined; + let renderer: Y.DiffRenderer | undefined; + if (baseline && keepDeleted) { + const snapshotRenderer = new SnapshotDiffRenderer( + baseline.doc, + snapshot.doc, + options, + ); + if (hasFix(experimental, "recreatedBlocks")) { + showCopiesOnce(editor, snapshot, baseline, snapshotRenderer); + } + renderer = snapshotRenderer; + } else if (baseline) { + renderer = Y.createDiffRenderer(baseline.doc, snapshot.doc, options); + } editor.exec( - configureYProsemirror({ - ytype: snapshot.fragment, - renderer: baseline - ? Y.createDiffRenderer( - baseline.doc, - snapshot.doc, - renderAttributions - ? { attributions: renderAttributions } - : undefined, - ) - : undefined, - }), + configureYProsemirror({ ytype: snapshot.fragment, renderer }), ); } finally { destroyDecodedFragment(snapshot); diff --git a/packages/core/src/y/extensions/versionDiffAttribution.test.ts b/packages/core/src/y/extensions/versionDiffAttribution.test.ts index 8fb59c4dba..266f813fe9 100644 --- a/packages/core/src/y/extensions/versionDiffAttribution.test.ts +++ b/packages/core/src/y/extensions/versionDiffAttribution.test.ts @@ -23,6 +23,9 @@ function collaborativeEditor(doc: Y.Doc) { collaboration: { fragment: doc.get("doc"), user: { name: "Test", color: "#ff0000" }, + experimental: { + versionDiffFixes: "implicitDeleteAttributionAndRecreatedBlocks", + }, }, }), ); @@ -81,8 +84,8 @@ function deletedBy(update: Uint8Array, user: string) { * does: what the update inserts, and what it explicitly deletes, each with a * time. */ -function history(base: Y.Doc) { - const server = new Y.Doc({ gc: false }); +function history(base: Y.Doc, gcFilter?: (item: Y.Item) => boolean) { + const server = new Y.Doc({ gc: gcFilter !== undefined, gcFilter }); Y.applyUpdateV2(server, Y.encodeStateAsUpdateV2(base)); const attributions = Y.createContentMap(); let time = 0; @@ -149,8 +152,7 @@ function showDiff( } describe("version diff of a deleted block", () => { - // To be fixed by #3166. - it.fails("does not attribute content added concurrently inside it to the deleter", () => { + it("does not attribute content added concurrently inside it to the deleter", () => { const base = baseDocument([ { id: "parent", @@ -193,8 +195,7 @@ describe("version diff of a deleted block", () => { ]); }); - // To be fixed by #3166. - it.fails("names no author when hovering content removed with it", () => { + it("names no author when hovering content removed with it", () => { const base = baseDocument([ { id: "parent", type: "paragraph", content: "Parent" }, { id: "next", type: "paragraph", content: "Next" }, @@ -302,22 +303,28 @@ describe("version diff of a moved block", () => { .map((change) => `${change.text}: ${change.users.join(", ")}`); } - it("attributes a move to the mover", () => { + it("names no author for a block moved into a concurrently deleted one", () => { const base = blocks(); + const bob = editOf(base, 2, nest); + const alice = editOf(base, 1, removeParent); const server = history(base); - const after = server.apply(editOf(base, 2, nest), "bob"); - expect( - deletions(Y.encodeStateAsUpdateV2(base), after, server.attributions), - ).toEqual(["[block moved]: bob"]); + const before = server.apply(alice, "alice"); + const after = server.apply(bob, "bob"); + expect(deletions(before, after, server.attributions)).toEqual([ + "[block moved]: ", + ]); }); - // To be fixed by #3166. - it.fails("names no author for a block moved into a concurrently deleted one", () => { + it("names no author for a block moved into a deleted one that was gc'd", () => { const base = blocks(); const bob = editOf(base, 2, nest); const alice = editOf(base, 1, removeParent); - const server = history(base); + // Like history before YHub's cutoff: Alice's deletion is gc'd, so Bob's + // copy, which lands in it, is only a gc stub. + let gc = true; + const server = history(base, () => gc); const before = server.apply(alice, "alice"); + gc = false; const after = server.apply(bob, "bob"); expect(deletions(before, after, server.attributions)).toEqual([ "[block moved]: ", @@ -340,8 +347,7 @@ describe("version diff of a moved block", () => { ).toEqual(["[block parent]: alice"]); }); - // To be fixed by #3166. - it.fails("names no author for a moved block that someone else deletes", () => { + it("names no author for a moved block that someone else deletes", () => { const base = blocks(); const server = history(base); server.apply(editOf(base, 1, nest), "alice"); @@ -436,8 +442,7 @@ describe("version diff of a type change", () => { return out; } - // To be fixed by #3166. - it.fails("shows a type change as a formatting change, not as replaced text", () => { + it("shows a type change as a formatting change, not as replaced text", () => { const base = blocks(); const server = history(base); const after = server.apply(editOf(base, 2, toHeading), "bob"); @@ -446,8 +451,7 @@ describe("version diff of a type change", () => { ).toEqual(["attrs : bob"]); }); - // To be fixed by #3166. - it.fails("credits a type-changed block's text to its writer, from before it existed", () => { + it("credits a type-changed block's text to its writer, from before it existed", () => { const base = baseDocument([ { id: "next", type: "paragraph", content: "Next" }, ]); @@ -472,8 +476,7 @@ describe("version diff of a type change", () => { ]); }); - // To be fixed by #3166. - it.fails("keeps later edits to a type-changed block as their author's", () => { + it("keeps later edits to a type-changed block as their author's", () => { const base = blocks(); const server = history(base); server.apply(editOf(base, 2, toHeading), "bob"); @@ -517,8 +520,7 @@ describe("version diff of a type change", () => { ]); }); - // To be fixed by #3166. - it.fails("credits an indented block's text to its writer, from before it existed", () => { + it("credits an indented block's text to its writer, from before it existed", () => { const base = blocks(); const server = history(base); server.apply( @@ -549,8 +551,7 @@ describe("version diff of a type change", () => { ]); }); - // To be fixed by #3166. - it.fails("shows a block moved among its siblings as a move at both places", () => { + it("shows a block moved among its siblings as a move at both places", () => { const base = baseDocument([ { id: "first", type: "paragraph", content: "First" }, { id: "second", type: "paragraph", content: "Second" }, @@ -572,8 +573,7 @@ describe("version diff of a type change", () => { ]); }); - // To be fixed by #3166. - it.fails("shows a type change as a formatting change after the text was rewritten", () => { + it("shows a type change as a formatting change after the text was rewritten", () => { const base = blocks(); const server = history(base); // The rewrite reuses some characters, so the block's stored text mixes @@ -590,8 +590,7 @@ describe("version diff of a type change", () => { ]); }); - // To be fixed by #3166. - it.fails("strikes a moved block's children through with it at its old place", () => { + it("strikes a moved block's children through with it at its old place", () => { const base = baseDocument([ { id: "first", type: "paragraph", content: "First" }, { @@ -653,8 +652,7 @@ describe("version diff of a type change", () => { ]); }); - // To be fixed by #3166. - it.fails.each([ + it.each([ ["the same user", "bob"], ["a different user", "carol"], ])( @@ -682,8 +680,7 @@ describe("version diff of a type change", () => { }, ); - // To be fixed by #3166. - it.fails("credits two type changes to the last one", () => { + it("credits two type changes to the last one", () => { const base = blocks(); const server = history(base); server.apply(editOf(base, 2, toHeading), "bob"); @@ -698,6 +695,39 @@ describe("version diff of a type change", () => { ).toEqual(["attrs : carol"]); }); + it("credits text typed into the last copy to its writer, not to an earlier copy's", () => { + const base = blocks(); + const server = history(base); + server.apply( + editOf(base, 2, (editor) => { + toHeading(editor); + editor.setTextCursorPosition("parent", "end"); + editor.insertInlineContent("d"); + }), + "bob", + ); + server.apply( + editOf(server.server, 3, (editor) => + editor.updateBlock("parent", { type: "bulletListItem" }), + ), + "carol", + ); + const after = server.apply( + editOf(server.server, 4, (editor) => { + editor.setTextCursorPosition("parent", "end"); + editor.insertInlineContent("ddd"); + }), + "dave", + ); + expect( + diff(Y.encodeStateAsUpdateV2(base), after, server.attributions), + ).toEqual([ + "attrs : carol", + "insert d: bob", + "insert ddd: dave", + ]); + }); + it.each([ [ "a paragraph into an image", @@ -791,8 +821,7 @@ describe("version diff of a document several users wrote", () => { return wrong; } - // To be fixed by #3166. - it.fails("credits every word to its writer, between any two versions", () => { + it("credits every word to its writer, between any two versions", () => { const base = baseDocument([ { id: "start", type: "paragraph", content: "" }, ]); diff --git a/packages/core/src/y/extensions/versionDiffFlags.test.ts b/packages/core/src/y/extensions/versionDiffFlags.test.ts new file mode 100644 index 0000000000..28809f312c --- /dev/null +++ b/packages/core/src/y/extensions/versionDiffFlags.test.ts @@ -0,0 +1,221 @@ +/** + * @vitest-environment jsdom + */ +import * as Y from "@y/y"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { blocksToYType } from "../utils.js"; +import { withCollaboration } from "./index.js"; +import type { ExperimentalVersionDiffs } from "./snapshotPreview.js"; +import { createYVersionView } from "./Versioning.js"; + +/** + * The experimental version-diff options only change how a diff is shown, so + * every combination must work, and none changes what an edit stores. + */ + +const editors: BlockNoteEditor[] = []; +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.unmount(); + } +}); + +function editorOn(doc: Y.Doc, experimental: ExperimentalVersionDiffs) { + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: doc.get("doc"), + user: { name: "Test", color: "#ff0000" }, + experimental, + }, + }), + ); + editor.mount(document.body.appendChild(document.createElement("div"))); + editors.push(editor); + return editor; +} + +const base = () => { + const doc = new Y.Doc({ gc: false }); + doc.clientID = 100; + const seed = BlockNoteEditor.create(); + seed.replaceBlocks(seed.document, [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + { id: "x", type: "paragraph", content: "X" }, + { id: "next", type: "paragraph", content: "Next" }, + ]); + blocksToYType(seed, seed.document, doc.get("doc")); + return doc; +}; + +/** One user's edit of `from`, as the updates their client sends. */ +function edit( + from: Y.Doc, + client: number, + experimental: ExperimentalVersionDiffs, + change: (editor: BlockNoteEditor) => void, +) { + const doc = new Y.Doc({ gc: false }); + doc.clientID = client; + Y.applyUpdateV2(doc, Y.encodeStateAsUpdateV2(from)); + const editor = editorOn(doc, experimental); + const updates: Uint8Array[] = []; + doc.on("updateV2", (update: Uint8Array) => updates.push(update)); + change(editor); + return Y.mergeUpdatesV2(updates); +} + +/** Changes shown in the diff, as `kind text: users`. */ +function diff( + experimental: ExperimentalVersionDiffs, + edits: Array<[client: number, user: string, change: (e: any) => void]>, +) { + const start = base(); + const server = new Y.Doc({ gc: false }); + Y.applyUpdateV2(server, Y.encodeStateAsUpdateV2(start)); + const attributions = Y.createContentMap(); + const updates = edits.map(([client, user, change]) => { + const update = edit(start, client, experimental, change); + return { update, user }; + }); + for (const { update, user } of updates) { + const before = Y.createInsertSetFromStructStore(server.store, false); + Y.applyUpdateV2(server, update); + Y.insertIntoIdMap( + attributions.inserts, + Y.createIdMapFromIdSet( + Y.diffIdSet( + Y.createInsertSetFromStructStore(server.store, false), + before, + ), + [Y.createContentAttribute("insert", user)], + ), + ); + Y.insertIntoIdMap( + attributions.deletes, + Y.createIdMapFromIdSet(Y.decodeUpdateV2(update).ds, [ + Y.createContentAttribute("delete", user), + ]), + ); + } + const after = Y.encodeStateAsUpdateV2(server); + const viewDoc = new Y.Doc(); + Y.applyUpdateV2(viewDoc, after); + const editor = editorOn(viewDoc, experimental); + const stored = Y.encodeStateAsUpdateV2(viewDoc); + let writes = 0; + viewDoc.on("update", () => writes++); + const view = createYVersionView(editor, viewDoc.get("doc")).open(); + view.show({ + content: after, + comparison: { content: Y.encodeStateAsUpdateV2(start), attributions }, + target: { type: "snapshot", id: "after" }, + }); + const out: string[] = []; + editor.prosemirrorState.doc.descendants((node) => { + for (const mark of node.marks) { + if (!node.isText || !mark.type.name.startsWith("y-attributed-")) { + continue; + } + const kind = mark.type.name.slice(13); + out.push( + `${kind} ${node.text}: ${(mark.attrs["userIds"] ?? []).join(",")}`, + ); + } + // Block-level: inserted, deleted and moved blocks, and formatting changes. + if (node.type.name === "blockContainer") { + for (const mark of node.marks) { + const kind = !mark.attrs["moved"] + ? mark.type.name.slice(13) + : mark.type.name === "y-attributed-delete" + ? "moved from" + : "moved"; + if (["insert", "delete", "moved", "moved from"].includes(kind)) { + out.push( + `${kind} block ${node.firstChild!.textContent}: ${(mark.attrs["userIds"] ?? []).join(",")}`, + ); + } + } + } + if ( + node.isTextblock && + node.marks.some((mark) => mark.type.name === "y-attributed-attrs") && + !node.marks.some((mark) => mark.type.name !== "y-attributed-attrs") + ) { + out.push(`formatting ${node.textContent}`); + } + return true; + }); + view.close(); + // Showing a diff never writes to the document. + expect(writes).toBe(0); + expect(Y.encodeStateAsUpdateV2(viewDoc)).toEqual(stored); + return out; +} + +const nest = (id: string) => (editor: any) => { + editor.setTextCursorPosition(id); + editor.nestBlock(); +}; +const scenarios: Record void]>> = { + "move into a concurrently deleted block": [ + [1, "alice", (editor) => editor.removeBlocks(["parent"])], + [2, "bob", nest("x")], + ], + "type change": [ + [2, "bob", (editor) => editor.updateBlock("x", { type: "heading" })], + ], + indent: [[2, "bob", nest("x")]], + "move up": [ + [ + 2, + "bob", + (editor) => { + editor.setTextCursorPosition("next"); + editor.moveBlocksUp(); + }, + ], + ], + "text edit": [ + [ + 2, + "bob", + (editor) => { + editor.setTextCursorPosition("x", "end"); + editor.insertInlineContent("!"); + }, + ], + ], +}; + +const combinations: ExperimentalVersionDiffs[] = [ + {}, + { versionDiffFixes: "implicitDeleteAttribution" }, + { versionDiffFixes: "implicitDeleteAttributionAndRecreatedBlocks" }, +]; + +describe.each(combinations)("experimental diffs %o", (experimental) => { + it.each(Object.entries(scenarios))("%s", (_, edits) => { + expect(diff(experimental, edits)).toMatchSnapshot(); + }); +}); + +it("stores the same edits with any combination", () => { + for (const edits of Object.values(scenarios)) { + const stored = combinations.map((experimental) => + edits.map(([client, , change]) => + Array.from(edit(base(), client, experimental, change)), + ), + ); + for (const other of stored.slice(1)) { + expect(other).toEqual(stored[0]); + } + } +}); diff --git a/packages/core/src/y/utils.test.ts b/packages/core/src/y/utils.test.ts index 2174c6ae53..90cf35d4f3 100644 --- a/packages/core/src/y/utils.test.ts +++ b/packages/core/src/y/utils.test.ts @@ -1289,12 +1289,19 @@ describe("yNodeToTransaction", () => { deleted.marks .filter((mark) => mark.type.name === "y-attributed-delete") .map((mark) => mark.toJSON()), - ).toEqual([{ type: "y-attributed-delete", attrs: { userIds: ["bob"] } }]); + ).toEqual([ + { type: "y-attributed-delete", attrs: { userIds: ["bob"], moved: null } }, + ]); expect( inserted.marks .filter((mark) => mark.type.name === "y-attributed-insert") .map((mark) => mark.toJSON()), - ).toEqual([{ type: "y-attributed-insert", attrs: { userIds: ["bob"] } }]); + ).toEqual([ + { + type: "y-attributed-insert", + attrs: { userIds: ["bob"], moved: null }, + }, + ]); const heading = inserted.firstChild!; expect(heading.type.name).toBe("heading"); expect(heading.attrs.level).toBe(2); diff --git a/packages/react/src/components/AttributionTooltip/AttributionTooltip.tsx b/packages/react/src/components/AttributionTooltip/AttributionTooltip.tsx index 15866aeae3..c89a0993bc 100644 --- a/packages/react/src/components/AttributionTooltip/AttributionTooltip.tsx +++ b/packages/react/src/components/AttributionTooltip/AttributionTooltip.tsx @@ -37,6 +37,9 @@ export const AttributionTooltip = (props: AttributionTooltipProps) => { if (props.modificationType === "delete") { return users ? changes.deleted_by(users) : changes.deleted; } + if (props.modificationType === "move") { + return users ? changes.moved_by(users) : changes.moved; + } if (props.modificationType === "change") { return users ? changes.changed_by(users) : changes.changed; } diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/versioning.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/versioning.test.tsx.snap index 243de25f9c..8c599595f1 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/versioning.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/versioning.test.tsx.snap @@ -1,5 +1,964 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`versioning diff (experimental): A adds column then row, B adds column 1`] = ` +[ + "insert A", + "insert A", + "insert "C1" A", + "insert B", + "insert B", + "insert "D1" B", + "insert A", + "insert A", + "insert "C2" A", + "insert B", + "insert B", + "insert "D2" B", + "insert A", + "insert A", + "insert A", + "insert "A3" A", + "insert A", + "insert A", + "insert "B3" A", + "insert A", + "insert A", + "insert "C3" A", + "insert ", + "insert ", +] +`; + +exports[`versioning diff (experimental): A adds row then column, B adds row 1`] = ` +[ + "insert A", + "insert A", + "insert "C1" A", + "insert A", + "insert A", + "insert "C2" A", + "insert A", + "insert A", + "insert A", + "insert "A3" A", + "insert A", + "insert A", + "insert "B3" A", + "insert A", + "insert A", + "insert "C3" A", + "insert B", + "insert B", + "insert B", + "insert "D1" B", + "insert B", + "insert B", + "insert "D2" B", + "insert ", + "insert ", +] +`; + +exports[`versioning diff (experimental): Add a block to a column 1`] = ` +[ + "insert block "Added to the left column" A", + "insert A", + "insert "Added to the left column" A", +] +`; + +exports[`versioning diff (experimental): Add a link 1`] = ` +[ + "format "the site" A", +] +`; + +exports[`versioning diff (experimental): Add a whole document 1`] = ` +[ + "insert block "Welcome to this demo 🙌!" A", + "insert A", + "insert "Welcome to this " A", + "format "Welcome to this " A", + "insert "demo 🙌!" A", + "format "demo 🙌!" A", + "insert A", + "insert block "Hello World nested" A", + "insert A", + "insert "Hello World nested" A", + "insert A", + "insert block "Hello World double nested" A", + "insert A", + "insert "Hello World double nested" A", + "insert block "This paragraph has a background color" A", + "insert A", + "insert "This paragraph has a background color" A", + "format "This paragraph has a background color" A", + "insert block "Paragraph" A", + "insert A", + "insert "Paragraph" A", + "insert block "" A", + "insert A", + "insert block "Heading" A", + "insert A", + "insert "Heading" A", + "insert block "Heading right" A", + "insert A", + "insert "Heading right" A", + "insert block "Heading 2" A", + "insert A", + "insert "Heading 2" A", + "insert block "Heading 3" A", + "insert A", + "insert "Heading 3" A", + "insert block "Heading 4" A", + "insert A", + "insert "Heading 4" A", + "insert block "Heading 5" A", + "insert A", + "insert "Heading 5" A", + "insert block "Heading 6" A", + "insert A", + "insert "Heading 6" A", + "insert block "Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️" A", + "insert A", + "insert "Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️" A", + "insert block "Centered paragraph" A", + "insert A", + "insert "Centered paragraph" A", + "insert block "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." A", + "insert A", + "insert "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." A", + "insert block "" A", + "insert A", + "insert block "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." A", + "insert A", + "insert "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." A", + "insert A", + "insert block "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." A", + "insert A", + "insert "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." A", + "insert block "Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." A", + "insert A", + "insert "Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." A", + "insert block "Numbered List Item 1" A", + "insert A", + "insert "Numbered List Item 1" A", + "insert block "Numbered List Item 2" A", + "insert A", + "insert "Numbered List Item 2" A", + "insert A", + "insert block "Numbered List Item Nested 1" A", + "insert A", + "insert "Numbered List Item Nested 1" A", + "insert block "Numbered List Item Nested 2" A", + "insert A", + "insert "Numbered List Item Nested 2" A", + "insert block "Numbered List Item Nested funky right" A", + "insert A", + "insert "Numbered List Item Nested funky right" A", + "insert block "Numbered List Item Nested funky center" A", + "insert A", + "insert "Numbered List Item Nested funky center" A", + "insert block "Numbered List Item" A", + "insert A", + "insert "Numbered List Item" A", + "insert block "Check List Item" A", + "insert A", + "insert "Check List Item" A", + "insert block "Checked List Item" A", + "insert A", + "insert "Checked List Item" A", + "insert block "Numbered List Item starting at 5" A", + "insert A", + "insert "Numbered List Item starting at 5" A", + "insert block "Numbered List Item 6" A", + "insert A", + "insert "Numbered List Item 6" A", + "insert block "Toggle List Item" A", + "insert A", + "insert "Toggle List Item" A", + "insert A", + "insert block "Content nested inside the toggle list item." A", + "insert A", + "insert "Content nested inside the toggle list item." A", + "insert block "A nested bullet inside the toggle" A", + "insert A", + "insert "A nested bullet inside the toggle" A", + "insert block "Toggle Heading" A", + "insert A", + "insert "Toggle Heading" A", + "insert A", + "insert block "Content nested inside the toggle heading." A", + "insert A", + "insert "Content nested inside the toggle heading." A", + "insert block "Wide CellTable CellTable CellWide CellTable CellTable CellWide CellTable CellTable Cell" A", + "insert A", + "insert A", + "insert A", + "insert A", + "insert "Wide Cell" A", + "insert A", + "insert A", + "insert "Table Cell" A", + "insert A", + "insert A", + "insert "Table Cell" A", + "insert A", + "insert A", + "insert A", + "insert "Wide Cell" A", + "insert A", + "insert A", + "insert "Table Cell" A", + "insert A", + "insert A", + "insert "Table Cell" A", + "insert A", + "insert A", + "insert A", + "insert "Wide Cell" A", + "insert A", + "insert A", + "insert "Table Cell" A", + "insert A", + "insert A", + "insert "Table Cell" A", + "insert block "" A", + "insert A", + "insert block "" A", + "insert A", + "insert block "" A", + "insert A", + "insert block "" A", + "insert
A", + "insert A", + "insert A", + "insert A", + "insert "Table Header 1" A", + "insert A", + "insert A", + "insert "Table Header 2" A", + "insert A", + "insert A", + "insert "Table Header 3" A", + "insert A", + "insert A", + "insert A", + "insert "Table Cell 4" A", + "insert A", + "insert A", + "insert "Table Cell Bold Colored 5" A", + "format "Table Cell Bold Colored 5" A", + "insert A", + "insert A", + "insert "Table Cell 6" A", + "insert A", + "insert A", + "insert A", + "insert "Table Cell 7" A", + "insert A", + "insert A", + "insert "Table Cell 8" A", + "insert A", + "insert A", + "insert "Table Cell 9" A", + "insert block "Merged HeaderHeader CHeader AHeader BHeader C2Merged RowsCell B1Cell C1Cell B2Cell C2" A", + "insert
A", + "insert A", + "insert A", + "insert A", + "insert "Merged Header" A", + "insert A", + "insert A", + "insert "Header C" A", + "insert A", + "insert A", + "insert A", + "insert "Header A" A", + "insert A", + "insert A", + "insert "Header B" A", + "insert A", + "insert A", + "insert "Header C2" A", + "insert A", + "insert A", + "insert A", + "insert "Merged Rows" A", + "insert A", + "insert A", + "insert "Cell B1" A", + "insert A", + "insert A", + "insert "Cell C1" A", + "insert A", + "insert A", + "insert A", + "insert "Cell B2" A", + "insert A", + "insert A", + "insert "Cell C2" A", + "insert block "A hard line breakwithin a single paragraph" A", + "insert A", + "insert "A hard line break" A", + "insert A", + "insert "within a single paragraph" A", + "insert block "const helloWorld = (message) => {\\n console.log(\\"Hello World\\", message);\\n};" A", + "insert A", + "insert "const helloWorld = (message) => {\\n console.log(\\"Hello World\\", message);\\n};" A", + "insert block "Some inline code: var foo = 'bar';" A", + "insert A", + "insert "Some inline code: " A", + "format "Some inline code: " A", + "insert "var foo = 'bar';" A", + "format "var foo = 'bar';" A", + "insert A", + "insert A", + "insert block "This paragraph is in a column!" A", + "insert A", + "insert "This paragraph is in a column!" A", + "insert A", + "insert block "So is this heading!" A", + "insert A", + "insert "So is this heading!" A", + "insert A", + "insert block "You can have multiple blocks in a column too" A", + "insert A", + "insert "You can have multiple blocks in a column too" A", + "insert block "Block 1" A", + "insert A", + "insert "Block 1" A", + "insert block "Block 2" A", + "insert A", + "insert "Block 2" A", + "insert block "Block 3" A", + "insert A", + "insert "Block 3" A", + "insert block "" A", + "insert A", + "insert block "All those moments will be lost in time, like tears in rain." A", + "insert A", + "insert "All those moments will be lost in time, like tears in rain." A", +] +`; + +exports[`versioning diff (experimental): Add an empty block 1`] = ` +[ + "insert block "" A", + "insert A", +] +`; + +exports[`versioning diff (experimental): Add bold 1`] = ` +[ + "format "world" A", +] +`; + +exports[`versioning diff (experimental): Add bullet item 1`] = ` +[ + "insert A", + "insert block "New bullet" A", + "insert A", + "insert "New bullet" A", +] +`; + +exports[`versioning diff (experimental): Add colored block with child 1`] = ` +[ + "insert A", + "insert block "Colored parent" A", + "insert A", + "insert "Colored parent" A", + "insert A", + "insert block "Child block" A", + "insert A", + "insert "Child block" A", +] +`; + +exports[`versioning diff (experimental): Add column 1`] = ` +[ + "insert A", + "insert A", + "insert "C1" A", + "insert A", + "insert A", + "insert "C2" A", +] +`; + +exports[`versioning diff (experimental): Add column vs add row 1`] = ` +[ + "insert A", + "insert A", + "insert "C1" A", + "insert A", + "insert A", + "insert "C2" A", + "insert B", + "insert B", + "insert B", + "insert "A3" B", + "insert B", + "insert B", + "insert "B3" B", + "insert ", + "insert ", +] +`; + +exports[`versioning diff (experimental): Add heading 1`] = ` +[ + "insert A", + "insert block "New heading" A", + "insert A", + "insert "New heading" A", +] +`; + +exports[`versioning diff (experimental): Add italic over bold 1`] = ` +[ + "format "world" A", +] +`; + +exports[`versioning diff (experimental): Add nested bullets 1`] = ` +[ + "insert A", + "insert block "Level 0" A", + "insert A", + "insert "Level 0" A", + "insert A", + "insert block "Level 1" A", + "insert A", + "insert "Level 1" A", + "insert A", + "insert block "Level 2" A", + "insert A", + "insert "Level 2" A", +] +`; + +exports[`versioning diff (experimental): Add numbered item 1`] = ` +[ + "insert A", + "insert block "New numbered" A", + "insert A", + "insert "New numbered" A", +] +`; + +exports[`versioning diff (experimental): Add paragraph after a block 1`] = ` +[ + "insert block "Body text" A", + "insert A", + "insert "Body text" A", +] +`; + +exports[`versioning diff (experimental): Add row 1`] = ` +[ + "insert A", + "insert A", + "insert A", + "insert "A3" A", + "insert A", + "insert A", + "insert "B3" A", +] +`; + +exports[`versioning diff (experimental): Add row vs add column 1`] = ` +[ + "insert B", + "insert B", + "insert "C1" B", + "insert B", + "insert B", + "insert "C2" B", + "insert A", + "insert A", + "insert A", + "insert "A3" A", + "insert A", + "insert A", + "insert "B3" A", + "insert ", + "insert ", +] +`; + +exports[`versioning diff (experimental): Bold vs italic 1`] = ` +[ + "format "world" A,B", +] +`; + +exports[`versioning diff (experimental): Both nest a new block under N0 1`] = ` +[ + "delete block "N0" A,B", + "insert block "N0" A", + "insert A", + "insert "N0" A", + "insert A", + "insert block "N1" A", + "insert A", + "insert "N1" A", + "insert block "N0" B", + "insert B", + "insert "N0" B", + "insert B", + "insert block "N2" B", + "insert B", + "insert "N2" B", +] +`; + +exports[`versioning diff (experimental): Cascading indents 1`] = ` +[ + "delete block "N1" A,B", + "insert A", + "insert block "N1" A", + "insert A", + "insert "N1" A", + "insert block "N1" B", + "insert B", + "insert "N1" B", + "insert B", + "moved block "N2" B", +] +`; + +exports[`versioning diff (experimental): Center-align 1`] = ` +[ + "attrs textAlignment:A", +] +`; + +exports[`versioning diff (experimental): Change a parent's type vs edit its child 1`] = ` +[ + "attrs backgroundColor:A textColor:A textAlignment:A level:A isToggleable:A", +] +`; + +exports[`versioning diff (experimental): Change image source 1`] = ` +[ + "attrs url:A", +] +`; + +exports[`versioning diff (experimental): Change type of a parent block 1`] = ` +[ + "attrs backgroundColor:A textColor:A textAlignment:A level:A isToggleable:A", +] +`; + +exports[`versioning diff (experimental): Create two columns 1`] = ` +[ + "insert A", + "insert A", + "insert block "Left column" A", + "insert A", + "insert "Left column" A", + "insert A", + "insert block "Right column" A", + "insert A", + "insert "Right column" A", +] +`; + +exports[`versioning diff (experimental): Delete a code block 1`] = ` +[ + "delete block "const x = 1;" A", + "insert block "" A", + "insert A", +] +`; + +exports[`versioning diff (experimental): Delete a divider 1`] = ` +[ + "delete block "" A", + "insert block "" A", + "insert A", +] +`; + +exports[`versioning diff (experimental): Delete a nested block 1`] = ` +[ + "delete block "Parent" A", + "insert block "Parent" A", + "insert A", + "insert "Parent" A", +] +`; + +exports[`versioning diff (experimental): Delete a parent block 1`] = ` +[ + "delete block "Parent" A", + "insert block "" A", + "insert A", +] +`; + +exports[`versioning diff (experimental): Delete a parent vs change its child's type 1`] = ` +[ + "delete block "Parent" A", +] +`; + +exports[`versioning diff (experimental): Delete a parent vs type in its child 1`] = ` +[ + "delete block "Parent" A", +] +`; + +exports[`versioning diff (experimental): Delete column vs add row 1`] = ` +[ + "delete A", + "delete A", + "insert B", + "insert B", + "insert B", + "insert "A3" B", + "insert B", + "insert B", + "insert "B3" B", +] +`; + +exports[`versioning diff (experimental): Delete image 1`] = ` +[ + "delete block "" A", + "insert block "" A", + "insert A", +] +`; + +exports[`versioning diff (experimental): Delete one of two empty blocks 1`] = ` +[ + "delete block "" A", +] +`; + +exports[`versioning diff (experimental): Delete parent with mixed children 1`] = ` +[ + "delete block "Parent" A", + "insert block "" A", + "insert A", +] +`; + +exports[`versioning diff (experimental): Delete row vs add column 1`] = ` +[ + "insert B", + "insert B", + "insert "C1" B", + "delete A", +] +`; + +exports[`versioning diff (experimental): Demote heading 1`] = ` +[ + "attrs level:A", +] +`; + +exports[`versioning diff (experimental): Edit a cell 1`] = ` +[ + "insert " edited" A", +] +`; + +exports[`versioning diff (experimental): Edit a diagram 1`] = ` +[ + "delete "Rev" A", + "insert "Publ" A", + "delete "ew" A", + "insert "sh" A", +] +`; + +exports[`versioning diff (experimental): Edit a link 1`] = ` +[ + "format "the " A", + "delete "old" A", + "insert "new" A", + "format "new" A", + "format " site" A", +] +`; + +exports[`versioning diff (experimental): Edit text vs change to heading 1`] = ` +[ + "attrs backgroundColor:B textColor:B textAlignment:B level:B isToggleable:B", +] +`; + +exports[`versioning diff (experimental): Enter at the start of a heading 1`] = ` +[ + "delete "Title" A", + "insert block "Title" A", + "insert A", + "insert "Title" A", +] +`; + +exports[`versioning diff (experimental): Fix typo vs delete word 1`] = ` +[ + "delete "w" B", + "insert "o" A", + "delete "r" B", + "delete "o" B,A", + "delete "ld" B", +] +`; + +exports[`versioning diff (experimental): Heading vs list item 1`] = ` +[ + "delete block "hello world" A,B", + "insert block "hello world" A", + "insert A", + "insert "hello world" A", + "insert block "hello world" B", + "insert B", + "insert "hello world" B", +] +`; + +exports[`versioning diff (experimental): Highlight a column 1`] = ` +[ + "attrs backgroundColor:A", + "attrs backgroundColor:A", +] +`; + +exports[`versioning diff (experimental): Indent a block 1`] = ` +[ + "insert A", + "moved block "N1" A", +] +`; + +exports[`versioning diff (experimental): Indent a block vs edit its text 1`] = ` +[ + "insert A", + "moved block "N1" A", +] +`; + +exports[`versioning diff (experimental): Insert a divider 1`] = ` +[ + "insert block "" A", + "insert A", +] +`; + +exports[`versioning diff (experimental): Insert an image 1`] = ` +[ + "insert A", + "insert block "" A", + "insert A", +] +`; + +exports[`versioning diff (experimental): List item → paragraph 1`] = ` +[ + "attrs backgroundColor:A textColor:A textAlignment:A", +] +`; + +exports[`versioning diff (experimental): Merge blocks vs edit block B 1`] = ` +[ + "insert "Second" A", + "delete block "Second" A", +] +`; + +exports[`versioning diff (experimental): Merge cells 1`] = ` +[ + "attrs colspan:A colwidth:A", + "insert "+B1" A", + "delete A", + "insert ", + "insert ", +] +`; + +exports[`versioning diff (experimental): Move a block into a block that is deleted 1`] = ` +[ + "delete block "Parent" A", + "delete block "X"", +] +`; + +exports[`versioning diff (experimental): Move paragraph up 1`] = ` +[ + "moved block "Middle" A", + "moved from block "Middle" A", +] +`; + +exports[`versioning diff (experimental): Move paragraph with children 1`] = ` +[ + "moved block "Parent" A", + "moved from block "Parent" A", +] +`; + +exports[`versioning diff (experimental): Nest a bullet under another 1`] = ` +[ + "insert A", + "moved block "Child" A", +] +`; + +exports[`versioning diff (experimental): Nest blocks into a block that is moved 1`] = ` +[ + "delete block "Q" B,A", + "insert B", + "insert block "Q" B", + "insert B", + "insert "Q" B", + "insert block "Q" A", + "insert A", + "insert "Q" A", + "insert A", + "moved block "B1" A", + "moved block "B2" A", + "moved block "B3" A", +] +`; + +exports[`versioning diff (experimental): Paragraph → heading 1`] = ` +[ + "attrs backgroundColor:A textColor:A textAlignment:A level:A isToggleable:A", +] +`; + +exports[`versioning diff (experimental): Remove a column 1`] = ` +[ + "delete A", + "moved block "Left column" A", +] +`; + +exports[`versioning diff (experimental): Remove a link 1`] = ` +[ + "format "the site" A", +] +`; + +exports[`versioning diff (experimental): Remove a middle column 1`] = ` +[ + "delete A", +] +`; + +exports[`versioning diff (experimental): Remove a paragraph 1`] = ` +[ + "delete block "Body text" A", +] +`; + +exports[`versioning diff (experimental): Remove bold 1`] = ` +[ + "format "world" A", +] +`; + +exports[`versioning diff (experimental): Remove column 1`] = ` +[ + "delete A", + "delete A", +] +`; + +exports[`versioning diff (experimental): Remove row 1`] = ` +[ + "delete A", +] +`; + +exports[`versioning diff (experimental): Remove the only block 1`] = ` +[ + "attrs block "Only block" id:A", + "delete "Only block" A", +] +`; + +exports[`versioning diff (experimental): Rename a word 1`] = ` +[ + "delete "wo" A", + "insert "unive" A", + "delete "ld" A", + "insert "se" A", +] +`; + +exports[`versioning diff (experimental): Resize image 1`] = ` +[ + "attrs previewWidth:A", +] +`; + +exports[`versioning diff (experimental): Split a block vs type at end 1`] = ` +[ + "delete "world" B", + "insert "!" A", + "insert block "world" B", + "insert B", + "insert "world" B", +] +`; + +exports[`versioning diff (experimental): Split a merged cell 1`] = ` +[ + "attrs colspan:A colwidth:A", + "delete "+B1" A", + "insert A", + "insert A", + "insert "B1" A", +] +`; + +exports[`versioning diff (experimental): Text color vs background color 1`] = ` +[ + "attrs backgroundColor:B textColor:A", +] +`; + +exports[`versioning diff (experimental): Unindent a block 1`] = ` +[ + "moved block "N1" A", +] +`; + exports[`versioning diff: A adds column then row, B adds column 1`] = ` [ "insert A", diff --git a/tests/src/end-to-end/y-prosemirror/versioning.test.tsx b/tests/src/end-to-end/y-prosemirror/versioning.test.tsx index 4723a97674..76efa4c65d 100644 --- a/tests/src/end-to-end/y-prosemirror/versioning.test.tsx +++ b/tests/src/end-to-end/y-prosemirror/versioning.test.tsx @@ -17,6 +17,7 @@ import { blocksToYDoc, getAttributeChanges, createYVersionView, + type ExperimentalVersionDiffs, withCollaboration, } from "@blocknote/core/y"; import * as Y from "@y/y"; @@ -52,7 +53,10 @@ function cloneWithId(source: Y.Doc, clientID: number): Y.Doc { } /** Mount a collaborative editor on `doc`, returning it + a teardown. */ -function mountEditor(doc: Y.Doc): { +function mountEditor( + doc: Y.Doc, + experimental: ExperimentalVersionDiffs = {}, +): { editor: GalleryEditor; teardown: () => void; } { @@ -65,6 +69,7 @@ function mountEditor(doc: Y.Doc): { fragment: doc.get("doc"), provider: undefined, user: { name: "User", color: "#8a6d1a" }, + experimental, }, }), ); @@ -93,14 +98,24 @@ const propertyChanges = new Map([ ["prop-image-source", "url"], ]); -for (const scenario of scenarios) { +// Each scenario's diff with the experimental flags off (as in the editor) and +// all on. +const ALL_FIXES: ExperimentalVersionDiffs = { + versionDiffFixes: "implicitDeleteAttributionAndRecreatedBlocks", +}; +const cases = scenarios.flatMap((scenario) => [ + { scenario, name: "versioning diff", experimental: {} }, + { scenario, name: "versioning diff (experimental)", experimental: ALL_FIXES }, +]); + +for (const { scenario, name, experimental } of cases) { const applies = scenario.kind === "single" ? [scenario.apply] : [scenario.applyA, scenario.applyB]; const runner = VERSIONING_CRASHES.has(scenario.id) ? test.skip : test; - runner(`versioning diff: ${scenario.title}`, async () => { + runner(`${name}: ${scenario.title}`, async () => { const teardown: Array<() => void> = []; try { // "Before": the scenario's initial blocks, seeded synchronously. @@ -136,7 +151,10 @@ for (const scenario of scenarios) { // The versioning diff render — this is the path that throws for the // nested-move / table-merge crashers. - const { editor: diffEditor, teardown: unmount } = mountEditor(afterDoc); + const { editor: diffEditor, teardown: unmount } = mountEditor( + afterDoc, + experimental, + ); teardown.push(unmount); const view = createYVersionView(diffEditor, afterDoc.get("doc")).open(); teardown.push(() => view.close()); @@ -168,7 +186,11 @@ for (const scenario of scenarios) { ? `block ${JSON.stringify(node.firstChild?.textContent ?? "")}` : `<${node.type.name}>`; for (const mark of marks) { - const kind = mark.type.name.replace("y-attributed-", ""); + const kind = !mark.attrs["moved"] + ? mark.type.name.replace("y-attributed-", "") + : mark.type.name === "y-attributed-delete" + ? "moved from" + : "moved"; if (kind === "attrs" && replaced) { continue; }