Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>} Attrs
*
Expand All @@ -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`,
Expand Down
116 changes: 107 additions & 9 deletions tests/consumer-typecheck/src/track-changes-helpers-typed.ts
Original file line number Diff line number Diff line change
@@ -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<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;

Expand Down Expand Up @@ -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<typeof liveInline, any[]> = 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<typeof tracked, any> = 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<typeof resultTr, any> = 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<typeof changes, any[]> = 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');
Loading