diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js index a7a2a9e412..449ff7d4bb 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js @@ -1,5 +1,25 @@ /** - * Find tracked mark between positions by mark name and attrs. + * Find a tracked mark in a document range by mark name and (optionally) + * a partial attrs match. Returns the first hit; expands by `offset` + * around the requested range so non-inclusive marks just outside it + * are still considered. If nothing matches inside the range, falls + * back to inspecting nodes adjacent to the range boundaries (handles + * Google-Docs-style text inserted directly under a paragraph without + * a wrapping run, and Firefox-vs-Chrome wrapping differences). + * + * @param {object} args + * @param {import('./types.js').Transaction} args.tr - Transaction + * whose `tr.doc` is searched. + * @param {number} args.from - Range start. + * @param {number} args.to - Range end. + * @param {string} args.markName - Mark type name to match. + * @param {import('./types.js').Attrs} [args.attrs] - Partial attrs + * to match; every key listed must equal the candidate's attr value. + * Defaults to `{}` (no attr constraint). + * @param {number} [args.offset] - Expand the range by this many + * positions on each side. Defaults to `1` to catch non-inclusive marks. + * @returns {import('./types.js').TrackedMarkRange | null} The first + * match `{ from, to, mark }`, or `null` if no candidate matches. */ export const findTrackedMarkBetween = ({ tr, @@ -14,6 +34,7 @@ export const findTrackedMarkBetween = ({ const startPos = Math.max(from - offset, 0); // $from.start() const endPos = Math.min(to + offset, doc.content.size); // $from.end() + /** @type {import('./types.js').TrackedMarkRange | null} */ let markFound = null; const tryMatch = (node, pos) => { diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js index 6d273a4c0c..affbab317f 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js @@ -1,4 +1,15 @@ +/** + * Collect the live PM marks present on inline nodes in `[from, to]`, + * deduplicated by `${typeName}:${attrsJson}`. + * + * @param {object} args + * @param {import('./types.js').PmNode} args.doc - Document to scan. + * @param {number} args.from - Range start (inclusive). + * @param {number} args.to - Range end (exclusive). + * @returns {import('./types.js').PmMark[]} Unique inline marks in range. + */ export const getLiveInlineMarksInRange = ({ doc, from, to }) => { + /** @type {import('./types.js').PmMark[]} */ const marks = []; const seen = new Set(); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js index 4450620adc..bc01183934 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js @@ -2,17 +2,24 @@ import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '. import { findInlineNodes } from './documentHelpers.js'; /** - * Get track changes marks. + * Get the tracked-change marks in the document. Each entry pairs the + * live PM mark with the `[from, to]` range of its bearing inline node. + * When `id` is supplied, the result is filtered to marks whose + * `attrs.id` equals it (used to find every range belonging to one + * tracked-change group). * - * Tolerates a missing or partially-initialized state and returns an empty array - * instead of throwing. Comment-import bootstrap can call this through a - * setTimeout(0) before the editor's PM state is attached (SD-2641). + * Tolerates a missing or partially-initialized state and returns an + * empty array instead of throwing. Comment-import bootstrap can call + * this through a `setTimeout(0)` before the editor's PM state is + * attached (SD-2641). * - * @param {import('prosemirror-state').EditorState | null | undefined} state - * @param {string} [id] - * @returns {Array} Array with track changes marks. + * @param {import('./types.js').EditorState | null | undefined} state + * @param {string | null} [id] - Filter to marks with this `attrs.id`. + * @returns {import('./types.js').TrackedMarkRange[]} `{ mark, from, to }` + * per tracked-change mark, optionally filtered by id. */ export const getTrackChanges = (state, id = null) => { + /** @type {import('./types.js').TrackedMarkRange[]} */ const trackedChanges = []; if (!state?.doc) return trackedChanges; const allInlineNodes = findInlineNodes(state.doc); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.d.ts b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.d.ts deleted file mode 100644 index 07e56a0a2c..0000000000 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.d.ts +++ /dev/null @@ -1 +0,0 @@ -export function trackedTransaction(...args: any[]): any; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js index 3b3af70cc1..5f1aadb789 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js @@ -308,9 +308,27 @@ const getPendingDeadKeyPlaceholder = ({ tr, newTr, user }) => { }; /** - * Tracked transaction to track changes. - * @param {{ tr: import('prosemirror-state').Transaction; state: import('prosemirror-state').EditorState; user: import('@core/types/EditorConfig.js').User; replacements?: 'paired' | 'independent' }} params - * @returns {import('prosemirror-state').Transaction} Modified transaction. + * Process a transaction through the track-changes pipeline and return + * a modified transaction with tracked-change marks applied (or the + * original transaction unchanged when track-changes is bypassed, e.g. + * for Yjs remote-origin transactions or disallowed meta). + * + * The per-property JSDoc style below is load-bearing: a single-blob + * `@param {{ ... }} params` form does not bind to the destructured + * arrow-function signature in vite-plugin-dts emit and resurfaces the + * function as `(...args: any[]): any` (SD-2980 PR C). + * + * @param {object} args + * @param {import('./types.js').Transaction} args.tr - The incoming + * transaction to process. + * @param {import('./types.js').EditorState} args.state - The editor + * state before `args.tr` is applied. + * @param {import('@core/types/EditorConfig.js').User} args.user - The + * acting user; required to attribute the tracked change. + * @param {'paired' | 'independent'} [args.replacements] - Strategy + * for processing replacement steps. Defaults to `'paired'`. + * @returns {import('./types.js').Transaction} The (possibly modified) + * transaction ready to dispatch. */ export const trackedTransaction = ({ tr, state, user, replacements = 'paired' }) => { const onlyInputTypeMeta = ['inputType', 'uiEvent', 'paste', 'pointer', 'composition']; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js index 090d5e8855..05251a6b67 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js @@ -11,6 +11,8 @@ * * @typedef {import('prosemirror-model').Node} PmNode * @typedef {import('prosemirror-model').Mark} PmMark + * @typedef {import('prosemirror-state').Transaction} Transaction + * @typedef {import('prosemirror-state').EditorState} EditorState * * @typedef {Record} Attrs * @@ -32,6 +34,11 @@ * @typedef {{ node: PmNode; pos: number }} NodePosEntry * The standard `findChildren` / `nodesBetween` result shape. * + * @typedef {{ from: number; to: number; mark: PmMark }} TrackedMarkRange + * A live ProseMirror mark located in a `[from, to]` document range. + * Used as `findTrackedMarkBetween`'s non-null return and as the + * element shape of `getTrackChanges`'s result array. + * * @typedef {{ type?: string; attrs?: Attrs }} SnapshotLike * Permissive snapshot shape for helper inputs that flow through * loosely-typed channels (e.g. `formatChangeMark.attrs.before`, diff --git a/tests/consumer-typecheck/src/track-changes-helpers-typed.ts b/tests/consumer-typecheck/src/track-changes-helpers-typed.ts index c8ccf41698..a1b7682d00 100644 --- a/tests/consumer-typecheck/src/track-changes-helpers-typed.ts +++ b/tests/consumer-typecheck/src/track-changes-helpers-typed.ts @@ -1,28 +1,35 @@ /** - * Consumer typecheck: `trackChangesHelpers` core helpers (PR B of - * SD-2980) return real shapes, not `any` / `any[]`. + * Consumer typecheck: every exported helper in `trackChangesHelpers` + * returns a real shape, not `any` / `any[]`. * - * Before this change, every exported helper in `markSnapshotHelpers` - * and `documentHelpers` resolved to `(...args: any[]) => any[]` in the - * published `.d.ts` (39 audit findings). PR B added JSDoc on every - * helper. This fixture pins the visible return / parameter shapes so a - * regression breaks the typecheck matrix, not just the inventory count. + * Initially landed for SD-2980 PR B (markSnapshotHelpers + + * documentHelpers, 39 findings); extended for PR C with the remaining + * 4 helpers (getLiveInlineMarksInRange, findTrackedMarkBetween, + * trackedTransaction, getTrackChanges, 14 findings) — together these + * drain the entire tier-3-helpers bucket for trackChanges. The fixture + * pins the visible return / parameter shapes so a regression breaks + * the typecheck matrix, not just the inventory count. * * Coverage: - * - markSnapshotHelpers: createMarkSnapshot, getTypeName, + * - markSnapshotHelpers (PR B): createMarkSnapshot, getTypeName, * isTrackFormatNoOp, attrsExactlyMatch, markSnapshotMatchesStepMark, * hasMatchingMark, upsertMarkSnapshotByType, findMarkInRangeBySnapshot - * - documentHelpers: findMarkPosition, flatten, findChildren, + * - documentHelpers (PR B): findMarkPosition, flatten, findChildren, * findInlineNodes (the 3-arg track-changes variant, distinct from * `@core/helpers/findChildren`) + * - PR C helpers: getLiveInlineMarksInRange, findTrackedMarkBetween, + * trackedTransaction, getTrackChanges */ import { trackChangesHelpers } from 'superdoc/super-editor'; import type { Node as PmNode, Mark as PmMark } from 'prosemirror-model'; +import type { EditorState, Transaction } from 'prosemirror-state'; declare const doc: PmNode; declare const mark: PmMark; declare const liveMarks: PmMark[]; +declare const state: EditorState; +declare const tr: Transaction; type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; @@ -144,3 +151,94 @@ trackChangesHelpers.documentHelpers.findChildren(doc, (s: string) => s.length > // @ts-expect-error SD-2980 PR B: findMarkPosition needs a string mark name. trackChangesHelpers.documentHelpers.findMarkPosition(doc, 5, 42); + +// ========================================================================= +// PR C: remaining trackChanges helpers +// ========================================================================= + +// --- getLiveInlineMarksInRange returns PmMark[] ------------------------- + +const liveInline = trackChangesHelpers.getLiveInlineMarksInRange({ doc, from: 0, to: 10 }); +const _liveInlineNotAnyArr: Equal = false; +void _liveInlineNotAnyArr; +if (liveInline[0]) { + // PM Mark exposes `.type.name`, not just `any`. + const _typeName: string = liveInline[0].type.name; + void _typeName; +} + +// @ts-expect-error SD-2980 PR C: getLiveInlineMarksInRange needs { doc, from, to }. +trackChangesHelpers.getLiveInlineMarksInRange({ doc, from: 0 }); + +// --- findTrackedMarkBetween returns TrackedMarkRange | null -------------- + +const tracked = trackChangesHelpers.findTrackedMarkBetween({ + tr, + from: 0, + to: 10, + markName: 'trackInsert', +}); +const _trackedNotAny: Equal = false; +void _trackedNotAny; +// `null` must be in the union (no match case). +const _trackedHandlesNull: null extends typeof tracked ? true : false = true; +void _trackedHandlesNull; +if (tracked) { + const _from: number = tracked.from; + const _to: number = tracked.to; + const _markType: string = tracked.mark.type.name; + void _from; + void _to; + void _markType; +} + +// Optional `attrs` and `offset` are accepted. +trackChangesHelpers.findTrackedMarkBetween({ + tr, + from: 0, + to: 10, + markName: 'trackInsert', + attrs: { id: 'abc' }, + offset: 0, +}); + +// @ts-expect-error SD-2980 PR C: markName is required and must be a string. +trackChangesHelpers.findTrackedMarkBetween({ tr, from: 0, to: 10 }); + +// --- trackedTransaction returns Transaction ------------------------------ + +declare const user: { name: string; email: string }; +const resultTr = trackChangesHelpers.trackedTransaction({ tr, state, user }); +const _resultTrNotAny: Equal = false; +void _resultTrNotAny; +// The return is a PM Transaction: it exposes `.docChanged`, `.steps`, etc. +const _docChanged: boolean = resultTr.docChanged; +const _stepsLen: number = resultTr.steps.length; +void _docChanged; +void _stepsLen; + +// Optional `replacements` accepts the literal union. +trackChangesHelpers.trackedTransaction({ tr, state, user, replacements: 'independent' }); + +// @ts-expect-error SD-2980 PR C: replacements must be 'paired' | 'independent'. +trackChangesHelpers.trackedTransaction({ tr, state, user, replacements: 'bogus' }); + +// --- getTrackChanges returns TrackedMarkRange[] ------------------------- + +const changes = trackChangesHelpers.getTrackChanges(state); +const _changesNotAnyArr: Equal = false; +void _changesNotAnyArr; +if (changes[0]) { + const _markType: string = changes[0].mark.type.name; + const _from: number = changes[0].from; + const _to: number = changes[0].to; + void _markType; + void _from; + void _to; +} + +// Tolerates missing state per the JSDoc contract. +trackChangesHelpers.getTrackChanges(null); +trackChangesHelpers.getTrackChanges(undefined); +// Filter by id. +trackChangesHelpers.getTrackChanges(state, 'change-1');