Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
75b01dd
Suggest mode 4/7: inline marker primitive
adamsilverstein Jul 17, 2026
b079a31
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 5, 2026
c2e863d
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 11, 2026
086fe9e
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 11, 2026
4e09140
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 12, 2026
d794763
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 12, 2026
b990210
Drop dependency-group comment blocks flagged by the new lint rule
adamsilverstein Aug 12, 2026
15002f4
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 22, 2026
46878ea
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 23, 2026
bbe9009
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 23, 2026
a1523ca
Convert inline-markers primitive to TypeScript
adamsilverstein Aug 23, 2026
6791175
Merge remote-tracking branch 'origin/trunk' into suggest/inline-markers
adamsilverstein Aug 24, 2026
23b2454
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 24, 2026
27595d2
Merge remote-tracking branch 'origin/trunk' into suggest/inline-markers
adamsilverstein Aug 24, 2026
ff2dcd6
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 24, 2026
fa9f079
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 24, 2026
bf88272
Merge branch 'relocate/block-capture' into relocate/inline-markers
adamsilverstein Aug 25, 2026
ea4ab5d
Move the marker-selector helper into the inline marker layer
adamsilverstein Aug 25, 2026
2eb3eac
Merge suggest/block-capture into suggest/inline-markers
adamsilverstein Aug 25, 2026
5ca7f73
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 27, 2026
0da5cfa
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 31, 2026
06c7877
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 31, 2026
3090e1c
Name DOM unit tests for the jsdom Jest project
adamsilverstein Aug 31, 2026
d2d853a
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Aug 31, 2026
8d19637
Inline markers: quote only owned characters and reject cross-attribut…
adamsilverstein Sep 4, 2026
a634d7f
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Sep 4, 2026
1d73afa
Run the inline marker layer's unit tests under Vitest
adamsilverstein Sep 4, 2026
27d33e1
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Sep 4, 2026
76c093a
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Sep 5, 2026
0d2ed93
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Sep 6, 2026
0cc4a22
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Sep 10, 2026
fec8dc6
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Sep 11, 2026
9bff1db
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 11, 2026
d69336a
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Sep 15, 2026
0681cf7
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 16, 2026
c2f35ab
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 16, 2026
ffb99ad
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 16, 2026
2c92b5a
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 18, 2026
c85b895
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 18, 2026
25bea9c
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 29, 2026
2f0c8ca
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 29, 2026
6b42ec8
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 29, 2026
eded596
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 30, 2026
da05506
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 30, 2026
4470360
Inline markers: Read RichTextData records without an HTML round trip
adamsilverstein Sep 30, 2026
cf75ec5
Inline markers: Batch annotation writes for a range set
adamsilverstein Sep 30, 2026
51a5b6b
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 30, 2026
6aba744
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 30, 2026
12f771f
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 30, 2026
bedb054
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 30, 2026
49c6563
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Sep 30, 2026
e8e5f48
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Sep 30, 2026
f5ac338
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Oct 1, 2026
501f3c6
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Oct 2, 2026
f336ae2
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Oct 2, 2026
96a9893
Merge remote-tracking branch 'origin/suggest/block-capture' into sugg…
adamsilverstein Oct 3, 2026
796c0e3
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Oct 5, 2026
56d22a5
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Oct 6, 2026
d9e2b6f
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Oct 6, 2026
f624d85
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Oct 6, 2026
bde4bc7
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Oct 7, 2026
b931b47
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Oct 7, 2026
8bf89ae
Merge branch 'suggest/block-capture' into suggest/inline-markers
adamsilverstein Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
195 changes: 195 additions & 0 deletions packages/editor/src/components/inline-markers/find-marker-range.ts
Original file line number Diff line number Diff line change
@@ -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 (`<mark>` 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;
}
21 changes: 21 additions & 0 deletions packages/editor/src/components/inline-markers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Inline markers: a format-agnostic primitive for anchoring inline ranges to
* edit-surviving `<mark>` 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';
37 changes: 37 additions & 0 deletions packages/editor/src/components/inline-markers/marker-selector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Build the CSS selector that matches an in-content `<mark>` marker by its id.
*
* Both consumers of the inline-markers primitive serialize their marker as a
* `<mark>` 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 }"]`;
}
40 changes: 40 additions & 0 deletions packages/editor/src/components/inline-markers/read-inline-caret.ts
Original file line number Diff line number Diff line change
@@ -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 ||
Comment thread
adamsilverstein marked this conversation as resolved.
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,
};
}
Original file line number Diff line number Diff line change
@@ -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 ||
Comment thread
adamsilverstein marked this conversation as resolved.
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,
};
}
Original file line number Diff line number Diff line change
@@ -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';
}
Loading
Loading