Skip to content

Commit e690fe9

Browse files
authored
feat(superdoc): built in find and replace (#2566)
* feat(superdoc): built in find and replace * fix(search): refresh replaceSearchMatch on the active transaction
1 parent 350f2c6 commit e690fe9

20 files changed

Lines changed: 3328 additions & 49 deletions

File tree

apps/docs/core/superdoc/configuration.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,14 +274,17 @@ new SuperDoc({
274274
<ParamField path="modules.surfaces.floating.autoFocus" type="boolean" default="true">
275275
Move focus into the first focusable child on open
276276
</ParamField>
277+
<ParamField path="modules.surfaces.findReplace" type="false | true | Object" default="false">
278+
Built-in find/replace popover for editor-backed documents. Disabled by default; set to `true` for the built-in UI, or pass an object to customize text, disable replace actions, provide a custom component or render function, or add a runtime resolver. See [Surfaces — Built-in Find and Replace](/core/superdoc/surfaces#built-in-find-and-replace).
279+
</ParamField>
277280
<ParamField path="modules.surfaces.passwordPrompt" type="false | true | Object" default="true">
278-
Password prompt for encrypted DOCX files. Enabled by default. Set to `false` to disable, `true` for defaults, or pass an object to customize text, provide a custom component/render function, or add a per-document resolver. See [Surfaces — Password prompt](/core/superdoc/surfaces#password-prompt).
281+
Built-in password prompt for encrypted DOCX files. Enabled by default when omitted; set to `false` to disable, `true` for defaults, or pass an object to customize text, provide a custom component or render function, or add a per-document resolver. See [Surfaces — Built-in password prompt](/core/superdoc/surfaces#built-in-password-prompt).
279282
</ParamField>
280283
</Expandable>
281284
</ParamField>
282285

283286
<Note>
284-
You only need `modules.surfaces` if you want shared defaults or a central resolver. Direct `superdoc.openSurface(...)` calls do not require any special setup.
287+
You only need `modules.surfaces` if you want shared defaults, a central resolver, or to enable/configure built-in surface behaviors like find/replace and the password prompt. Direct `superdoc.openSurface(...)` calls do not require any special setup.
285288
</Note>
286289

287290
See [Surfaces](/core/superdoc/surfaces) for the full API and examples.

apps/docs/core/superdoc/surfaces.mdx

Lines changed: 274 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Add `modules.surfaces` only when you want:
2727

2828
- global defaults for dialogs or floating overlays
2929
- a central `resolver` for intent-based requests using `kind`
30+
- built-in surface behaviors such as opt-in find/replace or password prompt customization
3031

3132
## Open a surface
3233

@@ -287,7 +288,7 @@ This is the same pattern as [external link popovers](/modules/links#external-ren
287288
288289
## Optional instantiation config
289290
290-
Add `modules.surfaces` only if you want shared defaults or a resolver.
291+
Add `modules.surfaces` only if you want shared defaults, a resolver, or built-in surface behaviors such as find/replace.
291292
292293
```javascript
293294
new SuperDoc({
@@ -346,7 +347,277 @@ superdoc.openSurface({
346347
There is no built-in surface registry yet. If you use `kind`, you must provide `modules.surfaces.resolver`.
347348
</Warning>
348349
349-
## Password prompt
350+
## Built-in Find and Replace
351+
352+
SuperDoc includes a built-in find/replace popover for editor-backed documents. It is disabled by default so existing apps can keep their own `Cmd+F` / `Ctrl+F` handling. When enabled, SuperDoc intercepts those shortcuts while focus is inside SuperDoc and opens the built-in UI.
353+
354+
From shortcut handling, SuperDoc only steals `Cmd+F` / `Ctrl+F` when it can actually open a surface. If `findReplace.resolver` returns `{ type: 'none' }`, or the config is invalid or throws, the browser's native find remains active.
355+
356+
Enable it via `modules.surfaces.findReplace`:
357+
358+
```javascript
359+
new SuperDoc({
360+
selector: '#editor',
361+
document: file,
362+
modules: {
363+
surfaces: {
364+
findReplace: true,
365+
},
366+
},
367+
});
368+
```
369+
370+
### Text customization
371+
372+
Override any of the built-in labels and placeholders:
373+
374+
```javascript
375+
modules: {
376+
surfaces: {
377+
findReplace: {
378+
findPlaceholder: 'Search in document',
379+
replacePlaceholder: 'Replace with',
380+
noResultsLabel: 'Nothing found',
381+
previousMatchLabel: 'Previous result',
382+
nextMatchLabel: 'Next result',
383+
closeAriaLabel: 'Close search',
384+
},
385+
},
386+
}
387+
```
388+
389+
### Find-only mode
390+
391+
Disable replace actions and keep only the find UI:
392+
393+
```javascript
394+
modules: {
395+
surfaces: {
396+
findReplace: {
397+
replaceEnabled: false,
398+
},
399+
},
400+
}
401+
```
402+
403+
<ParamField path="modules.surfaces.findReplace" type="false | true | Object" default="false">
404+
Find/replace configuration. Disabled by default; set to `true` to enable the built-in UI, or pass an object to customize labels, disable replace actions, or replace the rendering.
405+
406+
<Expandable title="text fields">
407+
<ParamField path="findPlaceholder" type="string" default="'Find'">
408+
Placeholder text for the find input
409+
</ParamField>
410+
<ParamField path="findAriaLabel" type="string" default="'Find text'">
411+
Accessible label for the find input. Also used as the floating surface label.
412+
</ParamField>
413+
<ParamField path="replacePlaceholder" type="string" default="'Replace'">
414+
Placeholder text for the replace input
415+
</ParamField>
416+
<ParamField path="replaceAriaLabel" type="string" default="'Replace text'">
417+
Accessible label for the replace input
418+
</ParamField>
419+
<ParamField path="noResultsLabel" type="string" default="'No results'">
420+
Text shown when the query has no matches
421+
</ParamField>
422+
<ParamField path="previousMatchLabel" type="string" default="'Previous match (Shift+Enter)'">
423+
Title/tooltip text for the previous-match button
424+
</ParamField>
425+
<ParamField path="previousMatchAriaLabel" type="string" default="'Previous match'">
426+
Accessible label for the previous-match button
427+
</ParamField>
428+
<ParamField path="nextMatchLabel" type="string" default="'Next match (Enter)'">
429+
Title/tooltip text for the next-match button
430+
</ParamField>
431+
<ParamField path="nextMatchAriaLabel" type="string" default="'Next match'">
432+
Accessible label for the next-match button
433+
</ParamField>
434+
<ParamField path="closeLabel" type="string" default="'Close (Escape)'">
435+
Title/tooltip text for the close button
436+
</ParamField>
437+
<ParamField path="closeAriaLabel" type="string" default="'Close find and replace'">
438+
Accessible label for the close button
439+
</ParamField>
440+
<ParamField path="replaceLabel" type="string" default="'Replace'">
441+
Replace-current button text
442+
</ParamField>
443+
<ParamField path="replaceAllLabel" type="string" default="'All'">
444+
Replace-all button text
445+
</ParamField>
446+
<ParamField path="toggleReplaceLabel" type="string" default="'Toggle replace'">
447+
Title/tooltip text for the show/hide replace-row button
448+
</ParamField>
449+
<ParamField path="toggleReplaceAriaLabel" type="string" default="'Toggle replace'">
450+
Accessible label for the show/hide replace-row button
451+
</ParamField>
452+
<ParamField path="matchCaseLabel" type="string" default="'Aa'">
453+
Text shown for the match-case toggle
454+
</ParamField>
455+
<ParamField path="matchCaseAriaLabel" type="string" default="'Match case'">
456+
Accessible label for the match-case toggle
457+
</ParamField>
458+
<ParamField path="ignoreDiacriticsLabel" type="string" default="'ä≡a'">
459+
Text shown for the ignore-diacritics toggle
460+
</ParamField>
461+
<ParamField path="ignoreDiacriticsAriaLabel" type="string" default="'Ignore diacritics'">
462+
Accessible label for the ignore-diacritics toggle
463+
</ParamField>
464+
</Expandable>
465+
466+
<Expandable title="behavior + custom UI fields">
467+
<ParamField path="replaceEnabled" type="boolean" default="true">
468+
Whether replace actions are available. Set to `false` for a find-only popover.
469+
</ParamField>
470+
<ParamField path="component" type="Vue Component">
471+
Custom Vue component rendered inside the floating surface shell. Receives a `findReplace` prop. Mutually exclusive with `render`.
472+
</ParamField>
473+
<ParamField path="props" type="Record<string, unknown>">
474+
Extra props passed to the custom Vue component. Component-only; ignored for `render`.
475+
</ParamField>
476+
<ParamField path="render" type="(ctx: FindReplaceRenderContext) => { destroy?: () => void } | void">
477+
Framework-agnostic renderer for React or manual DOM mounting. Mutually exclusive with `component`.
478+
</ParamField>
479+
<ParamField path="resolver" type="(ctx: FindReplaceContext) => FindReplaceResolution | null | undefined">
480+
Runtime resolver for choosing built-in, custom, external, or suppressed rendering. Can coexist with `component`/`render` — the resolver runs first, and `null`/`undefined`/`{ type: 'default' }` falls through to the direct component/render or built-in UI.
481+
</ParamField>
482+
</Expandable>
483+
</ParamField>
484+
485+
### Custom Vue component
486+
487+
Replace the built-in content with your own Vue component. Your component receives a `findReplace` prop with reactive state and actions:
488+
489+
```javascript
490+
import MyFindReplaceSurface from './MyFindReplaceSurface.vue';
491+
492+
modules: {
493+
surfaces: {
494+
findReplace: {
495+
component: MyFindReplaceSurface,
496+
},
497+
},
498+
}
499+
```
500+
501+
Inside your component:
502+
503+
```vue
504+
<script setup>
505+
import { ref, onMounted } from 'vue';
506+
507+
const props = defineProps({
508+
findReplace: { type: Object, required: true },
509+
});
510+
511+
const inputRef = ref(null);
512+
513+
onMounted(() => {
514+
props.findReplace.registerFocusFn(() => inputRef.value?.focus());
515+
});
516+
</script>
517+
518+
<template>
519+
<input
520+
ref="inputRef"
521+
:value="findReplace.findQuery.value"
522+
@input="findReplace.findQuery.value = $event.target.value"
523+
/>
524+
<button @click="findReplace.goPrev()">Prev</button>
525+
<button @click="findReplace.goNext()">Next</button>
526+
</template>
527+
```
528+
529+
### Custom render function (React, vanilla JS)
530+
531+
Use `render` for framework-agnostic mounting. The render function receives a `FindReplaceRenderContext`:
532+
533+
```javascript
534+
modules: {
535+
surfaces: {
536+
findReplace: {
537+
render: (ctx) => {
538+
const input = document.createElement('input');
539+
input.value = ctx.findReplace.findQuery;
540+
input.addEventListener('input', (event) => {
541+
ctx.findReplace.findQuery = event.target.value;
542+
});
543+
544+
ctx.findReplace.registerFocusFn(() => input.focus());
545+
ctx.container.appendChild(input);
546+
547+
return { destroy: () => input.remove() };
548+
},
549+
},
550+
},
551+
}
552+
```
553+
554+
`ctx` exposes `container`, `findReplace`, `resolve`, `close`, `surfaceId`, and `mode`.
555+
556+
`ctx.findReplace` exposes plain JavaScript getters/setters instead of Vue refs so non-Vue renderers can work with it directly.
557+
558+
### Conditional resolver
559+
560+
Use `resolver` when your app wants to decide at runtime whether to use built-in, custom, external, or suppressed rendering. The resolver receives a read-only context with `texts` and `replaceEnabled`:
561+
562+
```javascript
563+
modules: {
564+
surfaces: {
565+
findReplace: {
566+
replaceEnabled: false,
567+
resolver: (ctx) => {
568+
if (!ctx.replaceEnabled) {
569+
return { type: 'custom', component: ReadOnlyFindSurface };
570+
}
571+
return null; // fall through to built-in
572+
},
573+
},
574+
},
575+
}
576+
```
577+
578+
**Resolution types:**
579+
580+
| Type | Behavior |
581+
|------|----------|
582+
| `null` / `undefined` | Fall through to `component`/`render` or built-in |
583+
| `{ type: 'default' }` | Same as `null` — fall through |
584+
| `{ type: 'none' }` | Suppress SuperDoc's find/replace surface for this open attempt |
585+
| `{ type: 'custom', component, props? }` | Mount a Vue component in the floating shell |
586+
| `{ type: 'external', render }` | Mount framework-agnostic UI in the floating shell |
587+
588+
The resolver can coexist with `component`/`render`. If the resolver returns `null` or `{ type: 'default' }`, the direct `component`/`render` is used. If neither is configured, the built-in popover renders.
589+
590+
**Precedence:** resolver → `component`/`render` → built-in.
591+
592+
### The `findReplace` handle
593+
594+
Custom Vue components receive `findReplace` as a Vue handle with refs/computed refs. External `render` functions receive `ctx.findReplace` with the same API surface, but mutable fields are exposed as plain JavaScript getters/setters and derived fields are plain getters.
595+
596+
| Field | Vue `component` value | External `render` value | Description |
597+
|-------|-----------------------|-------------------------|-------------|
598+
| `findQuery` | `Ref<string>` | `string` getter/setter | Current search query |
599+
| `replaceText` | `Ref<string>` | `string` getter/setter | Current replacement text |
600+
| `caseSensitive` | `Ref<boolean>` | `boolean` getter/setter | Match-case toggle |
601+
| `ignoreDiacritics` | `Ref<boolean>` | `boolean` getter/setter | Ignore-diacritics toggle |
602+
| `showReplace` | `Ref<boolean>` | `boolean` getter/setter | Whether the replace row is expanded |
603+
| `matchCount` | `Ref<number>` | `number` getter | Total match count |
604+
| `activeMatchIndex` | `Ref<number>` | `number` getter | Active match index, `-1` when none |
605+
| `matchLabel` | `ComputedRef<string>` | `string` getter | Formatted label such as `3 of 12` or `No results` |
606+
| `hasMatches` | `ComputedRef<boolean>` | `boolean` getter | Whether there are any matches |
607+
| `replaceEnabled` | `boolean` | `boolean` | Whether replace actions are available |
608+
| `texts` | `ResolvedFindReplaceTexts` | `ResolvedFindReplaceTexts` | All text strings resolved with defaults |
609+
| `goNext` / `goPrev` | `() => void` | `() => void` | Navigate through matches |
610+
| `replaceCurrent` / `replaceAll` | `() => void` | `() => void` | Run replacement actions |
611+
| `registerFocusFn` | `(fn) => void` | `(fn) => void` | Register the function SuperDoc calls when the user presses `Cmd+F` / `Ctrl+F` again while the surface is already open |
612+
| `close` | `(reason?: unknown) => void` | `(reason?: unknown) => void` | Close the surface |
613+
614+
### `{ type: 'none' }` semantics
615+
616+
`{ type: 'none' }` means **suppress SuperDoc's find/replace surface** for that open attempt.
617+
618+
When find/replace is opened via `Cmd+F` / `Ctrl+F`, suppression falls back to the browser's native find dialog instead of swallowing the shortcut.
619+
620+
## Built-in password prompt
350621
351622
SuperDoc includes a built-in password dialog for encrypted DOCX files. Enabled by default. On wrong password, the dialog stays open and shows an error. On success, the document loads normally.
352623
@@ -587,4 +858,4 @@ When `passwordPrompt` is enabled, recoverable encryption errors are intercepted
587858
When multiple encrypted documents are loaded, the password prompt queues one dialog at a time in FIFO order. After the user submits or cancels for one document, the next dialog appears.
588859
## Styling
589860
590-
Background, shadow, border radius, padding, and edge offset are all customizable via `--sd-ui-surface-*` and `--sd-ui-floating-*` CSS variables. See the full token table in [Custom themes](/guides/general/custom-themes#surfaces).
861+
Background, shadow, border radius, padding, and edge offset are all customizable via `--sd-ui-surface-*` and `--sd-ui-floating-*` CSS variables. Find/replace controls use `--sd-ui-find-replace-*`, and search highlight colors use `--sd-ui-search-match-bg` plus `--sd-ui-search-match-active-bg`. See the full token table in [Custom themes](/guides/general/custom-themes#surfaces).

apps/docs/guides/general/custom-themes.mdx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,49 @@ Dialog and floating overlays rendered above document content. Style with CSS var
342342
| `--sd-ui-floating-max-height` | `min(60vh, calc(100% - 32px))` | Floating surface max height |
343343
| `--sd-ui-floating-edge-offset` | `16px` | Offset from edges for placement presets |
344344

345+
### Find and replace
346+
347+
The built-in find/replace popover inherits the shared surface tokens above and adds its own control-level variables.
348+
349+
| Variable | Default | Controls |
350+
|----------|---------|----------|
351+
| `--sd-ui-find-replace-gap` | `8px` | Vertical gap between main rows |
352+
| `--sd-ui-find-replace-input-height` | `30px` | Input height |
353+
| `--sd-ui-find-replace-input-padding` | `4px 8px` | Input padding |
354+
| `--sd-ui-find-replace-input-font-size` | inherits `--sd-ui-font-size-400` | Input font size |
355+
| `--sd-ui-find-replace-input-bg` | inherits `--sd-ui-surface-bg` | Input background |
356+
| `--sd-ui-find-replace-input-border` | inherits `--sd-ui-border` | Input border color |
357+
| `--sd-ui-find-replace-input-focus-border` | inherits `--sd-ui-action` | Input border on focus |
358+
| `--sd-ui-find-replace-input-radius` | `4px` | Input border radius |
359+
| `--sd-ui-find-replace-count-color` | inherits `--sd-ui-text-muted` | Match-count text color |
360+
| `--sd-ui-find-replace-count-font-size` | inherits `--sd-ui-font-size-300` | Match-count font size |
361+
| `--sd-ui-find-replace-count-inset` | `8px` | Right inset for the match-count overlay inside the input |
362+
| `--sd-ui-find-replace-btn-size` | `28px` | Icon-button size |
363+
| `--sd-ui-find-replace-btn-radius` | `4px` | Icon-button border radius |
364+
| `--sd-ui-find-replace-btn-hover-bg` | inherits `--sd-ui-hover-bg` | Icon-button hover background |
365+
| `--sd-ui-find-replace-btn-color` | inherits `--sd-ui-text` | Icon-button color |
366+
| `--sd-ui-find-replace-btn-disabled-opacity` | `0.4` | Icon-button opacity when disabled |
367+
| `--sd-ui-find-replace-btn-icon-font-size` | `12px` | Prev/next/close icon size |
368+
| `--sd-ui-find-replace-btn-toggle-font-size` | `12px` | Text-toggle font size |
369+
| `--sd-ui-find-replace-btn-toggle-padding` | `4px 8px` | Text-toggle padding |
370+
| `--sd-ui-find-replace-toggle-active-bg` | inherits `--sd-color-blue-100` | Active background for option toggles |
371+
| `--sd-ui-find-replace-toggle-active-color` | inherits `--sd-color-blue-700` | Active text/icon color for option toggles |
372+
| `--sd-ui-find-replace-action-btn-bg` | inherits `--sd-ui-action` | Replace button background |
373+
| `--sd-ui-find-replace-action-btn-color` | `#fff` | Replace button text color |
374+
| `--sd-ui-find-replace-action-btn-hover-bg` | inherits `--sd-ui-action-hover` | Replace button hover background |
375+
| `--sd-ui-find-replace-action-btn-padding` | `4px 10px` | Replace button padding |
376+
| `--sd-ui-find-replace-nav-gap` | `2px` | Gap between prev/next/close buttons |
377+
| `--sd-ui-find-replace-options-gap` | `4px` | Gap between the lower-row toggles/actions |
378+
379+
### Search highlights
380+
381+
Colors used for search result highlights inside the document view.
382+
383+
| Variable | Default | Controls |
384+
|----------|---------|----------|
385+
| `--sd-ui-search-match-bg` | `rgba(255, 213, 0, 0.4)` | Background for non-active search matches |
386+
| `--sd-ui-search-match-active-bg` | `rgba(255, 150, 0, 0.6)` | Background for the active search match |
387+
345388
### Password prompt
346389

347390
The built-in password dialog for encrypted DOCX files. All variables default to the shared UI tokens.

packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model';
44

55
import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/index.js';
66
import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js';
7-
import { customSearchHighlightsKey } from '@extensions/search/search.js';
87
import { AiPluginKey } from '@extensions/ai/ai-plugin.js';
98
import { CustomSelectionPluginKey } from '@core/selection-state.js';
109
import { LinkedStylesPluginKey } from '@extensions/linked-styles/plugin.js';
@@ -60,7 +59,6 @@ interface DesiredState {
6059
const EXCLUDED_PLUGIN_KEY_REF_LIST: PluginKey[] = [
6160
TrackChangesBasePluginKey,
6261
CommentsPluginKey,
63-
customSearchHighlightsKey,
6462
AiPluginKey,
6563
CustomSelectionPluginKey,
6664
LinkedStylesPluginKey,

0 commit comments

Comments
 (0)