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
144 lines
5.2 KiB
TypeScript
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
|
|
},
|
|
},
|
|
}),
|
|
]
|
|
},
|
|
})
|