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:
@@ -27,11 +27,14 @@ export const REWRITE_STYLES: RewriteStyle[] = [
|
||||
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 }: Props) {
|
||||
export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
||||
const [natural, ...tones] = REWRITE_STYLES
|
||||
|
||||
return (
|
||||
@@ -39,21 +42,25 @@ export function SelectionBubble({ style, onRewrite }: Props) {
|
||||
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"
|
||||
onMouseDown={(e) => e.preventDefault()} // keep the editor 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)' }}
|
||||
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>
|
||||
@@ -63,15 +70,30 @@ export function SelectionBubble({ style, onRewrite }: Props) {
|
||||
</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)' }}
|
||||
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`}
|
||||
|
||||
Reference in New Issue
Block a user