'use client'; import { useCallback, useEffect, useState } from 'react'; import { Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, List, Minus, Plus, } from 'lucide-react'; import { FONTS } from '@/configs/font'; import type { TextAttrs } from '@/lib/prosemirror/utils'; import { runActiveTextCommand, type TextCommandPayload, } from '@/lib/prosemirror/active-editor-registry'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { useCanvasStore } from '@/lib/store/canvas'; import { useI18n } from '@/lib/hooks/use-i18n'; import { ColorPicker } from './ColorPicker'; interface TextFormatBarProps { readonly elementId: string; readonly attrs: TextAttrs; } // Radix Select forbids an empty-string item value, but the canonical "default // font" in the FONTS registry IS the empty string. It rides through the Select // under this sentinel and is mapped back to '' at the command edge. const DEFAULT_FONT = '__default__'; interface ToggleButtonProps { readonly label: string; readonly active: boolean; readonly payload: TextCommandPayload; readonly run: (payload: TextCommandPayload) => void; readonly children: React.ReactNode; } // preventDefault on mousedown keeps ProseMirror focused so the command lands on // the live element. The Select and the color deliberately skip it — // they own their own focus. function BarButton({ label, onClick, className, children, }: { readonly label: string; readonly onClick: () => void; readonly className?: string; readonly children: React.ReactNode; }) { return ( ); } function ToggleButton({ label, active, payload, run, children }: ToggleButtonProps) { return ( run(payload)} className={`flex h-8 w-8 items-center justify-center rounded-md transition-colors ${ active ? 'bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300' : 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100' }`} > {children} ); } function Divider() { return
; } // Font-size bounds, shared by the size input commit and the −/+ stepper so the // two clamp paths can never drift apart. export const FONT_SIZE_MIN = 8; export const FONT_SIZE_MAX = 96; // Subtle raised −/+ button inside the size stepper pill. const STEP_BUTTON = 'flex h-7 w-7 items-center justify-center rounded text-zinc-600 transition-colors ' + 'hover:bg-white hover:text-zinc-900 hover:shadow-sm ' + 'dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-100'; export function TextFormatBar({ elementId, attrs }: TextFormatBarProps) { const { t } = useI18n(); const run = useCallback( (payload: TextCommandPayload) => runActiveTextCommand(elementId, payload), [elementId], ); const fontSize = parseInt(attrs.fontsize, 10) || 16; // Local mirror so the user can type freely; only commits on Enter / blur. // The effect re-syncs from `attrs.fontsize` whenever it changes externally // (+/- buttons, undo, font-attr resync) — `attrs.fontsize` doesn't change // mid-type, so this doesn't clobber the user's partial input. const [sizeInput, setSizeInput] = useState(String(fontSize)); useEffect(() => { setSizeInput(String(fontSize)); }, [fontSize]); const commitSize = useCallback(() => { const n = parseInt(sizeInput, 10); if (Number.isNaN(n)) { setSizeInput(String(fontSize)); return; } const clamped = Math.max(FONT_SIZE_MIN, Math.min(FONT_SIZE_MAX, n)); if (clamped !== fontSize) run({ command: 'fontsize', value: `${clamped}px` }); setSizeInput(String(clamped)); }, [sizeInput, fontSize, run]); const [colorOpen, setColorOpen] = useState(false); return ( // w-max keeps the row at its natural width so the popover (w-auto) sizes to // it — one clean line, nothing squished.
{/* Font — design-system Select; options come from the FONTS registry (configs/font.ts), scoped to fonts the app actually loads. */} {/* Font size — one cohesive stepper pill */}
run({ command: 'fontsize', value: stepFontSize(attrs.fontsize, -2) })} className={STEP_BUTTON} > setSizeInput(e.target.value.replace(/\D/g, ''))} onBlur={commitSize} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); else if (e.key === 'Escape') { setSizeInput(String(fontSize)); e.currentTarget.blur(); } }} className="w-9 bg-transparent text-center text-xs font-semibold tabular-nums text-zinc-800 outline-none focus:bg-white dark:text-zinc-100 dark:focus:bg-zinc-700" /> run({ command: 'fontsize', value: stepFontSize(attrs.fontsize, 2) })} className={STEP_BUTTON} >
{/* Text color — curated palette + hex input in a popover, replacing the OS color dialog. preventDefault on mousedown so opening the popover doesn't steal focus from ProseMirror. */} e.preventDefault()} onFocusOutside={(e) => e.preventDefault()} > run({ command: 'forecolor', value: c })} onCommit={(c) => { run({ command: 'forecolor', value: c }); setColorOpen(false); }} />
); } /** * Connected variant — subscribes to live richTextAttrs from the canvas store. * Keep separate from TextFormatBar so the pure component stays unit-testable. */ export function ConnectedTextFormatBar({ elementId }: { readonly elementId: string }) { const attrs = useCanvasStore.use.richTextAttrs(); return ; } export function stepFontSize(current: string, delta: number): string { const n = parseInt(current, 10) || 16; return `${Math.max(FONT_SIZE_MIN, Math.min(FONT_SIZE_MAX, n + delta))}px`; } /** * Display label for the current font: the registry entry's i18n label / * fallback label when matched, the raw family name when unmatched (legacy * theme fonts like `Microsoft YaHei` aren't in the curated FONTS), the * default-font label when empty. */ export function currentFontLabel(fontname: string, t: (k: string) => string): string { const matched = FONTS.find((f) => f.value === fontname); if (matched) return matched.labelKey ? t(matched.labelKey) : matched.label; return fontname || t('edit.text.fontDefault'); }