diff --git a/.gitignore b/.gitignore index 25c92e5..dfdfb9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +.yarn/ +package-lock.json bun.lockb *.tsbuildinfo diff --git a/README.md b/README.md index 0b5d85c..0b4b488 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ ## Features **Web Editor** — [try it now](https://tomladder.github.io/thermoprint/) -- Drag & drop label designer with text, images, QR codes, barcodes, and shapes +- Drag & drop label designer with text, images, icons, QR codes, barcodes, and shapes +- Iconify icon browser (`C` shortcut) supporting 200+ sets (>200,000 vector icons) - Resize, rotate, and align elements visually on a Konva.js canvas - Live-preview with gap/continuous paper simulation and ghost labels - Print directly from the browser via Web Bluetooth (Chrome/Edge) diff --git a/bun.lock b/bun.lock index 22af5ec..c94af5e 100644 --- a/bun.lock +++ b/bun.lock @@ -826,7 +826,7 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@thermoprint/core/@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@thermoprint/core/@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/qrcode/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], @@ -892,7 +892,7 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], - "@thermoprint/core/@types/bun/bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "@thermoprint/core/@types/bun/bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "@types/qrcode/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/packages/web/src/editor/canvas/elements/element-wrapper.tsx b/packages/web/src/editor/canvas/elements/element-wrapper.tsx index 63f6373..54e6c85 100644 --- a/packages/web/src/editor/canvas/elements/element-wrapper.tsx +++ b/packages/web/src/editor/canvas/elements/element-wrapper.tsx @@ -15,9 +15,10 @@ const ACCENT_MAP: Record = { interface Props { nodeRef: React.RefObject; isSelected: boolean; + deps?: unknown[]; } -export function ElementWrapper({ nodeRef, isSelected }: Props) { +export function ElementWrapper({ nodeRef, isSelected, deps = [] }: Props) { const trRef = useRef(null); const theme = useEditorV2Store((s) => s.theme); const { accent, accent600 } = ACCENT_MAP[theme] || ACCENT_MAP.cyan; @@ -27,7 +28,7 @@ export function ElementWrapper({ nodeRef, isSelected }: Props) { trRef.current.nodes([nodeRef.current]); trRef.current.getLayer()?.batchDraw(); } - }, [isSelected, nodeRef, accent]); + }, [isSelected, nodeRef, accent, nodeRef.current, ...deps]); if (!isSelected) return null; diff --git a/packages/web/src/editor/canvas/elements/image-element.tsx b/packages/web/src/editor/canvas/elements/image-element.tsx index b28de3d..2920111 100644 --- a/packages/web/src/editor/canvas/elements/image-element.tsx +++ b/packages/web/src/editor/canvas/elements/image-element.tsx @@ -23,7 +23,12 @@ export function ImageElement({ element, isSelected }: Props) { if (!p.src) return; const img = new window.Image(); img.src = p.src; - img.onload = () => setImage(img); + img.onload = () => { + setImage(img); + if (ref.current) { + ref.current.getLayer()?.batchDraw(); + } + }; }, [p.src]); if (!image) { @@ -61,7 +66,11 @@ export function ImageElement({ element, isSelected }: Props) { listening={false} /> - + ); } @@ -99,7 +108,11 @@ export function ImageElement({ element, isSelected }: Props) { }); }} /> - + ); } diff --git a/packages/web/src/editor/canvas/elements/text-element.tsx b/packages/web/src/editor/canvas/elements/text-element.tsx index 44cbce7..358a19c 100644 --- a/packages/web/src/editor/canvas/elements/text-element.tsx +++ b/packages/web/src/editor/canvas/elements/text-element.tsx @@ -27,6 +27,7 @@ export function TextElement({ element, isSelected }: Props) { fill?: string; align?: string; italic?: boolean; + uppercase?: boolean; }; const fontStyle = @@ -154,7 +155,7 @@ export function TextElement({ element, isSelected }: Props) { y={element.y} width={element.width} rotation={element.rotation} - text={p.text || "Text"} + text={p.uppercase ? (p.text || "Text").toUpperCase() : (p.text || "Text")} fontSize={p.fontSize || 18} fontFamily={p.fontFamily || "Inter"} fontStyle={fontStyle} @@ -189,7 +190,22 @@ export function TextElement({ element, isSelected }: Props) { }); }} /> - {!isEditing && } + {!isEditing && ( + + )} ); } diff --git a/packages/web/src/editor/dock/dock.tsx b/packages/web/src/editor/dock/dock.tsx index cb83c72..ea4db63 100644 --- a/packages/web/src/editor/dock/dock.tsx +++ b/packages/web/src/editor/dock/dock.tsx @@ -4,6 +4,7 @@ import { QrCode, Barcode, ImageIcon, + Sticker, Square, Minus, Layers, @@ -15,6 +16,7 @@ import { DockBtn } from "./dock-btn.tsx"; import { DockGroup } from "./dock-group.tsx"; import { DockDivider } from "./dock-divider.tsx"; import { Kbd } from "./kbd.tsx"; +import { useEditorV2Store } from "../../store/editor-store.ts"; import { addTextEl, addQrEl, @@ -26,19 +28,33 @@ import { import { LayersFlyout } from "./flyouts/layers-flyout.tsx"; import { LibraryFlyout } from "./flyouts/library-flyout.tsx"; import { PrintSettingsFlyout } from "./flyouts/print-settings-flyout.tsx"; +import { IconsFlyout } from "./flyouts/icons-flyout.tsx"; -type FlyoutKey = "layers" | "library" | "print" | "more-tools" | null; +type FlyoutKey = "layers" | "library" | "print" | "icons" | "more-tools" | null; + +interface ReplaceIconDetail { + initialPrefix?: string | null; + targetElementId?: string | null; +} export function Dock() { const [openFlyout, setOpenFlyout] = useState(null); + const [replaceDetail, setReplaceDetail] = useState( + null + ); - const toggle = (key: Exclude) => + const toggle = (key: Exclude) => { + setReplaceDetail(null); setOpenFlyout((cur) => (cur === key ? null : key)); + }; // Close flyout on Escape useEffect(() => { const h = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpenFlyout(null); + if (e.key === "Escape") { + setOpenFlyout(null); + setReplaceDetail(null); + } }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); @@ -51,12 +67,27 @@ export function Dock() { return () => window.removeEventListener("thermoprint:open-library", h); }, []); + // Listen for "open icons" & "replace icon" events + useEffect(() => { + const handleOpenIcons = (e: Event) => { + const customEvent = e as CustomEvent; + setReplaceDetail(customEvent.detail || null); + setOpenFlyout("icons"); + }; + window.addEventListener("thermoprint:open-icons", handleOpenIcons); + return () => + window.removeEventListener("thermoprint:open-icons", handleOpenIcons); + }, []); + return ( <> {openFlyout && (
setOpenFlyout(null)} + onClick={() => { + setOpenFlyout(null); + setReplaceDetail(null); + }} /> )} {openFlyout === "layers" && ( @@ -68,6 +99,16 @@ export function Dock() { {openFlyout === "print" && ( setOpenFlyout(null)} /> )} + {openFlyout === "icons" && ( + { + setOpenFlyout(null); + setReplaceDetail(null); + }} + initialPrefix={replaceDetail?.initialPrefix} + targetElementId={replaceDetail?.targetElementId} + /> + )} {openFlyout === "more-tools" && (
@@ -76,12 +117,16 @@ export function Dock() { { icon: QrCode, label: "QR Code", fn: addQrEl }, { icon: Barcode, label: "Barcode", fn: addBarcodeEl }, { icon: ImageIcon, label: "Image", fn: addImageEl }, + { icon: Sticker, label: "Icons", fn: () => setOpenFlyout("icons") }, { icon: Square, label: "Rectangle", fn: addRectEl }, { icon: Minus, label: "Line", fn: addLineEl }, ].map((t) => (
- {/* Mobile: Text + Image + More */} + {/* Mobile: Text + Image + Icons + More */}
+ toggle("icons")} + active={openFlyout === "icons"} + /> ⌘+scroll zoom - +
diff --git a/packages/web/src/editor/dock/flyouts/icons-flyout.tsx b/packages/web/src/editor/dock/flyouts/icons-flyout.tsx new file mode 100644 index 0000000..3229343 --- /dev/null +++ b/packages/web/src/editor/dock/flyouts/icons-flyout.tsx @@ -0,0 +1,689 @@ +import { useState, useEffect, useRef, useMemo } from "react"; +import { + Search, + X, + Sticker, + Loader2, + ArrowLeft, + ChevronRight, + Sparkles, + Grid, + Layers, +} from "lucide-react"; +import { + searchIcons, + fetchIconAsBlackDataUrl, + fetchCollectionDetails, + fetchAllCollectionsGrouped, + POPULAR_COLLECTIONS, + type CollectionDetails, + type AllCollectionItem, +} from "../../../lib/iconify.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; + +function uid() { + return Math.random().toString(36).substring(2, 9); +} + +interface Props { + onClose: () => void; + initialPrefix?: string | null; + targetElementId?: string | null; +} + +const PAGE_SIZE = 64; + +export function IconsFlyout({ + onClose, + initialPrefix = null, + targetElementId = null, +}: Props) { + const [query, setQuery] = useState(""); + const [activePrefix, setActivePrefix] = useState( + initialPrefix ?? null + ); + + // View state tracking + const [mainScreenView, setMainScreenView] = useState<"popular" | "all">("popular"); + const [fromView, setFromView] = useState<"popular" | "all">("popular"); + + // Filter states inside collection + const [selectedCategory, setSelectedCategory] = useState("all"); + const [selectedVariant, setSelectedVariant] = useState("all"); + + const [loading, setLoading] = useState(false); + const [insertingIcon, setInsertingIcon] = useState(null); + + // All collections grouped state + const [allGroupedCollections, setAllGroupedCollections] = useState< + Record + >({}); + const [collectionsMap, setCollectionsMap] = useState< + Record + >({}); + const [allCollectionsLoading, setAllCollectionsLoading] = useState(false); + + // Global search state + const [searchResults, setSearchResults] = useState<{ + icons: string[]; + collections: Record; + }>({ icons: [], collections: {} }); + + // Collection detail state + const [collectionDetails, setCollectionDetails] = + useState(null); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + const addElement = useEditorV2Store((s) => s.addElement); + const updateElement = useEditorV2Store((s) => s.updateElement); + const elements = useEditorV2Store((s) => s.elements); + const label = useEditorV2Store((s) => s.label); + + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Fetch all collections metadata on mount so sample icons are dynamically available + useEffect(() => { + if (Object.keys(collectionsMap).length === 0) { + setAllCollectionsLoading(true); + fetchAllCollectionsGrouped().then((res) => { + setAllGroupedCollections(res.grouped); + setCollectionsMap(res.collectionsMap); + setAllCollectionsLoading(false); + }); + } + }, [collectionsMap]); + + // Fetch collection details when activePrefix changes + useEffect(() => { + if (!activePrefix) { + setCollectionDetails(null); + setSelectedCategory("all"); + setSelectedVariant("all"); + return; + } + + setLoading(true); + fetchCollectionDetails(activePrefix).then((details) => { + setCollectionDetails(details); + setSelectedCategory("all"); + setSelectedVariant("all"); + setVisibleCount(PAGE_SIZE); + setLoading(false); + }); + }, [activePrefix]); + + // Global Search Debouncer (only when activePrefix is null and query is not empty) + useEffect(() => { + if (activePrefix || !query.trim()) { + if (!activePrefix) setSearchResults({ icons: [], collections: {} }); + return; + } + + setLoading(true); + const timer = setTimeout(() => { + searchIcons(query, undefined, 100).then((res) => { + setSearchResults({ icons: res.icons, collections: res.collections }); + setLoading(false); + }); + }, 250); + + return () => clearTimeout(timer); + }, [query, activePrefix]); + + // Reset pagination on filter changes + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [query, selectedCategory, selectedVariant]); + + const handleOpenCollection = (prefix: string) => { + setFromView(mainScreenView); + setActivePrefix(prefix); + }; + + const handleBackToCollections = () => { + setActivePrefix(null); + setSelectedCategory("all"); + setSelectedVariant("all"); + }; + + // Compute collection icons with Category + Dynamic Variant + Query filtering + const collectionIcons = useMemo(() => { + if (!activePrefix || !collectionDetails) return []; + + let list: string[] = []; + + if (selectedCategory === "all") { + const catList = Object.values(collectionDetails.categories).flat(); + list = Array.from( + new Set([...catList, ...collectionDetails.uncategorized]) + ); + } else if (collectionDetails.categories[selectedCategory]) { + list = collectionDetails.categories[selectedCategory]; + } + + let formattedList = list.map((item) => + item.includes(":") ? item : `${activePrefix}:${item}` + ); + + // Apply Dynamic Variant Filter + if (selectedVariant !== "all") { + const targetSuffix = selectedVariant.toLowerCase().replace(/\s+/g, "-"); + formattedList = formattedList.filter((icon) => { + const lower = icon.toLowerCase(); + return ( + lower.endsWith(`-${targetSuffix}`) || lower.endsWith(`_${targetSuffix}`) + ); + }); + } + + // Apply Query Filter + if (query.trim()) { + const q = query.trim().toLowerCase(); + return formattedList.filter((item) => item.toLowerCase().includes(q)); + } + + return formattedList; + }, [activePrefix, collectionDetails, selectedCategory, selectedVariant, query]); + + const handleSelectIcon = async (iconName: string, prefix: string) => { + if (insertingIcon) return; + setInsertingIcon(iconName); + + try { + const dataUrl = await fetchIconAsBlackDataUrl(iconName); + const collName = + collectionDetails?.title || + searchResults.collections[prefix]?.name || + prefix; + + const img = new Image(); + img.src = dataUrl; + await new Promise((resolve) => { + img.onload = resolve; + img.onerror = resolve; + }); + + const maxW = Math.min(200, label.widthPx * 0.8); + const maxH = Math.min(200, label.heightPx * 0.8); + let w = img.naturalWidth || 64; + let h = img.naturalHeight || 64; + + if (w > maxW || h > maxH) { + const ratio = Math.min(maxW / w, maxH / h); + w = Math.round(w * ratio); + h = Math.round(h * ratio); + } + + if (targetElementId) { + const targetEl = elements.find((e) => e.id === targetElementId); + if (targetEl) { + updateElement(targetElementId, { + width: w, + height: h, + props: { + ...targetEl.props, + src: dataUrl, + iconName, + collection: prefix, + collectionName: collName, + }, + }); + } + } else { + addElement({ + id: uid(), + type: "image", + x: Math.round((label.widthPx - w) / 2), + y: Math.round((label.heightPx - h) / 2), + width: w, + height: h, + rotation: 0, + props: { + src: dataUrl, + iconName, + collection: prefix, + collectionName: collName, + naturalWidth: img.naturalWidth || w, + naturalHeight: img.naturalHeight || h, + }, + }); + } + + onClose(); + } catch (err) { + console.error("Failed to insert icon:", err); + } finally { + setInsertingIcon(null); + } + }; + + const categoriesList = useMemo(() => { + if (!collectionDetails?.categories) return []; + return Object.keys(collectionDetails.categories); + }, [collectionDetails]); + + const variantsList = useMemo(() => { + return collectionDetails?.variants || []; + }, [collectionDetails]); + + const isHomeView = !activePrefix && !query.trim(); + const isCollectionView = Boolean(activePrefix); + + const displayedIcons = isCollectionView + ? collectionIcons.slice(0, visibleCount) + : searchResults.icons; + + return ( +
+ {/* Header */} +
+
+
+ {isCollectionView ? ( + + ) : ( + + )} + + {isCollectionView && ( + / + )} + + + {targetElementId + ? "Replace Icon" + : isCollectionView + ? collectionDetails?.title || activePrefix + : "Icon Search"} + + + {isCollectionView && collectionDetails && ( + + ({collectionDetails.total.toLocaleString()} icons) + + )} +
+ +
+ + Powered by{" "} + + Iconify + + + +
+
+ + {/* Row 2: Search Bar + Category Dropdown (if present) */} +
+
+ + setQuery(e.target.value)} + placeholder={ + isCollectionView + ? `Search inside ${collectionDetails?.title || "collection"}...` + : "Search icons (e.g. clock, box, warning, arrow)..." + } + className="bg-transparent text-ui-sm text-ink-100 placeholder-ink-500 outline-none flex-1 min-w-0" + /> + {query && ( + + )} +
+ + {/* Category Dropdown (Only rendered if collection has categories) */} + {isCollectionView && categoriesList.length > 0 && ( + + )} +
+ + {/* Row 3: Collection-Specific Dynamic Variant Pills (Only rendered if collection has multiple variants) */} + {isCollectionView && variantsList.length > 1 && ( +
+ + Style: + + + + {variantsList.map((variant) => ( + + ))} +
+ )} +
+ + {/* Main Content Area */} +
+ {/* MODE A1: Popular Collections Home View */} + {isHomeView && mainScreenView === "popular" && ( +
+
+
+ + Popular Icon Collections +
+ +
+ +
+ {POPULAR_COLLECTIONS.map((col) => { + const sampleIcon = collectionsMap[col.prefix]?.sampleIcon; + return ( + + ); + })} +
+ + {/* Banner Button to Browse All 200+ Collections */} +
+ +
+
+ )} + + {/* MODE A2: All Collections Grouped View */} + {isHomeView && mainScreenView === "all" && ( +
+
+
+ + All Iconify Collections by Category +
+ +
+ + {allCollectionsLoading && ( +
+ + Loading all 200+ collections... +
+ )} + + {!allCollectionsLoading && + Object.entries(allGroupedCollections).map(([catGroup, collections]) => ( +
+
+ {catGroup} ({collections.length}) +
+
+ {collections.map((col) => ( + + ))} +
+
+ ))} +
+ )} + + {/* Loading Spinner */} + {loading && ( +
+ + + {isCollectionView ? "Loading collection..." : "Searching icons..."} + +
+ )} + + {/* Empty Search / No Icons Found */} + {!loading && !isHomeView && displayedIcons.length === 0 && ( +
+
+ +
+
+ No icons found +
+
+ {isCollectionView + ? "Try selecting a different style/category or clearing search" + : "Try searching for 'box', 'warning', 'arrow', or 'recycle'"} +
+
+ )} + + {/* MODE B & C: Icon Grid */} + {!loading && displayedIcons.length > 0 && ( +
+
+ {displayedIcons.map((iconName) => { + const prefix = iconName.split(":")[0]; + const namePart = iconName.split(":")[1] || iconName; + const collName = + collectionDetails?.title || + searchResults.collections[prefix]?.name || + prefix; + const isInserting = insertingIcon === iconName; + + return ( +
+ {/* Icon Click Button */} + + + {/* Collection Badge Button: ONLY shown in Global Search mode */} + {!isCollectionView && ( + + )} +
+ ); + })} +
+ + {/* Load More Button for Collection Browsing */} + {isCollectionView && visibleCount < collectionIcons.length && ( +
+ +
+ )} +
+ )} +
+
+ ); +} diff --git a/packages/web/src/editor/inspector/fields.tsx b/packages/web/src/editor/inspector/fields.tsx index 12ed973..87dd871 100644 --- a/packages/web/src/editor/inspector/fields.tsx +++ b/packages/web/src/editor/inspector/fields.tsx @@ -31,7 +31,9 @@ export function Field({ return (
{label} @@ -55,19 +57,56 @@ export function NumInput({ max?: number; step?: number; }) { + const formatVal = (v: number) => { + if (isNaN(v)) return 0; + return Number(v.toFixed(2)); + }; + + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY < 0 ? step : -step; + let newVal = formatVal((value || 0) + delta); + if (min !== undefined) newVal = Math.max(min, newVal); + if (max !== undefined) newVal = Math.min(max, newVal); + onChange(newVal); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowUp") { + e.preventDefault(); + let newVal = formatVal((value || 0) + step); + if (max !== undefined) newVal = Math.min(max, newVal); + onChange(newVal); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + let newVal = formatVal((value || 0) - step); + if (min !== undefined) newVal = Math.max(min, newVal); + onChange(newVal); + } + }; + + const displayVal = + value === undefined || value === null + ? 0 + : Number.isInteger(value) + ? value + : Number(value.toFixed(1)); + return ( -
+
onChange(Number(e.target.value))} - className="w-full bg-transparent px-2 text-ui-sm text-ink-100 font-mono outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + className="w-full bg-transparent px-1 text-ui-sm text-ink-100 font-mono outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" /> {suffix && ( - + {suffix} )} @@ -92,7 +131,9 @@ export function TextInput({ placeholder={placeholder} autoFocus={autoFocus} onChange={(e) => onChange(e.target.value)} - onFocus={(e) => { if (autoFocus) e.target.select(); }} + onFocus={(e) => { + if (autoFocus) e.target.select(); + }} className="w-full h-7 px-2 rounded-md bg-ink-800 border border-white/5 focus:border-accent/50 outline-none text-ui-sm text-ink-100" /> ); @@ -138,9 +179,7 @@ export function SegBtn({ onClick={onClick} title={title} className={`h-7 flex-1 flex items-center justify-center rounded-[4px] text-ui-sm ${ - active - ? "bg-ink-700 text-accent" - : "text-ink-300 hover:text-ink-100" + active ? "bg-ink-700 text-accent" : "text-ink-300 hover:text-ink-100" }`} > {children} @@ -166,8 +205,8 @@ export function ColorInput({ return (
= { text: "Text", @@ -49,8 +51,9 @@ export function Inspector() { if (selected.length === 0) return null; const el: BaseElement | null = selected.length === 1 ? selected[0] : null; - const TypeIcon = el ? TYPE_ICONS[el.type] : null; - const typeLabel = el ? TYPE_LABELS[el.type] : null; + const isIcon = Boolean(el?.props?.iconName); + const TypeIcon = el ? (isIcon ? Sticker : TYPE_ICONS[el.type]) : null; + const typeLabel = el ? (isIcon ? "Icon" : TYPE_LABELS[el.type]) : null; return (
)} + {el.type === "image" && }
) : ( diff --git a/packages/web/src/editor/inspector/sections/image-section.tsx b/packages/web/src/editor/inspector/sections/image-section.tsx new file mode 100644 index 0000000..42b6db0 --- /dev/null +++ b/packages/web/src/editor/inspector/sections/image-section.tsx @@ -0,0 +1,143 @@ +import { Sticker, ImageIcon } from "lucide-react"; +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; +import { Field, ColorInput } from "../fields.tsx"; +import { fetchIconWithColorDataUrl } from "../../../lib/iconify.ts"; + +interface Props { + element: BaseElement; +} + +export function ImageSection({ element }: Props) { + const updateElement = useEditorV2Store((s) => s.updateElement); + const isIcon = Boolean(element.props.iconName); + const iconName = (element.props.iconName as string) || ""; + const collectionName = (element.props.collectionName as string) || ""; + const collectionPrefix = (element.props.collection as string) || null; + const iconFill = (element.props.fill as string) || "#000000"; + + const handleReplaceIcon = () => { + window.dispatchEvent( + new CustomEvent("thermoprint:open-icons", { + detail: { + initialPrefix: collectionPrefix, + targetElementId: element.id, + }, + }) + ); + }; + + const handleReplaceImageFile = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.src = reader.result as string; + img.onload = () => { + const { label } = useEditorV2Store.getState(); + const maxW = Math.min(200, label.widthPx * 0.8); + const maxH = Math.min(200, label.heightPx * 0.8); + let w = img.naturalWidth; + let h = img.naturalHeight; + + if (w > maxW || h > maxH) { + const ratio = Math.min(maxW / w, maxH / h); + w = Math.round(w * ratio); + h = Math.round(h * ratio); + } + + updateElement(element.id, { + width: w, + height: h, + props: { + src: reader.result as string, + naturalWidth: img.naturalWidth, + naturalHeight: img.naturalHeight, + }, + }); + }; + }; + reader.readAsDataURL(file); + }; + input.click(); + }; + + const handleColorChange = async (newColor: string) => { + if (!isIcon) return; + try { + const dataUrl = await fetchIconWithColorDataUrl(iconName, newColor); + updateElement(element.id, { + props: { + ...element.props, + fill: newColor, + src: dataUrl, + }, + }); + } catch { + updateElement(element.id, { + props: { + ...element.props, + fill: newColor, + }, + }); + } + }; + + return ( +
+
+ {isIcon ? "Icon Properties" : "Image Properties"} +
+ + {isIcon && ( +
+ + + {iconName} + + + + {collectionName && ( + + + {collectionName} + + + )} + +
+ + + +
+
+ )} + +
+ + +
+
+ ); +} diff --git a/packages/web/src/editor/inspector/sections/text-section.tsx b/packages/web/src/editor/inspector/sections/text-section.tsx index 77631ae..a5aa500 100644 --- a/packages/web/src/editor/inspector/sections/text-section.tsx +++ b/packages/web/src/editor/inspector/sections/text-section.tsx @@ -34,6 +34,7 @@ export function TextSection({ element }: Props) { fill?: string; align?: string; italic?: boolean; + uppercase?: boolean; }; const update = (patch: Record) => @@ -61,7 +62,7 @@ export function TextSection({ element }: Props) { />
-
+