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
18 changes: 14 additions & 4 deletions packages/superdoc/src/core/SuperDoc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
50 changes: 50 additions & 0 deletions packages/superdoc/src/core/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/superdoc/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/consumer-typecheck/src/all-public-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
ContextMenuSection,
CoreCommandMap,
DirectSurfaceRequest,
DocRange,
DocumentApi,
DocumentMode,
DocumentProtectionState,
Expand Down Expand Up @@ -137,6 +138,7 @@ import type {
Schema,
ScrollIntoViewInput,
ScrollIntoViewOutput,
SearchMatch,
SectionMetadata,
SelectionApi,
SelectionCommandContext,
Expand Down Expand Up @@ -207,6 +209,7 @@ const _real_ContextMenuItem: AssertNotAny<ContextMenuItem> = true;
const _real_ContextMenuSection: AssertNotAny<ContextMenuSection> = true;
const _real_CoreCommandMap: AssertNotAny<CoreCommandMap> = true;
const _real_DirectSurfaceRequest: AssertNotAny<DirectSurfaceRequest> = true;
const _real_DocRange: AssertNotAny<DocRange> = true;
const _real_DocumentApi: AssertNotAny<DocumentApi> = true;
const _real_DocumentMode: AssertNotAny<DocumentMode> = true;
const _real_DocumentProtectionState: AssertNotAny<DocumentProtectionState> = true;
Expand Down Expand Up @@ -302,6 +305,7 @@ const _real_SaveOptions: AssertNotAny<SaveOptions> = true;
const _real_Schema: AssertNotAny<Schema> = true;
const _real_ScrollIntoViewInput: AssertNotAny<ScrollIntoViewInput> = true;
const _real_ScrollIntoViewOutput: AssertNotAny<ScrollIntoViewOutput> = true;
const _real_SearchMatch: AssertNotAny<SearchMatch> = true;
const _real_SectionMetadata: AssertNotAny<SectionMetadata> = true;
const _real_SelectionApi: AssertNotAny<SelectionApi> = true;
const _real_SelectionCommandContext: AssertNotAny<SelectionCommandContext> = true;
Expand Down
46 changes: 46 additions & 0 deletions tests/consumer-typecheck/src/search-match.ts
Original file line number Diff line number Diff line change
@@ -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<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type AssertEqual<A, B> = Equal<A, B> extends true ? true : never;

const _searchReturnTypeIsExact: AssertEqual<ReturnType<SuperDoc['search']>, SearchMatch[] | undefined> = true;
const _goToParamTypeIsExact: AssertEqual<Parameters<SuperDoc['goToSearchResult']>[0], SearchMatch> = true;

void [_searchReturnTypeIsExact, _goToParamTypeIsExact, results];
25 changes: 25 additions & 0 deletions tests/consumer-typecheck/typecheck-matrix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading