diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index 5d685bee..56dc4144 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -425,6 +425,19 @@ export const repoCatalog = { "SiblingLocation", "SiblingRangeErrorCode", "SiblingRangeResult", + "TextSurface", + "TextSurfaceAtom", + "TextSurfaceError", + "TextSurfaceErrorCode", + "TextSurfaceFragment", + "TextSurfaceFragmentResult", + "TextSurfaceMutationRange", + "TextSurfaceMutationResult", + "TextSurfaceRange", + "TextSurfaceReplaceOptions", + "TextSurfaceReplaceResult", + "TextSurfaceReplacement", + "TextSurfaceSelectionRange", "appendSegment", "applyOperation", "applyPatch", @@ -436,13 +449,16 @@ export const repoCatalog = { "lastSegmentIndex", "parentPointer", "parsePointer", + "replaceTextSurfaceSelection", "resolveSiblingRange", + "syncTextSurfaceMutation", + "textSurfaceFragment", "trackPointer", "tryParsePointer", "unescapeSegment", "withLastSegment" ], - "publicExportCount": 118, + "publicExportCount": 134, "keywords": [ "clipboard", "crud", diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index 61405244..1899b1dd 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -424,6 +424,19 @@ "SiblingLocation", "SiblingRangeErrorCode", "SiblingRangeResult", + "TextSurface", + "TextSurfaceAtom", + "TextSurfaceError", + "TextSurfaceErrorCode", + "TextSurfaceFragment", + "TextSurfaceFragmentResult", + "TextSurfaceMutationRange", + "TextSurfaceMutationResult", + "TextSurfaceRange", + "TextSurfaceReplaceOptions", + "TextSurfaceReplaceResult", + "TextSurfaceReplacement", + "TextSurfaceSelectionRange", "appendSegment", "applyOperation", "applyPatch", @@ -435,13 +448,16 @@ "lastSegmentIndex", "parentPointer", "parsePointer", + "replaceTextSurfaceSelection", "resolveSiblingRange", + "syncTextSurfaceMutation", + "textSurfaceFragment", "trackPointer", "tryParsePointer", "unescapeSegment", "withLastSegment" ], - "publicExportCount": 118, + "publicExportCount": 134, "keywords": [ "clipboard", "crud", diff --git a/packages/json-document/public-contract.json b/packages/json-document/public-contract.json index 8ec467da..3b09f02b 100644 --- a/packages/json-document/public-contract.json +++ b/packages/json-document/public-contract.json @@ -14,7 +14,10 @@ "lastSegmentIndex", "parentPointer", "parsePointer", + "replaceTextSurfaceSelection", "resolveSiblingRange", + "syncTextSurfaceMutation", + "textSurfaceFragment", "trackPointer", "tryParsePointer", "unescapeSegment", @@ -120,7 +123,20 @@ "SelectionTextEditErrorCode", "SelectionTextEditOptions", "SelectionTextEditsResult", - "ClipboardSource" + "ClipboardSource", + "TextSurface", + "TextSurfaceAtom", + "TextSurfaceError", + "TextSurfaceErrorCode", + "TextSurfaceFragment", + "TextSurfaceFragmentResult", + "TextSurfaceMutationRange", + "TextSurfaceMutationResult", + "TextSurfaceRange", + "TextSurfaceReplaceOptions", + "TextSurfaceReplaceResult", + "TextSurfaceReplacement", + "TextSurfaceSelectionRange" ] }, "react": { diff --git a/packages/json-document/src/domain/text-surface/surface.ts b/packages/json-document/src/domain/text-surface/surface.ts new file mode 100644 index 00000000..0c40be8d --- /dev/null +++ b/packages/json-document/src/domain/text-surface/surface.ts @@ -0,0 +1,618 @@ +import { cloneJson } from "../../foundation/json/clone.js"; +import type { JSONPatchOperation } from "../../foundation/patch/contract.js"; +import { + escapeSegment, + readAt, + tryParsePointer, + type Pointer, +} from "../../foundation/pointer/index.js"; +import type { SelectionPoint, SelectionPointObject } from "../selection/point.js"; +import type { SelectionSnap } from "../selection/snap.js"; + +export interface TextSurface { + textPath: Pointer; + atomsPath?: Pointer | null; + rangesPath?: Pointer | null; +} + +export interface TextSurfaceAtom { + offset: number; + [key: string]: unknown; +} + +export interface TextSurfaceRange { + start: number; + end: number; + [key: string]: unknown; +} + +export interface TextSurfaceFragment { + text: string; + atoms?: Record; + ranges?: Record; +} + +export type TextSurfaceReplacement = string | TextSurfaceFragment; + +export interface TextSurfaceReplaceOptions { + /** Optional affinity attached to the collapsed selection produced after replacement. */ + affinity?: SelectionPointObject["affinity"]; +} + +export type TextSurfaceErrorCode = + | "invalid_pointer" + | "invalid_sidecar" + | "missing_selection" + | "multi_pointer_range" + | "not_string" + | "path_not_found"; + +export interface TextSurfaceError { + ok: false; + code: TextSurfaceErrorCode; + reason: string; + pointer: Pointer | null; +} + +export type TextSurfaceFragmentResult = + | { + ok: true; + fragment: TextSurfaceFragment; + selectionRange: TextSurfaceSelectionRange; + } + | TextSurfaceError; + +export type TextSurfaceReplaceResult = + | { + ok: true; + patch: JSONPatchOperation[]; + selectionAfter: SelectionSnap; + selectionRange: TextSurfaceSelectionRange; + fragment: TextSurfaceFragment; + } + | TextSurfaceError; + +export type TextSurfaceMutationResult = + | { + ok: true; + patch: JSONPatchOperation[]; + mutationRange: TextSurfaceMutationRange; + } + | TextSurfaceError; + +export interface TextSurfaceSelectionRange { + textPath: Pointer; + start: number; + end: number; + anchorOffset: number; + focusOffset: number; + collapsed: boolean; +} + +export interface TextSurfaceMutationRange { + start: number; + end: number; + insertedTextLength: number; +} + +export function textSurfaceFragment( + selection: SelectionSnap, + state: unknown, + surface: TextSurface, +): TextSurfaceFragmentResult { + const range = textSurfaceSelectionRange(selection, state, surface); + if (!range.ok) return range; + + const text = readString(state, surface.textPath); + if (!text.ok) return text; + + const atoms = textSurfaceAtomsInRange(state, surface.atomsPath ?? null, range.range); + if (!atoms.ok) return atoms; + const ranges = textSurfaceRangesInRange(state, surface.rangesPath ?? null, range.range); + if (!ranges.ok) return ranges; + + return { + ok: true, + fragment: compactFragment({ + text: text.value.slice(range.range.start, range.range.end), + atoms: atoms.atoms, + ranges: ranges.ranges, + }), + selectionRange: range.range, + }; +} + +export function replaceTextSurfaceSelection( + selection: SelectionSnap, + state: unknown, + surface: TextSurface, + replacement: TextSurfaceReplacement, + options: TextSurfaceReplaceOptions = {}, +): TextSurfaceReplaceResult { + const range = textSurfaceSelectionRange(selection, state, surface); + if (!range.ok) return range; + + const text = readString(state, surface.textPath); + if (!text.ok) return text; + + const fragment = normalizeReplacement(replacement); + const nextText = `${text.value.slice(0, range.range.start)}${fragment.text}${text.value.slice(range.range.end)}`; + const patch: JSONPatchOperation[] = [ + { op: "replace", path: surface.textPath, value: nextText }, + ]; + const sidecar = textSurfaceSidecarReplacementPatch( + state, + surface, + range.range, + fragment, + ); + if (!sidecar.ok) return sidecar; + patch.push(...sidecar.patch); + + return { + ok: true, + patch, + selectionAfter: textSurfaceSelectionAfter( + surface.textPath, + range.range.start + fragment.text.length, + options, + selection.context, + ), + selectionRange: range.range, + fragment, + }; +} + +export function syncTextSurfaceMutation( + state: unknown, + surface: TextSurface, + previousText: string, + nextText: string, +): TextSurfaceMutationResult { + const current = readString(state, surface.textPath); + if (!current.ok) return current; + + const mutationRange = changedTextRange(previousText, nextText); + if (mutationRange.start === mutationRange.end && mutationRange.insertedTextLength === 0) { + return { ok: true, patch: [], mutationRange }; + } + + const patch: JSONPatchOperation[] = [ + { op: "replace", path: surface.textPath, value: nextText }, + ]; + const sidecar = textSurfaceSidecarReplacementPatch( + state, + surface, + { + textPath: surface.textPath, + start: mutationRange.start, + end: mutationRange.end, + anchorOffset: mutationRange.start, + focusOffset: mutationRange.end, + collapsed: mutationRange.start === mutationRange.end, + }, + { text: nextText.slice(mutationRange.start, mutationRange.start + mutationRange.insertedTextLength) }, + ); + if (!sidecar.ok) return sidecar; + patch.push(...sidecar.patch); + return { ok: true, patch, mutationRange }; +} + +function textSurfaceSelectionRange( + selection: SelectionSnap, + state: unknown, + surface: TextSurface, +): { ok: true; range: TextSurfaceSelectionRange } | TextSurfaceError { + const text = readString(state, surface.textPath); + if (!text.ok) return text; + + const range = selection.selectionRanges[selection.primaryIndex]; + if (range === undefined) { + return { + ok: false, + code: "missing_selection", + reason: "text surface selection has no primary range", + pointer: surface.textPath, + }; + } + if (!isOffsetPoint(range.anchor) || !isOffsetPoint(range.focus)) { + return { + ok: false, + code: "missing_selection", + reason: "text surface selection points must include offsets", + pointer: surface.textPath, + }; + } + if (range.anchor.path !== surface.textPath || range.focus.path !== surface.textPath) { + return { + ok: false, + code: "multi_pointer_range", + reason: `text surface selection must stay inside ${surface.textPath}`, + pointer: surface.textPath, + }; + } + + const anchorOffset = clampOffset(range.anchor.offset, text.value.length); + const focusOffset = clampOffset(range.focus.offset, text.value.length); + return { + ok: true, + range: { + textPath: surface.textPath, + start: Math.min(anchorOffset, focusOffset), + end: Math.max(anchorOffset, focusOffset), + anchorOffset, + focusOffset, + collapsed: anchorOffset === focusOffset, + }, + }; +} + +function textSurfaceSidecarReplacementPatch( + state: unknown, + surface: TextSurface, + range: TextSurfaceSelectionRange, + fragment: TextSurfaceFragment, +): { ok: true; patch: JSONPatchOperation[] } | TextSurfaceError { + const patch: JSONPatchOperation[] = []; + const atoms = textSurfaceAtomReplacementPatch( + state, + surface.atomsPath ?? null, + range, + fragment.atoms ?? {}, + fragment.text.length, + ); + if (!atoms.ok) return atoms; + patch.push(...atoms.patch); + + const ranges = textSurfaceRangeReplacementPatch( + state, + surface.rangesPath ?? null, + range, + fragment.ranges ?? {}, + fragment.text.length, + ); + if (!ranges.ok) return ranges; + patch.push(...ranges.patch); + return { ok: true, patch }; +} + +function textSurfaceAtomReplacementPatch( + state: unknown, + atomsPath: Pointer | null, + range: TextSurfaceSelectionRange, + insertedAtoms: Record, + insertedTextLength: number, +): { ok: true; patch: JSONPatchOperation[] } | TextSurfaceError { + if (atomsPath === null) return { ok: true, patch: [] }; + const atoms = readAtomRecords(state, atomsPath); + if (!atoms.ok) return atoms; + + const patch: JSONPatchOperation[] = []; + const delta = insertedTextLength - (range.end - range.start); + const removed = new Set(); + for (const [id, atom] of Object.entries(atoms.atoms)) { + const path = `${atomsPath}/${escapeSegment(id)}`; + if (range.start <= atom.offset && atom.offset < range.end) { + removed.add(id); + patch.push({ op: "remove", path }); + continue; + } + if (atom.offset >= range.end) { + patch.push({ op: "replace", path: `${path}/offset`, value: atom.offset + delta }); + } + } + + const reserved = new Set(Object.keys(atoms.atoms)); + for (const id of removed) reserved.delete(id); + for (const [id, atom] of Object.entries(insertedAtoms)) { + const nextId = uniqueSidecarId(id, reserved); + reserved.add(nextId); + patch.push({ + op: "add", + path: `${atomsPath}/${escapeSegment(nextId)}`, + value: { ...cloneJson(atom), offset: range.start + atom.offset }, + }); + } + return { ok: true, patch }; +} + +function textSurfaceRangeReplacementPatch( + state: unknown, + rangesPath: Pointer | null, + range: TextSurfaceSelectionRange, + insertedRanges: Record, + insertedTextLength: number, +): { ok: true; patch: JSONPatchOperation[] } | TextSurfaceError { + if (rangesPath === null) return { ok: true, patch: [] }; + const ranges = readRangeRecords(state, rangesPath); + if (!ranges.ok) return ranges; + + const patch: JSONPatchOperation[] = []; + const delta = insertedTextLength - (range.end - range.start); + const removed = new Set(); + for (const [id, existing] of Object.entries(ranges.ranges)) { + const nextStart = mapRangeStart(existing.start, range, delta); + const nextEnd = mapRangeEnd(existing.end, range, insertedTextLength, delta); + const path = `${rangesPath}/${escapeSegment(id)}`; + if (nextEnd <= nextStart) { + removed.add(id); + patch.push({ op: "remove", path }); + continue; + } + if (nextStart !== existing.start) { + patch.push({ op: "replace", path: `${path}/start`, value: nextStart }); + } + if (nextEnd !== existing.end) { + patch.push({ op: "replace", path: `${path}/end`, value: nextEnd }); + } + } + + const reserved = new Set(Object.keys(ranges.ranges)); + for (const id of removed) reserved.delete(id); + for (const [id, inserted] of Object.entries(insertedRanges)) { + const nextId = uniqueSidecarId(id, reserved); + reserved.add(nextId); + patch.push({ + op: "add", + path: `${rangesPath}/${escapeSegment(nextId)}`, + value: { + ...cloneJson(inserted), + start: range.start + inserted.start, + end: range.start + inserted.end, + }, + }); + } + return { ok: true, patch }; +} + +function textSurfaceAtomsInRange( + state: unknown, + atomsPath: Pointer | null, + range: TextSurfaceSelectionRange, +): { ok: true; atoms: Record } | TextSurfaceError { + if (atomsPath === null) return { ok: true, atoms: {} }; + const atoms = readAtomRecords(state, atomsPath); + if (!atoms.ok) return atoms; + const selected: Record = {}; + for (const [id, atom] of Object.entries(atoms.atoms)) { + if (range.start <= atom.offset && atom.offset < range.end) { + selected[id] = { ...cloneJson(atom), offset: atom.offset - range.start }; + } + } + return { ok: true, atoms: selected }; +} + +function textSurfaceRangesInRange( + state: unknown, + rangesPath: Pointer | null, + range: TextSurfaceSelectionRange, +): { ok: true; ranges: Record } | TextSurfaceError { + if (rangesPath === null) return { ok: true, ranges: {} }; + const ranges = readRangeRecords(state, rangesPath); + if (!ranges.ok) return ranges; + const selected: Record = {}; + for (const [id, existing] of Object.entries(ranges.ranges)) { + const start = Math.max(existing.start, range.start); + const end = Math.min(existing.end, range.end); + if (start < end) { + selected[id] = { + ...cloneJson(existing), + start: start - range.start, + end: end - range.start, + }; + } + } + return { ok: true, ranges: selected }; +} + +function readString( + state: unknown, + pointer: Pointer, +): { ok: true; value: string } | TextSurfaceError { + const value = readPointer(state, pointer); + if (!value.ok) return value; + if (typeof value.value !== "string") { + return { + ok: false, + code: "not_string", + reason: `text surface target is not a string: ${pointer}`, + pointer, + }; + } + return { ok: true, value: value.value }; +} + +function readAtomRecords( + state: unknown, + pointer: Pointer, +): { ok: true; atoms: Record } | TextSurfaceError { + const record = readRecord(state, pointer); + if (!record.ok) return record; + const atoms: Record = {}; + for (const [id, value] of Object.entries(record.value)) { + if (isRecord(value) && typeof value.offset === "number") { + atoms[id] = value as TextSurfaceAtom; + continue; + } + return invalidSidecar(pointer, `text surface atom must include numeric offset: ${id}`); + } + return { ok: true, atoms }; +} + +function readRangeRecords( + state: unknown, + pointer: Pointer, +): { ok: true; ranges: Record } | TextSurfaceError { + const record = readRecord(state, pointer); + if (!record.ok) return record; + const ranges: Record = {}; + for (const [id, value] of Object.entries(record.value)) { + if ( + isRecord(value) && + typeof value.start === "number" && + typeof value.end === "number" + ) { + ranges[id] = value as TextSurfaceRange; + continue; + } + return invalidSidecar(pointer, `text surface range must include numeric start/end: ${id}`); + } + return { ok: true, ranges }; +} + +function readRecord( + state: unknown, + pointer: Pointer, +): { ok: true; value: Record } | TextSurfaceError { + const value = readPointer(state, pointer); + if (!value.ok) return value; + if (!isRecord(value.value) || Array.isArray(value.value)) { + return invalidSidecar(pointer, `text surface sidecar is not a record: ${pointer}`); + } + return { ok: true, value: value.value }; +} + +function readPointer( + state: unknown, + pointer: Pointer, +): { ok: true; value: unknown } | TextSurfaceError { + const segments = tryParsePointer(pointer); + if (segments === null) { + return { + ok: false, + code: "invalid_pointer", + reason: `invalid text surface pointer: ${pointer}`, + pointer, + }; + } + const value = readAt(state, segments); + if (!value.ok) { + return { + ok: false, + code: "path_not_found", + reason: `text surface path not found: ${pointer}`, + pointer, + }; + } + return { ok: true, value: value.value }; +} + +function invalidSidecar(pointer: Pointer, reason: string): TextSurfaceError { + return { + ok: false, + code: "invalid_sidecar", + reason, + pointer, + }; +} + +function normalizeReplacement(replacement: TextSurfaceReplacement): TextSurfaceFragment { + return typeof replacement === "string" + ? { text: replacement } + : compactFragment(cloneJson(replacement)); +} + +function compactFragment(fragment: TextSurfaceFragment): TextSurfaceFragment { + const next: TextSurfaceFragment = { text: fragment.text }; + if (fragment.atoms !== undefined && Object.keys(fragment.atoms).length > 0) { + next.atoms = fragment.atoms; + } + if (fragment.ranges !== undefined && Object.keys(fragment.ranges).length > 0) { + next.ranges = fragment.ranges; + } + return next; +} + +function textSurfaceSelectionAfter( + path: Pointer, + offset: number, + options: TextSurfaceReplaceOptions, + context: SelectionSnap["context"], +): SelectionSnap { + const point: SelectionPointObject = { path, offset }; + if (options.affinity !== undefined) point.affinity = options.affinity; + const selection: SelectionSnap = { + selectedPointers: [path], + selectionRanges: [{ anchor: point, focus: { ...point } }], + primaryIndex: 0, + anchor: { ...point }, + focus: { ...point }, + }; + return context === undefined ? selection : { ...selection, context: cloneJson(context) }; +} + +function changedTextRange(before: string, after: string): TextSurfaceMutationRange { + const prefix = commonPrefixLength(before, after); + const suffix = commonSuffixLength(before, after, prefix); + return { + start: prefix, + end: before.length - suffix, + insertedTextLength: after.length - prefix - suffix, + }; +} + +function mapRangeStart( + offset: number, + range: TextSurfaceSelectionRange, + delta: number, +): number { + if (offset <= range.start) return offset; + if (offset >= range.end) return offset + delta; + return range.start; +} + +function mapRangeEnd( + offset: number, + range: TextSurfaceSelectionRange, + insertedTextLength: number, + delta: number, +): number { + if (offset <= range.start) return offset; + if (offset >= range.end) return offset + delta; + return range.start + insertedTextLength; +} + +function uniqueSidecarId(id: string, reserved: Set): string { + if (!reserved.has(id)) return id; + let index = 2; + while (reserved.has(`${id}-${index}`)) index += 1; + return `${id}-${index}`; +} + +function commonPrefixLength(left: string, right: string): number { + const length = Math.min(left.length, right.length); + let index = 0; + while (index < length && left[index] === right[index]) index += 1; + return index; +} + +function commonSuffixLength( + left: string, + right: string, + prefixLength: number, +): number { + let length = 0; + const maxLength = Math.min(left.length, right.length) - prefixLength; + while ( + length < maxLength && + left[left.length - 1 - length] === right[right.length - 1 - length] + ) { + length += 1; + } + return length; +} + +function isOffsetPoint( + point: SelectionPoint, +): point is SelectionPointObject & { path: Pointer; offset: number } { + return typeof point === "object" && point !== null && typeof point.offset === "number"; +} + +function clampOffset(offset: number, length: number): number { + if (!Number.isFinite(offset)) return 0; + return Math.min(Math.max(Math.trunc(offset), 0), length); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} diff --git a/packages/json-document/src/index.ts b/packages/json-document/src/index.ts index 1811b8ea..e6f5c942 100644 --- a/packages/json-document/src/index.ts +++ b/packages/json-document/src/index.ts @@ -178,4 +178,24 @@ export type { SelectionTextDeleteDirection, SelectionTextDeleteOptions, } from "./domain/selection/textDelete.js"; +export { + replaceTextSurfaceSelection, + syncTextSurfaceMutation, + textSurfaceFragment, +} from "./domain/text-surface/surface.js"; +export type { + TextSurface, + TextSurfaceAtom, + TextSurfaceError, + TextSurfaceErrorCode, + TextSurfaceFragment, + TextSurfaceFragmentResult, + TextSurfaceMutationRange, + TextSurfaceMutationResult, + TextSurfaceRange, + TextSurfaceReplaceOptions, + TextSurfaceReplaceResult, + TextSurfaceReplacement, + TextSurfaceSelectionRange, +} from "./domain/text-surface/surface.js"; export { trackPointer } from "./foundation/patch/track.js"; diff --git a/packages/json-document/tests/document/text-surface.test.ts b/packages/json-document/tests/document/text-surface.test.ts new file mode 100644 index 00000000..c0bfdb2d --- /dev/null +++ b/packages/json-document/tests/document/text-surface.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from "vitest"; +import * as z from "zod"; + +import { + createJSONDocument, + replaceTextSurfaceSelection, + syncTextSurfaceMutation, + textSurfaceFragment, + type SelectionSnap, + type TextSurface, +} from "@interactive-os/json-document"; + +const ATOM = "\uFFFC"; + +const AtomSchema = z.object({ + type: z.literal("mention"), + label: z.string(), + offset: z.number().int().nonnegative(), +}); + +const MarkSchema = z.object({ + type: z.union([z.literal("bold"), z.literal("underline")]), + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), +}); + +const RichTextSchema = z.object({ + body: z.string(), + atoms: z.record(z.string(), AtomSchema), + marks: z.record(z.string(), MarkSchema), +}); + +const surface: TextSurface = { + textPath: "/body", + atomsPath: "/atoms", + rangesPath: "/marks", +}; + +function selection(anchor: number, focus: number): SelectionSnap { + const anchorPoint = { path: "/body", offset: anchor }; + const focusPoint = { path: "/body", offset: focus }; + return { + selectedPointers: ["/body"], + selectionRanges: [{ anchor: anchorPoint, focus: focusPoint }], + primaryIndex: 0, + anchor: anchorPoint, + focus: focusPoint, + }; +} + +describe("text surface primitives", () => { + test("extracts selected text with clipped atoms and ranges", () => { + const doc = createJSONDocument( + RichTextSchema, + { + body: `Hello ${ATOM}world`, + atoms: { + ada: { type: "mention", label: "@Ada", offset: 6 }, + }, + marks: { + bold: { type: "bold", start: 0, end: 5 }, + underline: { type: "underline", start: 6, end: 12 }, + }, + }, + { trustedInitial: true }, + ); + + const result = textSurfaceFragment(selection(0, 7), doc.value, surface); + + expect(result).toMatchObject({ + ok: true, + fragment: { + text: `Hello ${ATOM}`, + atoms: { + ada: { label: "@Ada", offset: 6 }, + }, + ranges: { + bold: { type: "bold", start: 0, end: 5 }, + underline: { type: "underline", start: 6, end: 7 }, + }, + }, + }); + }); + + test("replaces a text selection and maps existing sidecars in one patch", () => { + const doc = createJSONDocument( + RichTextSchema, + { + body: "Hello world", + atoms: { + ada: { type: "mention", label: "@Ada", offset: 6 }, + }, + marks: { + bold: { type: "bold", start: 0, end: 5 }, + underline: { type: "underline", start: 6, end: 11 }, + }, + }, + { history: 20, selection: true, trustedInitial: true }, + ); + + const planned = replaceTextSurfaceSelection(selection(0, 5), doc.value, surface, "Hi"); + expect(planned).toMatchObject({ + ok: true, + selectionAfter: { + focus: { path: "/body", offset: 2 }, + }, + }); + if (!planned.ok) throw new Error(planned.reason); + + expect(doc.commit(planned.patch, { selectionAfter: planned.selectionAfter })).toEqual({ ok: true }); + + expect(doc.value).toEqual({ + body: "Hi world", + atoms: { + ada: { type: "mention", label: "@Ada", offset: 3 }, + }, + marks: { + bold: { type: "bold", start: 0, end: 2 }, + underline: { type: "underline", start: 3, end: 8 }, + }, + }); + expect(doc.selection?.focus).toMatchObject({ path: "/body", offset: 2 }); + expect(doc.undo()).toEqual({ ok: true }); + expect(doc.value.body).toBe("Hello world"); + }); + + test("pastes structured fragments and suffixes sidecar id collisions", () => { + const doc = createJSONDocument( + RichTextSchema, + { + body: `${ATOM} Hello`, + atoms: { + ada: { type: "mention", label: "@Ada", offset: 0 }, + }, + marks: { + bold: { type: "bold", start: 0, end: 1 }, + }, + }, + { trustedInitial: true }, + ); + + const planned = replaceTextSurfaceSelection(selection(2, 7), doc.value, surface, { + text: `${ATOM}Yo`, + atoms: { + ada: { type: "mention", label: "@Ada", offset: 0 }, + }, + ranges: { + bold: { type: "bold", start: 0, end: 1 }, + }, + }); + if (!planned.ok) throw new Error(planned.reason); + + expect(doc.commit(planned.patch)).toEqual({ ok: true }); + expect(doc.value).toEqual({ + body: `${ATOM} ${ATOM}Yo`, + atoms: { + ada: { type: "mention", label: "@Ada", offset: 0 }, + "ada-2": { type: "mention", label: "@Ada", offset: 2 }, + }, + marks: { + bold: { type: "bold", start: 0, end: 1 }, + "bold-2": { type: "bold", start: 2, end: 3 }, + }, + }); + }); + + test("syncs a native text mutation into sidecar patches", () => { + const doc = createJSONDocument( + RichTextSchema, + { + body: "Hello brave world", + atoms: { + ada: { type: "mention", label: "@Ada", offset: 12 }, + }, + marks: { + underline: { type: "underline", start: 12, end: 17 }, + }, + }, + { trustedInitial: true }, + ); + + const planned = syncTextSurfaceMutation(doc.value, surface, "Hello brave world", "Hello world"); + if (!planned.ok) throw new Error(planned.reason); + + expect(doc.commit(planned.patch)).toEqual({ ok: true }); + expect(doc.value).toEqual({ + body: "Hello world", + atoms: { + ada: { type: "mention", label: "@Ada", offset: 6 }, + }, + marks: { + underline: { type: "underline", start: 6, end: 11 }, + }, + }); + }); + + test("leaves schema validation to normal document commit", () => { + const StrictSchema = RichTextSchema.extend({ + body: z.string().min(1), + }); + const doc = createJSONDocument( + StrictSchema, + { body: "A", atoms: {}, marks: {} }, + { trustedInitial: true }, + ); + + const planned = replaceTextSurfaceSelection(selection(0, 1), doc.value, surface, ""); + if (!planned.ok) throw new Error(planned.reason); + + expect(doc.commit(planned.patch)).toMatchObject({ + ok: false, + code: "schema_violation", + }); + expect(doc.value.body).toBe("A"); + }); +});