import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { AlignCenter, AlignLeft, AlignRight, Ban, Bold, Check, ChevronDown, Italic, List, ListOrdered, Repeat2, Search, Settings, Underline, XCircle, } from "lucide-react"; import type { TextSlideElement } from "@/components/slide-editor/state/state"; import { withHash } from "@/components/slide-editor/utils/color"; import type { Font, Marker } from "@/components/slide-editor/types"; import { elementFont, mergeFont, mergeFontForTextSelection, } from "@/components/slide-editor/model/element-model"; import { ensureGoogleFontLoaded, ensureTemplateFontLoaded, GOOGLE_FONT_OPTIONS, loadGoogleFontOptions, type GoogleFontOption, type TemplateFontOption, } from "@/components/slide-editor/text/google-fonts"; import { fontForTextSelection, normalizedTextSelectionRange, textRunsContent, type TextSelectionRange, } from "@/components/slide-editor/text/text-runs"; import { DeferredColorInput } from "@/components/slide-editor/toolbar/DeferredColorInput"; import { FloatingToolbarBoundsProvider, FloatingToolbarPanel, } from "@/components/slide-editor/toolbar/FloatingToolbar"; import { ComponentActionsMenu, ComponentUngroupButton, type ComponentActionsMenuActions, } from "@/components/slide-editor/selection/ComponentActionsMenu"; import { numericInputMode, preventInvalidNumberInput, sanitizeNumericInput, } from "@/components/slide-editor/toolbar/numericInput"; const EMPTY_TEMPLATE_FONTS: TemplateFontOption[] = []; const HORIZONTAL_ALIGNMENT_ICONS = { left: AlignLeft, center: AlignCenter, right: AlignRight, }; const MIN_FONT_SIZE = 4; const MAX_FONT_SIZE = 240; const MIN_LETTER_SPACING = -200; const MAX_LETTER_SPACING = 600; const MIN_LINE_HEIGHT = 0.8; const MAX_LINE_HEIGHT = 2.2; const DEFAULT_LINE_HEIGHT = 1.15; const TEXT_TOOLBAR_FALLBACK_WIDTH = 560; const TEXT_TOOLBAR_FALLBACK_HEIGHT = 44; const TEXT_TOOLBAR_EDGE_PADDING = 8; const TEXT_TOOLBAR_GAP = 8; const FONT_MENU_OPTION_HEIGHT = 30; const FONT_MENU_MAX_VISIBLE_ROWS = 8; const FONT_MENU_OVERSCAN_ROWS = 4; type TextToolbarPanel = "marker" | "settings"; type FontPickerSource = "template" | "google"; type ToolbarSurfaceRect = { height: number; left: number; top: number; width: number; scaleX: number; scaleY: number; }; function clampFontSize(size: number) { return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, size)); } function clampMetric(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function formatToolbarFontSize(size: number) { if (!Number.isFinite(size)) return "12"; return Number.isInteger(size) ? String(size) : size.toFixed(1); } function formatLineHeight(value: number) { return value.toFixed(2).replace(/\.?0+$/, ""); } function formatSettingsLetterSpacing(value: number) { const pixels = value / 100; return pixels.toFixed(1).replace(/\.0$/, ""); } function formatOpacity(value: number) { return value.toFixed(1).replace(/\.0$/, ""); } export function TextToolbar({ element, index, anchorBox, scale, componentActions, listMarker, selectionRange, templateFonts = EMPTY_TEMPLATE_FONTS, onChange, onListMarkerChange, }: { element: TextSlideElement; index: number; anchorBox?: { x: number; y: number; width: number; height: number; } | null; scale: number; componentActions?: ComponentActionsMenuActions | null; listMarker?: Marker | null; selectionRange?: TextSelectionRange | null; templateFonts?: TemplateFontOption[]; onChange: (index: number, element: TextSlideElement) => void; onListMarkerChange?: (marker: Marker) => void; }) { const activeSelectionRange = normalizedTextSelectionRange( selectionRange, textRunsContent(element.runs).length, ); const selectedFont = fontForTextSelection(element, activeSelectionRange); const font = elementFont({ font: selectedFont ?? element.font }); const horizontalAlignment = element.alignment?.horizontal ?? "left"; const letterSpacing = font.letterSpacing ?? 0; const lineHeight = font.lineHeight ?? DEFAULT_LINE_HEIGHT; const opacity = font.opacity ?? 1; const HorizontalAlignmentIcon = HORIZONTAL_ALIGNMENT_ICONS[horizontalAlignment]; const ListMarkerIcon = listMarker === "number" ? ListOrdered : listMarker === "none" ? Ban : List; const hasListMarkerControls = listMarker != null && onListMarkerChange != null; const formattedFontSize = formatToolbarFontSize(font.size); const [openPanel, setOpenPanel] = useState(null); const [hoveredControl, setHoveredControl] = useState(null); const [fontSizeDraft, setFontSizeDraft] = useState(formattedFontSize); const [fontSizeEditing, setFontSizeEditing] = useState(false); const anchorRef = useRef(null); const toolbarRef = useRef(null); const [mounted, setMounted] = useState(false); const [toolbarWidth, setToolbarWidth] = useState( TEXT_TOOLBAR_FALLBACK_WIDTH, ); const [toolbarHeight, setToolbarHeight] = useState( TEXT_TOOLBAR_FALLBACK_HEIGHT, ); const [surfaceRect, setSurfaceRect] = useState({ height: 0, left: 0, top: 0, width: 0, scaleX: 1, scaleY: 1, }); const updateFont = (fontPatch: Partial) => { onChange( index, activeSelectionRange ? mergeFontForTextSelection(element, activeSelectionRange, fontPatch) : mergeFont(element, fontPatch), ); }; const loadFontFamily = useCallback( (family: string) => { const templateFont = templateFonts.find( (fontOption) => fontOption.family === family, ); if (templateFont) { void ensureTemplateFontLoaded(templateFont); return; } void ensureGoogleFontLoaded(family); }, [templateFonts], ); const updateFontFamily = (family: string) => { loadFontFamily(family); updateFont({ family }); }; const commitFontSize = (nextSize: number) => { if (!Number.isFinite(nextSize)) return; updateFont({ size: clampFontSize(nextSize) }); }; const updateFontSize = (value: string) => { setFontSizeDraft(value); if (!value.trim()) return; commitFontSize(Number.parseFloat(value)); }; const commitFontSizeDraft = () => { const value = fontSizeDraft.trim(); const nextSize = Number.parseFloat(value); if (!value || !Number.isFinite(nextSize)) { setFontSizeDraft(formattedFontSize); return; } const clampedSize = clampFontSize(nextSize); commitFontSize(clampedSize); setFontSizeDraft(formatToolbarFontSize(clampedSize)); }; const stepFontSize = (delta: number) => { const draftSize = Number.parseFloat(fontSizeDraft); const currentSize = Number.isFinite(draftSize) ? draftSize : Number.isFinite(font.size) ? font.size : 12; const nextSize = clampFontSize(currentSize + delta); commitFontSize(nextSize); setFontSizeDraft(formatToolbarFontSize(nextSize)); }; const fontSizeInputOptions = { allowDecimal: true, min: MIN_FONT_SIZE, }; const updateAlignment = ( alignment: NonNullable, ) => { onChange(index, { ...element, alignment: { ...(element.alignment ?? {}), ...alignment, }, }); }; const updateOpacity = (nextOpacity: number) => { if (!Number.isFinite(nextOpacity)) return; updateFont({ opacity: clampMetric(nextOpacity, 0, 1) }); }; const updateLetterSpacing = (nextLetterSpacing: number) => { if (!Number.isFinite(nextLetterSpacing)) return; updateFont({ letter_spacing: clampMetric( nextLetterSpacing, MIN_LETTER_SPACING, MAX_LETTER_SPACING, ), }); }; const updateLineHeight = (nextLineHeight: number) => { if (!Number.isFinite(nextLineHeight)) return; updateFont({ line_height: clampMetric( nextLineHeight, MIN_LINE_HEIGHT, MAX_LINE_HEIGHT, ), }); }; useEffect(() => { if (!fontSizeEditing) { setFontSizeDraft(formattedFontSize); } }, [fontSizeEditing, formattedFontSize]); useEffect(() => { loadFontFamily(font.family); }, [font.family, loadFontFamily]); useEffect(() => { setMounted(true); }, []); useEffect(() => { if (!mounted || typeof window === "undefined") return; const anchor = anchorRef.current; if (!anchor) return; const surface = anchor.closest( "[data-template-v2-konva-surface]", ); const updateMeasurements = () => { const toolbar = toolbarRef.current; if (toolbar) { const toolbarRect = toolbar.getBoundingClientRect(); const nextWidth = toolbarRect.width; const nextHeight = toolbarRect.height; if (Number.isFinite(nextWidth) && nextWidth > 0) { setToolbarWidth(nextWidth); } if (Number.isFinite(nextHeight) && nextHeight > 0) { setToolbarHeight(nextHeight); } } if (surface) { const nextSurfaceRect = surface.getBoundingClientRect(); const surfaceScaleX = surface.offsetWidth > 0 ? nextSurfaceRect.width / surface.offsetWidth : 1; const surfaceScaleY = surface.offsetHeight > 0 ? nextSurfaceRect.height / surface.offsetHeight : 1; setSurfaceRect({ height: nextSurfaceRect.height, left: nextSurfaceRect.left, top: nextSurfaceRect.top, width: nextSurfaceRect.width, scaleX: Number.isFinite(surfaceScaleX) ? surfaceScaleX : 1, scaleY: Number.isFinite(surfaceScaleY) ? surfaceScaleY : 1, }); } }; updateMeasurements(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateMeasurements); if (observer) { const toolbar = toolbarRef.current; if (toolbar) observer.observe(toolbar); if (surface) observer.observe(surface); } window.addEventListener("resize", updateMeasurements); window.addEventListener("scroll", updateMeasurements, true); return () => { observer?.disconnect(); window.removeEventListener("resize", updateMeasurements); window.removeEventListener("scroll", updateMeasurements, true); }; }, [mounted]); const viewportWidth = typeof window === "undefined" ? surfaceRect.width : window.innerWidth; const surfaceWidth = surfaceRect.width > 0 ? surfaceRect.width : viewportWidth; const anchorX = (anchorBox?.x ?? (element.position?.x ?? 0) * scale) * surfaceRect.scaleX; const anchorY = (anchorBox?.y ?? (element.position?.y ?? 0) * scale) * surfaceRect.scaleY; const preferredToolbarLeft = surfaceRect.left + anchorX; const minToolbarLeft = Math.max( TEXT_TOOLBAR_EDGE_PADDING, surfaceRect.left + TEXT_TOOLBAR_EDGE_PADDING, ); const maxToolbarLeft = Math.max( TEXT_TOOLBAR_EDGE_PADDING, Math.min( surfaceRect.left + surfaceWidth - toolbarWidth - TEXT_TOOLBAR_EDGE_PADDING, viewportWidth - toolbarWidth - TEXT_TOOLBAR_EDGE_PADDING, ), ); const toolbarLeft = Math.max( minToolbarLeft, Math.min(preferredToolbarLeft, maxToolbarLeft), ); const toolbarTop = Math.max( TEXT_TOOLBAR_EDGE_PADDING, surfaceRect.top + anchorY - toolbarHeight - TEXT_TOOLBAR_GAP, ); const toolbarBounds = surfaceRect.width > 0 && surfaceRect.height > 0 ? { bottom: surfaceRect.top + surfaceRect.height, left: surfaceRect.left, right: surfaceRect.left + surfaceRect.width, top: surfaceRect.top, } : null; const toolbarNode = (
0 ? "visible" : "hidden", }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} >
{ setFontSizeEditing(true); setFontSizeDraft(formattedFontSize); }} onBlur={() => { setFontSizeEditing(false); commitFontSizeDraft(); }} onChange={(event) => updateFontSize( sanitizeNumericInput(event.target.value, fontSizeInputOptions), ) } onKeyDown={(event) => { if (preventInvalidNumberInput(event, fontSizeInputOptions)) return; if (event.key === "ArrowUp") { event.preventDefault(); stepFontSize(1); } if (event.key === "ArrowDown") { event.preventDefault(); stepFontSize(-1); } if (event.key === "Enter") { event.preventDefault(); commitFontSizeDraft(); event.currentTarget.blur(); } if (event.key === "Escape") { event.preventDefault(); setFontSizeDraft(formattedFontSize); event.currentTarget.blur(); } }} style={textToolbarStyles.fontSizeInput} />
updateFont({ bold: !(font.bold ?? false) })} > updateFont({ italic: !(font.italic ?? false) })} > updateFont({ underline: !(font.underline ?? false) }) } > updateAlignment({ horizontal: horizontalAlignment === "left" ? "center" : horizontalAlignment === "center" ? "right" : "left", }) } >
{hasListMarkerControls ? ( <>
setOpenPanel((current) => current === "marker" ? null : "marker", ) } > {openPanel === "marker" ? ( { onListMarkerChange(marker); setOpenPanel(null); }} /> ) : null}
) : null}
setOpenPanel((current) => current === "settings" ? null : "settings", ) } > {openPanel === "settings" ? ( ) : null}
{componentActions ? ( <> {componentActions.canUngroup ? : null} ) : null}
); return ( <> {mounted ? createPortal(toolbarNode, document.body) : null} ); } function ToolbarButton({ children, controlId, hoveredControl, onClick, pressed, setHoveredControl, title, }: { children: ReactNode; controlId: string; hoveredControl: string | null; onClick?: () => void; pressed?: boolean; setHoveredControl: (control: string | null) => void; title: string; }) { const hovered = hoveredControl === controlId; return ( ); } function uniqueFontFamilies(families: string[]) { const seenFamilies = new Set(); const uniqueFamilies: string[] = []; families.forEach((family) => { if (seenFamilies.has(family)) return; seenFamilies.add(family); uniqueFamilies.push(family); }); return uniqueFamilies; } function FontFamilyPicker({ selectedFamily, templateFonts, googleFonts, onSelect, }: { selectedFamily: string; templateFonts: TemplateFontOption[]; googleFonts: GoogleFontOption[]; onSelect: (family: string) => void; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(selectedFamily); const [searching, setSearching] = useState(false); const [loadedGoogleFonts, setLoadedGoogleFonts] = useState< GoogleFontOption[] | null >(null); const [activeSource, setActiveSource] = useState(() => templateFonts.some(({ family }) => family === selectedFamily) ? "template" : "google", ); const menuRef = useRef(null); const menuPanelRef = useRef(null); const searchInputRef = useRef(null); const googleFontLoadStartedRef = useRef(false); const loadFullGoogleFonts = useCallback(() => { if (googleFontLoadStartedRef.current) return; googleFontLoadStartedRef.current = true; void loadGoogleFontOptions().then( (options) => { setLoadedGoogleFonts(options); }, () => { googleFontLoadStartedRef.current = false; }, ); }, []); useEffect(() => { if (!open) return; loadFullGoogleFonts(); setQuery(selectedFamily); setSearching(false); setActiveSource( templateFonts.some(({ family }) => family === selectedFamily) ? "template" : "google", ); window.setTimeout(() => { searchInputRef.current?.focus(); searchInputRef.current?.select(); }, 0); }, [loadFullGoogleFonts, open, selectedFamily, templateFonts]); useEffect(() => { if (!open || typeof document === "undefined") return; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (target instanceof Node && menuRef.current?.contains(target)) return; const menuPanel = menuPanelRef.current; if (menuPanel) { const rect = menuPanel.getBoundingClientRect(); if ( event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom ) { return; } } setOpen(false); }; document.addEventListener("pointerdown", handlePointerDown, true); return () => { document.removeEventListener("pointerdown", handlePointerDown, true); }; }, [open]); const selectFamily = (family: string) => { onSelect(family); setOpen(false); }; const resolvedGoogleFonts = loadedGoogleFonts ?? googleFonts; const templateFontFamilySet = useMemo( () => new Set(templateFonts.map(({ family }) => family)), [templateFonts], ); const normalizedQuery = query.trim().toLowerCase(); const hasSearchQuery = searching && normalizedQuery.length > 0; const templateFamilies = useMemo( () => templateFonts.map(({ family }) => family), [templateFonts], ); const googleFamilies = useMemo( () => resolvedGoogleFonts .filter(({ family }) => !templateFontFamilySet.has(family)) .map(({ family }) => family), [resolvedGoogleFonts, templateFontFamilySet], ); const activeFamilies = activeSource === "template" && templateFamilies.length > 0 ? templateFamilies : googleFamilies; const searchFamilies = useMemo( () => uniqueFontFamilies([...templateFamilies, ...googleFamilies]), [googleFamilies, templateFamilies], ); const visibleFamilies = useMemo( () => hasSearchQuery ? searchFamilies.filter((family) => family.toLowerCase().includes(normalizedQuery), ) : activeFamilies, [activeFamilies, hasSearchQuery, normalizedQuery, searchFamilies], ); const activeTitle = hasSearchQuery ? "All Fonts" : activeSource === "template" && templateFamilies.length > 0 ? "Template Fonts" : "Google Fonts"; const swapFontSource = () => { setSearching(false); setQuery(selectedFamily); setActiveSource((current) => { if (current === "template") return "google"; return templateFamilies.length > 0 ? "template" : "google"; }); window.setTimeout(() => { searchInputRef.current?.focus(); searchInputRef.current?.select(); }, 0); }; return (
{ const target = event.target; if ( target instanceof HTMLElement && target.closest("[data-font-search-input='true']") ) { return; } event.preventDefault(); }} > {open ? ( event.stopPropagation()} onScroll={(event) => event.stopPropagation()} >
); } function FontMenuSection({ title, families, selectedFamily, onSelect, onSwap, }: { title: string; families: string[]; selectedFamily: string; onSelect: (family: string) => void; onSwap: () => void; }) { const optionsRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const viewportRows = Math.min(families.length, FONT_MENU_MAX_VISIBLE_ROWS); const viewportHeight = Math.max(1, viewportRows) * FONT_MENU_OPTION_HEIGHT; const firstVisibleIndex = Math.max( 0, Math.floor(scrollTop / FONT_MENU_OPTION_HEIGHT) - FONT_MENU_OVERSCAN_ROWS, ); const visibleOptionCount = viewportRows + FONT_MENU_OVERSCAN_ROWS * 2 + 1; const virtualFamilies = families.slice( firstVisibleIndex, firstVisibleIndex + visibleOptionCount, ); useEffect(() => { setScrollTop(0); if (optionsRef.current) { optionsRef.current.scrollTop = 0; } }, [families]); return (
{title}
setScrollTop(event.currentTarget.scrollTop)} > {families.length === 0 ? (
No fonts
) : (
{virtualFamilies.map((family, offset) => { const familyIndex = firstVisibleIndex + offset; const selected = family === selectedFamily; return ( ); })}
)}
); } function Divider() { return