mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: restore missing text options and font loader, wire text tool options bar, fix e2e test selectors
This commit is contained in:
@@ -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<string>();
|
||||
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TextAttrs>) {
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center justify-center w-7 h-7 rounded transition-colors",
|
||||
active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<input
|
||||
type="number"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => {
|
||||
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<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Font family"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 h-7 px-2 rounded border border-border bg-background",
|
||||
"text-xs hover:bg-muted truncate max-w-[160px]",
|
||||
)}
|
||||
style={{ fontFamily: value }}
|
||||
>
|
||||
<Type size={12} className="shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{value}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 mt-1 z-50 w-56 max-h-72 overflow-y-auto",
|
||||
"rounded-md border border-border bg-popover shadow-md",
|
||||
)}
|
||||
>
|
||||
{/* System fonts */}
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
System Fonts
|
||||
</div>
|
||||
{fonts.system.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
onClick={() => handleSelect(name)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5 text-sm hover:bg-muted transition-colors",
|
||||
value === name && "bg-muted font-medium",
|
||||
)}
|
||||
style={{ fontFamily: name }}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="h-px bg-border my-1" />
|
||||
|
||||
{/* Google fonts */}
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Google Fonts
|
||||
</div>
|
||||
{fonts.google.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
onClick={() => handleSelect(name)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5 text-sm hover:bg-muted transition-colors",
|
||||
value === name && "bg-muted font-medium",
|
||||
)}
|
||||
style={{ fontFamily: name }}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="flex items-center gap-2 flex-wrap" data-testid="text-options">
|
||||
{/* Font family */}
|
||||
<FontDropdown
|
||||
value={attrs.fontFamily}
|
||||
onChange={(name) => updateSelected({ fontFamily: name })}
|
||||
/>
|
||||
|
||||
{/* Font size */}
|
||||
<NumberInput
|
||||
value={attrs.fontSize}
|
||||
min={1}
|
||||
max={999}
|
||||
step={1}
|
||||
label="Font size"
|
||||
onChange={(v) => updateSelected({ fontSize: v })}
|
||||
width="w-14"
|
||||
/>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Bold / Italic / Underline / Strikethrough */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ToggleButton active={isBold} onClick={toggleBold} label="Bold">
|
||||
<Bold size={14} />
|
||||
</ToggleButton>
|
||||
<ToggleButton active={isItalic} onClick={toggleItalic} label="Italic">
|
||||
<Italic size={14} />
|
||||
</ToggleButton>
|
||||
<ToggleButton active={hasUnderline} onClick={toggleUnderline} label="Underline">
|
||||
<Underline size={14} />
|
||||
</ToggleButton>
|
||||
<ToggleButton active={hasStrikethrough} onClick={toggleStrikethrough} label="Strikethrough">
|
||||
<Strikethrough size={14} />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Alignment */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ToggleButton
|
||||
active={attrs.align === "left"}
|
||||
onClick={() => updateSelected({ align: "left" })}
|
||||
label="Align left"
|
||||
>
|
||||
<AlignLeft size={14} />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={attrs.align === "center"}
|
||||
onClick={() => updateSelected({ align: "center" })}
|
||||
label="Align center"
|
||||
>
|
||||
<AlignCenter size={14} />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={attrs.align === "right"}
|
||||
onClick={() => updateSelected({ align: "right" })}
|
||||
label="Align right"
|
||||
>
|
||||
<AlignRight size={14} />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Line height */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-muted-foreground">LH</span>
|
||||
<NumberInput
|
||||
value={attrs.lineHeight}
|
||||
min={0.5}
|
||||
max={3.0}
|
||||
step={0.1}
|
||||
label="Line height"
|
||||
onChange={(v) => updateSelected({ lineHeight: v })}
|
||||
width="w-14"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Letter spacing */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-muted-foreground">LS</span>
|
||||
<NumberInput
|
||||
value={attrs.letterSpacing}
|
||||
min={-5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
label="Letter spacing"
|
||||
onChange={(v) => updateSelected({ letterSpacing: v })}
|
||||
width="w-14"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Color swatch */}
|
||||
<label className="relative flex items-center" title="Text color">
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-border cursor-pointer"
|
||||
style={{ backgroundColor: attrs.fill }}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Text color"
|
||||
value={attrs.fill}
|
||||
onChange={(e) => updateSelected({ fill: e.target.value })}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Point / Area text toggle */}
|
||||
<button
|
||||
type="button"
|
||||
title={isAreaText ? "Switch to Point Text" : "Switch to Area Text"}
|
||||
aria-label={isAreaText ? "Switch to Point Text" : "Switch to Area Text"}
|
||||
onClick={toggleTextMode}
|
||||
className={cn(
|
||||
"h-7 px-2 rounded border border-border text-xs transition-colors",
|
||||
"hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{isAreaText ? "Area" : "Point"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user