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 ( 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} ) } const Divider = () => ( ) // 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(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 ( {trigger} {open && ( {children} )} ) } // 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 ( 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(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) => { const file = e.target.files?.[0] if (file) uploadImageInto(editor.view, file) e.target.value = '' // allow re-picking the same file } return ( editor.chain().focus().undo().run()}> ↶ editor.chain().focus().redo().run()}> ↷ editor.chain().focus().toggleBold().run()}> B editor.chain().focus().toggleItalic().run()}> I editor.chain().focus().toggleUnderline().run()} > U editor.chain().focus().toggleStrike().run()}> S {/* Text color */} setMenu(menu === 'color' ? null : 'color')}> A } > {TEXT_COLORS.map((c) => ( { if (c.value) editor.chain().focus().setColor(c.value).run() else editor.chain().focus().unsetColor().run() close() }} /> ))} {/* Highlight */} setMenu(menu === 'highlight' ? null : 'highlight')} > H } > {HIGHLIGHTS.map((c) => ( { if (c.value) editor.chain().focus().setHighlight({ color: c.value }).run() else editor.chain().focus().unsetHighlight().run() close() }} /> ))} {/* Font size */} setMenu(menu === 'size' ? null : 'size')}> A A } > {SIZES.map((s) => { const active = state.fontSize === s.value return ( 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)', }} > {s.label} ) })} editor.chain().focus().toggleHeading({ level: 1 }).run()}> H1 editor.chain().focus().toggleHeading({ level: 2 }).run()}> H2 editor.chain().focus().toggleHeading({ level: 3 }).run()}> H3 editor.chain().focus().toggleBulletList().run()}> • editor.chain().focus().toggleOrderedList().run()}> 1. editor.chain().focus().setTextAlign('left').run()}> ⇤ editor.chain().focus().setTextAlign('center').run()}> ↔ editor.chain().focus().setTextAlign('right').run()}> ⇥ {/* Link */} 🔗 } > 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)', }} /> {state.link ? ( 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 ) : ( )} 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 {/* Image */} fileInputRef.current?.click()}> 🖼️ {/* Table */} setMenu(menu === 'table' ? null : 'table')}> ▦ } > {state.inTable ? ( editor.chain().focus().addRowAfter().run()} /> editor.chain().focus().addColumnAfter().run()} /> editor.chain().focus().toggleHeaderRow().run()} /> editor.chain().focus().deleteRow().run()} /> editor.chain().focus().deleteColumn().run()} /> { editor.chain().focus().deleteTable().run() close() }} /> ) : ( { editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run() close() }} /> )} {/* Outline / document map */} setMenu(menu === 'outline' ? null : 'outline')}> ☰ } > 大纲 · Outline {headings.length === 0 ? ( 用 H1/H2/H3 添加标题,这里就会出现导航。 Add headings to navigate them here. ) : ( {headings.map((h, i) => ( 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} ))} )} 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" > {voicing ? 'Reading…' : 'Check my voice 🍯'} ) } // A row in the in-table editing menu. function TableAction({ label, onClick, danger }: { label: string; onClick: () => void; danger?: boolean }) { return ( e.preventDefault()} onClick={onClick} className="rounded px-2 py-1 text-left" style={{ color: danger ? 'var(--color-accent-hover)' : 'var(--color-plum)' }} > {label} ) } // 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 ( 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 ( 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)', }} /> ) })} {hover.r > 0 ? `${hover.r} × ${hover.c}` : 'Pick a size'} ) }
大纲 · Outline
用 H1/H2/H3 添加标题,这里就会出现导航。 Add headings to navigate them here.