diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index f321299fc9..004a5f5def 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -1585,17 +1585,27 @@ export class SuperDoc extends EventEmitter { } } /** - * Search for text or regex in the active editor + * Search for text or regex in the active editor. + * + * Returns `undefined` when there is no active editor; otherwise + * returns the array of matches the underlying search command produced + * (possibly empty). + * * @param {string | RegExp} text The text or regex to search for - * @returns {Object[]} The search results + * @returns {import('./types/index.js').SearchMatch[] | undefined} The search results */ search(text) { return this.activeEditor?.commands.search(text, { searchModel: 'visible' }); } /** - * Go to the next search result - * @param {Object} match The match object (returned as-is by `superdoc.search()`; pass it through unchanged). Stays loose here because the upstream `commands.goToSearchResult` expects a private `SearchMatch` shape that is not yet on the public surface; tightening this is a separate follow-up. + * Go to the next search result. + * + * Pass back a match returned by `superdoc.search()` unchanged; the + * runtime resolves its current document position via the embedded + * tracker ids. + * + * @param {import('./types/index.js').SearchMatch} match The match object returned by `superdoc.search()`. * @returns {void} */ goToSearchResult(match) { diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 11f7cfb3d2..73d390f87f 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -742,6 +742,56 @@ export interface ResolvedFindReplaceTexts { ignoreDiacriticsAriaLabel: string; } +/** + * A document position range, in ProseMirror coordinates. + * + * SD-2828: Surfaced on the public type contract so consumers can + * destructure `SearchMatch.ranges` without falling back to `any`. Mirrors + * the private `DocRange` typedef in the search extension; keep them in + * sync. Pure data, no methods. + */ +export interface DocRange { + /** Start position in the document. */ + from: number; + /** End position in the document. */ + to: number; +} + +/** + * One match returned by `SuperDoc.search()` (and consumed by + * `SuperDoc.goToSearchResult()`). + * + * SD-2828: Promoted from the private search-extension typedef to a + * public contract so consumers get real types instead of `any` on the + * search return value, and so `goToSearchResult` can declare the input + * shape it expects rather than accepting an opaque `Object`. Match + * instances are produced by the runtime; consumers should treat them as + * read-only and pass them back unchanged. + */ +export interface SearchMatch { + /** Combined match text across all ranges. */ + text: string; + /** Start position of the first range. */ + from: number; + /** End position of the last range. */ + to: number; + /** + * Stable match identifier. For single-range matches this is the + * position-tracker id; for multi-range (cross-paragraph) matches it is + * the first tracker id. Use as the dedupe / equality key when wiring a + * custom navigator. + */ + id: string; + /** + * Document ranges for the match. Present for multi-range matches + * (cross-paragraph), and may also be populated for single-range + * matches by the search runtime; consumers should not assume length 1. + */ + ranges?: DocRange[]; + /** Position-tracker ids, one per range in `ranges`. */ + trackerIds?: string[]; +} + /** * Handle object injected into find/replace UIs as the `findReplace` * prop/context field. Provides reactive search state and all action functions. diff --git a/packages/superdoc/src/index.js b/packages/superdoc/src/index.js index 0ffa6b570d..1034020a8c 100644 --- a/packages/superdoc/src/index.js +++ b/packages/superdoc/src/index.js @@ -212,6 +212,8 @@ import { getSchemaIntrospection } from './helpers/schema-introspection.js'; * @typedef {import('./core/types/index.js').PasswordPromptRenderContext} PasswordPromptRenderContext * @typedef {import('./core/types/index.js').PasswordPromptResolution} PasswordPromptResolution * @typedef {import('./core/types/index.js').PasswordPromptConfig} PasswordPromptConfig + * @typedef {import('./core/types/index.js').DocRange} DocRange + * @typedef {import('./core/types/index.js').SearchMatch} SearchMatch * @typedef {import('./core/types/index.js').ResolvedFindReplaceTexts} ResolvedFindReplaceTexts * @typedef {import('./core/types/index.js').FindReplaceHandle} FindReplaceHandle * @typedef {import('./core/types/index.js').FindReplaceContext} FindReplaceContext diff --git a/tests/consumer-typecheck/src/all-public-types.ts b/tests/consumer-typecheck/src/all-public-types.ts index 17b7bd72ba..f95772c886 100644 --- a/tests/consumer-typecheck/src/all-public-types.ts +++ b/tests/consumer-typecheck/src/all-public-types.ts @@ -42,6 +42,7 @@ import type { ContextMenuSection, CoreCommandMap, DirectSurfaceRequest, + DocRange, DocumentApi, DocumentMode, DocumentProtectionState, @@ -137,6 +138,7 @@ import type { Schema, ScrollIntoViewInput, ScrollIntoViewOutput, + SearchMatch, SectionMetadata, SelectionApi, SelectionCommandContext, @@ -207,6 +209,7 @@ const _real_ContextMenuItem: AssertNotAny = true; const _real_ContextMenuSection: AssertNotAny = true; const _real_CoreCommandMap: AssertNotAny = true; const _real_DirectSurfaceRequest: AssertNotAny = true; +const _real_DocRange: AssertNotAny = true; const _real_DocumentApi: AssertNotAny = true; const _real_DocumentMode: AssertNotAny = true; const _real_DocumentProtectionState: AssertNotAny = true; @@ -302,6 +305,7 @@ const _real_SaveOptions: AssertNotAny = true; const _real_Schema: AssertNotAny = true; const _real_ScrollIntoViewInput: AssertNotAny = true; const _real_ScrollIntoViewOutput: AssertNotAny = true; +const _real_SearchMatch: AssertNotAny = true; const _real_SectionMetadata: AssertNotAny = true; const _real_SelectionApi: AssertNotAny = true; const _real_SelectionCommandContext: AssertNotAny = true; diff --git a/tests/consumer-typecheck/src/search-match.ts b/tests/consumer-typecheck/src/search-match.ts new file mode 100644 index 0000000000..eb49125529 --- /dev/null +++ b/tests/consumer-typecheck/src/search-match.ts @@ -0,0 +1,46 @@ +/** + * Consumer typecheck: `SuperDoc.search()` returns `SearchMatch[] | undefined`, + * and `SuperDoc.goToSearchResult()` accepts `SearchMatch` (SD-2828). + * + * Before this contract was published, both APIs typed the match value as + * `Object` / `Object[]`, so consumers wiring a custom search UI lost field + * types on `id`, `from`, `to`, `text`. This fixture pins the shape: each + * field is read off a real match, and the same value is passed back into + * `goToSearchResult` to prove the round-trip type compatibility. + * + * If a future change re-narrows or strips any of the listed fields, the + * destructuring or the `goToSearchResult` call below stops compiling and + * CI fails. + */ +import type { SearchMatch, SuperDoc } from 'superdoc'; + +declare const sd: SuperDoc; + +const results: SearchMatch[] | undefined = sd.search('hello'); + +if (results && results.length > 0) { + const first: SearchMatch = results[0]; + + // Each public field must be a real type, not `any`. + const id: string = first.id; + const from: number = first.from; + const to: number = first.to; + const text: string = first.text; + + // Pass the match back through `goToSearchResult` unchanged. + sd.goToSearchResult(first); + + void [id, from, to, text]; +} + +// Strict type-equality: a future change that re-narrows the return type +// (e.g. to `unknown[]` or a more specific subtype) would still be assignable +// to `SearchMatch[] | undefined` from one direction; `Equal` fails the +// fixture if the type drifts in either direction. +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type AssertEqual = Equal extends true ? true : never; + +const _searchReturnTypeIsExact: AssertEqual, SearchMatch[] | undefined> = true; +const _goToParamTypeIsExact: AssertEqual[0], SearchMatch> = true; + +void [_searchReturnTypeIsExact, _goToParamTypeIsExact, results]; diff --git a/tests/consumer-typecheck/typecheck-matrix.mjs b/tests/consumer-typecheck/typecheck-matrix.mjs index b1b8bb8ef3..fff5ba84ae 100644 --- a/tests/consumer-typecheck/typecheck-matrix.mjs +++ b/tests/consumer-typecheck/typecheck-matrix.mjs @@ -495,6 +495,31 @@ const scenarios = [ files: ['src/provider-collaboration-provider.ts'], mustPass: true, }, + + // SD-2828: `SuperDoc.search()` returns `SearchMatch[] | undefined`, and + // `SuperDoc.goToSearchResult()` accepts `SearchMatch`. Promoting the + // search-match shape to the public type contract so consumers wiring + // a custom search UI get real types on `id`, `from`, `to`, `text` + // instead of `any`. Pinned here so a future change that strips or + // re-narrows fields would surface as a typecheck failure. + { + name: 'bundler / search returns SearchMatch[] (SD-2828)', + module: 'ESNext', + moduleResolution: 'bundler', + skipLibCheck: true, + strict: true, + files: ['src/search-match.ts'], + mustPass: true, + }, + { + name: 'node16 / search returns SearchMatch[] (SD-2828)', + module: 'Node16', + moduleResolution: 'node16', + skipLibCheck: true, + strict: true, + files: ['src/search-match.ts'], + mustPass: true, + }, ]; const tscPath = join(__dirname, 'node_modules', '.bin', 'tsc');