From 8208acfb78108c3dbc5fede7df3b114a5a10c5e1 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Thu, 7 May 2026 19:13:36 +0800 Subject: [PATCH] fix: restore missing text options and font loader, wire text tool options bar, fix e2e test selectors --- .../components/editor/common/font-loader.ts | 81 ++ .../components/editor/editor-options-bar.tsx | 8 +- .../editor/options/text-options.tsx | 415 ++++++++++ tests/e2e-editor/editor-full-gui-test.spec.ts | 712 ++++++++++++++++++ 4 files changed, 1213 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/editor/common/font-loader.ts create mode 100644 apps/web/src/components/editor/options/text-options.tsx create mode 100644 tests/e2e-editor/editor-full-gui-test.spec.ts diff --git a/apps/web/src/components/editor/common/font-loader.ts b/apps/web/src/components/editor/common/font-loader.ts new file mode 100644 index 00000000..fb743909 --- /dev/null +++ b/apps/web/src/components/editor/common/font-loader.ts @@ -0,0 +1,81 @@ +// apps/web/src/components/editor/common/font-loader.ts + +const SYSTEM_FONTS = [ + "Arial", + "Helvetica", + "Georgia", + "Times New Roman", + "Verdana", + "Courier New", + "Trebuchet MS", + "Impact", + "Comic Sans MS", +] as const; + +const GOOGLE_FONTS = [ + "Inter", + "Roboto", + "Open Sans", + "Lato", + "Montserrat", + "Poppins", + "Source Sans 3", + "Playfair Display", + "Merriweather", + "Raleway", + "Oswald", + "Nunito", + "Ubuntu", + "PT Sans", + "Fira Sans", + "Work Sans", + "Barlow", + "DM Sans", + "Space Grotesk", + "Bebas Neue", + "Caveat", + "Pacifico", + "Dancing Script", + "Permanent Marker", + "Press Start 2P", +] as const; + +const loadedFonts = new Set(); + +export function isSystemFont(name: string): boolean { + return (SYSTEM_FONTS as readonly string[]).includes(name); +} + +export function getAllFonts(): { system: string[]; google: string[] } { + return { + system: [...SYSTEM_FONTS], + google: [...GOOGLE_FONTS], + }; +} + +export async function loadGoogleFont(name: string): Promise { + if (isSystemFont(name) || loadedFonts.has(name)) return; + + const slug = name.replace(/ /g, "+"); + const url = `https://fonts.googleapis.com/css2?family=${slug}:wght@400;700&display=swap`; + + // Add the stylesheet link so the browser fetches the font files + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + document.head.appendChild(link); + + // Use the CSS Font Loading API to detect when the font is actually ready + try { + await document.fonts.load(`16px "${name}"`); + loadedFonts.add(name); + } catch { + // Font may still load via the stylesheet even if the API rejects; + // mark as loaded so we don't retry endlessly. + loadedFonts.add(name); + } +} + +export function isFontLoaded(name: string): boolean { + return isSystemFont(name) || loadedFonts.has(name); +} diff --git a/apps/web/src/components/editor/editor-options-bar.tsx b/apps/web/src/components/editor/editor-options-bar.tsx index 0f0c451f..eb1af6ec 100644 --- a/apps/web/src/components/editor/editor-options-bar.tsx +++ b/apps/web/src/components/editor/editor-options-bar.tsx @@ -12,6 +12,7 @@ import { MoveOptions } from "./options/move-options"; import { PixelBrushOptions } from "./options/pixel-brush-options"; import { SelectionOptions } from "./options/selection-options"; import { ShapeOptions } from "./options/shape-options"; +import { TextOptions } from "./options/text-options"; function getOptionsComponent(tool: ToolType): React.ComponentType | null { switch (tool) { @@ -50,11 +51,12 @@ function getOptionsComponent(tool: ToolType): React.ComponentType | null { case "shape-polygon": case "shape-star": return ShapeOptions; + case "text": + return TextOptions; + case "transform": + case "eyedropper": case "hand": case "zoom": - case "eyedropper": - case "text": - case "transform": return null; default: return null; diff --git a/apps/web/src/components/editor/options/text-options.tsx b/apps/web/src/components/editor/options/text-options.tsx new file mode 100644 index 00000000..33e26dbe --- /dev/null +++ b/apps/web/src/components/editor/options/text-options.tsx @@ -0,0 +1,415 @@ +// apps/web/src/components/editor/options/text-options.tsx + +import { + AlignCenter, + AlignLeft, + AlignRight, + Bold, + Italic, + Strikethrough, + Type, + Underline, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { TextAttrs } from "@/types/editor"; +import { getAllFonts, isSystemFont, loadGoogleFont } from "../common/font-loader"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getSelectedTextAttrs(): TextAttrs | null { + const { selectedObjectIds, objects } = useEditorStore.getState(); + if (selectedObjectIds.length !== 1) return null; + const obj = objects.find((o) => o.id === selectedObjectIds[0] && o.type === "text"); + return obj ? (obj.attrs as TextAttrs) : null; +} + +function updateSelected(partial: Partial) { + const { selectedObjectIds } = useEditorStore.getState(); + for (const id of selectedObjectIds) { + useEditorStore.getState().updateObject(id, partial); + } +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function ToggleButton({ + active, + onClick, + label, + children, +}: { + active: boolean; + onClick: () => void; + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function NumberInput({ + value, + min, + max, + step, + label, + onChange, + width = "w-16", +}: { + value: number; + min: number; + max: number; + step: number; + label: string; + onChange: (v: number) => void; + width?: string; +}) { + return ( + { + const n = Number.parseFloat(e.target.value); + if (!Number.isNaN(n)) onChange(Math.max(min, Math.min(max, n))); + }} + className={cn( + "h-7 rounded border border-border bg-background px-1.5 text-xs text-center", + "focus:outline-none focus:ring-1 focus:ring-ring", + width, + )} + /> + ); +} + +// --------------------------------------------------------------------------- +// Font Dropdown +// --------------------------------------------------------------------------- + +function FontDropdown({ value, onChange }: { value: string; onChange: (name: string) => void }) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const fonts = useMemo(() => getAllFonts(), []); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const handleSelect = useCallback( + async (name: string) => { + if (!isSystemFont(name)) { + await loadGoogleFont(name); + } + onChange(name); + setOpen(false); + }, + [onChange], + ); + + return ( +
+ + + {open && ( +
+ {/* System fonts */} +
+ System Fonts +
+ {fonts.system.map((name) => ( + + ))} + +
+ + {/* Google fonts */} +
+ Google Fonts +
+ {fonts.google.map((name) => ( + + ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Component +// --------------------------------------------------------------------------- + +export function TextOptions() { + const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); + const objects = useEditorStore((s) => s.objects); + + // Derive current attrs from the selected text object + const attrs = useMemo(() => { + if (selectedObjectIds.length !== 1) return null; + const obj = objects.find((o) => o.id === selectedObjectIds[0] && o.type === "text"); + return obj ? (obj.attrs as TextAttrs) : null; + }, [selectedObjectIds, objects]); + + if (!attrs) return null; + + const isBold = attrs.fontStyle.includes("bold"); + const isItalic = attrs.fontStyle.includes("italic"); + const hasUnderline = attrs.textDecoration.includes("underline"); + const hasStrikethrough = attrs.textDecoration.includes("line-through"); + const isAreaText = attrs.wrap !== undefined; + + const toggleBold = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const wasBold = current.fontStyle.includes("bold"); + const parts = current.fontStyle.split(" ").filter((p) => p !== "bold" && p !== ""); + if (!wasBold) parts.push("bold"); + updateSelected({ fontStyle: parts.join(" ") || "normal" }); + }; + + const toggleItalic = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const wasItalic = current.fontStyle.includes("italic"); + const parts = current.fontStyle.split(" ").filter((p) => p !== "italic" && p !== ""); + if (!wasItalic) parts.push("italic"); + updateSelected({ fontStyle: parts.join(" ") || "normal" }); + }; + + const toggleUnderline = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const had = current.textDecoration.includes("underline"); + const parts = current.textDecoration.split(" ").filter((p) => p !== "underline" && p !== ""); + if (!had) parts.push("underline"); + updateSelected({ textDecoration: parts.join(" ") }); + }; + + const toggleStrikethrough = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + const had = current.textDecoration.includes("line-through"); + const parts = current.textDecoration.split(" ").filter((p) => p !== "line-through" && p !== ""); + if (!had) parts.push("line-through"); + updateSelected({ textDecoration: parts.join(" ") }); + }; + + const toggleTextMode = () => { + const current = getSelectedTextAttrs(); + if (!current) return; + if (current.wrap !== undefined) { + // Switch to point text: remove width/height/wrap + updateSelected({ + width: undefined, + height: undefined, + wrap: undefined, + }); + } else { + // Switch to area text + updateSelected({ + width: 200, + height: 100, + wrap: "word", + }); + } + }; + + return ( +
+ {/* Font family */} + updateSelected({ fontFamily: name })} + /> + + {/* Font size */} + updateSelected({ fontSize: v })} + width="w-14" + /> + +
+ + {/* Bold / Italic / Underline / Strikethrough */} +
+ + + + + + + + + + + + +
+ +
+ + {/* Alignment */} +
+ updateSelected({ align: "left" })} + label="Align left" + > + + + updateSelected({ align: "center" })} + label="Align center" + > + + + updateSelected({ align: "right" })} + label="Align right" + > + + +
+ +
+ + {/* Line height */} +
+ LH + updateSelected({ lineHeight: v })} + width="w-14" + /> +
+ + {/* Letter spacing */} +
+ LS + updateSelected({ letterSpacing: v })} + width="w-14" + /> +
+ +
+ + {/* Color swatch */} +