Files
petal/web/src/components/Toolbar/Toolbar.tsx
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

655 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react'
import { useEffect, useRef, useState } from 'react'
import { uploadImageInto } from '../Editor/EditorCore'
interface Props {
editor: Editor | null
// Runs the whole-document voice-consistency pass; `voicing` shows its progress.
onVoiceCheck: () => void
voicing: boolean
}
// A formatting button. `active` gets the rose pill treatment so the writer can
// see what's applied at the cursor.
function TBtn({
onClick,
active,
disabled,
label,
title,
children,
}: {
onClick: () => void
active?: boolean
disabled?: boolean
label: string
title?: string
children: React.ReactNode
}) {
return (
<button
type="button"
aria-label={label}
title={title ?? label}
aria-pressed={active}
disabled={disabled}
onMouseDown={(e) => e.preventDefault()} // keep editor selection
onClick={onClick}
className="flex h-8 min-w-8 items-center justify-center px-2 text-sm font-semibold disabled:opacity-40"
style={{
borderRadius: 'var(--radius-input)',
color: active ? 'var(--color-accent-hover)' : 'var(--color-muted)',
background: active ? 'var(--color-surface-alt)' : 'transparent',
}}
>
{children}
</button>
)
}
const Divider = () => (
<span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} />
)
// A popover anchored under its trigger. The trigger + panel share a relative
// wrapper; `open`/`onClose` are owned by the toolbar so only one is open at once.
// A pointer-down outside the wrapper closes it.
function Popover({
open,
onClose,
trigger,
children,
width = 200,
}: {
open: boolean
onClose: () => void
trigger: React.ReactNode
children: React.ReactNode
width?: number
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose()
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [open, onClose])
return (
<div ref={ref} className="relative flex items-center">
{trigger}
{open && (
<div
className="absolute left-0 top-full z-40 mt-1.5 p-2"
style={{
width,
borderRadius: 'var(--radius-card)',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-soft)',
}}
>
{children}
</div>
)}
</div>
)
}
// Text-color swatches. `null` clears the color back to the theme default.
const TEXT_COLORS: { label: string; value: string | null }[] = [
{ label: 'Default', value: null },
{ label: 'Rose', value: '#D98AAF' },
{ label: 'Coral', value: '#E0785C' },
{ label: 'Honey', value: '#C68B2E' },
{ label: 'Green', value: '#4F9E7F' },
{ label: 'Sky', value: '#4E8FCC' },
{ label: 'Lavender', value: '#7D63C4' },
{ label: 'Plum', value: '#3D2E39' },
]
// Highlighter colors — soft pastels so dark text stays readable on top.
const HIGHLIGHTS: { label: string; value: string | null }[] = [
{ label: 'None', value: null },
{ label: 'Yellow', value: '#FFF1A8' },
{ label: 'Pink', value: '#FAD4E4' },
{ label: 'Mint', value: '#CDEFE2' },
{ label: 'Peach', value: '#FBE0CF' },
{ label: 'Lavender', value: '#E6DCFA' },
{ label: 'Sky', value: '#D6E8FB' },
]
// Font-size presets. `null` clears back to the document default.
const SIZES: { label: string; value: string | null; em: string }[] = [
{ label: 'Small', value: '0.85em', em: '0.85em' },
{ label: 'Normal', value: null, em: '1em' },
{ label: 'Large', value: '1.3em', em: '1.3em' },
{ label: 'Title', value: '1.7em', em: '1.7em' },
]
// A round color chip used in the color/highlight palettes.
function Swatch({
color,
active,
onClick,
title,
}: {
color: string | null
active: boolean
onClick: () => void
title: string
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className="flex h-7 w-7 items-center justify-center"
style={{
borderRadius: 'var(--radius-pill)',
background: color ?? 'var(--color-surface)',
border: active ? '2px solid var(--color-accent)' : '1px solid var(--color-border)',
// The "clear" chip (no color) shows a tiny diagonal stroke.
backgroundImage: color
? undefined
: 'linear-gradient(135deg, transparent 44%, var(--color-accent) 44%, var(--color-accent) 56%, transparent 56%)',
}}
/>
)
}
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
// useEditorState subscribes to just the flags it reads, so the buttons reflect
// the current selection without re-rendering the whole tree on every keystroke.
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
// Which popover (if any) is open. Only one at a time.
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const state = useEditorState({
editor,
selector: ({ editor }) =>
editor
? {
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
underline: editor.isActive('underline'),
strike: editor.isActive('strike'),
h1: editor.isActive('heading', { level: 1 }),
h2: editor.isActive('heading', { level: 2 }),
h3: editor.isActive('heading', { level: 3 }),
bullet: editor.isActive('bulletList'),
ordered: editor.isActive('orderedList'),
left: editor.isActive({ textAlign: 'left' }),
center: editor.isActive({ textAlign: 'center' }),
right: editor.isActive({ textAlign: 'right' }),
link: editor.isActive('link'),
inTable: editor.isActive('table'),
color: editor.getAttributes('textStyle').color ?? null,
highlight: editor.getAttributes('highlight').color ?? null,
fontSize: editor.getAttributes('textStyle').fontSize ?? null,
canUndo: editor.can().undo(),
canRedo: editor.can().redo(),
}
: null,
})
if (!editor || !state) return null
const close = () => setMenu(null)
// Open the link popover, pre-filling the field with any link already on the
// selection so it can be edited rather than retyped.
const openLink = () => {
setLinkUrl(editor.getAttributes('link').href ?? '')
setMenu(menu === 'link' ? null : 'link')
}
const applyLink = () => {
const url = linkUrl.trim()
if (!url) {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
} else {
// Default to https:// when the writer omits a scheme.
const href = /^(https?:|mailto:|\/)/i.test(url) ? url : `https://${url}`
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
}
close()
}
// Collect the document's headings (with their positions) for the outline. Read
// fresh each time the popover opens, so it always reflects the current doc.
const headings: { level: number; text: string; pos: number }[] = []
if (menu === 'outline') {
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
headings.push({ level: (node.attrs.level as number) || 1, text: node.textContent || '(无标题)', pos })
}
})
}
// Scroll a heading into view without moving the selection (which would pop the
// rewrite bubble). domAtPos resolves the heading's DOM node to scroll to.
const gotoHeading = (pos: number) => {
const dom = editor.view.domAtPos(pos + 1)
const el = dom.node.nodeType === Node.TEXT_NODE ? dom.node.parentElement : (dom.node as HTMLElement)
el?.scrollIntoView({ block: 'start', behavior: 'smooth' })
close()
}
const onPickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) uploadImageInto(editor.view, file)
e.target.value = '' // allow re-picking the same file
}
return (
<div
className="petal-toolbar mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
style={{
borderRadius: 'var(--radius-card)',
background: 'var(--color-surface)',
boxShadow: 'var(--shadow-soft)',
}}
>
<TBtn label="Undo" disabled={!state.canUndo} onClick={() => editor.chain().focus().undo().run()}>
</TBtn>
<TBtn label="Redo" disabled={!state.canRedo} onClick={() => editor.chain().focus().redo().run()}>
</TBtn>
<Divider />
<TBtn label="Bold" active={state.bold} onClick={() => editor.chain().focus().toggleBold().run()}>
<span className="font-extrabold">B</span>
</TBtn>
<TBtn label="Italic" active={state.italic} onClick={() => editor.chain().focus().toggleItalic().run()}>
<span className="italic">I</span>
</TBtn>
<TBtn
label="Underline"
active={state.underline}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<span className="underline">U</span>
</TBtn>
<TBtn label="Strikethrough" active={state.strike} onClick={() => editor.chain().focus().toggleStrike().run()}>
<span className="line-through">S</span>
</TBtn>
<Divider />
{/* Text color */}
<Popover
open={menu === 'color'}
onClose={close}
width={188}
trigger={
<TBtn label="Text color" active={menu === 'color'} onClick={() => setMenu(menu === 'color' ? null : 'color')}>
<span className="flex flex-col items-center leading-none">
<span className="text-sm font-bold">A</span>
<span
className="mt-0.5 h-1 w-4 rounded-full"
style={{ background: state.color ?? 'var(--color-accent)' }}
/>
</span>
</TBtn>
}
>
<div className="grid grid-cols-4 gap-1.5">
{TEXT_COLORS.map((c) => (
<Swatch
key={c.label}
color={c.value}
title={c.label}
active={state.color === c.value}
onClick={() => {
if (c.value) editor.chain().focus().setColor(c.value).run()
else editor.chain().focus().unsetColor().run()
close()
}}
/>
))}
</div>
</Popover>
{/* Highlight */}
<Popover
open={menu === 'highlight'}
onClose={close}
width={170}
trigger={
<TBtn
label="Highlight"
active={menu === 'highlight' || !!state.highlight}
onClick={() => setMenu(menu === 'highlight' ? null : 'highlight')}
>
<span
className="flex h-5 w-5 items-center justify-center rounded text-xs font-bold"
style={{ background: state.highlight ?? '#FFF1A8', color: 'var(--color-plum)' }}
>
H
</span>
</TBtn>
}
>
<div className="grid grid-cols-4 gap-1.5">
{HIGHLIGHTS.map((c) => (
<Swatch
key={c.label}
color={c.value}
title={c.label}
active={state.highlight === c.value}
onClick={() => {
if (c.value) editor.chain().focus().setHighlight({ color: c.value }).run()
else editor.chain().focus().unsetHighlight().run()
close()
}}
/>
))}
</div>
</Popover>
{/* Font size */}
<Popover
open={menu === 'size'}
onClose={close}
width={140}
trigger={
<TBtn label="Text size" active={menu === 'size'} onClick={() => setMenu(menu === 'size' ? null : 'size')}>
<span className="text-xs font-bold tracking-tight">
<span className="text-[0.7rem]">A</span>
<span className="text-sm">A</span>
</span>
</TBtn>
}
>
<div className="flex flex-col gap-0.5">
{SIZES.map((s) => {
const active = state.fontSize === s.value
return (
<button
key={s.label}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (s.value) editor.chain().focus().setFontSize(s.value).run()
else editor.chain().focus().unsetFontSize().run()
close()
}}
className="flex items-center justify-between rounded px-2 py-1 text-left"
style={{
background: active ? 'var(--color-surface-alt)' : 'transparent',
color: active ? 'var(--color-accent-hover)' : 'var(--color-plum)',
}}
>
<span style={{ fontSize: s.em }}>{s.label}</span>
</button>
)
})}
</div>
</Popover>
<Divider />
<TBtn label="Heading 1" active={state.h1} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>
H1
</TBtn>
<TBtn label="Heading 2" active={state.h2} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
H2
</TBtn>
<TBtn label="Heading 3" active={state.h3} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
H3
</TBtn>
<TBtn label="Bullet list" active={state.bullet} onClick={() => editor.chain().focus().toggleBulletList().run()}>
</TBtn>
<TBtn label="Numbered list" active={state.ordered} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
1.
</TBtn>
<Divider />
<TBtn label="Align left" active={state.left} onClick={() => editor.chain().focus().setTextAlign('left').run()}>
</TBtn>
<TBtn label="Align center" active={state.center} onClick={() => editor.chain().focus().setTextAlign('center').run()}>
</TBtn>
<TBtn label="Align right" active={state.right} onClick={() => editor.chain().focus().setTextAlign('right').run()}>
</TBtn>
<Divider />
{/* Link */}
<Popover
open={menu === 'link'}
onClose={close}
width={236}
trigger={
<TBtn label="Link" active={state.link || menu === 'link'} onClick={openLink}>
🔗
</TBtn>
}
>
<div className="flex flex-col gap-2">
<input
autoFocus
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applyLink()
if (e.key === 'Escape') close()
}}
placeholder="https://…"
className="w-full px-2 py-1.5 text-sm focus:outline-none"
style={{
borderRadius: 'var(--radius-input)',
border: '1px solid var(--color-border)',
color: 'var(--color-plum)',
}}
/>
<div className="flex items-center justify-between">
{state.link ? (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
close()
}}
className="px-2 py-1 text-xs font-semibold"
style={{ color: 'var(--color-muted)' }}
>
Remove
</button>
) : (
<span />
)}
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={applyLink}
className="px-3 py-1 text-xs font-bold"
style={{
borderRadius: 'var(--radius-pill)',
background: 'var(--color-accent)',
color: '#fff',
}}
>
Apply
</button>
</div>
</div>
</Popover>
{/* Image */}
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onPickImage} />
<TBtn label="Insert image" onClick={() => fileInputRef.current?.click()}>
🖼
</TBtn>
{/* Table */}
<Popover
open={menu === 'table'}
onClose={close}
width={state.inTable ? 188 : 150}
trigger={
<TBtn label="Table" active={state.inTable || menu === 'table'} onClick={() => setMenu(menu === 'table' ? null : 'table')}>
</TBtn>
}
>
{state.inTable ? (
<div className="flex flex-col gap-0.5 text-sm" style={{ color: 'var(--color-plum)' }}>
<TableAction label="Add row below" onClick={() => editor.chain().focus().addRowAfter().run()} />
<TableAction label="Add column right" onClick={() => editor.chain().focus().addColumnAfter().run()} />
<TableAction label="Toggle header row" onClick={() => editor.chain().focus().toggleHeaderRow().run()} />
<TableAction label="Delete row" onClick={() => editor.chain().focus().deleteRow().run()} />
<TableAction label="Delete column" onClick={() => editor.chain().focus().deleteColumn().run()} />
<TableAction
label="Delete table"
danger
onClick={() => {
editor.chain().focus().deleteTable().run()
close()
}}
/>
</div>
) : (
<GridPicker
onPick={(rows, cols) => {
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run()
close()
}}
/>
)}
</Popover>
{/* Outline / document map */}
<Popover
open={menu === 'outline'}
onClose={close}
width={240}
trigger={
<TBtn label="Outline" active={menu === 'outline'} onClick={() => setMenu(menu === 'outline' ? null : 'outline')}>
</TBtn>
}
>
<div className="max-h-72 overflow-y-auto">
<p className="mb-1.5 px-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Outline
</p>
{headings.length === 0 ? (
<p className="px-1 py-2 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
H1/H2/H3 <br />
Add headings to navigate them here.
</p>
) : (
<div className="flex flex-col">
{headings.map((h, i) => (
<button
key={i}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => gotoHeading(h.pos)}
className="truncate rounded px-1.5 py-1 text-left text-sm hover:bg-[var(--color-surface-alt)]"
style={{
paddingLeft: `${(h.level - 1) * 12 + 6}px`,
color: 'var(--color-plum)',
fontWeight: h.level === 1 ? 700 : 500,
}}
title={h.text}
>
{h.text}
</button>
))}
</div>
)}
</div>
</Popover>
<Divider />
<button
type="button"
aria-label="Check my voice"
disabled={voicing}
onMouseDown={(e) => e.preventDefault()} // keep editor selection
onClick={onVoiceCheck}
className="ml-0.5 inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-xs font-bold transition-colors disabled:opacity-70"
style={{
borderRadius: 'var(--radius-pill)',
color: 'var(--color-plum)',
background: 'var(--color-honey)',
}}
title="Read the whole document for passages that don't sound like you"
>
<span
className={voicing ? 'petal-checkpoint-dot inline-block h-2 w-2 rounded-full' : 'hidden'}
style={{ background: 'var(--color-plum)' }}
aria-hidden
/>
{voicing ? 'Reading…' : 'Check my voice 🍯'}
</button>
</div>
)
}
// A row in the in-table editing menu.
function TableAction({ label, onClick, danger }: { label: string; onClick: () => void; danger?: boolean }) {
return (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className="rounded px-2 py-1 text-left"
style={{ color: danger ? 'var(--color-accent-hover)' : 'var(--color-plum)' }}
>
{label}
</button>
)
}
// A hover-to-size grid for inserting a table. Hovering a cell highlights the
// rectangle from the top-left; clicking inserts that many rows × columns.
function GridPicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const MAX = 6
const [hover, setHover] = useState<{ r: number; c: number }>({ r: 0, c: 0 })
return (
<div>
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${MAX}, 1fr)` }}
onMouseLeave={() => setHover({ r: 0, c: 0 })}
>
{Array.from({ length: MAX * MAX }).map((_, i) => {
const r = Math.floor(i / MAX) + 1
const c = (i % MAX) + 1
const on = r <= hover.r && c <= hover.c
return (
<button
key={i}
type="button"
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHover({ r, c })}
onClick={() => onPick(r, c)}
className="h-4 w-4"
style={{
borderRadius: 3,
background: on ? 'var(--color-accent)' : 'var(--color-surface-alt)',
border: '1px solid var(--color-border)',
}}
/>
)
})}
</div>
<div className="mt-1.5 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
{hover.r > 0 ? `${hover.r} × ${hover.c}` : 'Pick a size'}
</div>
</div>
)
}