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
This commit is contained in:
143
web/src/components/Editor/SearchHighlight.ts
Normal file
143
web/src/components/Editor/SearchHighlight.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user