From 75b01dd4d11e9cfa20431407d24f218d2ae39422 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 17 Jul 2026 11:46:30 -0700 Subject: [PATCH 1/9] Suggest mode 4/7: inline marker primitive Adds the generic inline-marker layer: wrap a rich-text range in a core/suggestion , find a marker's range by id, read the caret and selection in rich-text offset space, reconcile a marker after the user edits across it, and decorate markers via the annotations API. This is a self-contained rich-text utility with no suggestion-specific logic; the suggestion operations that build on it land in the next step. --- package-lock.json | 1 + packages/editor/package.json | 1 + .../inline-markers/find-marker-range.js | 137 ++++++++++ .../src/components/inline-markers/index.js | 20 ++ .../inline-markers/read-inline-caret.js | 36 +++ .../inline-markers/read-inline-selection.js | 34 +++ .../reconcile-marker-removal.js | 31 +++ .../inline-markers/test/find-marker-range.js | 257 ++++++++++++++++++ .../test/read-inline-selection.js | 72 +++++ .../test/reconcile-marker-removal.js | 36 +++ .../inline-markers/test/wrap-inline-marker.js | 92 +++++++ .../inline-markers/use-annotate-ranges.js | 46 ++++ .../inline-markers/wrap-inline-marker.js | 33 +++ packages/editor/tsconfig.json | 1 + 14 files changed, 797 insertions(+) create mode 100644 packages/editor/src/components/inline-markers/find-marker-range.js create mode 100644 packages/editor/src/components/inline-markers/index.js create mode 100644 packages/editor/src/components/inline-markers/read-inline-caret.js create mode 100644 packages/editor/src/components/inline-markers/read-inline-selection.js create mode 100644 packages/editor/src/components/inline-markers/reconcile-marker-removal.js create mode 100644 packages/editor/src/components/inline-markers/test/find-marker-range.js create mode 100644 packages/editor/src/components/inline-markers/test/read-inline-selection.js create mode 100644 packages/editor/src/components/inline-markers/test/reconcile-marker-removal.js create mode 100644 packages/editor/src/components/inline-markers/test/wrap-inline-marker.js create mode 100644 packages/editor/src/components/inline-markers/use-annotate-ranges.js create mode 100644 packages/editor/src/components/inline-markers/wrap-inline-marker.js diff --git a/package-lock.json b/package-lock.json index 0b11075cefd88d..9f52536d879bd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45604,6 +45604,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 34f7f6f8b224b4..54453d2a2022d4 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -60,6 +60,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.js b/packages/editor/src/components/inline-markers/find-marker-range.js new file mode 100644 index 00000000000000..7dc66c283e2fca --- /dev/null +++ b/packages/editor/src/components/inline-markers/find-marker-range.js @@ -0,0 +1,137 @@ +/** + * WordPress dependencies + */ +import { create, RichTextData } from '@wordpress/rich-text'; + +/** + * Parse a block attribute value into a rich-text record, applying the cheap + * `quickReject` substring check before the (relatively costly) parse. 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 {Object} options + * @param {number|string} options.id Marker id (only its presence is checked here). + * @param {string} [options.quickReject] Optional substring used to skip parsing when absent. + * @return {?Object} Rich-text record, or null when there is nothing to search. + */ +function parseMarkerValue( value, { id, quickReject } ) { + if ( id === undefined || id === null ) { + return null; + } + let html = null; + if ( value instanceof RichTextData ) { + html = value.toHTMLString(); + } else if ( typeof value === 'string' ) { + html = value; + } + if ( ! html ) { + return null; + } + // Cheap reject before the (relatively costly) rich-text parse. + if ( quickReject && html.indexOf( quickReject ) === -1 ) { + return null; + } + return create( { html } ); +} + +/** + * 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. Any short gap between + * fragments is the suggester's own run and belongs to the suggestion. + * + * @param {Object} record Rich-text record. + * @param {string} formatType Rich-text format type to match. + * @param {string} idAttribute Marker attribute holding the id. + * @param {number|string} id Marker id to search for. + * @return {?{start: number, end: number}} Range or null when no marker is found. + */ +function rangeInRecord( record, formatType, idAttribute, id ) { + const target = String( id ); + const formats = record.formats; + let start = -1; + let end = -1; + for ( let i = 0; i < formats.length; i++ ) { + const stack = formats[ i ]; + const hit = stack?.find( + ( f ) => + f.type === formatType && + f.attributes && + f.attributes[ idAttribute ] === target + ); + if ( hit ) { + 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 {Object} options + * @param {string} options.formatType Rich-text format type to match (e.g. `core/note`). + * @param {string} [options.idAttribute='data-id'] Marker attribute holding the id. + * @param {number|string} options.id Marker id to search for. + * @param {string} [options.quickReject] Optional substring (e.g. the marker class) used to + * skip parsing when it is absent from the HTML. + * @return {?{start: number, end: number}} Range or null when no marker is found. + */ +export function findMarkerRange( + value, + { formatType, idAttribute = 'data-id', id, quickReject } +) { + 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 {Object} options + * @param {string} options.formatType Rich-text format type to match. + * @param {string} [options.idAttribute='data-id'] Marker attribute holding the id. + * @param {number|string} options.id Marker id to search for. + * @param {string} [options.quickReject] Optional substring used to skip parsing when absent. + * @return {string} The marked text, or '' when no marker is found. + */ +export function findMarkerText( + value, + { formatType, idAttribute = 'data-id', id, quickReject } +) { + const record = parseMarkerValue( value, { id, quickReject } ); + if ( ! record ) { + return ''; + } + const range = rangeInRecord( record, formatType, idAttribute, id ); + if ( ! range ) { + return ''; + } + return record.text.slice( range.start, range.end ); +} diff --git a/packages/editor/src/components/inline-markers/index.js b/packages/editor/src/components/inline-markers/index.js new file mode 100644 index 00000000000000..1d781e0aa57caf --- /dev/null +++ b/packages/editor/src/components/inline-markers/index.js @@ -0,0 +1,20 @@ +/** + * 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 { 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/read-inline-caret.js b/packages/editor/src/components/inline-markers/read-inline-caret.js new file mode 100644 index 00000000000000..6d3a7679d046ca --- /dev/null +++ b/packages/editor/src/components/inline-markers/read-inline-caret.js @@ -0,0 +1,36 @@ +/** + * 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 {Function} getSelectionStart Block-editor selector. + * @param {Function} getSelectionEnd Block-editor selector. + * @return {?Object} { clientId, attributeKey, start, end } or null. + */ +export function readInlineCaret( getSelectionStart, getSelectionEnd ) { + const start = getSelectionStart(); + const end = getSelectionEnd(); + if ( + ! start?.clientId || + start.clientId !== end.clientId || + ! start.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.js b/packages/editor/src/components/inline-markers/read-inline-selection.js new file mode 100644 index 00000000000000..938e2de2467a28 --- /dev/null +++ b/packages/editor/src/components/inline-markers/read-inline-selection.js @@ -0,0 +1,34 @@ +/** + * 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 {Function} getSelectionStart Block-editor selector. + * @param {Function} getSelectionEnd Block-editor selector. + * @return {?Object} { clientId, attributeKey, start, end } or null. + */ +export function readInlineSelection( getSelectionStart, getSelectionEnd ) { + const start = getSelectionStart(); + const end = getSelectionEnd(); + if ( + ! start?.clientId || + start.clientId !== end.clientId || + ! start.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.js b/packages/editor/src/components/inline-markers/reconcile-marker-removal.js new file mode 100644 index 00000000000000..5b2ae84c07a2f5 --- /dev/null +++ b/packages/editor/src/components/inline-markers/reconcile-marker-removal.js @@ -0,0 +1,31 @@ +/** + * 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 {boolean|null|undefined} markerPresent Whether the marker was found in content; null/undefined when undeterminable. + * @param {*} id Stable marker id, used as the key in `anchored`. + * @param {Set} anchored Ids whose marker has been observed present this session. + * @return {'anchor'|'delete'|'skip'} The action to take. + */ +export function reconcileMarkerRemoval( markerPresent, id, anchored ) { + 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.js b/packages/editor/src/components/inline-markers/test/find-marker-range.js new file mode 100644 index 00000000000000..0e2ed1a8d7fc31 --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/find-marker-range.js @@ -0,0 +1,257 @@ +/** + * WordPress dependencies + */ +import { + RichTextData, + registerFormatType, + unregisterFormatType, + store as richTextStore, +} from '@wordpress/rich-text'; +import { select } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { findMarkerRange, findMarkerText } from '../find-marker-range'; + +const FORMAT_NAME = 'test/marker'; + +const isRegistered = () => + !! select( richTextStore ).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, + } ); + } + } ); + + 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, + } ); + } + } ); + + 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( '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/read-inline-selection.js b/packages/editor/src/components/inline-markers/test/read-inline-selection.js new file mode 100644 index 00000000000000..f92366073827ad --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/read-inline-selection.js @@ -0,0 +1,72 @@ +/** + * Internal dependencies + */ +import { readInlineSelection } from '../read-inline-selection'; + +/** + * Build the two block-editor selection selectors from a single pair of points. + * + * @param {Object} start Selection start point. + * @param {Object} end Selection end point. + * @return {[Function, Function]} `[getSelectionStart, getSelectionEnd]`. + */ +function selectors( start, end ) { + 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 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.js b/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.js new file mode 100644 index 00000000000000..5dd29e71aea8f5 --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.js @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ +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.js b/packages/editor/src/components/inline-markers/test/wrap-inline-marker.js new file mode 100644 index 00000000000000..0fd2b450ef6117 --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/wrap-inline-marker.js @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import { + RichTextData, + registerFormatType, + unregisterFormatType, + store as richTextStore, +} from '@wordpress/rich-text'; +import { select } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { wrapInlineMarker } from '../wrap-inline-marker'; +import { findMarkerRange } from '../find-marker-range'; + +const FORMAT_NAME = 'test/marker'; + +const isRegistered = () => + !! select( richTextStore ).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, + } ); + } + } ); + + 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.js b/packages/editor/src/components/inline-markers/use-annotate-ranges.js new file mode 100644 index 00000000000000..cb96c81f81396d --- /dev/null +++ b/packages/editor/src/components/inline-markers/use-annotate-ranges.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import { useEffect } from '@wordpress/element'; +import { useDispatch } 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 {string} source Annotation source identifier. + * @param {Array} ranges Ranges to decorate: `{ id, clientId, attributeKey, start, end }`. + */ +export function useAnnotateRanges( source, ranges ) { + const { + __experimentalAddAnnotation: addAnnotation, + __experimentalRemoveAnnotationsBySource: removeAnnotationsBySource, + } = useDispatch( annotationsStore ); + + useEffect( () => { + if ( ! ranges?.length ) { + return; + } + 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 ); + }; + }, [ source, ranges, addAnnotation, removeAnnotationsBySource ] ); +} diff --git a/packages/editor/src/components/inline-markers/wrap-inline-marker.js b/packages/editor/src/components/inline-markers/wrap-inline-marker.js new file mode 100644 index 00000000000000..4a3b1e4ba77fa7 --- /dev/null +++ b/packages/editor/src/components/inline-markers/wrap-inline-marker.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +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 {Object} options + * @param {string} options.formatType Rich-text format type to apply (e.g. `core/note`). + * @param {Object} options.attributes Marker attributes (e.g. `{ 'data-id': '7' }`). + * @param {number} options.start Range start offset. + * @param {number} options.end Range end offset. + * @return {?RichTextData} Wrapped value or null when the attribute isn't rich text. + */ +export function wrapInlineMarker( + value, + { formatType, attributes, start, end } +) { + if ( ! ( value instanceof RichTextData ) ) { + return null; + } + const record = applyFormat( + create( { html: value.toHTMLString() } ), + { type: formatType, attributes }, + start, + end + ); + return new RichTextData( record ); +} diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json index 4cd6bce8787db7..4ba0df3cf4b25f 100644 --- a/packages/editor/tsconfig.json +++ b/packages/editor/tsconfig.json @@ -7,6 +7,7 @@ }, "references": [ { "path": "../a11y" }, + { "path": "../annotations" }, { "path": "../api-fetch" }, { "path": "../blob" }, { "path": "../block-editor" }, From b9902108e3f4d7cd073816d99f454d7634f3b23a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 11:31:50 -0700 Subject: [PATCH 2/9] Drop dependency-group comment blocks flagged by the new lint rule Trunk switched @wordpress/dependency-group to 'never' mode, so the import group headers in these files now fail lint. --- .../src/components/inline-markers/find-marker-range.js | 3 --- .../components/inline-markers/test/find-marker-range.js | 7 ------- .../inline-markers/test/read-inline-selection.js | 3 --- .../inline-markers/test/reconcile-marker-removal.js | 3 --- .../components/inline-markers/test/wrap-inline-marker.js | 7 ------- .../src/components/inline-markers/use-annotate-ranges.js | 3 --- .../src/components/inline-markers/wrap-inline-marker.js | 3 --- 7 files changed, 29 deletions(-) diff --git a/packages/editor/src/components/inline-markers/find-marker-range.js b/packages/editor/src/components/inline-markers/find-marker-range.js index 7dc66c283e2fca..3d97154de6fbfa 100644 --- a/packages/editor/src/components/inline-markers/find-marker-range.js +++ b/packages/editor/src/components/inline-markers/find-marker-range.js @@ -1,6 +1,3 @@ -/** - * WordPress dependencies - */ import { create, RichTextData } from '@wordpress/rich-text'; /** diff --git a/packages/editor/src/components/inline-markers/test/find-marker-range.js b/packages/editor/src/components/inline-markers/test/find-marker-range.js index 0e2ed1a8d7fc31..861eb31ad0feab 100644 --- a/packages/editor/src/components/inline-markers/test/find-marker-range.js +++ b/packages/editor/src/components/inline-markers/test/find-marker-range.js @@ -1,6 +1,3 @@ -/** - * WordPress dependencies - */ import { RichTextData, registerFormatType, @@ -8,10 +5,6 @@ import { store as richTextStore, } from '@wordpress/rich-text'; import { select } from '@wordpress/data'; - -/** - * Internal dependencies - */ import { findMarkerRange, findMarkerText } from '../find-marker-range'; const FORMAT_NAME = 'test/marker'; diff --git a/packages/editor/src/components/inline-markers/test/read-inline-selection.js b/packages/editor/src/components/inline-markers/test/read-inline-selection.js index f92366073827ad..f50cef4e2ea174 100644 --- a/packages/editor/src/components/inline-markers/test/read-inline-selection.js +++ b/packages/editor/src/components/inline-markers/test/read-inline-selection.js @@ -1,6 +1,3 @@ -/** - * Internal dependencies - */ import { readInlineSelection } from '../read-inline-selection'; /** diff --git a/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.js b/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.js index 5dd29e71aea8f5..bdded2368537c0 100644 --- a/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.js +++ b/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.js @@ -1,6 +1,3 @@ -/** - * Internal dependencies - */ import { reconcileMarkerRemoval } from '../reconcile-marker-removal'; describe( 'reconcileMarkerRemoval', () => { diff --git a/packages/editor/src/components/inline-markers/test/wrap-inline-marker.js b/packages/editor/src/components/inline-markers/test/wrap-inline-marker.js index 0fd2b450ef6117..49d2a110045626 100644 --- a/packages/editor/src/components/inline-markers/test/wrap-inline-marker.js +++ b/packages/editor/src/components/inline-markers/test/wrap-inline-marker.js @@ -1,6 +1,3 @@ -/** - * WordPress dependencies - */ import { RichTextData, registerFormatType, @@ -8,10 +5,6 @@ import { store as richTextStore, } from '@wordpress/rich-text'; import { select } from '@wordpress/data'; - -/** - * Internal dependencies - */ import { wrapInlineMarker } from '../wrap-inline-marker'; import { findMarkerRange } from '../find-marker-range'; diff --git a/packages/editor/src/components/inline-markers/use-annotate-ranges.js b/packages/editor/src/components/inline-markers/use-annotate-ranges.js index cb96c81f81396d..c8af248cb82cfe 100644 --- a/packages/editor/src/components/inline-markers/use-annotate-ranges.js +++ b/packages/editor/src/components/inline-markers/use-annotate-ranges.js @@ -1,6 +1,3 @@ -/** - * WordPress dependencies - */ import { useEffect } from '@wordpress/element'; import { useDispatch } from '@wordpress/data'; import { store as annotationsStore } from '@wordpress/annotations'; diff --git a/packages/editor/src/components/inline-markers/wrap-inline-marker.js b/packages/editor/src/components/inline-markers/wrap-inline-marker.js index 4a3b1e4ba77fa7..0018b80e64e489 100644 --- a/packages/editor/src/components/inline-markers/wrap-inline-marker.js +++ b/packages/editor/src/components/inline-markers/wrap-inline-marker.js @@ -1,6 +1,3 @@ -/** - * WordPress dependencies - */ import { RichTextData, applyFormat, create } from '@wordpress/rich-text'; /** From a1523cad68e2b86fc4090618caab11c3ea7b9ff1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 23 Aug 2026 07:58:14 -0700 Subject: [PATCH 3/9] Convert inline-markers primitive to TypeScript Rename the files this PR adds from .js to .ts and add types so the strict type check covers them. The marker id stays nullable in the public signatures because findMarkerRange/findMarkerText explicitly short-circuit on a missing id. No behavior changes. --- ...d-marker-range.js => find-marker-range.ts} | 101 ++++++++++++------ .../inline-markers/{index.js => index.ts} | 0 ...d-inline-caret.js => read-inline-caret.ts} | 11 +- ...-selection.js => read-inline-selection.ts} | 11 +- ...removal.js => reconcile-marker-removal.ts} | 14 ++- ...d-marker-range.js => find-marker-range.ts} | 6 +- ...-selection.js => read-inline-selection.ts} | 8 +- ...removal.js => reconcile-marker-removal.ts} | 0 ...inline-marker.js => wrap-inline-marker.ts} | 6 +- ...otate-ranges.js => use-annotate-ranges.ts} | 6 +- .../inline-markers/wrap-inline-marker.js | 30 ------ .../inline-markers/wrap-inline-marker.ts | 40 +++++++ 12 files changed, 142 insertions(+), 91 deletions(-) rename packages/editor/src/components/inline-markers/{find-marker-range.js => find-marker-range.ts} (55%) rename packages/editor/src/components/inline-markers/{index.js => index.ts} (100%) rename packages/editor/src/components/inline-markers/{read-inline-caret.js => read-inline-caret.ts} (78%) rename packages/editor/src/components/inline-markers/{read-inline-selection.js => read-inline-selection.ts} (75%) rename packages/editor/src/components/inline-markers/{reconcile-marker-removal.js => reconcile-marker-removal.ts} (68%) rename packages/editor/src/components/inline-markers/test/{find-marker-range.js => find-marker-range.ts} (98%) rename packages/editor/src/components/inline-markers/test/{read-inline-selection.js => read-inline-selection.ts} (90%) rename packages/editor/src/components/inline-markers/test/{reconcile-marker-removal.js => reconcile-marker-removal.ts} (100%) rename packages/editor/src/components/inline-markers/test/{wrap-inline-marker.js => wrap-inline-marker.ts} (93%) rename packages/editor/src/components/inline-markers/{use-annotate-ranges.js => use-annotate-ranges.ts} (86%) delete mode 100644 packages/editor/src/components/inline-markers/wrap-inline-marker.js create mode 100644 packages/editor/src/components/inline-markers/wrap-inline-marker.ts diff --git a/packages/editor/src/components/inline-markers/find-marker-range.js b/packages/editor/src/components/inline-markers/find-marker-range.ts similarity index 55% rename from packages/editor/src/components/inline-markers/find-marker-range.js rename to packages/editor/src/components/inline-markers/find-marker-range.ts index 3d97154de6fbfa..f72593994f5bd4 100644 --- a/packages/editor/src/components/inline-markers/find-marker-range.js +++ b/packages/editor/src/components/inline-markers/find-marker-range.ts @@ -6,13 +6,19 @@ import { create, RichTextData } from '@wordpress/rich-text'; * 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 {Object} options - * @param {number|string} options.id Marker id (only its presence is checked here). - * @param {string} [options.quickReject] Optional substring used to skip parsing when absent. - * @return {?Object} Rich-text record, or null when there is nothing to search. + * @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, { id, quickReject } ) { +function parseMarkerValue( + value: any, + { + id, + quickReject, + }: { id: number | string | null | undefined; quickReject?: string } +) { if ( id === undefined || id === null ) { return null; } @@ -43,13 +49,18 @@ function parseMarkerValue( value, { id, quickReject } ) { * — truncated accept/reject to a fragment of the marker. Any short gap between * fragments is the suggester's own run and belongs to the suggestion. * - * @param {Object} record Rich-text record. - * @param {string} formatType Rich-text format type to match. - * @param {string} idAttribute Marker attribute holding the id. - * @param {number|string} id Marker id to search for. - * @return {?{start: number, end: number}} Range or null when no marker is found. + * @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, formatType, idAttribute, id ) { +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; @@ -57,7 +68,7 @@ function rangeInRecord( record, formatType, idAttribute, id ) { for ( let i = 0; i < formats.length; i++ ) { const stack = formats[ i ]; const hit = stack?.find( - ( f ) => + ( f: any ) => f.type === formatType && f.attributes && f.attributes[ idAttribute ] === target @@ -84,24 +95,34 @@ function rangeInRecord( record, formatType, idAttribute, id ) { * 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 {Object} options - * @param {string} options.formatType Rich-text format type to match (e.g. `core/note`). - * @param {string} [options.idAttribute='data-id'] Marker attribute holding the id. - * @param {number|string} options.id Marker id to search for. - * @param {string} [options.quickReject] Optional substring (e.g. the marker class) used to - * skip parsing when it is absent from the HTML. - * @return {?{start: number, end: number}} Range or null when no marker is found. + * @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, - { formatType, idAttribute = 'data-id', id, quickReject } + 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 ); + return rangeInRecord( record, formatType, idAttribute, id! ); } /** @@ -110,23 +131,33 @@ export function findMarkerRange( * 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 {Object} options - * @param {string} options.formatType Rich-text format type to match. - * @param {string} [options.idAttribute='data-id'] Marker attribute holding the id. - * @param {number|string} options.id Marker id to search for. - * @param {string} [options.quickReject] Optional substring used to skip parsing when absent. - * @return {string} The marked text, or '' when no marker is 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, - { formatType, idAttribute = 'data-id', id, quickReject } -) { + 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 ); + const range = rangeInRecord( record, formatType, idAttribute, id! ); if ( ! range ) { return ''; } diff --git a/packages/editor/src/components/inline-markers/index.js b/packages/editor/src/components/inline-markers/index.ts similarity index 100% rename from packages/editor/src/components/inline-markers/index.js rename to packages/editor/src/components/inline-markers/index.ts diff --git a/packages/editor/src/components/inline-markers/read-inline-caret.js b/packages/editor/src/components/inline-markers/read-inline-caret.ts similarity index 78% rename from packages/editor/src/components/inline-markers/read-inline-caret.js rename to packages/editor/src/components/inline-markers/read-inline-caret.ts index 6d3a7679d046ca..4f72756eb7c961 100644 --- a/packages/editor/src/components/inline-markers/read-inline-caret.js +++ b/packages/editor/src/components/inline-markers/read-inline-caret.ts @@ -6,11 +6,14 @@ * when nothing is selected. Returns null for block-level or cross-attribute * selections. * - * @param {Function} getSelectionStart Block-editor selector. - * @param {Function} getSelectionEnd Block-editor selector. - * @return {?Object} { clientId, attributeKey, start, end } or null. + * @param getSelectionStart Block-editor selector. + * @param getSelectionEnd Block-editor selector. + * @return `{ clientId, attributeKey, start, end }` or null. */ -export function readInlineCaret( getSelectionStart, getSelectionEnd ) { +export function readInlineCaret( + getSelectionStart: () => any, + getSelectionEnd: () => any +) { const start = getSelectionStart(); const end = getSelectionEnd(); if ( diff --git a/packages/editor/src/components/inline-markers/read-inline-selection.js b/packages/editor/src/components/inline-markers/read-inline-selection.ts similarity index 75% rename from packages/editor/src/components/inline-markers/read-inline-selection.js rename to packages/editor/src/components/inline-markers/read-inline-selection.ts index 938e2de2467a28..67e6bea2599259 100644 --- a/packages/editor/src/components/inline-markers/read-inline-selection.js +++ b/packages/editor/src/components/inline-markers/read-inline-selection.ts @@ -3,11 +3,14 @@ * normalized anchor data when a non-collapsed selection sits inside a single * rich-text attribute. Returns null for block-level or collapsed selections. * - * @param {Function} getSelectionStart Block-editor selector. - * @param {Function} getSelectionEnd Block-editor selector. - * @return {?Object} { clientId, attributeKey, start, end } or null. + * @param getSelectionStart Block-editor selector. + * @param getSelectionEnd Block-editor selector. + * @return `{ clientId, attributeKey, start, end }` or null. */ -export function readInlineSelection( getSelectionStart, getSelectionEnd ) { +export function readInlineSelection( + getSelectionStart: () => any, + getSelectionEnd: () => any +) { const start = getSelectionStart(); const end = getSelectionEnd(); if ( diff --git a/packages/editor/src/components/inline-markers/reconcile-marker-removal.js b/packages/editor/src/components/inline-markers/reconcile-marker-removal.ts similarity index 68% rename from packages/editor/src/components/inline-markers/reconcile-marker-removal.js rename to packages/editor/src/components/inline-markers/reconcile-marker-removal.ts index 5b2ae84c07a2f5..da9fb93977cbf1 100644 --- a/packages/editor/src/components/inline-markers/reconcile-marker-removal.js +++ b/packages/editor/src/components/inline-markers/reconcile-marker-removal.ts @@ -15,12 +15,16 @@ * The session `Set` guard is what distinguishes a genuine removal (seen, now * gone) from content that simply has not loaded its marker yet. * - * @param {boolean|null|undefined} markerPresent Whether the marker was found in content; null/undefined when undeterminable. - * @param {*} id Stable marker id, used as the key in `anchored`. - * @param {Set} anchored Ids whose marker has been observed present this session. - * @return {'anchor'|'delete'|'skip'} The action to take. + * @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, id, anchored ) { +export function reconcileMarkerRemoval( + markerPresent: boolean | null | undefined, + id: any, + anchored: Set< any > +): 'anchor' | 'delete' | 'skip' { if ( markerPresent === null || markerPresent === undefined ) { return 'skip'; } diff --git a/packages/editor/src/components/inline-markers/test/find-marker-range.js b/packages/editor/src/components/inline-markers/test/find-marker-range.ts similarity index 98% rename from packages/editor/src/components/inline-markers/test/find-marker-range.js rename to packages/editor/src/components/inline-markers/test/find-marker-range.ts index 861eb31ad0feab..6ddd9d616b5361 100644 --- a/packages/editor/src/components/inline-markers/test/find-marker-range.js +++ b/packages/editor/src/components/inline-markers/test/find-marker-range.ts @@ -10,7 +10,7 @@ import { findMarkerRange, findMarkerText } from '../find-marker-range'; const FORMAT_NAME = 'test/marker'; const isRegistered = () => - !! select( richTextStore ).getFormatType( FORMAT_NAME ); + !! ( select( richTextStore as any ) as any ).getFormatType( FORMAT_NAME ); const options = { formatType: FORMAT_NAME, @@ -30,7 +30,7 @@ describe( 'findMarkerRange', () => { 'data-suggestion-id': 'data-suggestion-id', }, edit: () => null, - } ); + } as any ); } } ); @@ -207,7 +207,7 @@ describe( 'findMarkerText', () => { 'data-suggestion-id': 'data-suggestion-id', }, edit: () => null, - } ); + } as any ); } } ); diff --git a/packages/editor/src/components/inline-markers/test/read-inline-selection.js b/packages/editor/src/components/inline-markers/test/read-inline-selection.ts similarity index 90% rename from packages/editor/src/components/inline-markers/test/read-inline-selection.js rename to packages/editor/src/components/inline-markers/test/read-inline-selection.ts index f50cef4e2ea174..286f1aae145684 100644 --- a/packages/editor/src/components/inline-markers/test/read-inline-selection.js +++ b/packages/editor/src/components/inline-markers/test/read-inline-selection.ts @@ -3,11 +3,11 @@ import { readInlineSelection } from '../read-inline-selection'; /** * Build the two block-editor selection selectors from a single pair of points. * - * @param {Object} start Selection start point. - * @param {Object} end Selection end point. - * @return {[Function, Function]} `[getSelectionStart, getSelectionEnd]`. + * @param start Selection start point. + * @param end Selection end point. + * @return `[getSelectionStart, getSelectionEnd]`. */ -function selectors( start, end ) { +function selectors( start: any, end: any ) { return [ () => start, () => end ]; } diff --git a/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.js b/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.ts similarity index 100% rename from packages/editor/src/components/inline-markers/test/reconcile-marker-removal.js rename to packages/editor/src/components/inline-markers/test/reconcile-marker-removal.ts diff --git a/packages/editor/src/components/inline-markers/test/wrap-inline-marker.js b/packages/editor/src/components/inline-markers/test/wrap-inline-marker.ts similarity index 93% rename from packages/editor/src/components/inline-markers/test/wrap-inline-marker.js rename to packages/editor/src/components/inline-markers/test/wrap-inline-marker.ts index 49d2a110045626..4311cae65b81eb 100644 --- a/packages/editor/src/components/inline-markers/test/wrap-inline-marker.js +++ b/packages/editor/src/components/inline-markers/test/wrap-inline-marker.ts @@ -11,7 +11,7 @@ import { findMarkerRange } from '../find-marker-range'; const FORMAT_NAME = 'test/marker'; const isRegistered = () => - !! select( richTextStore ).getFormatType( FORMAT_NAME ); + !! ( select( richTextStore as any ) as any ).getFormatType( FORMAT_NAME ); describe( 'wrapInlineMarker', () => { beforeAll( () => { @@ -22,7 +22,7 @@ describe( 'wrapInlineMarker', () => { className: 'wp-marker', attributes: { 'data-id': 'data-id' }, edit: () => null, - } ); + } as any ); } } ); @@ -60,7 +60,7 @@ describe( 'wrapInlineMarker', () => { end: 11, } ); expect( wrapped ).toBeInstanceOf( RichTextData ); - const html = wrapped.toHTMLString(); + const html = wrapped!.toHTMLString(); expect( html ).toContain( 'data-id="7"' ); expect( html ).toContain( 'world' ); } ); diff --git a/packages/editor/src/components/inline-markers/use-annotate-ranges.js b/packages/editor/src/components/inline-markers/use-annotate-ranges.ts similarity index 86% rename from packages/editor/src/components/inline-markers/use-annotate-ranges.js rename to packages/editor/src/components/inline-markers/use-annotate-ranges.ts index c8af248cb82cfe..0df79f0a5b05aa 100644 --- a/packages/editor/src/components/inline-markers/use-annotate-ranges.js +++ b/packages/editor/src/components/inline-markers/use-annotate-ranges.ts @@ -14,10 +14,10 @@ import { store as annotationsStore } from '@wordpress/annotations'; * Callers are responsible for memoizing `ranges` so the effect only re-runs * when the resolved ranges actually change. * - * @param {string} source Annotation source identifier. - * @param {Array} ranges Ranges to decorate: `{ id, clientId, attributeKey, start, end }`. + * @param source Annotation source identifier. + * @param ranges Ranges to decorate: `{ id, clientId, attributeKey, start, end }`. */ -export function useAnnotateRanges( source, ranges ) { +export function useAnnotateRanges( source: string, ranges: any[] ) { const { __experimentalAddAnnotation: addAnnotation, __experimentalRemoveAnnotationsBySource: removeAnnotationsBySource, diff --git a/packages/editor/src/components/inline-markers/wrap-inline-marker.js b/packages/editor/src/components/inline-markers/wrap-inline-marker.js deleted file mode 100644 index 0018b80e64e489..00000000000000 --- a/packages/editor/src/components/inline-markers/wrap-inline-marker.js +++ /dev/null @@ -1,30 +0,0 @@ -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 {Object} options - * @param {string} options.formatType Rich-text format type to apply (e.g. `core/note`). - * @param {Object} options.attributes Marker attributes (e.g. `{ 'data-id': '7' }`). - * @param {number} options.start Range start offset. - * @param {number} options.end Range end offset. - * @return {?RichTextData} Wrapped value or null when the attribute isn't rich text. - */ -export function wrapInlineMarker( - value, - { formatType, attributes, start, end } -) { - if ( ! ( value instanceof RichTextData ) ) { - return null; - } - const record = applyFormat( - create( { html: value.toHTMLString() } ), - { type: formatType, attributes }, - start, - end - ); - return new RichTextData( record ); -} 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 ); +} From ea4ab5dfa6675d1466ab74f8b8391823d7baaa85 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 25 Aug 2026 15:59:09 -0700 Subject: [PATCH 4/9] Move the marker-selector helper into the inline marker layer The marker element lookup is a primitive of the inline marker module, not of the wiring that consumes it, and this layer's barrel already exports it. Carry the helper and its tests down so the primitive ships whole. --- .../src/components/inline-markers/index.ts | 1 + .../inline-markers/marker-selector.ts | 37 +++++++++++++++++++ .../inline-markers/test/marker-selector.ts | 32 ++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 packages/editor/src/components/inline-markers/marker-selector.ts create mode 100644 packages/editor/src/components/inline-markers/test/marker-selector.ts diff --git a/packages/editor/src/components/inline-markers/index.ts b/packages/editor/src/components/inline-markers/index.ts index 1d781e0aa57caf..cfe08b9181870f 100644 --- a/packages/editor/src/components/inline-markers/index.ts +++ b/packages/editor/src/components/inline-markers/index.ts @@ -13,6 +13,7 @@ */ 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'; 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/test/marker-selector.ts b/packages/editor/src/components/inline-markers/test/marker-selector.ts new file mode 100644 index 00000000000000..3304cb00d5ff9f --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/marker-selector.ts @@ -0,0 +1,32 @@ +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"]' + ); + } ); +} ); From 3090e1c6c72ecbcb17302894703a31bb460348a0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 31 Aug 2026 16:25:44 -0700 Subject: [PATCH 5/9] Name DOM unit tests for the jsdom Jest project Trunk splits the Jest run into node and jsdom projects, and only *.jsdom.test.* files are collected by the jsdom one, so these suites failed with 'window is not defined' after the trunk merge. --- .../{find-marker-range.ts => find-marker-range.jsdom.test.ts} | 0 .../{wrap-inline-marker.ts => wrap-inline-marker.jsdom.test.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/editor/src/components/inline-markers/test/{find-marker-range.ts => find-marker-range.jsdom.test.ts} (100%) rename packages/editor/src/components/inline-markers/test/{wrap-inline-marker.ts => wrap-inline-marker.jsdom.test.ts} (100%) diff --git a/packages/editor/src/components/inline-markers/test/find-marker-range.ts b/packages/editor/src/components/inline-markers/test/find-marker-range.jsdom.test.ts similarity index 100% rename from packages/editor/src/components/inline-markers/test/find-marker-range.ts rename to packages/editor/src/components/inline-markers/test/find-marker-range.jsdom.test.ts diff --git a/packages/editor/src/components/inline-markers/test/wrap-inline-marker.ts b/packages/editor/src/components/inline-markers/test/wrap-inline-marker.jsdom.test.ts similarity index 100% rename from packages/editor/src/components/inline-markers/test/wrap-inline-marker.ts rename to packages/editor/src/components/inline-markers/test/wrap-inline-marker.jsdom.test.ts From 8d196376f3a42954c74b242461de384a889f26f8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 4 Sep 2026 08:52:31 -0700 Subject: [PATCH 6/9] Inline markers: quote only owned characters and reject cross-attribute selections findMarkerText sliced a fragmented marker's first-to-last span, so the suggestion summary could quote gap text or another marker's text that accept and reject never touch. Build the text from the characters that carry the id instead, sharing the predicate with rangeInRecord. readInlineCaret and readInlineSelection compared clientId but not attributeKey, so a selection dragged between two rich-text fields of one block (a quote's value and citation) returned offsets from two fields under one key. Reject that selection. --- .../inline-markers/find-marker-range.ts | 52 +++++++++++--- .../inline-markers/read-inline-caret.ts | 1 + .../inline-markers/read-inline-selection.ts | 1 + .../test/find-marker-range.jsdom.test.ts | 23 ++++++ .../inline-markers/test/read-inline-caret.ts | 71 +++++++++++++++++++ .../test/read-inline-selection.ts | 10 +++ 6 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 packages/editor/src/components/inline-markers/test/read-inline-caret.ts diff --git a/packages/editor/src/components/inline-markers/find-marker-range.ts b/packages/editor/src/components/inline-markers/find-marker-range.ts index f72593994f5bd4..83bcebf1cbb38c 100644 --- a/packages/editor/src/components/inline-markers/find-marker-range.ts +++ b/packages/editor/src/components/inline-markers/find-marker-range.ts @@ -38,6 +38,29 @@ function parseMarkerValue( 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. @@ -46,8 +69,10 @@ function parseMarkerValue( * 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. Any short gap between - * fragments is the suggester's own run and belongs to the suggestion. + * — 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. @@ -66,14 +91,7 @@ function rangeInRecord( let start = -1; let end = -1; for ( let i = 0; i < formats.length; i++ ) { - const stack = formats[ i ]; - const hit = stack?.find( - ( f: any ) => - f.type === formatType && - f.attributes && - f.attributes[ idAttribute ] === target - ); - if ( hit ) { + if ( carriesId( formats[ i ], formatType, idAttribute, target ) ) { if ( start === -1 ) { start = i; } @@ -161,5 +179,17 @@ export function findMarkerText( if ( ! range ) { return ''; } - return record.text.slice( range.start, range.end ); + // 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/read-inline-caret.ts b/packages/editor/src/components/inline-markers/read-inline-caret.ts index 4f72756eb7c961..33316f14466408 100644 --- a/packages/editor/src/components/inline-markers/read-inline-caret.ts +++ b/packages/editor/src/components/inline-markers/read-inline-caret.ts @@ -20,6 +20,7 @@ export function readInlineCaret( ! start?.clientId || start.clientId !== end.clientId || ! start.attributeKey || + start.attributeKey !== end.attributeKey || start.offset === undefined || end.offset === undefined ) { diff --git a/packages/editor/src/components/inline-markers/read-inline-selection.ts b/packages/editor/src/components/inline-markers/read-inline-selection.ts index 67e6bea2599259..56a3b273a53a4e 100644 --- a/packages/editor/src/components/inline-markers/read-inline-selection.ts +++ b/packages/editor/src/components/inline-markers/read-inline-selection.ts @@ -17,6 +17,7 @@ export function readInlineSelection( ! start?.clientId || start.clientId !== end.clientId || ! start.attributeKey || + start.attributeKey !== end.attributeKey || start.offset === undefined || end.offset === undefined || start.offset === end.offset 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 index 6ddd9d616b5361..2522612e5b72fc 100644 --- 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 @@ -233,6 +233,29 @@ describe( 'findMarkerText', () => { ); } ); + 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' 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..e3d9b11240972b --- /dev/null +++ b/packages/editor/src/components/inline-markers/test/read-inline-caret.ts @@ -0,0 +1,71 @@ +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 index 286f1aae145684..47f5a30c1cbc27 100644 --- a/packages/editor/src/components/inline-markers/test/read-inline-selection.ts +++ b/packages/editor/src/components/inline-markers/test/read-inline-selection.ts @@ -33,6 +33,16 @@ describe( 'readInlineSelection', () => { 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 }, From 1d73afa878182b1386cf7d9dc31b7e9e6e85e7df Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 4 Sep 2026 09:19:31 -0700 Subject: [PATCH 7/9] Run the inline marker layer's unit tests under Vitest Import the test API explicitly; these suites use no mocks. --- .../inline-markers/test/find-marker-range.jsdom.test.ts | 1 + .../editor/src/components/inline-markers/test/marker-selector.ts | 1 + .../src/components/inline-markers/test/read-inline-caret.ts | 1 + .../src/components/inline-markers/test/read-inline-selection.ts | 1 + .../components/inline-markers/test/reconcile-marker-removal.ts | 1 + .../inline-markers/test/wrap-inline-marker.jsdom.test.ts | 1 + 6 files changed, 6 insertions(+) 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 index 2522612e5b72fc..2b251901f6e5c9 100644 --- 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 @@ -1,3 +1,4 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { RichTextData, registerFormatType, diff --git a/packages/editor/src/components/inline-markers/test/marker-selector.ts b/packages/editor/src/components/inline-markers/test/marker-selector.ts index 3304cb00d5ff9f..d02cc3801f0f01 100644 --- a/packages/editor/src/components/inline-markers/test/marker-selector.ts +++ b/packages/editor/src/components/inline-markers/test/marker-selector.ts @@ -1,3 +1,4 @@ +import { describe, expect, it } from 'vitest'; import { getMarkerSelector } from '../marker-selector'; describe( 'getMarkerSelector', () => { 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 index e3d9b11240972b..cc08f2eb36831b 100644 --- a/packages/editor/src/components/inline-markers/test/read-inline-caret.ts +++ b/packages/editor/src/components/inline-markers/test/read-inline-caret.ts @@ -1,3 +1,4 @@ +import { describe, expect, it } from 'vitest'; import { readInlineCaret } from '../read-inline-caret'; /** 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 index 47f5a30c1cbc27..d06ba184043d37 100644 --- a/packages/editor/src/components/inline-markers/test/read-inline-selection.ts +++ b/packages/editor/src/components/inline-markers/test/read-inline-selection.ts @@ -1,3 +1,4 @@ +import { describe, expect, it } from 'vitest'; import { readInlineSelection } from '../read-inline-selection'; /** 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 index bdded2368537c0..a375cfc13d947a 100644 --- a/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.ts +++ b/packages/editor/src/components/inline-markers/test/reconcile-marker-removal.ts @@ -1,3 +1,4 @@ +import { describe, expect, it } from 'vitest'; import { reconcileMarkerRemoval } from '../reconcile-marker-removal'; describe( 'reconcileMarkerRemoval', () => { 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 index 4311cae65b81eb..f6db1cd0917a33 100644 --- 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 @@ -1,3 +1,4 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { RichTextData, registerFormatType, From 44703609df0e278d81b824477870c7ba42f58f21 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 09:27:52 -0700 Subject: [PATCH 8/9] Inline markers: Read RichTextData records without an HTML round trip findMarkerRange and findMarkerText serialized a RichTextData value to HTML and parsed it back on every lookup, which runs per suggestion per store update. RichTextData already exposes formats and text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YHz7zkCC2S8crriSWsYPDk --- .../components/inline-markers/find-marker-range.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/inline-markers/find-marker-range.ts b/packages/editor/src/components/inline-markers/find-marker-range.ts index 83bcebf1cbb38c..4f6d4c93feaa40 100644 --- a/packages/editor/src/components/inline-markers/find-marker-range.ts +++ b/packages/editor/src/components/inline-markers/find-marker-range.ts @@ -1,8 +1,8 @@ import { create, RichTextData } from '@wordpress/rich-text'; /** - * Parse a block attribute value into a rich-text record, applying the cheap - * `quickReject` substring check before the (relatively costly) parse. Shared + * 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. * @@ -22,12 +22,12 @@ function parseMarkerValue( if ( id === undefined || id === null ) { return null; } - let html = null; + // `RichTextData` already holds a parsed record; serializing it only to + // parse it again cost two full passes per lookup. if ( value instanceof RichTextData ) { - html = value.toHTMLString(); - } else if ( typeof value === 'string' ) { - html = value; + return { formats: value.formats, text: value.text }; } + const html = typeof value === 'string' ? value : null; if ( ! html ) { return null; } From cf75ec5e5c4d8948b610beaf1742ccd9818ff6a0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 30 Sep 2026 09:27:57 -0700 Subject: [PATCH 9/9] Inline markers: Batch annotation writes for a range set Each range dispatched its own addAnnotation, so every annotated block re-rendered once per range whenever the ranges changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YHz7zkCC2S8crriSWsYPDk --- .../inline-markers/use-annotate-ranges.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/editor/src/components/inline-markers/use-annotate-ranges.ts b/packages/editor/src/components/inline-markers/use-annotate-ranges.ts index 0df79f0a5b05aa..7cf3364aa05d8d 100644 --- a/packages/editor/src/components/inline-markers/use-annotate-ranges.ts +++ b/packages/editor/src/components/inline-markers/use-annotate-ranges.ts @@ -1,5 +1,5 @@ import { useEffect } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; +import { useDispatch, useRegistry } from '@wordpress/data'; import { store as annotationsStore } from '@wordpress/annotations'; /** @@ -18,6 +18,7 @@ import { store as annotationsStore } from '@wordpress/annotations'; * @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, @@ -27,17 +28,21 @@ export function useAnnotateRanges( source: string, ranges: any[] ) { if ( ! ranges?.length ) { return; } - for ( const range of ranges ) { - addAnnotation( { - id: range.id, - source, - blockClientId: range.clientId, - richTextIdentifier: range.attributeKey, - range: { start: range.start, end: range.end }, - } ); - } + // 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 ); }; - }, [ source, ranges, addAnnotation, removeAnnotationsBySource ] ); + }, [ registry, source, ranges, addAnnotation, removeAnnotationsBySource ] ); }