'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 */}
{/* 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');
}