diff --git a/.changeset/sdk-inline-comments.md b/.changeset/sdk-inline-comments.md new file mode 100644 index 0000000000..3f8e38ca88 --- /dev/null +++ b/.changeset/sdk-inline-comments.md @@ -0,0 +1,5 @@ +--- +'@portabletext/plugin-sdk-value': minor +--- + +Add inline comment hooks: `useSDKCommentDecorations` draws highlights for comment threads anchored to text in the field, and `useSDKCommentAuthoring` captures the current selection and starts a thread on it through `@sanity/sdk-react`. Comments are written in the shape Sanity Studio stores, so threads round-trip between an SDK app and the Studio. Highlights track edits while typing and re-anchor by text diff when comments load. The composer UI is the app's to render, the same split presence uses for carets. Requires `@sanity/sdk-react` 2.20.1 or later. diff --git a/packages/plugin-sdk-value/package.json b/packages/plugin-sdk-value/package.json index 01b624db6c..df08100151 100644 --- a/packages/plugin-sdk-value/package.json +++ b/packages/plugin-sdk-value/package.json @@ -61,6 +61,7 @@ }, "dependencies": { "@portabletext/patches": "workspace:^", + "@sanity/diff-match-patch": "catalog:", "@sanity/diff-patch": "^6.0.0", "@sanity/json-match": "^1.0.5", "@xstate/react": "catalog:", @@ -71,9 +72,8 @@ "@portabletext/editor": "workspace:^", "@portabletext/schema": "workspace:^", "@portabletext/test": "workspace:^", - "@sanity/diff-match-patch": "catalog:", "@sanity/pkg-utils": "catalog:tooling", - "@sanity/sdk-react": "^2.19.0", + "@sanity/sdk-react": "^2.20.1", "@sanity/tsconfig": "catalog:tooling", "@types/debug": "catalog:", "@types/react": "catalog:tooling", @@ -92,7 +92,7 @@ }, "peerDependencies": { "@portabletext/editor": "workspace:^", - "@sanity/sdk-react": "^2.19.0", + "@sanity/sdk-react": "^2.20.1", "react": "^19.2", "react-dom": "^19.2" }, diff --git a/packages/plugin-sdk-value/src/comments-anchoring.test.ts b/packages/plugin-sdk-value/src/comments-anchoring.test.ts new file mode 100644 index 0000000000..5e0eef12c8 --- /dev/null +++ b/packages/plugin-sdk-value/src/comments-anchoring.test.ts @@ -0,0 +1,255 @@ +import {describe, expect, test} from 'vitest' +import { + COMMENT_INDICATORS, + relativeCommentPath, + resolveCommentSelections, + type StoredTextSelection, +} from './comments-anchoring' + +/** + * The scenarios are ported from the Studio's + * `buildRangeDecorationSelectionsFromComments` suite, fixtures included, so the + * two implementations can be diffed against the same cases. One known gap the + * Studio's suite also carries is pinned with `test.fails` below. + */ + +function span(_key: string, text: string) { + return {_type: 'span', _key, marks: [], text} +} + +function block(_key: string, children: ReturnType[]) { + return {_key, _type: 'block', style: 'normal', markDefs: [], children} +} + +function stored(text: string, _key = '6222e4072b6e'): StoredTextSelection { + return {type: 'text', value: [{_key, text}]} +} + +const MARKED = `Hello ${COMMENT_INDICATORS[0]}there${COMMENT_INDICATORS[1]} world` + +function resolve(value: unknown[], selection: StoredTextSelection) { + return resolveCommentSelections({ + value, + comments: [{commentId: 'c1', relativePath: [], selection}], + }).map((anchored) => anchored.selection) +} + +describe('resolveCommentSelections', () => { + test('exact match between the stored text and the block', () => { + const value = [ + block('6222e4072b6e', [span('9d9c95878a6e0', 'Hello there world')]), + ] + + expect(resolve(value, stored(MARKED))).toEqual([ + { + anchor: { + offset: 6, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '9d9c95878a6e0'}], + }, + focus: { + offset: 11, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '9d9c95878a6e0'}], + }, + }, + ]) + }) + + test('text before the range was bolded, splitting the span', () => { + const value = [ + block('6222e4072b6e', [ + span('9d9c95878a6e0', 'Hello'), + span('5d176cf77466', ' there world'), + ]), + ] + + expect(resolve(value, stored(MARKED))).toEqual([ + { + anchor: { + offset: 1, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '5d176cf77466'}], + }, + focus: { + offset: 6, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '5d176cf77466'}], + }, + }, + ]) + }) + + test('text inside the range was bolded, splitting it across three spans', () => { + const value = [ + block('6222e4072b6e', [ + span('9d9c95878a6e0', 'Hello th'), + span('ea97036ed5c4', 'e'), + span('8daa33e86194', 're world'), + ]), + ] + + expect(resolve(value, stored(MARKED))).toEqual([ + { + anchor: { + offset: 6, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '9d9c95878a6e0'}], + }, + focus: { + offset: 2, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '8daa33e86194'}], + }, + }, + ]) + }) + + test('bolding spans both inside and outside the range', () => { + const value = [ + block('6222e4072b6e', [ + span('9d9c95878a6e0', 'Hel'), + span('897d8881c889', 'lo th'), + span('3b404dd88fc1', 'ere world'), + ]), + ] + + expect(resolve(value, stored(MARKED))).toEqual([ + { + anchor: { + offset: 3, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '897d8881c889'}], + }, + focus: { + offset: 3, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '3b404dd88fc1'}], + }, + }, + ]) + }) + + test('an edit inside the commented word grows the range to cover it', () => { + const value = [ + block('6222e4072b6e', [span('9d9c95878a6e0', 'Hello the123re world')]), + ] + + expect(resolve(value, stored(MARKED))).toEqual([ + { + anchor: { + offset: 6, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '9d9c95878a6e0'}], + }, + focus: { + offset: 14, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '9d9c95878a6e0'}], + }, + }, + ]) + }) + + test('a similar word added before the range does not steal the anchor', () => { + const value = [ + block('6222e4072b6e', [span('9d9c95878a6e0', 'Hello where there world')]), + ] + + expect(resolve(value, stored(MARKED))).toEqual([ + { + anchor: { + offset: 12, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '9d9c95878a6e0'}], + }, + focus: { + offset: 17, + path: [{_key: '6222e4072b6e'}, 'children', {_key: '9d9c95878a6e0'}], + }, + }, + ]) + }) + + test('a comment whose block is gone resolves to nothing', () => { + const value = [block('another-block', [span('s1', 'Hello there world')])] + + expect(resolve(value, stored(MARKED))).toEqual([]) + }) + + // A known gap, carried by the Studio's own suite too: text typed + // immediately after the range is absorbed into it. Pinned with `fails` so + // this flips loudly if the algorithm ever gains the behaviour. + test.fails('an edit immediately after the range does not expand it', () => { + const value = [ + block('6222e4072b6e', [span('9d9c95878a6e0', 'Hello there123 world')]), + ] + expect(resolve(value, stored(MARKED))).toEqual([]) + }) + + test('a dramatically changed block drops the anchor', () => { + const value = [ + block('6222e4072b6e', [span('9d9c95878a6e0', 'Something else entirely')]), + ] + expect(resolve(value, stored(MARKED))).toEqual([]) + }) + + test('a nested block resolves through its container path', () => { + const value = [ + { + _key: 'callout', + _type: 'callout', + content: [block('b1', [span('s1', 'Hello there world')])], + }, + ] + + const anchored = resolveCommentSelections({ + value, + comments: [ + { + commentId: 'c1', + relativePath: [{_key: 'callout'}, 'content'], + selection: stored(MARKED, 'b1'), + }, + ], + }) + + expect(anchored.map((a) => a.selection)).toEqual([ + { + anchor: { + offset: 6, + path: [ + {_key: 'callout'}, + 'content', + {_key: 'b1'}, + 'children', + {_key: 's1'}, + ], + }, + focus: { + offset: 11, + path: [ + {_key: 'callout'}, + 'content', + {_key: 'b1'}, + 'children', + {_key: 's1'}, + ], + }, + }, + ]) + }) +}) + +describe('relativeCommentPath', () => { + test('reduces a stored field path to a path inside the editor', () => { + expect( + relativeCommentPath(['body'], 'body[_key=="callout"].content'), + ).toEqual([{_key: 'callout'}, 'content']) + }) + + test('an exact match reduces to the empty path', () => { + expect(relativeCommentPath(['body'], 'body')).toEqual([]) + }) + + test("a sibling editor's comment does not match", () => { + expect(relativeCommentPath(['body'], 'summary')).toBe(undefined) + }) + + test('an unparseable stored path is skipped rather than thrown on', () => { + expect(relativeCommentPath(['body'], '[[[')).toBe(undefined) + }) + + test('an empty stored path is skipped', () => { + expect(relativeCommentPath(['body'], '')).toBe(undefined) + }) +}) diff --git a/packages/plugin-sdk-value/src/comments-anchoring.ts b/packages/plugin-sdk-value/src/comments-anchoring.ts new file mode 100644 index 0000000000..618b5615f1 --- /dev/null +++ b/packages/plugin-sdk-value/src/comments-anchoring.ts @@ -0,0 +1,344 @@ +import type {EditorSelection} from '@portabletext/editor' +import type {Path, PathSegment} from '@portabletext/patches' +import { + applyPatches, + cleanupEfficiency, + DIFF_DELETE, + DIFF_EQUAL, + DIFF_INSERT, + makeDiff, + makePatches, + type Diff, + type Patch, +} from '@sanity/diff-match-patch' +import {arrayifyPath} from './plugin.sdk-value' + +/** + * How the Studio stores an inline comment's anchor: per Portable Text block the + * selection touches, the block's entire plain text with these two private-use + * characters inserted where the selection starts and ends. Text rather than + * offsets, so the anchor can be re-found after the text around it changes. + */ +export const COMMENT_INDICATORS = ['\uF000', '\uF001'] as const + +const COMMENT_INDICATORS_REGEX = new RegExp( + `[${COMMENT_INDICATORS.join('')}]`, + 'g', +) + +/** + * Inserted between spans when diffing, so a plain-text offset can be mapped + * back to the span it belongs to afterwards. + */ +const CHILD_SYMBOL = '\uF0D0' + +/** + * Kept from the Studio implementation: high enough to avoid re-anchoring onto + * the wrong occurrence of a repeated word, low enough not to hurt. + */ +const DMP_MARGIN = 15 + +/** + * The stored anchor of one inline comment, in the shape the SDK returns it. + * Structurally `CommentTextSelection` from `@sanity/sdk`, declared here so the + * pure modules in this package stay import-light. + */ +export interface StoredTextSelection { + type: 'text' + value: {_key: string; text: string}[] +} + +export interface AnchoredComment { + /** The comment's id, echoed back so the caller can correlate. */ + commentId: string + /** Where the comment's text sits in the current editor value. */ + selection: NonNullable +} + +interface ResolveOptions { + /** The editor's current value. */ + value: unknown[] + /** + * Each comment's stored anchor, with its field path already reduced to the + * path *inside* the editor: `[]` for a block directly in the decorated + * field, or the keyed path of the containing array for a nested block. + */ + comments: Array<{ + commentId: string + relativePath: Path + selection: StoredTextSelection + }> +} + +interface SpanLike { + _key: string + _type: string + text?: string +} + +interface TextBlockLike { + _key: string + children: SpanLike[] +} + +function isTextBlock(node: unknown): node is TextBlockLike { + return ( + typeof node === 'object' && + node !== null && + Array.isArray((node as TextBlockLike).children) && + typeof (node as TextBlockLike)._key === 'string' + ) +} + +function isSpan(child: SpanLike): boolean { + return child._type === 'span' && typeof child.text === 'string' +} + +function getValueAtPath(value: unknown, path: Path): unknown { + let current: unknown = value + for (const segment of path) { + if (current === null || typeof current !== 'object') { + return undefined + } + if (typeof segment === 'string') { + current = (current as Record)[segment] + } else if (typeof segment === 'number') { + current = Array.isArray(current) ? current[segment] : undefined + } else if (isKeyedSegment(segment)) { + current = Array.isArray(current) + ? current.find( + (item) => + typeof item === 'object' && + item !== null && + (item as {_key?: string})._key === segment._key, + ) + : undefined + } else { + return undefined + } + } + return current +} + +function isKeyedSegment(segment: PathSegment): segment is {_key: string} { + return typeof segment === 'object' && segment !== null && '_key' in segment +} + +function toPlainTextWithChildSeparators(block: TextBlockLike): string { + return block.children + .map((child) => + isSpan(child) ? (child.text ?? '').replaceAll(CHILD_SYMBOL, ' ') : '', + ) + .join(CHILD_SYMBOL) +} + +function diffText( + current: string, + next: string, +): {patches: Patch[]; levenshtein: number} { + const diff = makeDiff(current, next) + const diffs = cleanupEfficiency(diff) + return { + patches: makePatches(current, diffs, {margin: DMP_MARGIN}), + levenshtein: diffsLevenshtein(diffs), + } +} + +function diffApply(current: string, patches: Patch[]): string { + return applyPatches(patches, current, { + allowExceedingIndices: true, + margin: DMP_MARGIN, + })[0] +} + +/** + * Finds each stored anchor in the current editor value. + * + * A direct port of the Studio's `buildRangeDecorationSelectionsFromComments`, + * minus its Studio-only inputs. The stored text is diffed against the block's + * current text, so an anchor survives edits around it and inside it up to a + * similarity threshold. An anchor whose text is gone, or changed beyond + * recognition, is dropped rather than drawn somewhere wrong. + * + * Live tracking while the user types is not this function's job: the editor's + * `RangeDecoration.onMoved` does that. This runs when comments load or change. + */ +export function resolveCommentSelections( + options: ResolveOptions, +): AnchoredComment[] { + const {value, comments} = options + const anchored: AnchoredComment[] = [] + + for (const {commentId, relativePath, selection} of comments) { + for (const selectionMember of selection.value) { + const container = + relativePath.length > 0 ? getValueAtPath(value, relativePath) : value + const matchedBlock = Array.isArray(container) + ? container.find( + (block) => + isTextBlock(block) && block._key === selectionMember._key, + ) + : undefined + if (!matchedBlock || !isTextBlock(matchedBlock)) { + continue + } + + const selectionText = selectionMember.text.replaceAll( + COMMENT_INDICATORS_REGEX, + '', + ) + const textWithChildSeparators = + toPlainTextWithChildSeparators(matchedBlock) + const {patches} = diffText(selectionText, selectionMember.text) + const diffedText = diffApply(textWithChildSeparators, patches) + const startIndex = diffedText.indexOf(COMMENT_INDICATORS[0]) + const endIndex = diffedText + .replaceAll(COMMENT_INDICATORS[0], '') + .indexOf(COMMENT_INDICATORS[1]) + const textWithoutCommentTags = diffedText.replaceAll( + COMMENT_INDICATORS_REGEX, + '', + ) + + if (startIndex === -1 || endIndex === -1) { + continue + } + + const oldCommentedText = selectionMember.text.slice( + selectionMember.text.indexOf(COMMENT_INDICATORS[0]) + 1, + selectionMember.text.indexOf(COMMENT_INDICATORS[1]), + ) + const newCommentedText = textWithoutCommentTags.slice( + startIndex, + endIndex, + ) + const {levenshtein} = diffText(newCommentedText, oldCommentedText) + // Kept from the Studio, oddity included: only the *old* length is halved. + const threshold = Math.round( + newCommentedText.length + oldCommentedText.length / 2, + ) + + // The anchor is lost when its text is gone or no longer recognisable. + // Better no highlight than a highlight on the wrong words. + if ( + newCommentedText.length === 0 || + levenshtein > threshold || + startIndex + 1 === endIndex + ) { + continue + } + + let childIndexAnchor = 0 + let anchorOffset = 0 + let childIndexFocus = 0 + let focusOffset = 0 + for (let i = 0; i < textWithoutCommentTags.length; i++) { + if (textWithoutCommentTags[i] === CHILD_SYMBOL) { + if (i <= startIndex) { + anchorOffset = -1 + childIndexAnchor++ + } + focusOffset = -1 + childIndexFocus++ + } + if (i < startIndex) { + anchorOffset++ + } + if (i < startIndex + newCommentedText.length) { + focusOffset++ + } + if (i === startIndex + newCommentedText.length) { + break + } + } + + anchored.push({ + commentId, + selection: { + anchor: { + path: [ + ...relativePath, + {_key: matchedBlock._key}, + 'children', + {_key: matchedBlock.children[childIndexAnchor]._key}, + ], + offset: anchorOffset, + }, + focus: { + path: [ + ...relativePath, + {_key: matchedBlock._key}, + 'children', + {_key: matchedBlock.children[childIndexFocus]._key}, + ], + offset: focusOffset, + }, + }, + }) + } + } + + return anchored +} + +function diffsLevenshtein(diffs: Diff[]): number { + let levenshtein = 0 + let insertions = 0 + let deletions = 0 + for (const [op, data] of diffs) { + switch (op) { + case DIFF_INSERT: + insertions += data.length + break + case DIFF_DELETE: + deletions += data.length + break + case DIFF_EQUAL: + // A deletion and an insertion together count as one substitution. + levenshtein += Math.max(insertions, deletions) + insertions = 0 + deletions = 0 + break + default: + break + } + } + levenshtein += Math.max(insertions, deletions) + return levenshtein +} + +/** + * Reduces a comment's stored field path to a path inside this editor. + * + * `undefined` means the comment belongs to some other editor: a different + * field, a sibling container, or a stored path that does not parse. Skipping + * those is what lets several editors on one document each decorate only their + * own comments. + */ +export function relativeCommentPath( + basePath: Path, + fieldPath: string, +): Path | undefined { + let parsed: Path + try { + parsed = arrayifyPath(fieldPath) + } catch { + return undefined + } + if (parsed.length < basePath.length) { + return undefined + } + for (let i = 0; i < basePath.length; i++) { + if (!segmentsEqual(basePath[i], parsed[i])) { + return undefined + } + } + return parsed.slice(basePath.length) +} + +function segmentsEqual(a: PathSegment, b: PathSegment): boolean { + if (isKeyedSegment(a) && isKeyedSegment(b)) { + return a._key === b._key + } + return a === b +} diff --git a/packages/plugin-sdk-value/src/comments-selection.test.ts b/packages/plugin-sdk-value/src/comments-selection.test.ts new file mode 100644 index 0000000000..e3a5382794 --- /dev/null +++ b/packages/plugin-sdk-value/src/comments-selection.test.ts @@ -0,0 +1,173 @@ +import {describe, expect, test} from 'vitest' +import { + COMMENT_INDICATORS, + resolveCommentSelections, +} from './comments-anchoring' +import {buildStoredSelection} from './comments-selection' + +const [START, END] = COMMENT_INDICATORS + +function span(_key: string, text: string) { + return {_type: 'span', _key, marks: [], text} +} + +function block(_key: string, children: ReturnType[]) { + return {_key, _type: 'block', style: 'normal', markDefs: [], children} +} + +function point(blockKey: string, spanKey: string, offset: number) { + return {path: [{_key: blockKey}, 'children', {_key: spanKey}], offset} +} + +describe('buildStoredSelection', () => { + test('marks the selected run inside a single span', () => { + const foo = block('b1', [span('s1', 'foo bar baz')]) + + expect( + buildStoredSelection({ + selection: {anchor: point('b1', 's1', 4), focus: point('b1', 's1', 7)}, + selectedBlocks: [{node: foo, path: [{_key: 'b1'}]}], + }), + ).toEqual({ + containerPath: [], + selection: { + type: 'text', + value: [{_key: 'b1', text: `foo ${START}bar${END} baz`}], + }, + }) + }) + + test('a backward selection marks the same run', () => { + const foo = block('b1', [span('s1', 'foo bar baz')]) + + expect( + buildStoredSelection({ + selection: { + anchor: point('b1', 's1', 7), + focus: point('b1', 's1', 4), + backward: true, + }, + selectedBlocks: [{node: foo, path: [{_key: 'b1'}]}], + }), + ).toEqual({ + containerPath: [], + selection: { + type: 'text', + value: [{_key: 'b1', text: `foo ${START}bar${END} baz`}], + }, + }) + }) + + test('offsets count across earlier spans in the block', () => { + const foo = block('b1', [span('s1', 'foo '), span('s2', 'bar baz')]) + + expect( + buildStoredSelection({ + selection: {anchor: point('b1', 's2', 0), focus: point('b1', 's2', 3)}, + selectedBlocks: [{node: foo, path: [{_key: 'b1'}]}], + })?.selection.value, + ).toEqual([{_key: 'b1', text: `foo ${START}bar${END} baz`}]) + }) + + test('a selection spanning blocks marks each block, middle blocks whole', () => { + const first = block('b1', [span('s1', 'foo bar')]) + const middle = block('b2', [span('s2', 'baz')]) + const last = block('b3', [span('s3', 'qux quux')]) + + expect( + buildStoredSelection({ + selection: {anchor: point('b1', 's1', 4), focus: point('b3', 's3', 3)}, + selectedBlocks: [ + {node: first, path: [{_key: 'b1'}]}, + {node: middle, path: [{_key: 'b2'}]}, + {node: last, path: [{_key: 'b3'}]}, + ], + })?.selection.value, + ).toEqual([ + {_key: 'b1', text: `foo ${START}bar${END}`}, + {_key: 'b2', text: `${START}baz${END}`}, + {_key: 'b3', text: `${START}qux${END} quux`}, + ]) + }) + + test('nested blocks report their containing array', () => { + const nested = block('b1', [span('s1', 'foo bar')]) + const path = [{_key: 'callout'}, 'content', {_key: 'b1'}] + + expect( + buildStoredSelection({ + selection: { + anchor: { + path: [...path.slice(0, 2), {_key: 'b1'}, 'children', {_key: 's1'}], + offset: 0, + }, + focus: { + path: [...path.slice(0, 2), {_key: 'b1'}, 'children', {_key: 's1'}], + offset: 3, + }, + }, + selectedBlocks: [{node: nested, path}], + })?.containerPath, + ).toEqual([{_key: 'callout'}, 'content']) + }) + + test('blocks from different containers refuse to build', () => { + const topLevel = block('b1', [span('s1', 'foo')]) + const nested = block('b2', [span('s2', 'bar')]) + + expect( + buildStoredSelection({ + selection: {anchor: point('b1', 's1', 0), focus: point('b2', 's2', 3)}, + selectedBlocks: [ + {node: topLevel, path: [{_key: 'b1'}]}, + {node: nested, path: [{_key: 'callout'}, 'content', {_key: 'b2'}]}, + ], + }), + ).toBe(null) + }) + + test('a collapsed selection refuses to build', () => { + const foo = block('b1', [span('s1', 'foo bar')]) + + expect( + buildStoredSelection({ + selection: {anchor: point('b1', 's1', 4), focus: point('b1', 's1', 4)}, + selectedBlocks: [{node: foo, path: [{_key: 'b1'}]}], + }), + ).toBe(null) + }) + + test('a null selection refuses to build', () => { + expect(buildStoredSelection({selection: null, selectedBlocks: []})).toBe( + null, + ) + }) + + test('what it writes, the reader finds again', () => { + // The round trip is the point of matching the Studio's format: written + // here, resolved by the same code path that resolves Studio comments. + const foo = block('b1', [span('s1', 'foo bar baz')]) + const built = buildStoredSelection({ + selection: {anchor: point('b1', 's1', 4), focus: point('b1', 's1', 7)}, + selectedBlocks: [{node: foo, path: [{_key: 'b1'}]}], + }) + + const anchored = resolveCommentSelections({ + value: [foo], + comments: [ + { + commentId: 'c1', + relativePath: built!.containerPath, + selection: built!.selection, + }, + ], + }) + + expect(anchored.map((a) => a.selection)).toEqual([ + { + anchor: {offset: 4, path: [{_key: 'b1'}, 'children', {_key: 's1'}]}, + focus: {offset: 7, path: [{_key: 'b1'}, 'children', {_key: 's1'}]}, + }, + ]) + }) +}) diff --git a/packages/plugin-sdk-value/src/comments-selection.ts b/packages/plugin-sdk-value/src/comments-selection.ts new file mode 100644 index 0000000000..1468a1ef2d --- /dev/null +++ b/packages/plugin-sdk-value/src/comments-selection.ts @@ -0,0 +1,153 @@ +import type {EditorSelection} from '@portabletext/editor' +import type {Path, PathSegment} from '@portabletext/patches' +import { + COMMENT_INDICATORS, + type StoredTextSelection, +} from './comments-anchoring' + +interface SpanLike { + _key: string + _type: string + text?: string +} + +interface TextBlockLike { + _key: string + children: SpanLike[] +} + +export interface SelectedTextBlock { + node: TextBlockLike + path: Path +} + +export interface BuiltSelection { + /** + * The path of the array holding the selected blocks, relative to the editor. + * Empty for blocks at the top level. Comments anchor on the containing array, + * matching what the Studio stores. + */ + containerPath: Path + /** The anchor in the Studio's stored shape, ready to pass to `createComment`. */ + selection: StoredTextSelection +} + +/** + * Turns the current editor selection into the stored comment anchor. + * + * Per selected block, the block's entire plain text with the selection + * boundaries marked by the two indicator characters. The write-time mirror of + * `resolveCommentSelections`, and deliberately built the way the Studio builds + * it so a comment written here re-anchors there and back. + * + * Returns `null` when there is nothing commentable: a collapsed selection, + * no selected text, or a selection spanning blocks from different containing + * arrays, which a single stored path cannot describe. + */ +export function buildStoredSelection(options: { + selection: EditorSelection + selectedBlocks: SelectedTextBlock[] +}): BuiltSelection | null { + const {selection, selectedBlocks} = options + if (!selection || selectedBlocks.length === 0) { + return null + } + + const [start, end] = selection.backward + ? [selection.focus, selection.anchor] + : [selection.anchor, selection.focus] + + const containerPath = selectedBlocks[0].path.slice(0, -1) + const sharedContainer = selectedBlocks.every((selected) => + pathsEqual(selected.path.slice(0, -1), containerPath), + ) + if (!sharedContainer) { + return null + } + + let selectedCharacters = 0 + const value = selectedBlocks.map((selected, index) => { + const isFirst = index === 0 + const isLast = index === selectedBlocks.length - 1 + const plain = plainText(selected.node) + + const from = isFirst + ? plainOffset(selected.node, start.path, start.offset) + : 0 + const to = isLast + ? plainOffset(selected.node, end.path, end.offset) + : plain.length + + selectedCharacters += Math.max(0, to - from) + + return { + _key: selected.node._key, + text: `${plain.slice(0, from)}${COMMENT_INDICATORS[0]}${plain.slice(from, to)}${COMMENT_INDICATORS[1]}${plain.slice(to)}`, + } + }) + + // A collapsed selection, or one that only touches empty text, anchors to + // nothing worth highlighting. + if (selectedCharacters === 0) { + return null + } + + return {containerPath, selection: {type: 'text', value}} +} + +function plainText(block: TextBlockLike): string { + return block.children + .map((child) => (isSpan(child) ? (child.text ?? '') : '')) + .join('') +} + +/** + * Converts a selection point into an offset within the block's plain text: the + * text of every span before the point's child, plus the offset inside it. A + * point whose child is not in this block clamps to the block edge, which is + * where a cross-block selection boundary lands. + */ +function plainOffset( + block: TextBlockLike, + pointPath: Path, + offset: number, +): number { + const childSegment = pointPath[pointPath.length - 1] + if (!isKeyedSegment(childSegment)) { + return offset + } + + let total = 0 + for (const child of block.children) { + if (child._key === childSegment._key) { + return total + (isSpan(child) ? offset : 0) + } + if (isSpan(child)) { + total += (child.text ?? '').length + } + } + return total +} + +function isSpan(child: SpanLike): boolean { + return child._type === 'span' && typeof child.text === 'string' +} + +function isKeyedSegment( + segment: PathSegment | undefined, +): segment is {_key: string} { + return typeof segment === 'object' && segment !== null && '_key' in segment +} + +function pathsEqual(a: Path, b: Path): boolean { + if (a.length !== b.length) { + return false + } + return a.every((segment, index) => { + const other = b[index] + if (isKeyedSegment(segment) && isKeyedSegment(other)) { + return segment._key === other._key + } + return segment === other + }) +} diff --git a/packages/plugin-sdk-value/src/index.ts b/packages/plugin-sdk-value/src/index.ts index f5c48361af..19f1002da4 100644 --- a/packages/plugin-sdk-value/src/index.ts +++ b/packages/plugin-sdk-value/src/index.ts @@ -6,6 +6,14 @@ export { type SDKRemoteCursor, type UseSDKPresenceCursorsOptions, } from './plugin.sdk-presence' +export { + useSDKCommentAuthoring, + useSDKCommentDecorations, + type RenderCommentDecorationFunction, + type SDKCommentAuthoring, + type UseSDKCommentAuthoringOptions, + type UseSDKCommentDecorationsOptions, +} from './plugin.sdk-comments' export {SDKValuePlugin, ValueSyncPlugin} from './plugin.sdk-value' export { SDKPortableTextEditable, diff --git a/packages/plugin-sdk-value/src/plugin.sdk-comments.tsx b/packages/plugin-sdk-value/src/plugin.sdk-comments.tsx new file mode 100644 index 0000000000..f88824f417 --- /dev/null +++ b/packages/plugin-sdk-value/src/plugin.sdk-comments.tsx @@ -0,0 +1,317 @@ +import { + useEditor, + useEditorSelector, + type EditorSelection, + type RangeDecoration, +} from '@portabletext/editor' +import { + getSelectedTextBlocks, + getSelection, +} from '@portabletext/editor/selectors' +import {isEqualSelections} from '@portabletext/editor/utils' +import {stringifyPath} from '@sanity/json-match' +import { + useCommentActions, + useComments, + type Comment, + type CommentMessage, + type DocumentHandle, +} from '@sanity/sdk-react' +import { + useCallback, + useMemo, + useState, + type PropsWithChildren, + type ReactElement, +} from 'react' +import { + relativeCommentPath, + resolveCommentSelections, + type AnchoredComment, +} from './comments-anchoring' +import { + buildStoredSelection, + type SelectedTextBlock, +} from './comments-selection' +import {arrayifyPath} from './plugin.sdk-value' + +const NO_MOVES: Record = {} + +/** + * Anchors count as unchanged when every comment still sits at the same spot, + * so resolving on each editor emission only re-renders when a highlight + * actually needs to draw somewhere else. + */ +function sameAnchors(a: AnchoredComment[], b: AnchoredComment[]): boolean { + if (a.length !== b.length) { + return false + } + return a.every((anchor, index) => { + const other = b[index] + return ( + anchor.commentId === other.commentId && + samePoint(anchor.selection.anchor, other.selection.anchor) && + samePoint(anchor.selection.focus, other.selection.focus) + ) + }) +} + +function samePoint( + a: {path: unknown[]; offset: number}, + b: {path: unknown[]; offset: number}, +): boolean { + if (a.offset !== b.offset || a.path.length !== b.path.length) { + return false + } + return a.path.every((segment, index) => { + const other = b.path[index] + if ( + typeof segment === 'object' && + segment !== null && + typeof other === 'object' && + other !== null + ) { + return ( + (segment as {_key?: string})._key === (other as {_key?: string})._key + ) + } + return segment === other + }) +} + +/** + * Draws one comment's highlight. The plugin has no opinion about how a + * highlight looks, so it is the caller's to provide, the same way presence + * takes `renderCursor`. + * + * @public + */ +export type RenderCommentDecorationFunction = ( + comment: Comment, +) => (props: PropsWithChildren) => ReactElement + +/** + * Options for {@link useSDKCommentDecorations}. + * + * @public + */ +export interface UseSDKCommentDecorationsOptions extends DocumentHandle { + /** + * The document path of the Portable Text field, for example `content`. The + * same form `SDKValuePlugin` takes. + */ + path: string + renderDecoration: RenderCommentDecorationFunction +} + +/** + * Inline comment highlights for a Portable Text field, as range decorations. + * + * Pass the result to ``. Each + * thread's first comment that carries a text anchor on this field gets one + * decoration. Highlights stay put while the local user types, and a highlight + * whose text has been deleted or rewritten beyond recognition is dropped + * rather than drawn on the wrong words. + * + * Suspends while the document's comments load, like every SDK read hook. + * + * Resolved threads draw nothing: this mirrors the Studio, where resolving a + * thread removes its highlight from the text. + * + * @public + */ +export function useSDKCommentDecorations( + options: UseSDKCommentDecorationsOptions, +): RangeDecoration[] { + const {path, renderDecoration, ...handle} = options + const editor = useEditor() + const {comments} = useComments({...handle}) + + const inline = useMemo(() => { + const basePath = arrayifyPath(path) + return comments.flatMap((comment) => { + if ( + comment.parentCommentId || + !comment.selection || + comment.status !== 'open' + ) { + return [] + } + const relativePath = relativeCommentPath(basePath, comment.fieldPath) + if (relativePath === undefined) { + return [] + } + return [{comment, relativePath, selection: comment.selection}] + }) + }, [comments, path]) + + // Resolved inside the selector so every resolution reads the text as it is + // right now. Anything less fresh mis-anchors a comment written on text typed + // since the staler reading, and on first load finds nothing at all, since + // the field's value can arrive after the comments do. The anchor equality + // keeps re-renders to actual highlight changes, and positions the editor is + // already tracking through `onMoved` win over these resolutions anyway. + const resolveAnchors = useCallback( + (snapshot: {context: {value: unknown[]}}) => + resolveCommentSelections({ + value: snapshot.context.value, + comments: inline.map(({comment, relativePath, selection}) => ({ + commentId: comment.id, + relativePath, + selection, + })), + }), + [inline], + ) + const anchored = useEditorSelector(editor, resolveAnchors, sameAnchors) + + // Moves are remembered against the comment list they were reported for, so a + // re-resolution wins over stale positions without a state reset. + const [moved, setMoved] = useState<{ + forComments: typeof inline + selections: Record + }>({forComments: inline, selections: {}}) + const movedSelections = + moved.forComments === inline ? moved.selections : NO_MOVES + + return useMemo(() => { + const commentsById = new Map( + inline.map(({comment}) => [comment.id, comment]), + ) + + return anchored.flatMap((anchor) => { + const comment = commentsById.get(anchor.commentId) + if (!comment) { + return [] + } + + const movedSelection = movedSelections[anchor.commentId] + const selection = + movedSelection === undefined ? anchor.selection : movedSelection + if (selection === null) { + // The editor reported the range lost, for example its text was deleted. + return [] + } + + return [ + { + component: renderDecoration(comment), + selection, + onMoved: ({newSelection}) => { + setMoved((previous) => ({ + forComments: inline, + selections: { + ...(previous.forComments === inline ? previous.selections : {}), + [anchor.commentId]: newSelection, + }, + })) + }, + payload: {commentId: anchor.commentId}, + } satisfies RangeDecoration, + ] + }) + }, [anchored, inline, movedSelections, renderDecoration]) +} + +/** + * Options for {@link useSDKCommentAuthoring}. + * + * @public + */ +export interface UseSDKCommentAuthoringOptions extends DocumentHandle { + /** + * The document path of the Portable Text field, for example `content`. + */ + path: string +} + +/** + * What {@link useSDKCommentAuthoring} returns. + * + * @public + */ +export interface SDKCommentAuthoring { + /** + * The current selection when it can take a comment, `null` otherwise. Show + * the comment affordance when this is set, and position it off the + * selection. A selection can take a comment when it is expanded, contains + * text, and stays within one array of blocks. + */ + commentableSelection: EditorSelection + /** + * Starts a comment thread anchored to the text selected right now. + * + * The anchor is captured from the live selection at call time, so call this + * from the affordance while the selection still stands. Rejects when nothing + * commentable is selected. + */ + createInlineComment: (options: { + message: CommentMessage + /** Reuse the id of a failed comment to retry it. */ + commentId?: string + }) => Promise +} + +/** Reports the selection when it can take a comment, `null` otherwise. */ +function getCommentableSelection( + snapshot: Parameters[0], +): EditorSelection { + const selection = getSelection(snapshot) + const built = buildStoredSelection({ + selection, + selectedBlocks: getSelectedTextBlocks(snapshot) as SelectedTextBlock[], + }) + return built ? selection : null +} + +/** + * Lets the app author inline comments on a Portable Text field. + * + * The plugin captures the selection and writes the comment; the composer UI is + * the app's, the same split the Studio and Canvas use. Comments are written in + * the shape the Studio stores, so a thread started here shows up there. + * + * @public + */ +export function useSDKCommentAuthoring( + options: UseSDKCommentAuthoringOptions, +): SDKCommentAuthoring { + const {path, ...handle} = options + const editor = useEditor() + const {createComment} = useCommentActions() + + const commentableSelection = useEditorSelector( + editor, + getCommentableSelection, + isEqualSelections, + ) + + return { + commentableSelection, + createInlineComment: ({message, commentId}) => { + const snapshot = editor.getSnapshot() + const built = buildStoredSelection({ + selection: getSelection(snapshot), + selectedBlocks: getSelectedTextBlocks(snapshot) as SelectedTextBlock[], + }) + if (!built) { + return Promise.reject( + new Error( + 'Nothing commentable is selected, so there is nothing to anchor the comment to.', + ), + ) + } + + return createComment({ + ...handle, + fieldPath: stringifyPath([ + ...arrayifyPath(path), + ...built.containerPath, + ]), + selection: built.selection, + message, + ...(commentId === undefined ? {} : {commentId}), + }) + }, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 893308b462..381dc1c3ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1141,6 +1141,9 @@ importers: '@portabletext/patches': specifier: workspace:* version: link:../patches + '@sanity/diff-match-patch': + specifier: 'catalog:' + version: 3.2.0 '@sanity/diff-patch': specifier: ^6.0.0 version: 6.0.0 @@ -1166,15 +1169,12 @@ importers: '@portabletext/test': specifier: workspace:^ version: link:../test - '@sanity/diff-match-patch': - specifier: 'catalog:' - version: 3.2.0 '@sanity/pkg-utils': specifier: catalog:tooling version: 12.3.0(@babel/runtime@7.28.4)(@tsdown/css@0.22.14)(@types/node@24.12.2)(@volar/typescript@2.4.28)(babel-plugin-react-compiler@1.0.0)(oxc-resolver@11.19.1)(oxc-transform-react@0.145.0)(typescript@7.0.2)(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3)) '@sanity/sdk-react': - specifier: ^2.19.0 - version: 2.19.0(@types/react@19.2.17)(immer@11.0.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))(xstate@5.32.5) + specifier: ^2.20.1 + version: 2.20.1(@types/react@19.2.17)(immer@11.0.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))(xstate@5.32.5) '@sanity/tsconfig': specifier: catalog:tooling version: 2.1.0 @@ -4774,10 +4774,18 @@ packages: resolution: {integrity: sha512-7GEzTFRY6kWRjHbJYUDEGKPXHVl/XItGpV5LYCLcWMvBlgfujXuAjdp4hJVZqX8F8OtBzIFqdhiSn5Kh5u/V5Q==} engines: {node: '>=20'} + '@sanity/client@7.26.2': + resolution: {integrity: sha512-iEkGiHbrxCnT/A30A6pH+vX4otP3lLp935Vwuv0/YrnETxrKCn6PnsZU+TfTm6ZEZ9kZGs8wKP5yBLmZw5S6OQ==} + engines: {node: '>=20'} + '@sanity/comlink@4.0.1': resolution: {integrity: sha512-vdGOd6sxNjqTo2H3Q3L2/Gepy+cDBiQ1mr9ck7c/A9o4NnmBLoDliifsNHIwgNwBUz37oH4+EIz/lIjNy8hSew==} engines: {node: '>=20.19 <22 || >=22.12'} + '@sanity/comlink@4.0.3': + resolution: {integrity: sha512-gCnltbeB8BmXVLSr/6XHAA3tQmhJDMKXufOVRXYxAhIEv+5n9CtnxupjY5IzmdI+7v+G67TZV/8zCQhvaFlVww==} + engines: {node: '>=20.19 <22 || >=22.12'} + '@sanity/descriptors@1.3.0': resolution: {integrity: sha512-S2KYYGRUVZy+FDjPp3meoyczbCjobSQvZcgNayo3oYlYS9Qz0E+6RezGxi/KOb6iF52Oir3LEXp9SVfIgEwNjg==} engines: {node: '>=18.0.0'} @@ -4811,8 +4819,8 @@ packages: '@sanity/media-library-types@1.6.0': resolution: {integrity: sha512-1Nqw8GSUr7E8AFue9luyTOM4Ev0ZlJ35SHcQrHZ6T68G5llfXw99e3GkLXzk6qYFk9r9fJaBTSpW5IyO2mObZQ==} - '@sanity/message-protocol@0.23.0': - resolution: {integrity: sha512-UfQDuWRzbK4dRTfLURGCZo7ZlR0sK+2lwT2QMAfqsM5kMq5GR31lbX4LMcSw27h7rwUv8ZSf1hBEbluVpgRmlg==} + '@sanity/message-protocol@0.24.0': + resolution: {integrity: sha512-F/MwFVc3fN8uVvCIGuTxHN5EVo148wLhjL7h/u6wbShWJGMKgcGx0n6MwfCPyi6ftpdvRXsPZVDgZ1GMeeBf2Q==} engines: {node: '>=20.0.0'} '@sanity/mutate@0.18.1': @@ -4852,14 +4860,14 @@ packages: '@sanity/schema@6.9.1': resolution: {integrity: sha512-bLMnZaBx/dT+avceIgIaNDQ/WccS27zvtuCpsuhvX9sjjtv5Latp8vCHQlRBQa7t8gyE6C6h+gbOr0dbPg9+nw==} - '@sanity/sdk-react@2.19.0': - resolution: {integrity: sha512-dphdWEAY7vO3i9Y4Eht85LJnezqwAhTH2U/7iewMMqLXBYPrfRHIEium2eTWb5rX7UpigiqRcJpREi9ZkcKjBA==} + '@sanity/sdk-react@2.20.1': + resolution: {integrity: sha512-UA9dGqw4URpjiP4A2qqguGQEUpBP27mtlRlQ1UQmNVLZKzY4GYoiP2Hx3qxIvHhYe7MifwE8tw/BAmhq4U5n4g==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@sanity/sdk@2.19.0': - resolution: {integrity: sha512-UOrxOmISioW22b0IyGgkRJCk1VnzmevH7jY8WZpANStmwa/4lsujRNlWOYeW3IguhIz0HptgtVfRXXyZwr9UXQ==} + '@sanity/sdk@2.20.1': + resolution: {integrity: sha512-0x/ZGrLXU+1OjWWrfMks6Mn7MrV6A1sDPqXOIMmAaeJiUKi1oRFWFO4yoUBAuWwWViWjAiuUjb+aaC/J3r/Edw==} '@sanity/signed-urls@2.0.4': resolution: {integrity: sha512-wH7L9iOxQDVTa7COVEXoknalNgjpqxLJoOcZYQa3D/2JVEQOGUm82RgFVgZkKoclhQ8Y8dBby+KPkFoIDoUc1g==} @@ -4893,11 +4901,6 @@ packages: oxc-transform-react: optional: true - '@sanity/types@6.8.0': - resolution: {integrity: sha512-VvhWNrupGjXxytz/tRbjOHeEHctUsZqf7AF+eT1MxP0pgOdhtRlXsroV6e4Uayoux/cbbuYTX5JpCnW6cAuZYw==} - peerDependencies: - '@types/react': ^19.2.17 - '@sanity/types@6.9.1': resolution: {integrity: sha512-C9Pe/mVn1ZGqgE4Zpwd1Y2IeTjL33/60imsDocqzdwezJPGN5I3XPOced/M/6Uvxl+afuyt0Sx9+B+TXmrUrBQ==} peerDependencies: @@ -6515,10 +6518,6 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} - get-it@8.8.1: - resolution: {integrity: sha512-t8HkAfY0DkInjAv3o4EzkjJ/9vD1PWXMlX3ASzQ6yN1QlmYN8z2ISRD7BCf/rGC73TYPbbN7FsnOV7jw90a9Yg==} - engines: {node: '>=14.0.0'} - get-it@8.8.3: resolution: {integrity: sha512-IfkbWqOGO2kd2Ccgiz/NIzk3bcwjtVqBm3RNa3j/veHg2q7c1DgS3EXrCrvIWH0jbNTbhkdblnxBP7Dm8CiH+A==} engines: {node: '>=14.0.0'} @@ -7484,11 +7483,6 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.17: resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -8898,6 +8892,10 @@ packages: resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -12578,6 +12576,15 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.5': optional: true + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.28.4)(rolldown@1.2.2)(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3))': + dependencies: + '@babel/core': 7.29.7 + picomatch: 4.0.5 + rolldown: 1.2.2 + optionalDependencies: + '@babel/runtime': 7.28.4 + vite: 8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3) + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.28.4)(rolldown@1.2.5)(vite@8.2.0(@types/node@20.19.25)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.7 @@ -12595,6 +12602,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.28.4 vite: 8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3) + optional: true '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -12766,7 +12774,14 @@ snapshots: '@sanity/client@7.26.0': dependencies: '@sanity/eventsource': 5.0.4 - get-it: 8.8.1 + get-it: 8.8.3 + nanoid: 3.3.17 + rxjs: 7.8.2 + + '@sanity/client@7.26.2': + dependencies: + '@sanity/eventsource': 5.0.4 + get-it: 8.8.3 nanoid: 3.3.17 rxjs: 7.8.2 @@ -12776,6 +12791,12 @@ snapshots: uuid: 13.0.0 xstate: 5.32.5 + '@sanity/comlink@4.0.3': + dependencies: + rxjs: 7.8.2 + uuid: 14.0.2 + xstate: 5.32.5 + '@sanity/descriptors@1.3.0': dependencies: sha256-uint8array: 0.10.7 @@ -12809,14 +12830,14 @@ snapshots: '@sanity/media-library-types@1.6.0': {} - '@sanity/message-protocol@0.23.0': + '@sanity/message-protocol@0.24.0': dependencies: '@sanity/comlink': 4.0.1 '@sanity/mutate@0.18.1(xstate@5.32.5)': dependencies: '@isaacs/ttlcache': 2.1.5 - '@sanity/client': 7.26.0 + '@sanity/client': 7.26.2 '@sanity/diff-match-patch': 3.2.0 '@sanity/uuid': 3.0.2 hotscript: 1.0.13 @@ -12949,12 +12970,12 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@sanity/sdk-react@2.19.0(@types/react@19.2.17)(immer@11.0.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))(xstate@5.32.5)': + '@sanity/sdk-react@2.20.1(@types/react@19.2.17)(immer@11.0.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))(xstate@5.32.5)': dependencies: - '@sanity/client': 7.26.0 - '@sanity/message-protocol': 0.23.0 - '@sanity/sdk': 2.19.0(@types/react@19.2.17)(immer@11.0.1)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))(xstate@5.32.5) - '@sanity/types': 6.8.0(@types/react@19.2.17) + '@sanity/client': 7.26.2 + '@sanity/message-protocol': 0.24.0 + '@sanity/sdk': 2.20.1(@types/react@19.2.17)(immer@11.0.1)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))(xstate@5.32.5) + '@sanity/types': 6.9.1(@types/react@19.2.17) groq: 3.88.1-typegen-experimental.0 react: 19.2.8 react-compiler-runtime: 1.0.0(react@19.2.8) @@ -12967,20 +12988,20 @@ snapshots: - use-sync-external-store - xstate - '@sanity/sdk@2.19.0(@types/react@19.2.17)(immer@11.0.1)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))(xstate@5.32.5)': + '@sanity/sdk@2.20.1(@types/react@19.2.17)(immer@11.0.1)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))(xstate@5.32.5)': dependencies: '@sanity/bifur-client': 1.0.0 - '@sanity/client': 7.26.0 - '@sanity/comlink': 4.0.1 + '@sanity/client': 7.26.2 + '@sanity/comlink': 4.0.3 '@sanity/diff-match-patch': 3.2.0 '@sanity/diff-patch': 6.0.0 '@sanity/id-utils': 1.0.0 '@sanity/image-url': 2.1.1 '@sanity/json-match': 1.0.5 - '@sanity/message-protocol': 0.23.0 + '@sanity/message-protocol': 0.24.0 '@sanity/mutate': 0.18.1(xstate@5.32.5) '@sanity/telemetry': 1.1.0(react@19.2.8) - '@sanity/types': 6.8.0(@types/react@19.2.17) + '@sanity/types': 6.9.1(@types/react@19.2.17) groq: 3.88.1-typegen-experimental.0 groq-js: 2.0.0 reselect: 5.1.1 @@ -13042,7 +13063,7 @@ snapshots: '@babel/core': 7.29.7 '@microsoft/api-extractor': 7.58.12(@types/node@24.12.2) '@microsoft/tsdoc-config': 0.18.1 - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.28.4)(rolldown@1.2.5)(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.28.4)(rolldown@1.2.2)(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3)) '@sanity/browserslist-config': 1.0.5 '@sanity/vanilla-extract-tsdown-plugin': 0.3.2(rolldown@1.2.5)(tsdown@0.22.14) '@typescript/typescript6': 6.0.2 @@ -13067,7 +13088,7 @@ snapshots: - supports-color - vite - '@sanity/types@6.8.0(@types/react@19.2.17)': + '@sanity/types@6.9.1(@types/react@19.2.17)': dependencies: '@sanity/client': 7.26.0 '@sanity/media-library-types': 1.6.0 @@ -13653,7 +13674,7 @@ snapshots: magicast: 0.5.3 obug: 2.1.4 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@20.19.25)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.2.0(@types/node@20.19.25)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3)) + vitest: 4.1.11(@types/node@24.12.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3)) transitivePeerDependencies: - supports-color @@ -13669,9 +13690,9 @@ snapshots: obug: 2.1.4 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@20.19.25)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.2.0(@types/node@20.19.25)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3)) + vitest: 4.1.11(@types/node@24.12.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3)) optionalDependencies: - '@vitest/browser': 4.1.11(vite@8.2.0(@types/node@20.19.25)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3))(vitest@4.1.11) + '@vitest/browser': 4.1.11(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.8.3))(vitest@4.1.11) '@vitest/expect@4.1.11': dependencies: @@ -14706,13 +14727,6 @@ snapshots: get-east-asian-width@1.5.0: {} - get-it@8.8.1: - dependencies: - decompress-response: 7.0.0 - is-retry-allowed: 2.2.0 - through2: 4.0.2 - tunnel-agent: 0.6.0 - get-it@8.8.3: dependencies: decompress-response: 7.0.0 @@ -16105,8 +16119,6 @@ snapshots: muggle-string@0.4.1: {} - nanoid@3.3.12: {} - nanoid@3.3.17: {} nanoid@5.1.11: {} @@ -16452,7 +16464,7 @@ snapshots: postcss@8.5.19: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -17751,6 +17763,8 @@ snapshots: uuid@13.0.0: {} + uuid@14.0.2: {} + uuid@8.3.2: {} uuidv7@0.4.4: {}