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
655 lines
22 KiB
TypeScript
655 lines
22 KiB
TypeScript
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>
|
||
)
|
||
}
|