Files
petal/web/src/components/Editor/SearchHighlight.ts
prosolis db737fa612 Editor: Find & Replace, read-aloud, backup, typography, phonetic, org niceties
Writer power-ups (Phase 11), plus the selection-bubble vs copy/paste fix.

- Find & Replace (Ctrl/Cmd+F): SearchHighlight decoration extension +
  FindReplace bar (match-case, replace-all back-to-front, scroll without
  popping the selection bubble).
- Read-aloud (Web Speech, offline) on the word card and selection bubble.
- Keyboard/touch access to the ESL helpers: Ctrl/Cmd+D look up word at caret,
  Ctrl/Cmd+J rewrite selection, touch long-press lookup. Refactored the
  right-click handler into a shared openWordLookup(pos).
- Whole-corpus backup: GET /api/docs/export-all zips every doc (md/docx),
  de-dupes filenames, dated name; sidebar download links. TestExportAll.
- Smart typography input rules (curly quotes/em-dash/ellipsis), ASCII-only so
  CJK is untouched.
- Duplicate doc, sidebar sort (Recent/Title/Longest), toolbar outline popover.
- English phonetic (chosen over pinyin for an English learner): ECDICT-built
  phonetic.json.gz (46,579 words) + Result.Phonetic + WordCard IPA line;
  scripts/build_phonetic.py (full build + --seed fallback).
- Selection bubble no longer blocks copy/paste: deferred to pointer-up and made
  click-through except on its buttons.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:47:26 -07:00

144 lines
5.2 KiB
TypeScript

import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import type { Node as PMNode } from '@tiptap/pm/model'
import { mapOffset } from './SuggestionHighlight'
// SearchHighlight powers the in-document Find & Replace bar. Like the suggestion
// layer it uses ProseMirror *decorations* (not stored marks), so matches are
// recomputed from the live document on every edit and never leave a stale
// highlight behind. Matches are found per-textblock (a query never spans a
// paragraph break), mirroring how the rest of the editor anchors text.
export interface Match {
from: number
to: number
}
interface PluginState {
query: string
caseSensitive: boolean
matches: Match[]
active: number // index into matches, or -1 when there are none
decorations: DecorationSet
}
export const searchPluginKey = new PluginKey<PluginState>('petalSearch')
// findMatches collects every occurrence of `query` across the document's
// textblocks and maps each to an absolute ProseMirror range.
function findMatches(doc: PMNode, query: string, caseSensitive: boolean): Match[] {
if (!query) return []
const needle = caseSensitive ? query : query.toLowerCase()
const matches: Match[] = []
doc.descendants((node, pos) => {
if (!node.isTextblock) return true
const haystackRaw = node.textContent
const haystack = caseSensitive ? haystackRaw : haystackRaw.toLowerCase()
let idx = haystack.indexOf(needle)
while (idx >= 0) {
matches.push({
from: mapOffset(node, pos, idx),
to: mapOffset(node, pos, idx + query.length),
})
idx = haystack.indexOf(needle, idx + query.length)
}
return false // don't descend into inline children
})
return matches
}
function build(doc: PMNode, query: string, caseSensitive: boolean, preferred: number): PluginState {
const matches = findMatches(doc, query, caseSensitive)
const active = matches.length === 0 ? -1 : Math.max(0, Math.min(preferred, matches.length - 1))
const decos = matches.map((m, i) =>
Decoration.inline(m.from, m.to, {
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
}),
)
return { query, caseSensitive, matches, active, decorations: DecorationSet.create(doc, decos) }
}
const EMPTY: PluginState = {
query: '',
caseSensitive: false,
matches: [],
active: -1,
decorations: DecorationSet.empty,
}
// setSearch updates the query / case-sensitivity and recomputes matches. Passing
// an empty query clears the layer.
export function setSearch(
state: EditorState,
dispatch: (tr: Transaction) => void,
query: string,
caseSensitive: boolean,
) {
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'search', query, caseSensitive }))
}
// setActive moves the highlighted (current) match by index.
export function setActive(state: EditorState, dispatch: (tr: Transaction) => void, index: number) {
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'active', index }))
}
// clearSearch tears the layer down (on close).
export function clearSearch(state: EditorState, dispatch: (tr: Transaction) => void) {
dispatch(state.tr.setMeta(searchPluginKey, { kind: 'clear' }))
}
// getSearchState reads the live plugin state (match list + active index) so the
// Find bar can render "n / m" and drive navigation/replacement.
export function getSearchState(state: EditorState): PluginState {
return searchPluginKey.getState(state) ?? EMPTY
}
type Meta =
| { kind: 'search'; query: string; caseSensitive: boolean }
| { kind: 'active'; index: number }
| { kind: 'clear' }
export const SearchHighlight = Extension.create({
name: 'searchHighlight',
addProseMirrorPlugins() {
return [
new Plugin<PluginState>({
key: searchPluginKey,
state: {
init: () => EMPTY,
apply(tr, value, _oldState, newState): PluginState {
const meta = tr.getMeta(searchPluginKey) as Meta | undefined
if (meta?.kind === 'search') {
return build(newState.doc, meta.query, meta.caseSensitive, value.active < 0 ? 0 : value.active)
}
if (meta?.kind === 'active') {
if (value.matches.length === 0) return value
const active = ((meta.index % value.matches.length) + value.matches.length) % value.matches.length
const decos = value.matches.map((m, i) =>
Decoration.inline(m.from, m.to, {
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
}),
)
return { ...value, active, decorations: DecorationSet.create(newState.doc, decos) }
}
if (meta?.kind === 'clear') return EMPTY
// Re-anchor on any document change so highlights track edits/replaces.
if (tr.docChanged && value.query) {
return build(newState.doc, value.query, value.caseSensitive, value.active)
}
return value
},
},
props: {
decorations(state) {
return searchPluginKey.getState(state)?.decorations
},
},
}),
]
},
})