diff --git a/package-lock.json b/package-lock.json index 84231b227398d7..5190a989fce80a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42394,6 +42394,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/a11y": "file:../a11y", + "@wordpress/annotations": "file:../annotations", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/base-styles": "file:../base-styles", "@wordpress/blob": "file:../blob", diff --git a/packages/editor/package.json b/packages/editor/package.json index 707b1a4717a10d..c6ab0bec4fa8f2 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -59,6 +59,7 @@ ], "dependencies": { "@wordpress/a11y": "file:../a11y", + "@wordpress/annotations": "file:../annotations", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/base-styles": "file:../base-styles", "@wordpress/blob": "file:../blob", diff --git a/packages/editor/src/components/inline-markers/find-marker-range.ts b/packages/editor/src/components/inline-markers/find-marker-range.ts new file mode 100644 index 00000000000000..4f6d4c93feaa40 --- /dev/null +++ b/packages/editor/src/components/inline-markers/find-marker-range.ts @@ -0,0 +1,195 @@ +import { create, RichTextData } from '@wordpress/rich-text'; + +/** + * Read a block attribute value as a rich-text record. A plain string is + * parsed, after the cheap `quickReject` substring check. Shared + * by `findMarkerRange` and `findMarkerText` so both resolve offsets the same + * way from a single code path. + * + * @param value Block attribute value (RichTextData, string, or other). + * @param options Options. + * @param options.id Marker id (only its presence is checked here). + * @param options.quickReject Optional substring used to skip parsing when absent. + * @return Rich-text record, or null when there is nothing to search. + */ +function parseMarkerValue( + value: any, + { + id, + quickReject, + }: { id: number | string | null | undefined; quickReject?: string } +) { + if ( id === undefined || id === null ) { + return null; + } + // `RichTextData` already holds a parsed record; serializing it only to + // parse it again cost two full passes per lookup. + if ( value instanceof RichTextData ) { + return { formats: value.formats, text: value.text }; + } + const html = typeof value === 'string' ? value : null; + if ( ! html ) { + return null; + } + // Cheap reject before the (relatively costly) rich-text parse. + if ( quickReject && html.indexOf( quickReject ) === -1 ) { + return null; + } + return create( { html } ); +} + +/** + * Whether a character's format stack carries the marker with the given id. + * + * @param stack Formats applied to one character. + * @param formatType Rich-text format type to match. + * @param idAttribute Marker attribute holding the id. + * @param target Marker id, as a string. + * @return True when the stack holds the marker. + */ +function carriesId( + stack: any[] | undefined, + formatType: string, + idAttribute: string, + target: string +): boolean { + return !! stack?.some( + ( f: any ) => + f.type === formatType && + f.attributes && + f.attributes[ idAttribute ] === target + ); +} + +/** + * Find the character range of the marker matching `id` within an already-parsed + * rich-text record. + * + * The range spans from the first to the last character carrying the id, so a + * marker that rich-text split into non-contiguous runs for the same id (an edit + * inside the run, a nested-format grow, a serialization quirk) still resolves as + * one range. Returning only the first contiguous run — as an earlier version did + * — truncated accept/reject to a fragment of the marker. The gap between + * fragments may hold unmarked text or another marker, so consumers that act on + * characters rather than on the span (`findMarkerText`, `removeMarkedRange` in + * inline-suggestions) test each character with `carriesId`. + * + * @param record Rich-text record. + * @param formatType Rich-text format type to match. + * @param idAttribute Marker attribute holding the id. + * @param id Marker id to search for. + * @return Range or null when no marker is found. + */ +function rangeInRecord( + record: any, + formatType: string, + idAttribute: string, + id: number | string +): { start: number; end: number } | null { + const target = String( id ); + const formats = record.formats; + let start = -1; + let end = -1; + for ( let i = 0; i < formats.length; i++ ) { + if ( carriesId( formats[ i ], formatType, idAttribute, target ) ) { + if ( start === -1 ) { + start = i; + } + end = i + 1; + } + } + if ( start === -1 ) { + return null; + } + return { start, end }; +} + +/** + * Search a rich-text value for an inline marker (`` format) matching a + * given id and return its character range. + * + * This is the single place inline-marker offsets are resolved: positions are + * derived from the in-content marker on every read rather than stored, so a + * marker survives unrelated edits elsewhere in the same attribute. It is the + * intended swap point for a future CRDT-backed resolver. + * + * @param value Block attribute value (RichTextData, string, or other). + * @param options Options. + * @param options.formatType Rich-text format type to match (e.g. `core/note`). + * @param options.idAttribute Marker attribute holding the id. + * @param options.id Marker id to search for. + * @param options.quickReject Optional substring (e.g. the marker class) used to + * skip parsing when it is absent from the HTML. + * @return Range or null when no marker is found. + */ +export function findMarkerRange( + value: any, + { + formatType, + idAttribute = 'data-id', + id, + quickReject, + }: { + formatType: string; + idAttribute?: string; + id: number | string | null | undefined; + quickReject?: string; + } +) { + const record = parseMarkerValue( value, { id, quickReject } ); + if ( ! record ) { + return null; + } + return rangeInRecord( record, formatType, idAttribute, id! ); +} + +/** + * Resolve the visible text wrapped by the marker matching `id`. Companion to + * `findMarkerRange`: where that returns offsets, this returns the marked text + * itself (e.g. for a sidebar summary of what a suggestion adds or removes). + * Returns an empty string when the marker can no longer be found. + * + * @param value Block attribute value (RichTextData, string, or other). + * @param options Options. + * @param options.formatType Rich-text format type to match. + * @param options.idAttribute Marker attribute holding the id. + * @param options.id Marker id to search for. + * @param options.quickReject Optional substring used to skip parsing when absent. + * @return The marked text, or '' when no marker is found. + */ +export function findMarkerText( + value: any, + { + formatType, + idAttribute = 'data-id', + id, + quickReject, + }: { + formatType: string; + idAttribute?: string; + id: number | string | null | undefined; + quickReject?: string; + } +): string { + const record = parseMarkerValue( value, { id, quickReject } ); + if ( ! record ) { + return ''; + } + const range = rangeInRecord( record, formatType, idAttribute, id! ); + if ( ! range ) { + return ''; + } + // A fragmented marker's span can hold unmarked text or another marker's + // text; quote only the characters this marker owns, as accept and reject + // act on those. + const target = String( id ); + let text = ''; + for ( let i = range.start; i < range.end; i++ ) { + if ( + carriesId( record.formats[ i ], formatType, idAttribute, target ) + ) { + text += record.text[ i ]; + } + } + return text; +} diff --git a/packages/editor/src/components/inline-markers/index.ts b/packages/editor/src/components/inline-markers/index.ts new file mode 100644 index 00000000000000..cfe08b9181870f --- /dev/null +++ b/packages/editor/src/components/inline-markers/index.ts @@ -0,0 +1,21 @@ +/** + * Inline markers: a format-agnostic primitive for anchoring inline ranges to + * edit-surviving `` markers in block content. + * + * Positions are always derived from the in-content marker on read (never + * stored), so a marked range survives unrelated edits elsewhere in the same + * attribute. `findMarkerRange` is the single offset-resolution point and the + * intended swap point for a future CRDT-backed resolver. + * + * Consumed by Notes (`core/note`) and Suggestions (`core/suggestion`); each + * passes its own format type, id attribute, and annotation source so the two + * coexist on one block without colliding. + */ + +export { findMarkerRange, findMarkerText } from './find-marker-range'; +export { getMarkerSelector } from './marker-selector'; +export { wrapInlineMarker } from './wrap-inline-marker'; +export { readInlineSelection } from './read-inline-selection'; +export { readInlineCaret } from './read-inline-caret'; +export { reconcileMarkerRemoval } from './reconcile-marker-removal'; +export { useAnnotateRanges } from './use-annotate-ranges'; diff --git a/packages/editor/src/components/inline-markers/marker-selector.ts b/packages/editor/src/components/inline-markers/marker-selector.ts new file mode 100644 index 00000000000000..39e8b3ececc168 --- /dev/null +++ b/packages/editor/src/components/inline-markers/marker-selector.ts @@ -0,0 +1,37 @@ +/** + * Build the CSS selector that matches an in-content `` marker by its id. + * + * Both consumers of the inline-markers primitive serialize their marker as a + * `` carrying a class token and an id attribute (`wp-note` /`data-id` for + * Notes, `wp-suggestion` / `data-suggestion-id` for Suggestions), so resolving + * a marker element from the DOM — to tint it, to scroll to it, or to anchor a + * floating card to it — is the same operation for both. + * + * @param className Exact class token on the marker. + * @param idAttribute Attribute holding the marker id. + * @param id Marker id to match. + * @return Selector for the marker element(s). + */ +export function getMarkerSelector( + className: string, + idAttribute: string, + id: number | string +): string { + /* + * The id is a server comment ID (always a positive integer), but the value + * composes a selector from stored data, so escape it defensively. + * + * Deliberately not `CSS.escape`: that escapes for *identifier* context, + * where a leading digit is illegal, so it renders the id 7 as `\37 `. That + * is valid, and matches, but it makes every generated rule unreadable. + * Inside a quoted attribute value the only characters that need escaping + * are the quote, the backslash, and raw line breaks (a parse error in a + * CSS string). + */ + const escapedId = String( id ).replace( /["\\\n\r\f]/g, ( char ) => + char === '"' || char === '\\' + ? `\\${ char }` + : `\\${ char.codePointAt( 0 )!.toString( 16 ) } ` + ); + return `mark.${ className }[${ idAttribute }="${ escapedId }"]`; +} diff --git a/packages/editor/src/components/inline-markers/read-inline-caret.ts b/packages/editor/src/components/inline-markers/read-inline-caret.ts new file mode 100644 index 00000000000000..33316f14466408 --- /dev/null +++ b/packages/editor/src/components/inline-markers/read-inline-caret.ts @@ -0,0 +1,40 @@ +/** + * Read the current caret (or selection) from block-editor selection state when + * it sits inside a single rich-text attribute, returning normalized anchor + * data. Unlike `readInlineSelection`, this accepts a *collapsed* caret + * (`start === end`) so callers driving insertion (typing) get a position even + * when nothing is selected. Returns null for block-level or cross-attribute + * selections. + * + * @param getSelectionStart Block-editor selector. + * @param getSelectionEnd Block-editor selector. + * @return `{ clientId, attributeKey, start, end }` or null. + */ +export function readInlineCaret( + getSelectionStart: () => any, + getSelectionEnd: () => any +) { + const start = getSelectionStart(); + const end = getSelectionEnd(); + if ( + ! start?.clientId || + start.clientId !== end.clientId || + ! start.attributeKey || + start.attributeKey !== end.attributeKey || + start.offset === undefined || + end.offset === undefined + ) { + return null; + } + // Normalize direction so callers don't have to think about reversed ranges. + const [ startOffset, endOffset ] = + start.offset <= end.offset + ? [ start.offset, end.offset ] + : [ end.offset, start.offset ]; + return { + clientId: start.clientId, + attributeKey: start.attributeKey, + start: startOffset, + end: endOffset, + }; +} diff --git a/packages/editor/src/components/inline-markers/read-inline-selection.ts b/packages/editor/src/components/inline-markers/read-inline-selection.ts new file mode 100644 index 00000000000000..56a3b273a53a4e --- /dev/null +++ b/packages/editor/src/components/inline-markers/read-inline-selection.ts @@ -0,0 +1,38 @@ +/** + * Read an inline selection from block-editor selection state, returning + * normalized anchor data when a non-collapsed selection sits inside a single + * rich-text attribute. Returns null for block-level or collapsed selections. + * + * @param getSelectionStart Block-editor selector. + * @param getSelectionEnd Block-editor selector. + * @return `{ clientId, attributeKey, start, end }` or null. + */ +export function readInlineSelection( + getSelectionStart: () => any, + getSelectionEnd: () => any +) { + const start = getSelectionStart(); + const end = getSelectionEnd(); + if ( + ! start?.clientId || + start.clientId !== end.clientId || + ! start.attributeKey || + start.attributeKey !== end.attributeKey || + start.offset === undefined || + end.offset === undefined || + start.offset === end.offset + ) { + return null; + } + // Normalize direction so callers don't have to think about reversed ranges. + const [ startOffset, endOffset ] = + start.offset < end.offset + ? [ start.offset, end.offset ] + : [ end.offset, start.offset ]; + return { + clientId: start.clientId, + attributeKey: start.attributeKey, + start: startOffset, + end: endOffset, + }; +} diff --git a/packages/editor/src/components/inline-markers/reconcile-marker-removal.ts b/packages/editor/src/components/inline-markers/reconcile-marker-removal.ts new file mode 100644 index 00000000000000..da9fb93977cbf1 --- /dev/null +++ b/packages/editor/src/components/inline-markers/reconcile-marker-removal.ts @@ -0,0 +1,35 @@ +/** + * Decide what to do with an anchored inline marker based on whether its + * in-content marker is still present. Pure so it can be unit-tested without + * React/stores. + * + * - `'anchor'`: the marker is present; the caller should record that it has + * been seen this session. + * - `'delete'`: the marker was seen earlier this session but is now gone (the + * user removed the marked text), so the linked record should be deleted. + * - `'skip'`: the marker can't be evaluated (not an inline marker, block not + * loaded yet) or is absent for a marker never observed this session (e.g. a + * legacy/never-anchored record), which keeps any fallback rather than being + * deleted. + * + * The session `Set` guard is what distinguishes a genuine removal (seen, now + * gone) from content that simply has not loaded its marker yet. + * + * @param markerPresent Whether the marker was found in content; null/undefined when undeterminable. + * @param id Stable marker id, used as the key in `anchored`. + * @param anchored Ids whose marker has been observed present this session. + * @return The action to take. + */ +export function reconcileMarkerRemoval( + markerPresent: boolean | null | undefined, + id: any, + anchored: Set< any > +): 'anchor' | 'delete' | 'skip' { + if ( markerPresent === null || markerPresent === undefined ) { + return 'skip'; + } + if ( markerPresent ) { + return 'anchor'; + } + return anchored.has( id ) ? 'delete' : 'skip'; +} diff --git a/packages/editor/src/components/inline-markers/test/find-marker-range.jsdom.test.ts b/packages/editor/src/components/inline-markers/test/find-marker-range.jsdom.test.ts new file mode 100644 index 00000000000000..2b251901f6e5c9 --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/find-marker-range.jsdom.test.ts @@ -0,0 +1,274 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + RichTextData, + registerFormatType, + unregisterFormatType, + store as richTextStore, +} from '@wordpress/rich-text'; +import { select } from '@wordpress/data'; +import { findMarkerRange, findMarkerText } from '../find-marker-range'; + +const FORMAT_NAME = 'test/marker'; + +const isRegistered = () => + !! ( select( richTextStore as any ) as any ).getFormatType( FORMAT_NAME ); + +const options = { + formatType: FORMAT_NAME, + idAttribute: 'data-id', + quickReject: 'wp-marker', +}; + +describe( 'findMarkerRange', () => { + beforeAll( () => { + if ( ! isRegistered() ) { + registerFormatType( FORMAT_NAME, { + title: 'Marker', + tagName: 'mark', + className: 'wp-marker', + attributes: { + 'data-id': 'data-id', + 'data-suggestion-id': 'data-suggestion-id', + }, + edit: () => null, + } as any ); + } + } ); + + afterAll( () => { + if ( isRegistered() ) { + unregisterFormatType( FORMAT_NAME ); + } + } ); + + it( 'returns null for null/undefined input', () => { + expect( findMarkerRange( null, { ...options, id: 7 } ) ).toBeNull(); + expect( + findMarkerRange( undefined, { ...options, id: 7 } ) + ).toBeNull(); + } ); + + it( 'returns null when no marker is present', () => { + const value = RichTextData.fromHTMLString( 'hello world' ); + expect( findMarkerRange( value, { ...options, id: 7 } ) ).toBeNull(); + } ); + + it( 'returns range for a marker matching the id (RichTextData)', () => { + const value = RichTextData.fromHTMLString( + 'hello marked world' + ); + expect( findMarkerRange( value, { ...options, id: 7 } ) ).toEqual( { + start: 6, + end: 12, + } ); + } ); + + it( 'returns range for a marker matching the id (string)', () => { + const html = + 'hello marked world'; + expect( findMarkerRange( html, { ...options, id: 7 } ) ).toEqual( { + start: 6, + end: 12, + } ); + } ); + + it( 'returns null when the marker id does not match', () => { + const value = RichTextData.fromHTMLString( + 'x' + ); + expect( findMarkerRange( value, { ...options, id: 7 } ) ).toBeNull(); + } ); + + it( 'coerces ids to strings so numeric vs string ids match', () => { + const value = RichTextData.fromHTMLString( + 'x' + ); + expect( findMarkerRange( value, { ...options, id: '7' } ) ).toEqual( { + start: 0, + end: 1, + } ); + } ); + + it( 'returns null when the id itself is null/undefined', () => { + const value = RichTextData.fromHTMLString( + 'x' + ); + expect( findMarkerRange( value, { ...options, id: null } ) ).toBeNull(); + expect( + findMarkerRange( value, { ...options, id: undefined } ) + ).toBeNull(); + } ); + + it( 'rejects via quickReject without parsing when the token is absent', () => { + // The marker is present but the quickReject token is not, so the + // cheap substring check short-circuits before any rich-text parse. + const html = 'x'; + expect( + findMarkerRange( html, { + formatType: FORMAT_NAME, + idAttribute: 'data-id', + id: 7, + quickReject: 'wp-marker', + } ) + ).toBeNull(); + } ); + + it( 'matches a custom id attribute', () => { + const value = RichTextData.fromHTMLString( + 'x' + ); + expect( + findMarkerRange( value, { + formatType: FORMAT_NAME, + idAttribute: 'data-suggestion-id', + id: 9, + quickReject: 'wp-marker', + } ) + ).toEqual( { start: 0, end: 1 } ); + } ); + + it( 'resolves the range after an unrelated edit shifts the marker', () => { + // Anchoring contract: a marker survives edits elsewhere in the value + // and resolves to its current (shifted) offset, never a stored one. + const value = RichTextData.fromHTMLString( + 'prefix marked' + ); + expect( findMarkerRange( value, { ...options, id: 7 } ) ).toEqual( { + start: 7, + end: 13, + } ); + } ); + + it( 'spans a split (non-contiguous) run of the same id', () => { + // An edit inside the run can leave the same id in two separate marks. + // The range must span first -> last hit, not stop at the first gap, + // or accept/reject would only resolve the first fragment. + const value = RichTextData.fromHTMLString( + 'ABXY' + + 'CD' + ); + expect( findMarkerRange( value, { ...options, id: 7 } ) ).toEqual( { + start: 0, + end: 6, + } ); + } ); + + it( 'does not merge distinct marker ids that share the value', () => { + const value = RichTextData.fromHTMLString( + 'ABXY' + + 'CD' + ); + expect( findMarkerRange( value, { ...options, id: 7 } ) ).toEqual( { + start: 0, + end: 2, + } ); + expect( findMarkerRange( value, { ...options, id: 8 } ) ).toEqual( { + start: 4, + end: 6, + } ); + } ); + + it( 'spans across ANOTHER marker interleaved inside a fragmented run', () => { + /* + * A fragmented marker (same id split in two) with a DIFFERENT + * suggestion's marker sitting in the gap: the outer id's range still + * spans first -> last hit, which includes the inner marker's text. + * Pinned here because span-consumers must not treat the whole range + * as belonging to the outer id — see `removeMarkedRange` + * (inline-suggestions/operations.js), which removes only the + * characters carrying the outer id so accept/reject of the outer + * marker can't delete the inner marker's text. + */ + const value = RichTextData.fromHTMLString( + 'AB' + + 'IN' + + 'CD' + ); + expect( findMarkerRange( value, { ...options, id: 7 } ) ).toEqual( { + start: 0, + end: 6, + } ); + // The inner marker still resolves independently. + expect( findMarkerRange( value, { ...options, id: 8 } ) ).toEqual( { + start: 2, + end: 4, + } ); + } ); +} ); + +describe( 'findMarkerText', () => { + beforeAll( () => { + if ( ! isRegistered() ) { + registerFormatType( FORMAT_NAME, { + title: 'Marker', + tagName: 'mark', + className: 'wp-marker', + attributes: { + 'data-id': 'data-id', + 'data-suggestion-id': 'data-suggestion-id', + }, + edit: () => null, + } as any ); + } + } ); + + afterAll( () => { + if ( isRegistered() ) { + unregisterFormatType( FORMAT_NAME ); + } + } ); + + it( 'returns the marked text for a matching id', () => { + const value = RichTextData.fromHTMLString( + 'hello new text world' + ); + expect( findMarkerText( value, { ...options, id: 7 } ) ).toBe( + 'new text' + ); + } ); + + it( 'returns the marked text from a plain HTML string', () => { + const html = 'a marked b'; + expect( findMarkerText( html, { ...options, id: 7 } ) ).toBe( + 'marked' + ); + } ); + + it( 'quotes only the characters carrying the id in a fragmented marker', () => { + // The span of a fragmented marker can hold unmarked text (a gap)... + const gap = RichTextData.fromHTMLString( + 'ABXY' + + 'CD' + ); + expect( findMarkerText( gap, { ...options, id: 7 } ) ).toBe( 'ABCD' ); + + // ...or another marker. Neither belongs to the suggestion the summary + // describes; accept and reject act on the owned characters only. + const interleaved = RichTextData.fromHTMLString( + 'AB' + + 'IN' + + 'CD' + ); + expect( findMarkerText( interleaved, { ...options, id: 7 } ) ).toBe( + 'ABCD' + ); + expect( findMarkerText( interleaved, { ...options, id: 8 } ) ).toBe( + 'IN' + ); + } ); + + it( 'returns an empty string when no marker matches', () => { + const value = RichTextData.fromHTMLString( + 'x' + ); + expect( findMarkerText( value, { ...options, id: 7 } ) ).toBe( '' ); + } ); + + it( 'returns an empty string for null input or a missing id', () => { + expect( findMarkerText( null, { ...options, id: 7 } ) ).toBe( '' ); + const value = RichTextData.fromHTMLString( + 'x' + ); + expect( findMarkerText( value, { ...options, id: null } ) ).toBe( '' ); + } ); +} ); diff --git a/packages/editor/src/components/inline-markers/test/marker-selector.ts b/packages/editor/src/components/inline-markers/test/marker-selector.ts new file mode 100644 index 00000000000000..d02cc3801f0f01 --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/marker-selector.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { getMarkerSelector } from '../marker-selector'; + +describe( 'getMarkerSelector', () => { + it( 'matches a marker by class token and id attribute', () => { + expect( getMarkerSelector( 'wp-note', 'data-id', 12 ) ).toBe( + 'mark.wp-note[data-id="12"]' + ); + expect( + getMarkerSelector( 'wp-suggestion', 'data-suggestion-id', 12 ) + ).toBe( 'mark.wp-suggestion[data-suggestion-id="12"]' ); + } ); + + it( 'leaves a numeric id readable rather than identifier-escaped', () => { + // `CSS.escape` would render 7 as `\37 `, which matches but makes every + // generated rule unreadable. + expect( getMarkerSelector( 'wp-note', 'data-id', 7 ) ).toBe( + 'mark.wp-note[data-id="7"]' + ); + } ); + + it( 'escapes characters that would break out of the attribute value', () => { + expect( getMarkerSelector( 'wp-note', 'data-id', 'a"b' ) ).toBe( + 'mark.wp-note[data-id="a\\"b"]' + ); + expect( getMarkerSelector( 'wp-note', 'data-id', 'a\\b' ) ).toBe( + 'mark.wp-note[data-id="a\\\\b"]' + ); + expect( getMarkerSelector( 'wp-note', 'data-id', 'a\nb' ) ).toBe( + 'mark.wp-note[data-id="a\\a b"]' + ); + } ); +} ); diff --git a/packages/editor/src/components/inline-markers/test/read-inline-caret.ts b/packages/editor/src/components/inline-markers/test/read-inline-caret.ts new file mode 100644 index 00000000000000..cc08f2eb36831b --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/read-inline-caret.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { readInlineCaret } from '../read-inline-caret'; + +/** + * Build the two block-editor selection selectors from a single pair of points. + * + * @param start Selection start point. + * @param end Selection end point. + * @return `[getSelectionStart, getSelectionEnd]`. + */ +function selectors( start: any, end: any ) { + return [ () => start, () => end ]; +} + +describe( 'readInlineCaret', () => { + it( 'returns null when there is no clientId', () => { + const [ s, e ] = selectors( {}, {} ); + expect( readInlineCaret( s, e ) ).toBeNull(); + } ); + + it( 'returns null when start and end are in different blocks', () => { + const [ s, e ] = selectors( + { clientId: 'a', attributeKey: 'content', offset: 0 }, + { clientId: 'b', attributeKey: 'content', offset: 5 } + ); + expect( readInlineCaret( s, e ) ).toBeNull(); + } ); + + it( 'returns null without an attributeKey (block-level selection)', () => { + const [ s, e ] = selectors( + { clientId: 'a', offset: 0 }, + { clientId: 'a', offset: 5 } + ); + expect( readInlineCaret( s, e ) ).toBeNull(); + } ); + + it( 'returns null when start and end are in different attributes of the same block', () => { + // Two rich-text fields of one block, such as a quote's `value` and + // `citation`: their offsets are not comparable. + const [ s, e ] = selectors( + { clientId: 'a', attributeKey: 'value', offset: 2 }, + { clientId: 'a', attributeKey: 'citation', offset: 1 } + ); + expect( readInlineCaret( s, e ) ).toBeNull(); + } ); + + it( 'accepts a collapsed caret', () => { + const [ s, e ] = selectors( + { clientId: 'a', attributeKey: 'content', offset: 3 }, + { clientId: 'a', attributeKey: 'content', offset: 3 } + ); + expect( readInlineCaret( s, e ) ).toEqual( { + clientId: 'a', + attributeKey: 'content', + start: 3, + end: 3, + } ); + } ); + + it( 'normalizes a reversed (backward) selection', () => { + const [ s, e ] = selectors( + { clientId: 'a', attributeKey: 'content', offset: 8 }, + { clientId: 'a', attributeKey: 'content', offset: 2 } + ); + expect( readInlineCaret( s, e ) ).toEqual( { + clientId: 'a', + attributeKey: 'content', + start: 2, + end: 8, + } ); + } ); +} ); diff --git a/packages/editor/src/components/inline-markers/test/read-inline-selection.ts b/packages/editor/src/components/inline-markers/test/read-inline-selection.ts new file mode 100644 index 00000000000000..d06ba184043d37 --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/read-inline-selection.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { readInlineSelection } from '../read-inline-selection'; + +/** + * Build the two block-editor selection selectors from a single pair of points. + * + * @param start Selection start point. + * @param end Selection end point. + * @return `[getSelectionStart, getSelectionEnd]`. + */ +function selectors( start: any, end: any ) { + return [ () => start, () => end ]; +} + +describe( 'readInlineSelection', () => { + it( 'returns null when there is no clientId', () => { + const [ s, e ] = selectors( {}, {} ); + expect( readInlineSelection( s, e ) ).toBeNull(); + } ); + + it( 'returns null when start and end are in different blocks', () => { + const [ s, e ] = selectors( + { clientId: 'a', attributeKey: 'content', offset: 0 }, + { clientId: 'b', attributeKey: 'content', offset: 5 } + ); + expect( readInlineSelection( s, e ) ).toBeNull(); + } ); + + it( 'returns null without an attributeKey (block-level selection)', () => { + const [ s, e ] = selectors( + { clientId: 'a', offset: 0 }, + { clientId: 'a', offset: 5 } + ); + expect( readInlineSelection( s, e ) ).toBeNull(); + } ); + + it( 'returns null when start and end are in different attributes of the same block', () => { + // Two rich-text fields of one block, such as a quote's `value` and + // `citation`: their offsets are not comparable. + const [ s, e ] = selectors( + { clientId: 'a', attributeKey: 'value', offset: 2 }, + { clientId: 'a', attributeKey: 'citation', offset: 5 } + ); + expect( readInlineSelection( s, e ) ).toBeNull(); + } ); + + it( 'returns null for a collapsed selection', () => { + const [ s, e ] = selectors( + { clientId: 'a', attributeKey: 'content', offset: 3 }, + { clientId: 'a', attributeKey: 'content', offset: 3 } + ); + expect( readInlineSelection( s, e ) ).toBeNull(); + } ); + + it( 'returns normalized anchor data for a forward selection', () => { + const [ s, e ] = selectors( + { clientId: 'a', attributeKey: 'content', offset: 2 }, + { clientId: 'a', attributeKey: 'content', offset: 8 } + ); + expect( readInlineSelection( s, e ) ).toEqual( { + clientId: 'a', + attributeKey: 'content', + start: 2, + end: 8, + } ); + } ); + + it( 'normalizes a reversed (backward) selection', () => { + const [ s, e ] = selectors( + { clientId: 'a', attributeKey: 'content', offset: 8 }, + { clientId: 'a', attributeKey: 'content', offset: 2 } + ); + expect( readInlineSelection( s, e ) ).toEqual( { + clientId: 'a', + attributeKey: 'content', + start: 2, + end: 8, + } ); + } ); +} ); diff --git a/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.ts b/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.ts new file mode 100644 index 00000000000000..a375cfc13d947a --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { reconcileMarkerRemoval } from '../reconcile-marker-removal'; + +describe( 'reconcileMarkerRemoval', () => { + it( 'returns "anchor" when the marker is present', () => { + const anchored = new Set(); + expect( reconcileMarkerRemoval( true, 7, anchored ) ).toBe( 'anchor' ); + } ); + + it( 'returns "delete" when a previously anchored marker is now gone', () => { + const anchored = new Set( [ 7 ] ); + expect( reconcileMarkerRemoval( false, 7, anchored ) ).toBe( 'delete' ); + } ); + + it( 'returns "skip" when the marker is gone but was never observed', () => { + const anchored = new Set(); + expect( reconcileMarkerRemoval( false, 7, anchored ) ).toBe( 'skip' ); + } ); + + it( 'returns "skip" when presence is undeterminable (null/undefined)', () => { + const anchored = new Set( [ 7 ] ); + expect( reconcileMarkerRemoval( null, 7, anchored ) ).toBe( 'skip' ); + expect( reconcileMarkerRemoval( undefined, 7, anchored ) ).toBe( + 'skip' + ); + } ); + + it( 'keys the session guard by id', () => { + const anchored = new Set( [ 1 ] ); + // id 1 was seen -> delete; id 2 was not -> skip. + expect( reconcileMarkerRemoval( false, 1, anchored ) ).toBe( 'delete' ); + expect( reconcileMarkerRemoval( false, 2, anchored ) ).toBe( 'skip' ); + } ); +} ); diff --git a/packages/editor/src/components/inline-markers/test/wrap-inline-marker.jsdom.test.ts b/packages/editor/src/components/inline-markers/test/wrap-inline-marker.jsdom.test.ts new file mode 100644 index 00000000000000..f6db1cd0917a33 --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/wrap-inline-marker.jsdom.test.ts @@ -0,0 +1,86 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + RichTextData, + registerFormatType, + unregisterFormatType, + store as richTextStore, +} from '@wordpress/rich-text'; +import { select } from '@wordpress/data'; +import { wrapInlineMarker } from '../wrap-inline-marker'; +import { findMarkerRange } from '../find-marker-range'; + +const FORMAT_NAME = 'test/marker'; + +const isRegistered = () => + !! ( select( richTextStore as any ) as any ).getFormatType( FORMAT_NAME ); + +describe( 'wrapInlineMarker', () => { + beforeAll( () => { + if ( ! isRegistered() ) { + registerFormatType( FORMAT_NAME, { + title: 'Marker', + tagName: 'mark', + className: 'wp-marker', + attributes: { 'data-id': 'data-id' }, + edit: () => null, + } as any ); + } + } ); + + afterAll( () => { + if ( isRegistered() ) { + unregisterFormatType( FORMAT_NAME ); + } + } ); + + it( 'returns null when the value is not RichTextData', () => { + expect( + wrapInlineMarker( 'plain string', { + formatType: FORMAT_NAME, + attributes: { 'data-id': '7' }, + start: 0, + end: 5, + } ) + ).toBeNull(); + expect( + wrapInlineMarker( undefined, { + formatType: FORMAT_NAME, + attributes: { 'data-id': '7' }, + start: 0, + end: 5, + } ) + ).toBeNull(); + } ); + + it( 'wraps the given range and returns a RichTextData', () => { + const value = RichTextData.fromHTMLString( 'hello world' ); + const wrapped = wrapInlineMarker( value, { + formatType: FORMAT_NAME, + attributes: { 'data-id': '7' }, + start: 6, + end: 11, + } ); + expect( wrapped ).toBeInstanceOf( RichTextData ); + const html = wrapped!.toHTMLString(); + expect( html ).toContain( 'data-id="7"' ); + expect( html ).toContain( 'world' ); + } ); + + it( 'produces a marker that findMarkerRange resolves to the same range', () => { + const value = RichTextData.fromHTMLString( 'hello world' ); + const wrapped = wrapInlineMarker( value, { + formatType: FORMAT_NAME, + attributes: { 'data-id': '7' }, + start: 6, + end: 11, + } ); + expect( + findMarkerRange( wrapped, { + formatType: FORMAT_NAME, + idAttribute: 'data-id', + id: 7, + quickReject: 'wp-marker', + } ) + ).toEqual( { start: 6, end: 11 } ); + } ); +} ); diff --git a/packages/editor/src/components/inline-markers/use-annotate-ranges.ts b/packages/editor/src/components/inline-markers/use-annotate-ranges.ts new file mode 100644 index 00000000000000..7cf3364aa05d8d --- /dev/null +++ b/packages/editor/src/components/inline-markers/use-annotate-ranges.ts @@ -0,0 +1,48 @@ +import { useEffect } from '@wordpress/element'; +import { useDispatch, useRegistry } from '@wordpress/data'; +import { store as annotationsStore } from '@wordpress/annotations'; + +/** + * Decorate a set of inline ranges using the annotations API. Decoration is + * runtime-only: annotations are never written back to block content, and every + * range registered under `source` is cleared together on cleanup. + * + * Each consumer passes its own `source` (e.g. `core-note`, `core-suggestion`), + * which the annotations API turns into an `annotation-text-{source}` class on + * the rendered ``, so different consumers' decorations never collide. + * + * Callers are responsible for memoizing `ranges` so the effect only re-runs + * when the resolved ranges actually change. + * + * @param source Annotation source identifier. + * @param ranges Ranges to decorate: `{ id, clientId, attributeKey, start, end }`. + */ +export function useAnnotateRanges( source: string, ranges: any[] ) { + const registry = useRegistry(); + const { + __experimentalAddAnnotation: addAnnotation, + __experimentalRemoveAnnotationsBySource: removeAnnotationsBySource, + } = useDispatch( annotationsStore ); + + useEffect( () => { + if ( ! ranges?.length ) { + return; + } + // Batched so every annotated block re-renders once per range set, + // not once per range. + registry.batch( () => { + for ( const range of ranges ) { + addAnnotation( { + id: range.id, + source, + blockClientId: range.clientId, + richTextIdentifier: range.attributeKey, + range: { start: range.start, end: range.end }, + } ); + } + } ); + return () => { + removeAnnotationsBySource( source ); + }; + }, [ registry, source, ranges, addAnnotation, removeAnnotationsBySource ] ); +} diff --git a/packages/editor/src/components/inline-markers/wrap-inline-marker.ts b/packages/editor/src/components/inline-markers/wrap-inline-marker.ts new file mode 100644 index 00000000000000..40389f1d8065b5 --- /dev/null +++ b/packages/editor/src/components/inline-markers/wrap-inline-marker.ts @@ -0,0 +1,40 @@ +import { RichTextData, applyFormat, create } from '@wordpress/rich-text'; + +/** + * Wrap a rich-text range with an inline `` marker format. Returns a new + * RichTextData ready to write back into block attributes, or null when the + * incoming value isn't a rich-text instance (legacy/string attributes). + * + * @param value Existing block attribute value. + * @param options Options. + * @param options.formatType Rich-text format type to apply (e.g. `core/note`). + * @param options.attributes Marker attributes (e.g. `{ 'data-id': '7' }`). + * @param options.start Range start offset. + * @param options.end Range end offset. + * @return Wrapped value or null when the attribute isn't rich text. + */ +export function wrapInlineMarker( + value: any, + { + formatType, + attributes, + start, + end, + }: { + formatType: string; + attributes: Record< string, string >; + start: number; + end: number; + } +) { + if ( ! ( value instanceof RichTextData ) ) { + return null; + } + const record = applyFormat( + create( { html: value.toHTMLString() } ), + { type: formatType, attributes } as any, + start, + end + ); + return new RichTextData( record as any ); +} diff --git a/packages/editor/tsconfig.build.json b/packages/editor/tsconfig.build.json index 0f831136bc7661..33abd6fdcfdd19 100644 --- a/packages/editor/tsconfig.build.json +++ b/packages/editor/tsconfig.build.json @@ -7,6 +7,7 @@ }, "references": [ { "path": "../a11y/tsconfig.build.json" }, + { "path": "../annotations/tsconfig.build.json" }, { "path": "../api-fetch/tsconfig.build.json" }, { "path": "../blob/tsconfig.build.json" }, { "path": "../block-editor/tsconfig.build.json" }, diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json index ce451c27e2ea09..5b2aeb667ce9dc 100644 --- a/packages/editor/tsconfig.json +++ b/packages/editor/tsconfig.json @@ -13,6 +13,7 @@ "references": [ { "path": "./tsconfig.build.json" }, { "path": "../a11y/tsconfig.build.json" }, + { "path": "../annotations/tsconfig.build.json" }, { "path": "../api-fetch/tsconfig.build.json" }, { "path": "../blob/tsconfig.build.json" }, { "path": "../block-editor/tsconfig.build.json" },