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
108 lines
4.4 KiB
TypeScript
108 lines
4.4 KiB
TypeScript
// SelectionBubble floats above a text selection and offers to rewrite it: a
|
|
// prominent "✨ 更自然 Say it naturally" action plus the tone vocabulary (学术,
|
|
// 轻松, …) mirrored from the document-tone picker. Picking one hands the style up
|
|
// to EditorCore, which calls the LLM and shows a preview. Buttons use
|
|
// onMouseDown→preventDefault so clicking them doesn't collapse the selection
|
|
// before the handler captures its range.
|
|
|
|
export interface RewriteStyle {
|
|
value: string
|
|
emoji: string
|
|
zh: string
|
|
en: string
|
|
}
|
|
|
|
// 'natural' is the default "say it more naturally" rewrite; the rest mirror the
|
|
// llm styleGuidance keys (and the ToneSelect labels) so the two stay in step.
|
|
export const REWRITE_STYLES: RewriteStyle[] = [
|
|
{ value: 'natural', emoji: '✨', zh: '更自然', en: 'Natural' },
|
|
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
|
|
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
|
|
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
|
|
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
|
|
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
|
|
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
|
|
]
|
|
|
|
interface Props {
|
|
style: React.CSSProperties
|
|
onRewrite: (style: string) => void
|
|
// Read the selected text aloud (null when speech isn't available — the button
|
|
// is then hidden).
|
|
onSpeak: (() => void) | null
|
|
}
|
|
|
|
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
|
|
|
export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
|
const [natural, ...tones] = REWRITE_STYLES
|
|
|
|
return (
|
|
<div
|
|
className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2"
|
|
role="toolbar"
|
|
aria-label="Rewrite the selection"
|
|
style={{
|
|
background: 'var(--color-surface)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--radius-pill)',
|
|
boxShadow: 'var(--shadow-soft)',
|
|
fontFamily: CJK,
|
|
// Only the buttons capture the pointer; clicks landing on the bubble's
|
|
// padding/gaps fall through to the text underneath so it never blocks
|
|
// where the user is trying to click.
|
|
pointerEvents: 'none',
|
|
...style,
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
|
onClick={() => onRewrite(natural.value)}
|
|
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
|
title="Rewrite the selection to sound more natural"
|
|
>
|
|
<span aria-hidden>{natural.emoji}</span>
|
|
<span>{natural.zh}</span>
|
|
<span className="font-semibold" style={{ color: 'var(--color-plum)', opacity: 0.7 }}>
|
|
{natural.en}
|
|
</span>
|
|
</button>
|
|
|
|
{onSpeak && (
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
|
onClick={onSpeak}
|
|
className="inline-flex h-8 items-center justify-center px-2 text-sm"
|
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
|
title="朗读所选 · Read selection aloud"
|
|
aria-label="Read selection aloud"
|
|
>
|
|
🔊
|
|
</button>
|
|
)}
|
|
|
|
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
|
|
|
|
{tones.map((t) => (
|
|
<button
|
|
key={t.value}
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
|
onClick={() => onRewrite(t.value)}
|
|
className="inline-flex h-8 items-center gap-1 whitespace-nowrap px-2.5 text-sm font-semibold"
|
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
|
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
|
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
|
|
>
|
|
<span aria-hidden>{t.emoji}</span>
|
|
<span>{t.zh}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|