From eb5ef37bf6e28e9b7894538688f1eb503443962e Mon Sep 17 00:00:00 2001 From: Thomas Leiter Date: Fri, 24 Apr 2026 18:33:52 +0200 Subject: [PATCH 1/2] web editor improvements --- packages/core/src/printer.ts | 57 +- packages/core/src/protocol/l11/commands.ts | 40 ++ packages/core/src/protocol/l11/protocol.ts | 15 + packages/core/src/protocol/types.ts | 5 + packages/web/index.html | 22 +- packages/web/package.json | 5 +- packages/web/src/App.tsx | 4 +- packages/web/src/assets/logo.svg | 9 + packages/web/src/components/tooltip.tsx | 1 + packages/web/src/editor/canvas.tsx | 227 ------ packages/web/src/editor/canvas/canvas.tsx | 562 +++++++++++++++ .../{ => canvas}/elements/barcode-element.tsx | 62 +- .../canvas/elements/element-wrapper.tsx | 78 +++ .../editor/canvas/elements/image-element.tsx | 105 +++ .../editor/canvas/elements/line-element.tsx | 60 ++ .../elements/qr-element.tsx} | 60 +- .../editor/canvas/elements/rect-element.tsx | 62 ++ .../editor/canvas/elements/text-element.tsx | 195 ++++++ .../web/src/editor/canvas/label-paper.tsx | 21 + .../src/editor/canvas/selection-handles.tsx | 236 +++++++ .../src/editor/connect-flow/connect-flow.tsx | 343 ++++++++++ packages/web/src/editor/dock/dock-btn.tsx | 77 +++ packages/web/src/editor/dock/dock-divider.tsx | 7 + packages/web/src/editor/dock/dock-group.tsx | 15 + packages/web/src/editor/dock/dock.tsx | 175 +++++ .../src/editor/dock/flyouts/layers-flyout.tsx | 115 ++++ .../editor/dock/flyouts/library-flyout.tsx | 501 ++++++++++++++ .../dock/flyouts/print-settings-flyout.tsx | 365 ++++++++++ packages/web/src/editor/dock/kbd.tsx | 9 + packages/web/src/editor/editor.tsx | 163 +++++ packages/web/src/editor/element-tree.tsx | 100 --- .../src/editor/elements/element-wrapper.tsx | 66 -- .../web/src/editor/elements/image-element.tsx | 67 -- .../web/src/editor/elements/shape-element.tsx | 77 --- .../web/src/editor/elements/text-element.tsx | 88 --- packages/web/src/editor/inspector/fields.tsx | 179 +++++ .../web/src/editor/inspector/inspector.tsx | 123 ++++ .../inspector/sections/barcode-section.tsx | 53 ++ .../editor/inspector/sections/qr-section.tsx | 60 ++ .../inspector/sections/shape-section.tsx | 50 ++ .../inspector/sections/text-section.tsx | 129 ++++ .../inspector/sections/transform-section.tsx | 76 +++ packages/web/src/editor/label-editor.tsx | 45 -- packages/web/src/editor/palette/commands.ts | 146 ++++ packages/web/src/editor/palette/palette.tsx | 148 ++++ .../web/src/editor/print-progress-toast.tsx | 143 ++++ .../editor/properties/barcode-properties.tsx | 35 - .../editor/properties/image-properties.tsx | 48 -- .../editor/properties/properties-panel.tsx | 110 --- .../editor/properties/qr-code-properties.tsx | 30 - .../editor/properties/shape-properties.tsx | 38 -- .../src/editor/properties/text-properties.tsx | 27 - packages/web/src/editor/status-bar.tsx | 39 ++ .../web/src/editor/toolbar/font-controls.tsx | 66 -- packages/web/src/editor/toolbar/toolbar.tsx | 191 ------ .../editor/top-chrome/debug-log-section.tsx | 117 ++++ .../web/src/editor/top-chrome/file-chip.tsx | 231 +++++++ .../src/editor/top-chrome/print-button.tsx | 206 ++++++ .../src/editor/top-chrome/printer-chip.tsx | 206 ++++++ .../src/editor/top-chrome/theme-picker.tsx | 96 +++ .../web/src/editor/top-chrome/top-chrome.tsx | 123 ++++ packages/web/src/hooks/use-canvas-export.ts | 89 --- packages/web/src/hooks/use-debug-log.ts | 15 + packages/web/src/hooks/use-history.ts | 9 - packages/web/src/hooks/use-keyboard.ts | 64 -- packages/web/src/hooks/use-printer.ts | 47 -- packages/web/src/hooks/use-snap.ts | 11 - packages/web/src/hooks/use-templates.ts | 103 --- packages/web/src/hooks/use-web-bluetooth.ts | 39 +- packages/web/src/index.css | 143 +++- packages/web/src/lib/keyboard.ts | 326 +++++++++ packages/web/src/lib/library.ts | 99 +++ packages/web/src/lib/units.ts | 3 + packages/web/src/main.tsx | 38 +- packages/web/src/preview/print-preview.tsx | 121 ---- packages/web/src/printer/debug-log-button.tsx | 88 --- packages/web/src/printer/print-button.tsx | 28 - .../web/src/printer/print-settings-panel.tsx | 201 ------ packages/web/src/printer/printer-panel.tsx | 47 -- packages/web/src/store/editor-store.ts | 644 ++++++++++++++---- packages/web/src/store/printer-store.ts | 28 +- packages/web/src/templates/template-card.tsx | 24 - .../web/src/templates/template-manager.tsx | 42 -- packages/web/src/theme/theme-provider.tsx | 62 -- packages/web/src/theme/theme-toggle.tsx | 24 - packages/web/src/themes.css | 288 ++++++++ 86 files changed, 6898 insertions(+), 2394 deletions(-) create mode 100644 packages/web/src/assets/logo.svg delete mode 100644 packages/web/src/editor/canvas.tsx create mode 100644 packages/web/src/editor/canvas/canvas.tsx rename packages/web/src/editor/{ => canvas}/elements/barcode-element.tsx (55%) create mode 100644 packages/web/src/editor/canvas/elements/element-wrapper.tsx create mode 100644 packages/web/src/editor/canvas/elements/image-element.tsx create mode 100644 packages/web/src/editor/canvas/elements/line-element.tsx rename packages/web/src/editor/{elements/qr-code-element.tsx => canvas/elements/qr-element.tsx} (56%) create mode 100644 packages/web/src/editor/canvas/elements/rect-element.tsx create mode 100644 packages/web/src/editor/canvas/elements/text-element.tsx create mode 100644 packages/web/src/editor/canvas/label-paper.tsx create mode 100644 packages/web/src/editor/canvas/selection-handles.tsx create mode 100644 packages/web/src/editor/connect-flow/connect-flow.tsx create mode 100644 packages/web/src/editor/dock/dock-btn.tsx create mode 100644 packages/web/src/editor/dock/dock-divider.tsx create mode 100644 packages/web/src/editor/dock/dock-group.tsx create mode 100644 packages/web/src/editor/dock/dock.tsx create mode 100644 packages/web/src/editor/dock/flyouts/layers-flyout.tsx create mode 100644 packages/web/src/editor/dock/flyouts/library-flyout.tsx create mode 100644 packages/web/src/editor/dock/flyouts/print-settings-flyout.tsx create mode 100644 packages/web/src/editor/dock/kbd.tsx create mode 100644 packages/web/src/editor/editor.tsx delete mode 100644 packages/web/src/editor/element-tree.tsx delete mode 100644 packages/web/src/editor/elements/element-wrapper.tsx delete mode 100644 packages/web/src/editor/elements/image-element.tsx delete mode 100644 packages/web/src/editor/elements/shape-element.tsx delete mode 100644 packages/web/src/editor/elements/text-element.tsx create mode 100644 packages/web/src/editor/inspector/fields.tsx create mode 100644 packages/web/src/editor/inspector/inspector.tsx create mode 100644 packages/web/src/editor/inspector/sections/barcode-section.tsx create mode 100644 packages/web/src/editor/inspector/sections/qr-section.tsx create mode 100644 packages/web/src/editor/inspector/sections/shape-section.tsx create mode 100644 packages/web/src/editor/inspector/sections/text-section.tsx create mode 100644 packages/web/src/editor/inspector/sections/transform-section.tsx delete mode 100644 packages/web/src/editor/label-editor.tsx create mode 100644 packages/web/src/editor/palette/commands.ts create mode 100644 packages/web/src/editor/palette/palette.tsx create mode 100644 packages/web/src/editor/print-progress-toast.tsx delete mode 100644 packages/web/src/editor/properties/barcode-properties.tsx delete mode 100644 packages/web/src/editor/properties/image-properties.tsx delete mode 100644 packages/web/src/editor/properties/properties-panel.tsx delete mode 100644 packages/web/src/editor/properties/qr-code-properties.tsx delete mode 100644 packages/web/src/editor/properties/shape-properties.tsx delete mode 100644 packages/web/src/editor/properties/text-properties.tsx create mode 100644 packages/web/src/editor/status-bar.tsx delete mode 100644 packages/web/src/editor/toolbar/font-controls.tsx delete mode 100644 packages/web/src/editor/toolbar/toolbar.tsx create mode 100644 packages/web/src/editor/top-chrome/debug-log-section.tsx create mode 100644 packages/web/src/editor/top-chrome/file-chip.tsx create mode 100644 packages/web/src/editor/top-chrome/print-button.tsx create mode 100644 packages/web/src/editor/top-chrome/printer-chip.tsx create mode 100644 packages/web/src/editor/top-chrome/theme-picker.tsx create mode 100644 packages/web/src/editor/top-chrome/top-chrome.tsx delete mode 100644 packages/web/src/hooks/use-canvas-export.ts create mode 100644 packages/web/src/hooks/use-debug-log.ts delete mode 100644 packages/web/src/hooks/use-history.ts delete mode 100644 packages/web/src/hooks/use-keyboard.ts delete mode 100644 packages/web/src/hooks/use-printer.ts delete mode 100644 packages/web/src/hooks/use-snap.ts delete mode 100644 packages/web/src/hooks/use-templates.ts create mode 100644 packages/web/src/lib/keyboard.ts create mode 100644 packages/web/src/lib/library.ts create mode 100644 packages/web/src/lib/units.ts delete mode 100644 packages/web/src/preview/print-preview.tsx delete mode 100644 packages/web/src/printer/debug-log-button.tsx delete mode 100644 packages/web/src/printer/print-button.tsx delete mode 100644 packages/web/src/printer/print-settings-panel.tsx delete mode 100644 packages/web/src/printer/printer-panel.tsx delete mode 100644 packages/web/src/templates/template-card.tsx delete mode 100644 packages/web/src/templates/template-manager.tsx delete mode 100644 packages/web/src/theme/theme-provider.tsx delete mode 100644 packages/web/src/theme/theme-toggle.tsx create mode 100644 packages/web/src/themes.css diff --git a/packages/core/src/printer.ts b/packages/core/src/printer.ts index 69a5514..25190c3 100644 --- a/packages/core/src/printer.ts +++ b/packages/core/src/printer.ts @@ -157,10 +157,10 @@ export class Printer { debugLog("PRINT", "done"); } - async getStatus(): Promise { + async getStatus(timeoutMs = 5000): Promise { const cmd = this.protocol.buildStatusQuery(); await this.flowController.send(cmd.data); - const response = await this.waitForResponse("status"); + const response = await this.waitForResponse("status", timeoutMs); return { status: (response?.value as string) ?? "unknown", raw: response?.raw ?? new Uint8Array(), @@ -170,8 +170,28 @@ export class Printer { async getBattery(): Promise { const cmd = this.protocol.buildBatteryQuery(); await this.flowController.send(cmd.data); - const response = await this.waitForResponse("battery"); - return (response?.value as number) ?? -1; + const response = await this.waitForResponse("battery", 3000); + // Battery is in response[1] as a raw byte (0-100) + if (response.value !== undefined) return response.value as number; + if (response.raw.length >= 2) return response.raw[1]; + return response.raw[0] ?? -1; + } + + async getModel(): Promise { + const cmd = this.protocol.buildModelQuery(); + await this.flowController.send(cmd.data); + const response = await this.waitForResponse("model", 3000); + if (typeof response.value === "string" && response.value) return response.value; + return new TextDecoder().decode(response.raw).replace(/\0/g, "").trim(); + } + + async getInfo(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): Promise { + const cmd = this.protocol.buildInfoQuery(type); + await this.flowController.send(cmd.data); + const response = await this.waitForResponse(type, 3000); + if (typeof response.value === "string" && response.value) return response.value; + if (typeof response.value === "number") return String(response.value); + return new TextDecoder().decode(response.raw).replace(/\0/g, "").trim(); } async disconnect(): Promise { @@ -219,16 +239,31 @@ export class Printer { private handleRxData(data: Uint8Array): void { const response = this.protocol.parseResponse(data); debugLog("RX", `${formatBytes(data)}${response ? ` → ${response.type}` : " (unknown)"}${response?.value !== undefined ? ` value=${response.value}` : ""}`); - if (!response) return; - if (response.type === "status") { - this.emit("status", { status: response.value as string, raw: data }); + if (response) { + if (response.type === "status") { + this.emit("status", { status: response.value as string, raw: data }); + } + + // Resolve any pending waiters matching the parsed type + const idx = this.pendingResponses.findIndex((p) => p.type === response.type); + if (idx !== -1) { + this.pendingResponses.splice(idx, 1)[0].resolve(response); + return; + } } - // Resolve any pending waiters - const idx = this.pendingResponses.findIndex((p) => p.type === response.type); - if (idx !== -1) { - this.pendingResponses.splice(idx, 1)[0].resolve(response); + // Unrecognized data — if there's a pending query waiter (battery/model/firmware), + // deliver the raw bytes to it. The Marklife printer returns query responses as + // raw data without echoing the command prefix. + if (!response && this.pendingResponses.length > 0) { + const waiter = this.pendingResponses[0]; + const queryTypes = ["battery", "model", "firmware", "serial", "mac", "bt-version", "bt-name", "speed", "status"]; + if (queryTypes.includes(waiter.type)) { + debugLog("RX", `routing raw data to pending "${waiter.type}" waiter`); + this.pendingResponses.splice(0, 1); + waiter.resolve({ type: waiter.type as PrinterResponse["type"], raw: data }); + } } } diff --git a/packages/core/src/protocol/l11/commands.ts b/packages/core/src/protocol/l11/commands.ts index 0bc5193..473fcb1 100644 --- a/packages/core/src/protocol/l11/commands.ts +++ b/packages/core/src/protocol/l11/commands.ts @@ -99,6 +99,46 @@ export function getFirmware(): PrintCommand { }; } +/** Query serial number: 10 FF 20 F2 */ +export function getSerial(): PrintCommand { + return { + label: "get-serial", + data: Uint8Array.from([0x10, 0xff, 0x20, 0xf2]), + }; +} + +/** Query Bluetooth MAC address: 10 FF 20 F3 */ +export function getMac(): PrintCommand { + return { + label: "get-mac", + data: Uint8Array.from([0x10, 0xff, 0x20, 0xf3]), + }; +} + +/** Query BT module version: 10 FF 30 10 */ +export function getBtVersion(): PrintCommand { + return { + label: "get-bt-version", + data: Uint8Array.from([0x10, 0xff, 0x30, 0x10]), + }; +} + +/** Query BT device name: 10 FF 30 11 */ +export function getBtName(): PrintCommand { + return { + label: "get-bt-name", + data: Uint8Array.from([0x10, 0xff, 0x30, 0x11]), + }; +} + +/** Query print speed: 1F 60 00 */ +export function getSpeed(): PrintCommand { + return { + label: "get-speed", + data: Uint8Array.from([0x1f, 0x60, 0x00]), + }; +} + /** Print self-test page: 1F 40 */ export function selfCheck(): PrintCommand { return { label: "self-check", data: Uint8Array.from([0x1f, 0x40]) }; diff --git a/packages/core/src/protocol/l11/protocol.ts b/packages/core/src/protocol/l11/protocol.ts index 0d1284d..c0d9913 100644 --- a/packages/core/src/protocol/l11/protocol.ts +++ b/packages/core/src/protocol/l11/protocol.ts @@ -54,6 +54,21 @@ export class L11Protocol implements PrinterProtocol { return cmd.getBattery(); } + buildModelQuery(): PrintCommand { + return cmd.getModel(); + } + + buildInfoQuery(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): PrintCommand { + switch (type) { + case "firmware": return cmd.getFirmware(); + case "serial": return cmd.getSerial(); + case "mac": return cmd.getMac(); + case "bt-version": return cmd.getBtVersion(); + case "bt-name": return cmd.getBtName(); + case "speed": return cmd.getSpeed(); + } + } + parseResponse(data: Uint8Array): PrinterResponse | null { if (data.length < 1) return null; diff --git a/packages/core/src/protocol/types.ts b/packages/core/src/protocol/types.ts index 6ca91b2..a4696cb 100644 --- a/packages/core/src/protocol/types.ts +++ b/packages/core/src/protocol/types.ts @@ -13,6 +13,9 @@ export type PrinterResponseType = | "firmware" | "serial" | "mac" + | "bt-version" + | "bt-name" + | "speed" | "credit" | "mtu"; @@ -44,5 +47,7 @@ export interface PrinterProtocol { buildWakeup(): PrintCommand[]; buildStatusQuery(): PrintCommand; buildBatteryQuery(): PrintCommand; + buildModelQuery(): PrintCommand; + buildInfoQuery(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): PrintCommand; parseResponse(data: Uint8Array): PrinterResponse | null; } diff --git a/packages/web/index.html b/packages/web/index.html index d872955..fdd08bb 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -12,14 +12,26 @@ /> - +
diff --git a/packages/web/package.json b/packages/web/package.json index 72bd442..1d238be 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -18,7 +18,10 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-konva": "^19.2.3", - "zustand": "^5.0.11" + "zundo": "^2.3.0", + "zustand": "^5.0.11", + "@fontsource/inter": "^5.2.5", + "@fontsource-variable/jetbrains-mono": "^5.2.5" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 5131274..1143a69 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,4 +1,4 @@ -import { LabelEditor } from "./editor/label-editor.tsx"; +import { Editor } from "./editor/editor.tsx"; function BluetoothUnsupportedBanner() { return ( @@ -25,7 +25,7 @@ export function App() { return ( <> {!isBluetoothSupported && } - + ); } diff --git a/packages/web/src/assets/logo.svg b/packages/web/src/assets/logo.svg new file mode 100644 index 0000000..487d93f --- /dev/null +++ b/packages/web/src/assets/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/web/src/components/tooltip.tsx b/packages/web/src/components/tooltip.tsx index 749c93d..6771227 100644 --- a/packages/web/src/components/tooltip.tsx +++ b/packages/web/src/components/tooltip.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck — legacy v1 component, not used by the redesigned editor import { useState, useRef, type ReactNode } from "react"; import { createPortal } from "react-dom"; diff --git a/packages/web/src/editor/canvas.tsx b/packages/web/src/editor/canvas.tsx deleted file mode 100644 index 6b02ee1..0000000 --- a/packages/web/src/editor/canvas.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { forwardRef, useRef, useState, useEffect, useCallback } from "react"; -import { Stage, Layer, Rect } from "react-konva"; -import type Konva from "konva"; -import { useEditorStore } from "../store/editor-store.ts"; -import { usePrinterStore } from "../store/printer-store.ts"; -import { TextElement } from "./elements/text-element.tsx"; -import { ImageElement } from "./elements/image-element.tsx"; -import { QrCodeElement } from "./elements/qr-code-element.tsx"; -import { BarcodeElement } from "./elements/barcode-element.tsx"; -import { ShapeElement } from "./elements/shape-element.tsx"; - -const MIN_SCALE = 1; -const MAX_SCALE = 5; -const PADDING = 80; -const GAP_PX_BASE = 30; - -function GhostLabel({ displayW, displayH, radius }: { displayW: number; displayH: number; radius: number }) { - return ( -
- ); -} - -function renderElements( - elements: ReturnType["elements"], - selectedId: string | null, -) { - return elements.map((el) => { - const isSelected = el.id === selectedId; - switch (el.type) { - case "text": - return ; - case "image": - return ; - case "qrcode": - return ; - case "barcode": - return ; - case "rect": - case "line": - return ; - default: - return null; - } - }); -} - -export const Canvas = forwardRef(function Canvas(_props, ref) { - const elements = useEditorStore((s) => s.elements); - const selectedId = useEditorStore((s) => s.selectedId); - const setSelectedId = useEditorStore((s) => s.setSelectedId); - const labelConfig = useEditorStore((s) => s.labelConfig); - const paperType = usePrinterStore((s) => s.settings.paperType); - - const containerRef = useRef(null); - const [scale, setScale] = useState(2.5); - const [containerSize, setContainerSize] = useState({ w: 800, h: 600 }); - const [ready, setReady] = useState(false); - - const { widthPx, heightPx } = labelConfig; - - const computeScale = useCallback(() => { - const el = containerRef.current; - if (!el) return; - const cw = el.clientWidth; - const ch = el.clientHeight; - setContainerSize({ w: cw, h: ch }); - - const availW = cw - PADDING * 2; - const availH = ch - PADDING * 2; - const isGap = paperType === "gap"; - const shrink = isGap ? 0.6 : 0.85; - const fitW = (availW * shrink) / widthPx; - const fitH = availH / heightPx; - const fit = Math.min(fitW, fitH); - setScale(Math.min(MAX_SCALE, Math.max(MIN_SCALE, fit))); - setReady(true); - }, [widthPx, heightPx, paperType]); - - useEffect(() => { - computeScale(); - const el = containerRef.current; - if (!el) return; - const ro = new ResizeObserver(computeScale); - ro.observe(el); - return () => ro.disconnect(); - }, [computeScale]); - - const displayW = widthPx * scale; - const displayH = heightPx * scale; - const isGap = paperType === "gap"; - const gapPx = GAP_PX_BASE * scale; - const borderRadius = Math.round(10 * scale); - - // Layer offset to center the label within the full-size Stage - const layerX = (containerSize.w - displayW) / 2; - const layerY = (containerSize.h - displayH) / 2; - - const handleDeselect = (e: Konva.KonvaEventObject) => { - if (e.target === e.target.getStage() || e.target.attrs.id === "label-bg") { - setSelectedId(null); - } - }; - - return ( -
- {/* Gap mode decorations (HTML overlay) */} - {isGap && ( -
-
- {/* Backing paper strip */} -
- {/* Left ghost */} -
- -
- {/* Main label outline */} -
- {/* Right ghost */} -
- -
-
-
- )} - - {/* Continuous mode: label shadow/border decoration */} - {!isGap && ( -
-
-
- )} - - {/* Full-size Stage — elements can extend beyond label bounds */} - - - - {renderElements(elements, selectedId)} - - -
- ); -}); diff --git a/packages/web/src/editor/canvas/canvas.tsx b/packages/web/src/editor/canvas/canvas.tsx new file mode 100644 index 0000000..aed54ab --- /dev/null +++ b/packages/web/src/editor/canvas/canvas.tsx @@ -0,0 +1,562 @@ +import { useRef, useState, useEffect, useCallback, useLayoutEffect, forwardRef } from "react"; +import { createPortal } from "react-dom"; +import { Stage, Layer, Rect } from "react-konva"; +import type Konva from "konva"; +import { useEditorV2Store, type BaseElement } from "../../store/editor-store.ts"; +import { mmToPx } from "../../utils/px-mm.ts"; +import { usePrinterStore } from "../../store/printer-store.ts"; +import { getLabelSizes } from "../../label/label-sizes.ts"; +import { ChevronDown } from "lucide-react"; +import { LabelPaper } from "./label-paper.tsx"; +import { TextElement } from "./elements/text-element.tsx"; +import { RectElement } from "./elements/rect-element.tsx"; +import { LineElement } from "./elements/line-element.tsx"; +import { QrElement } from "./elements/qr-element.tsx"; +import { BarcodeElement } from "./elements/barcode-element.tsx"; +import { ImageElement } from "./elements/image-element.tsx"; + +function LabelSizeSelector({ + originX, + originY, + displayW, + displayH, + containerRef, +}: { + originX: number; + originY: number; + displayW: number; + displayH: number; + containerRef: React.RefObject; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const label = useEditorV2Store((s) => s.label); + const paperType = useEditorV2Store((s) => s.paperType); + const rollDirection = useEditorV2Store((s) => s.rollDirection); + const modelId = usePrinterStore((s) => s.modelId); + const sizes = getLabelSizes(modelId, paperType); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const setSize = (widthMm: number, heightMm: number) => { + useEditorV2Store.setState({ + label: { widthMm, heightMm, widthPx: mmToPx(widthMm), heightPx: mmToPx(heightMm) }, + }); + setOpen(false); + }; + + // Compute fixed (viewport) position from container-relative coordinates + const containerRect = containerRef.current?.getBoundingClientRect(); + const fixedLeft = (containerRect?.left ?? 0) + originX + displayW / 2; + const fixedTop = (containerRect?.top ?? 0) + originY + displayH + 8; + + return createPortal( +
+ + {paperType === "gap" && ( + + )} + {open && ( +
+
+ {sizes.map((s) => { + const active = s.widthMm === label.widthMm && s.heightMm === label.heightMm; + return ( + + ); + })} +
+
+ )} +
, + document.body, + ); +} + +function renderElement(el: BaseElement, isSelected: boolean) { + switch (el.type) { + case "text": + return ; + case "rect": + return ; + case "line": + return ; + case "qrcode": + return ; + case "barcode": + return ; + case "image": + return ; + default: + return null; + } +} + +export const Canvas = forwardRef(function Canvas(_props, ref) { + const containerRef = useRef(null); + const stageRef = useRef(null); + const [size, setSize] = useState({ w: 800, h: 600 }); + const [spaceHeld, setSpaceHeld] = useState(false); + const spaceDown = useRef(false); + const panStart = useRef<{ + x: number; + y: number; + panX: number; + panY: number; + } | null>(null); + const [panning, setPanning] = useState(false); + + // Marquee state + const [marquee, setMarquee] = useState<{ + x: number; + y: number; + w: number; + h: number; + } | null>(null); + const marqueeStart = useRef<{ x: number; y: number } | null>(null); + + const elements = useEditorV2Store((s) => s.elements); + const selectedIds = useEditorV2Store((s) => s.selectedIds); + const label = useEditorV2Store((s) => s.label); + const zoom = useEditorV2Store((s) => s.zoom); + const panX = useEditorV2Store((s) => s.panX); + const panY = useEditorV2Store((s) => s.panY); + const gridVisible = useEditorV2Store((s) => s.gridVisible); + const rulersVisible = useEditorV2Store((s) => s.rulersVisible); + const paperType = useEditorV2Store((s) => s.paperType); + const rollDirection = useEditorV2Store((s) => s.rollDirection); + + const selectOnly = useEditorV2Store((s) => s.selectOnly); + const setZoom = useEditorV2Store((s) => s.setZoom); + const setPan = useEditorV2Store((s) => s.setPan); + + // Track container size + useLayoutEffect(() => { + const el = containerRef.current; + if (!el) return; + const update = () => setSize({ w: el.clientWidth, h: el.clientHeight }); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Fit to screen on mount and when label changes (new label, open label) + const currentLabelId = useEditorV2Store((s) => s.currentLabelId); + useEffect(() => { + const pad = window.innerWidth < 768 ? 80 : 200; + const fitW = (size.w - pad) / label.widthPx; + const fitH = (size.h - pad) / label.heightPx; + const fit = Math.max(0.5, Math.min(4, Math.min(fitW, fitH))); + setZoom(fit); + setPan(0, 0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentLabelId, label.widthPx, label.heightPx]); + + // Space key tracking for pan mode + useEffect(() => { + const onDown = (e: KeyboardEvent) => { + if (e.code === "Space" && !e.repeat) { spaceDown.current = true; setSpaceHeld(true); } + }; + const onUp = (e: KeyboardEvent) => { + if (e.code === "Space") { spaceDown.current = false; setSpaceHeld(false); } + }; + window.addEventListener("keydown", onDown); + window.addEventListener("keyup", onUp); + return () => { + window.removeEventListener("keydown", onDown); + window.removeEventListener("keyup", onUp); + }; + }, []); + + // Wheel zoom (cmd/ctrl + scroll) + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const delta = -e.deltaY * 0.002; + const store = useEditorV2Store.getState(); + const next = store.zoom * (1 + delta); + setZoom(next); + } + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [setZoom]); + + // Compute label position centered in viewport + const displayW = label.widthPx * zoom; + const displayH = label.heightPx * zoom; + const originX = size.w / 2 + panX - displayW / 2; + const originY = size.h / 2 + panY - displayH / 2; + + // Stage mouse handlers — only pan and marquee; elements handle their own click/drag + const handleStageMouseDown = useCallback( + (e: Konva.KonvaEventObject) => { + // Pan with space+click or middle mouse + if (spaceDown.current || e.evt.button === 1) { + e.evt.preventDefault(); + const store = useEditorV2Store.getState(); + panStart.current = { + x: e.evt.clientX, + y: e.evt.clientY, + panX: store.panX, + panY: store.panY, + }; + setPanning(true); + return; + } + + // Click on empty canvas → deselect + start marquee + const target = e.target; + const stage = target.getStage(); + if (!stage) return; + + const clickedOnStage = + target === stage || target.attrs.id === "canvas-bg" || target.attrs.id === "label-bg"; + + if (clickedOnStage) { + selectOnly([]); + const pointer = stage.getPointerPosition(); + if (pointer) { + marqueeStart.current = { x: pointer.x, y: pointer.y }; + } + } + }, + [selectOnly], + ); + + // Global mouse move/up for pan and marquee + useEffect(() => { + const onMove = (e: MouseEvent) => { + // Pan + if (panStart.current) { + const dx = e.clientX - panStart.current.x; + const dy = e.clientY - panStart.current.y; + setPan(panStart.current.panX + dx, panStart.current.panY + dy); + return; + } + + // Marquee + if (marqueeStart.current) { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const sx = marqueeStart.current.x; + const sy = marqueeStart.current.y; + setMarquee({ + x: Math.min(sx, mx), + y: Math.min(sy, my), + w: Math.abs(mx - sx), + h: Math.abs(my - sy), + }); + } + }; + + const onUp = () => { + if (panStart.current) { + panStart.current = null; + setPanning(false); + } + if (marqueeStart.current && marquee) { + // Determine which elements fall inside the marquee + const store = useEditorV2Store.getState(); + const ids = store.elements + .filter((el) => { + // Convert element position to screen coordinates + const elScreenX = originX + el.x * zoom; + const elScreenY = originY + el.y * zoom; + const elScreenW = el.width * zoom; + const elScreenH = el.height * zoom; + return ( + elScreenX + elScreenW > marquee.x && + elScreenX < marquee.x + marquee.w && + elScreenY + elScreenH > marquee.y && + elScreenY < marquee.y + marquee.h + ); + }) + .map((el) => el.id); + selectOnly(ids); + } + marqueeStart.current = null; + setMarquee(null); + }; + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [marquee, originX, originY, zoom, setPan, selectOnly]); + + return ( +
+ {/* Gap mode: backing paper strip, ghost labels, perforation marks */} + {paperType === "gap" && (() => { + const vertical = rollDirection === "vertical"; + const gap = 24 * zoom; + const rollOverhang = 16 * zoom; + const stride = vertical ? displayH + gap : displayW + gap; + const span = vertical ? size.h : size.w; + const ghostCount = Math.ceil(span / stride) + 2; + const ghosts: { pos: number; opacity: number }[] = []; + for (let i = 1; i <= ghostCount; i++) { + const p = (vertical ? originY : originX) - i * stride; + if (p + (vertical ? displayH : displayW) < -50) break; + ghosts.push({ pos: p, opacity: Math.max(0.3, 0.8 - i * 0.1) }); + } + for (let i = 1; i <= ghostCount; i++) { + const p = (vertical ? originY : originX) + i * stride; + if (p > span + 50) break; + ghosts.push({ pos: p, opacity: Math.max(0.3, 0.8 - i * 0.1) }); + } + + // Backing ribbon — uses ink-600 for theme-aware visibility + const ribbonColor = "var(--color-ink-600)"; + const ribbonStyle: React.CSSProperties = vertical + ? { + position: "absolute", top: 0, bottom: 0, + left: originX - rollOverhang, width: displayW + rollOverhang * 2, + pointerEvents: "none", + background: `linear-gradient(to right, transparent 0%, color-mix(in srgb, ${ribbonColor} 8%, transparent) 8%, color-mix(in srgb, ${ribbonColor} 12%, transparent) 50%, color-mix(in srgb, ${ribbonColor} 8%, transparent) 92%, transparent 100%)`, + borderLeft: `1px dashed color-mix(in srgb, ${ribbonColor} 20%, transparent)`, + borderRight: `1px dashed color-mix(in srgb, ${ribbonColor} 20%, transparent)`, + } + : { + position: "absolute", left: 0, right: 0, + top: originY - rollOverhang, height: displayH + rollOverhang * 2, + pointerEvents: "none", + background: `linear-gradient(to bottom, transparent 0%, color-mix(in srgb, ${ribbonColor} 8%, transparent) 8%, color-mix(in srgb, ${ribbonColor} 12%, transparent) 50%, color-mix(in srgb, ${ribbonColor} 8%, transparent) 92%, transparent 100%)`, + borderTop: `1px dashed color-mix(in srgb, ${ribbonColor} 20%, transparent)`, + borderBottom: `1px dashed color-mix(in srgb, ${ribbonColor} 20%, transparent)`, + }; + + const activePos = vertical ? originY : originX; + return ( + <> +
+ {/* Perforation marks */} + {ghosts.concat([{ pos: activePos, opacity: 0 }]).map((g, i) => + vertical ? ( +
+ ) : ( +
+ ), + )} + {/* Ghost labels */} + {ghosts.map((g, i) => ( +
+ ))} + + ); + })()} + + { + stageRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }} + width={size.w} + height={size.h} + onMouseDown={handleStageMouseDown} + style={{ position: "absolute", inset: 0 }} + > + {/* Background layer — click to deselect */} + + + + + {/* Paper + elements layer */} + + + {elements.map((el) => + renderElement(el, selectedIds.includes(el.id)), + )} + + + + {/* Marquee selection rectangle (HTML overlay) */} + {marquee && marquee.w > 2 && marquee.h > 2 && ( +
+ )} + + {/* Rulers overlay — hidden on mobile */} + {rulersVisible && window.innerWidth >= 768 && (() => { + const pxPerMm = mmToPx(1); + return ( + <> + {/* Top ruler */} +
+
+ {Array.from({ length: Math.ceil(label.widthMm) + 1 }).map((_, mm) => { + const x = originX + mm * pxPerMm * zoom; + if (x < 0 || x > size.w) return null; + const major = mm % 5 === 0; + return ( +
+
+ {major && ( +
{mm}
+ )} +
+ ); + })} +
+
+ {/* Left ruler */} +
+
+ {Array.from({ length: Math.ceil(label.heightMm) + 1 }).map((_, mm) => { + const y = originY + mm * pxPerMm * zoom; + if (y < 0 || y > size.h) return null; + const major = mm % 5 === 0; + return ( +
+
+ {major && ( +
{mm}
+ )} +
+ ); + })} +
+
+ + ); + })()} + + {/* Label size selector */} + +
+ ); +}); diff --git a/packages/web/src/editor/elements/barcode-element.tsx b/packages/web/src/editor/canvas/elements/barcode-element.tsx similarity index 55% rename from packages/web/src/editor/elements/barcode-element.tsx rename to packages/web/src/editor/canvas/elements/barcode-element.tsx index 2bdc4bc..8188f1e 100644 --- a/packages/web/src/editor/elements/barcode-element.tsx +++ b/packages/web/src/editor/canvas/elements/barcode-element.tsx @@ -1,30 +1,40 @@ -import { useRef, useEffect, useState } from "react"; -import { Image } from "react-konva"; +import { useEffect, useState, useMemo, useRef } from "react"; +import { Image as KonvaImage, Rect } from "react-konva"; import type Konva from "konva"; import JsBarcode from "jsbarcode"; -import type { EditorElement, BarcodeProps } from "../../store/types.ts"; -import { useEditorStore } from "../../store/editor-store.ts"; +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; import { ElementWrapper } from "./element-wrapper.tsx"; -interface BarcodeElementProps { - element: EditorElement; +interface Props { + element: BaseElement; isSelected: boolean; } -export function BarcodeElement({ element, isSelected }: BarcodeElementProps) { +export function BarcodeElement({ element, isSelected }: Props) { const ref = useRef(null); - const props = element.props as BarcodeProps; - const updateElement = useEditorStore((s) => s.updateElement); - const pushHistory = useEditorStore((s) => s.pushHistory); - const setSelectedId = useEditorStore((s) => s.setSelectedId); + const updateElement = useEditorV2Store((s) => s.updateElement); + const selectOnly = useEditorV2Store((s) => s.selectOnly); + + const p = element.props as { + content?: string; + format?: string; + displayValue?: boolean; + }; + + const cacheKey = useMemo( + () => `${p.content || ""}|${p.format || "CODE128"}|${p.displayValue}`, + [p.content, p.format, p.displayValue], + ); + const [image, setImage] = useState(null); useEffect(() => { try { const canvas = document.createElement("canvas"); - JsBarcode(canvas, props.content || "0000", { - format: props.format, - displayValue: props.displayValue, + JsBarcode(canvas, p.content || "0000", { + format: p.format || "CODE128", + displayValue: p.displayValue ?? true, margin: 2, height: 60, background: "#ffffff", @@ -36,13 +46,25 @@ export function BarcodeElement({ element, isSelected }: BarcodeElementProps) { } catch { // Invalid barcode content for format } - }, [props.content, props.format, props.displayValue]); + }, [cacheKey, p.content, p.format, p.displayValue]); - if (!image) return null; + if (!image) { + return ( + + ); + } return ( <> - setSelectedId(element.id)} - onTap={() => setSelectedId(element.id)} - onDragStart={() => pushHistory()} + onClick={() => selectOnly([element.id])} + onTap={() => selectOnly([element.id])} onDragEnd={(e) => { updateElement(element.id, { x: e.target.x(), y: e.target.y() }); }} onTransformEnd={() => { const node = ref.current; if (!node) return; - pushHistory(); const scaleX = node.scaleX(); const scaleY = node.scaleY(); node.scaleX(1); diff --git a/packages/web/src/editor/canvas/elements/element-wrapper.tsx b/packages/web/src/editor/canvas/elements/element-wrapper.tsx new file mode 100644 index 0000000..63f6373 --- /dev/null +++ b/packages/web/src/editor/canvas/elements/element-wrapper.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from "react"; +import { Transformer } from "react-konva"; +import type Konva from "konva"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; + +const ACCENT_MAP: Record = { + cyan: { accent: "#2ad0ff", accent600: "#10b5e6" }, + amber: { accent: "#ff9f40", accent600: "#e68a2e" }, + graphite: { accent: "#e6e6eb", accent600: "#c8c8d0" }, + violet: { accent: "#a78bfa", accent600: "#8b6fe8" }, + forest: { accent: "#6ecc93", accent600: "#52b878" }, + paper: { accent: "#e2bc7a", accent600: "#c8a460" }, +}; + +interface Props { + nodeRef: React.RefObject; + isSelected: boolean; +} + +export function ElementWrapper({ nodeRef, isSelected }: Props) { + const trRef = useRef(null); + const theme = useEditorV2Store((s) => s.theme); + const { accent, accent600 } = ACCENT_MAP[theme] || ACCENT_MAP.cyan; + + useEffect(() => { + if (isSelected && trRef.current && nodeRef.current) { + trRef.current.nodes([nodeRef.current]); + trRef.current.getLayer()?.batchDraw(); + } + }, [isSelected, nodeRef, accent]); + + if (!isSelected) return null; + + return ( + { + if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) { + return oldBox; + } + return newBox; + }} + anchorSize={10} + anchorCornerRadius={5} + anchorStroke={accent} + anchorStrokeWidth={1.5} + anchorFill="#ffffff" + borderStroke={accent} + borderStrokeWidth={1.5} + rotateAnchorOffset={28} + rotateAnchorCursor="grab" + anchorStyleFunc={(anchor: Konva.Shape) => { + if (anchor.hasName("rotater")) { + (anchor as unknown as Konva.Rect).cornerRadius(10); + anchor.fill(accent); + anchor.stroke(accent600); + anchor.width(12); + anchor.height(12); + anchor.offsetX(6); + anchor.offsetY(6); + } + anchor.on("mouseenter", () => { + const stage = anchor.getStage(); + if (stage) + stage.container().style.cursor = anchor.hasName("rotater") + ? "grab" + : "nwse-resize"; + }); + anchor.on("mouseleave", () => { + const stage = anchor.getStage(); + if (stage) stage.container().style.cursor = "default"; + }); + }} + /> + ); +} diff --git a/packages/web/src/editor/canvas/elements/image-element.tsx b/packages/web/src/editor/canvas/elements/image-element.tsx new file mode 100644 index 0000000..b28de3d --- /dev/null +++ b/packages/web/src/editor/canvas/elements/image-element.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState, useRef } from "react"; +import { Image as KonvaImage, Rect, Text, Group } from "react-konva"; +import type Konva from "konva"; +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; +import { ElementWrapper } from "./element-wrapper.tsx"; + +interface Props { + element: BaseElement; + isSelected: boolean; +} + +export function ImageElement({ element, isSelected }: Props) { + const ref = useRef(null); + const updateElement = useEditorV2Store((s) => s.updateElement); + const selectOnly = useEditorV2Store((s) => s.selectOnly); + + const p = element.props as { src?: string }; + + const [image, setImage] = useState(null); + + useEffect(() => { + if (!p.src) return; + const img = new window.Image(); + img.src = p.src; + img.onload = () => setImage(img); + }, [p.src]); + + if (!image) { + return ( + <> + } + id={element.id} + x={element.x} + y={element.y} + rotation={element.rotation} + draggable + onClick={() => selectOnly([element.id])} + onTap={() => selectOnly([element.id])} + onDragEnd={(e) => { + updateElement(element.id, { x: e.target.x(), y: e.target.y() }); + }} + > + + + + + + ); + } + + return ( + <> + selectOnly([element.id])} + onTap={() => selectOnly([element.id])} + onDragEnd={(e) => { + updateElement(element.id, { x: e.target.x(), y: e.target.y() }); + }} + onTransformEnd={() => { + const node = ref.current; + if (!node) return; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + node.scaleX(1); + node.scaleY(1); + updateElement(element.id, { + x: node.x(), + y: node.y(), + width: Math.max(5, node.width() * scaleX), + height: Math.max(5, node.height() * scaleY), + rotation: node.rotation(), + }); + }} + /> + + + ); +} diff --git a/packages/web/src/editor/canvas/elements/line-element.tsx b/packages/web/src/editor/canvas/elements/line-element.tsx new file mode 100644 index 0000000..56bda06 --- /dev/null +++ b/packages/web/src/editor/canvas/elements/line-element.tsx @@ -0,0 +1,60 @@ +import { useRef } from "react"; +import { Line } from "react-konva"; +import type Konva from "konva"; +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; +import { ElementWrapper } from "./element-wrapper.tsx"; + +interface Props { + element: BaseElement; + isSelected: boolean; +} + +export function LineElement({ element, isSelected }: Props) { + const ref = useRef(null); + const updateElement = useEditorV2Store((s) => s.updateElement); + const selectOnly = useEditorV2Store((s) => s.selectOnly); + + const p = element.props as { + stroke?: string; + strokeWidth?: number; + }; + + return ( + <> + selectOnly([element.id])} + onTap={() => selectOnly([element.id])} + onDragEnd={(e) => { + updateElement(element.id, { x: e.target.x(), y: e.target.y() }); + }} + onTransformEnd={() => { + const node = ref.current; + if (!node) return; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + node.scaleX(1); + node.scaleY(1); + updateElement(element.id, { + x: node.x(), + y: node.y(), + width: Math.max(5, element.width * scaleX), + height: element.height * scaleY, + rotation: node.rotation(), + }); + }} + /> + } isSelected={isSelected} /> + + ); +} diff --git a/packages/web/src/editor/elements/qr-code-element.tsx b/packages/web/src/editor/canvas/elements/qr-element.tsx similarity index 56% rename from packages/web/src/editor/elements/qr-code-element.tsx rename to packages/web/src/editor/canvas/elements/qr-element.tsx index deb9ba2..c7163e8 100644 --- a/packages/web/src/editor/elements/qr-code-element.tsx +++ b/packages/web/src/editor/canvas/elements/qr-element.tsx @@ -1,31 +1,41 @@ -import { useRef, useEffect, useState } from "react"; -import { Image } from "react-konva"; +import { useEffect, useState, useMemo, useRef } from "react"; +import { Image as KonvaImage, Rect } from "react-konva"; import type Konva from "konva"; import QRCode from "qrcode"; -import type { EditorElement, QrCodeProps } from "../../store/types.ts"; -import { useEditorStore } from "../../store/editor-store.ts"; +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; import { ElementWrapper } from "./element-wrapper.tsx"; -interface QrCodeElementProps { - element: EditorElement; +interface Props { + element: BaseElement; isSelected: boolean; } -export function QrCodeElement({ element, isSelected }: QrCodeElementProps) { +export function QrElement({ element, isSelected }: Props) { const ref = useRef(null); - const props = element.props as QrCodeProps; - const updateElement = useEditorStore((s) => s.updateElement); - const pushHistory = useEditorStore((s) => s.pushHistory); - const setSelectedId = useEditorStore((s) => s.setSelectedId); + const updateElement = useEditorV2Store((s) => s.updateElement); + const selectOnly = useEditorV2Store((s) => s.selectOnly); + + const p = element.props as { + content?: string; + errorCorrectionLevel?: string; + }; + + const cacheKey = useMemo( + () => `${p.content || ""}|${p.errorCorrectionLevel || "M"}`, + [p.content, p.errorCorrectionLevel], + ); + const [image, setImage] = useState(null); useEffect(() => { const canvas = document.createElement("canvas"); QRCode.toCanvas( canvas, - props.content || " ", + p.content || " ", { - errorCorrectionLevel: props.errorCorrectionLevel, + errorCorrectionLevel: + (p.errorCorrectionLevel as "L" | "M" | "Q" | "H") || "M", margin: 1, scale: 4, color: { dark: "#000000", light: "#ffffff" }, @@ -37,13 +47,25 @@ export function QrCodeElement({ element, isSelected }: QrCodeElementProps) { img.onload = () => setImage(img); }, ); - }, [props.content, props.errorCorrectionLevel]); + }, [cacheKey, p.content, p.errorCorrectionLevel]); - if (!image) return null; + if (!image) { + return ( + + ); + } return ( <> - setSelectedId(element.id)} - onTap={() => setSelectedId(element.id)} - onDragStart={() => pushHistory()} + onClick={() => selectOnly([element.id])} + onTap={() => selectOnly([element.id])} onDragEnd={(e) => { updateElement(element.id, { x: e.target.x(), y: e.target.y() }); }} onTransformEnd={() => { const node = ref.current; if (!node) return; - pushHistory(); const scaleX = node.scaleX(); const scaleY = node.scaleY(); node.scaleX(1); diff --git a/packages/web/src/editor/canvas/elements/rect-element.tsx b/packages/web/src/editor/canvas/elements/rect-element.tsx new file mode 100644 index 0000000..a5a067e --- /dev/null +++ b/packages/web/src/editor/canvas/elements/rect-element.tsx @@ -0,0 +1,62 @@ +import { useRef } from "react"; +import { Rect } from "react-konva"; +import type Konva from "konva"; +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; +import { ElementWrapper } from "./element-wrapper.tsx"; + +interface Props { + element: BaseElement; + isSelected: boolean; +} + +export function RectElement({ element, isSelected }: Props) { + const ref = useRef(null); + const updateElement = useEditorV2Store((s) => s.updateElement); + const selectOnly = useEditorV2Store((s) => s.selectOnly); + + const p = element.props as { + fill?: string; + stroke?: string; + strokeWidth?: number; + }; + + return ( + <> + selectOnly([element.id])} + onTap={() => selectOnly([element.id])} + onDragEnd={(e) => { + updateElement(element.id, { x: e.target.x(), y: e.target.y() }); + }} + onTransformEnd={() => { + const node = ref.current; + if (!node) return; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + node.scaleX(1); + node.scaleY(1); + updateElement(element.id, { + x: node.x(), + y: node.y(), + width: Math.max(5, node.width() * scaleX), + height: Math.max(5, node.height() * scaleY), + rotation: node.rotation(), + }); + }} + /> + + + ); +} diff --git a/packages/web/src/editor/canvas/elements/text-element.tsx b/packages/web/src/editor/canvas/elements/text-element.tsx new file mode 100644 index 0000000..44cbce7 --- /dev/null +++ b/packages/web/src/editor/canvas/elements/text-element.tsx @@ -0,0 +1,195 @@ +import { useRef, useEffect, useCallback } from "react"; +import { Text } from "react-konva"; +import type Konva from "konva"; +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; +import { ElementWrapper } from "./element-wrapper.tsx"; + +interface Props { + element: BaseElement; + isSelected: boolean; +} + +export function TextElement({ element, isSelected }: Props) { + const ref = useRef(null); + const updateElement = useEditorV2Store((s) => s.updateElement); + const selectOnly = useEditorV2Store((s) => s.selectOnly); + const editingTextId = useEditorV2Store((s) => s.editingTextId); + + const isEditing = editingTextId === element.id; + + const p = element.props as { + text?: string; + fontSize?: number; + fontFamily?: string; + fontWeight?: number; + letterSpacing?: number; + fill?: string; + align?: string; + italic?: boolean; + }; + + const fontStyle = + [p.italic ? "italic" : "", p.fontWeight && p.fontWeight >= 700 ? "bold" : ""] + .filter(Boolean) + .join(" ") || "normal"; + + // Auto-measure height after font loads + useEffect(() => { + const node = ref.current; + if (!node) return; + const measure = () => { + if (!ref.current) return; + const h = ref.current.height(); + if (Math.abs(h - element.height) > 1) { + updateElement(element.id, { height: h }); + } + }; + document.fonts.load(`16px "${p.fontFamily || "Inter"}"`).then(() => { + node.getLayer()?.batchDraw(); + requestAnimationFrame(measure); + }); + requestAnimationFrame(measure); + }, [p.text, p.fontSize, p.fontFamily, fontStyle, element.width, element.id, element.height, updateElement]); + + const startEditing = useCallback(() => { + const node = ref.current; + if (!node) return; + + const stage = node.getStage(); + if (!stage) return; + + useEditorV2Store.setState({ editingTextId: element.id }); + + // Measure position BEFORE hiding + const absPos = node.getAbsolutePosition(); + const stageContainer = stage.container(); + const stageRect = stageContainer.getBoundingClientRect(); + const scale = node.getAbsoluteScale(); + + // Hide the Konva text while editing + node.hide(); + node.getLayer()?.batchDraw(); + + const textarea = document.createElement("textarea"); + textarea.value = p.text || ""; + const borderWidth = 2; + textarea.style.position = "fixed"; + textarea.style.left = `${stageRect.left + absPos.x - borderWidth}px`; + textarea.style.top = `${stageRect.top + absPos.y - borderWidth}px`; + textarea.style.width = `${element.width * scale.x}px`; + textarea.style.height = `${element.height * scale.y}px`; + textarea.style.boxSizing = "content-box"; + const scaledFontSize = (p.fontSize || 18) * scale.y; + textarea.style.fontSize = `${scaledFontSize}px`; + textarea.style.fontFamily = `'${p.fontFamily || "Inter"}', sans-serif`; + // Match Konva's fontStyle exactly: "normal", "bold", "italic", or "italic bold" + const isBold = fontStyle.includes("bold"); + const isItalic = fontStyle.includes("italic"); + textarea.style.fontWeight = isBold ? "bold" : "normal"; + textarea.style.fontStyle = isItalic ? "italic" : "normal"; + textarea.style.letterSpacing = `${(p.letterSpacing || 0) * scale.x}px`; + textarea.style.color = p.fill || "#000000"; + textarea.style.textAlign = (p.align as string) || "left"; + textarea.style.border = "2px solid var(--color-accent)"; + textarea.style.borderRadius = "2px"; + textarea.style.background = "rgba(255,255,255,0.95)"; + textarea.style.outline = "none"; + textarea.style.padding = "0px"; + textarea.style.margin = "0px"; + textarea.style.resize = "none"; + textarea.style.overflow = "hidden"; + textarea.style.lineHeight = `${node.lineHeight()}`; + textarea.style.wordBreak = "break-word"; + textarea.style.whiteSpace = "pre-wrap"; + textarea.style.zIndex = "1000"; + textarea.style.transformOrigin = "left top"; + if (element.rotation) { + textarea.style.transform = `rotate(${element.rotation}deg)`; + } + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + // Auto-resize height only on input (not on creation to avoid initial growth) + const autoSize = () => { + textarea.style.height = "auto"; + textarea.style.height = `${textarea.scrollHeight}px`; + }; + textarea.addEventListener("input", autoSize); + + const commit = () => { + const newText = textarea.value; + updateElement(element.id, { props: { text: newText } }); + useEditorV2Store.setState({ editingTextId: null }); + document.body.removeChild(textarea); + node.show(); + node.getLayer()?.batchDraw(); + }; + + textarea.addEventListener("blur", commit); + textarea.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + // Cancel — restore original text + textarea.removeEventListener("blur", commit); + useEditorV2Store.setState({ editingTextId: null }); + document.body.removeChild(textarea); + node.show(); + node.getLayer()?.batchDraw(); + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + textarea.blur(); + } + }); + }, [element, p, updateElement]); + + return ( + <> + selectOnly([element.id])} + onTap={() => selectOnly([element.id])} + onDblClick={startEditing} + onDblTap={startEditing} + onDragEnd={(e) => { + updateElement(element.id, { x: e.target.x(), y: e.target.y() }); + }} + onTransformEnd={() => { + const node = ref.current; + if (!node) return; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + node.scaleX(1); + node.scaleY(1); + const newFontSize = Math.max(4, Math.round((p.fontSize || 18) * scaleY)); + const newWidth = Math.max(5, node.width() * scaleX); + updateElement(element.id, { + x: node.x(), + y: node.y(), + width: newWidth, + height: node.height(), + rotation: node.rotation(), + props: { fontSize: newFontSize }, + }); + }} + /> + {!isEditing && } + + ); +} diff --git a/packages/web/src/editor/canvas/label-paper.tsx b/packages/web/src/editor/canvas/label-paper.tsx new file mode 100644 index 0000000..cb1518d --- /dev/null +++ b/packages/web/src/editor/canvas/label-paper.tsx @@ -0,0 +1,21 @@ +import { Rect } from "react-konva"; +import { useEditorV2Store } from "../../store/editor-store.ts"; + +export function LabelPaper() { + const label = useEditorV2Store((s) => s.label); + + return ( + + ); +} diff --git a/packages/web/src/editor/canvas/selection-handles.tsx b/packages/web/src/editor/canvas/selection-handles.tsx new file mode 100644 index 0000000..e91760e --- /dev/null +++ b/packages/web/src/editor/canvas/selection-handles.tsx @@ -0,0 +1,236 @@ +import { useCallback, useRef } from "react"; +import { Group, Rect, Circle } from "react-konva"; +import type Konva from "konva"; +import { + useEditorV2Store, + type BaseElement, +} from "../../store/editor-store.ts"; + +const ACCENT_MAP: Record = { + cyan: "#2ad0ff", + amber: "#ff9f40", + graphite: "#e6e6eb", + violet: "#a78bfa", + forest: "#6ecc93", + paper: "#e2bc7a", +}; + +const HANDLE_SIZE = 8; +const ROTATION_HANDLE_OFFSET = 24; + +type HandleAnchor = + | "top-left" + | "top-center" + | "top-right" + | "middle-left" + | "middle-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + +const HANDLE_POSITIONS: { anchor: HandleAnchor; xFrac: number; yFrac: number }[] = [ + { anchor: "top-left", xFrac: 0, yFrac: 0 }, + { anchor: "top-center", xFrac: 0.5, yFrac: 0 }, + { anchor: "top-right", xFrac: 1, yFrac: 0 }, + { anchor: "middle-left", xFrac: 0, yFrac: 0.5 }, + { anchor: "middle-right", xFrac: 1, yFrac: 0.5 }, + { anchor: "bottom-left", xFrac: 0, yFrac: 1 }, + { anchor: "bottom-center", xFrac: 0.5, yFrac: 1 }, + { anchor: "bottom-right", xFrac: 1, yFrac: 1 }, +]; + +function cursorForAnchor(anchor: HandleAnchor): string { + const map: Record = { + "top-left": "nwse-resize", + "top-center": "ns-resize", + "top-right": "nesw-resize", + "middle-left": "ew-resize", + "middle-right": "ew-resize", + "bottom-left": "nesw-resize", + "bottom-center": "ns-resize", + "bottom-right": "nwse-resize", + }; + return map[anchor]; +} + +interface Props { + element: BaseElement; + zoom: number; +} + +export function SelectionHandles({ element, zoom }: Props) { + const theme = useEditorV2Store((s) => s.theme); + const accentColor = ACCENT_MAP[theme] || ACCENT_MAP.cyan; + const updateElement = useEditorV2Store((s) => s.updateElement); + const dragStart = useRef<{ + anchor: HandleAnchor; + startX: number; + startY: number; + origX: number; + origY: number; + origW: number; + origH: number; + } | null>(null); + + const handleSize = HANDLE_SIZE / zoom; + const half = handleSize / 2; + + const onResizeStart = useCallback( + (anchor: HandleAnchor, e: Konva.KonvaEventObject) => { + e.cancelBubble = true; + const stage = e.target.getStage(); + if (!stage) return; + + dragStart.current = { + anchor, + startX: e.evt.clientX, + startY: e.evt.clientY, + origX: element.x, + origY: element.y, + origW: element.width, + origH: element.height, + }; + + const onMove = (ev: MouseEvent) => { + if (!dragStart.current) return; + const d = dragStart.current; + const dx = (ev.clientX - d.startX) / zoom; + const dy = (ev.clientY - d.startY) / zoom; + + let newX = d.origX; + let newY = d.origY; + let newW = d.origW; + let newH = d.origH; + + // Horizontal + if (anchor.includes("left")) { + newX = d.origX + dx; + newW = d.origW - dx; + } else if (anchor.includes("right")) { + newW = d.origW + dx; + } + + // Vertical + if (anchor.includes("top")) { + newY = d.origY + dy; + newH = d.origH - dy; + } else if (anchor.includes("bottom")) { + newH = d.origH + dy; + } + + // Enforce minimum size + const minSize = 8; + if (newW < minSize) { + if (anchor.includes("left")) newX = d.origX + d.origW - minSize; + newW = minSize; + } + if (newH < minSize) { + if (anchor.includes("top")) newY = d.origY + d.origH - minSize; + newH = minSize; + } + + updateElement(element.id, { + x: Math.round(newX), + y: Math.round(newY), + width: Math.round(newW), + height: Math.round(newH), + }); + }; + + const onUp = () => { + dragStart.current = null; + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + }; + + document.body.style.cursor = cursorForAnchor(anchor); + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [element, zoom, updateElement], + ); + + const onRotateStart = useCallback( + (e: Konva.KonvaEventObject) => { + e.cancelBubble = true; + const centerX = element.x + element.width / 2; + const centerY = element.y + element.height / 2; + + const onMove = (ev: MouseEvent) => { + const stage = e.target.getStage(); + if (!stage) return; + const pointer = stage.getPointerPosition(); + if (!pointer) return; + // Convert pointer to label coordinates + const labelGroup = stage.findOne("#label-group") as Konva.Group | undefined; + if (!labelGroup) return; + const transform = labelGroup.getAbsoluteTransform().copy().invert(); + const pos = transform.point(pointer); + + const angle = Math.atan2(pos.y - centerY, pos.x - centerX); + let deg = (angle * 180) / Math.PI + 90; + // Snap to 15° increments when shift held + if (ev.shiftKey) { + deg = Math.round(deg / 15) * 15; + } + updateElement(element.id, { rotation: Math.round(deg) }); + }; + + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + }; + + document.body.style.cursor = "grabbing"; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [element, updateElement], + ); + + return ( + + {/* Selection outline */} + + + {/* Resize handles */} + {HANDLE_POSITIONS.map(({ anchor, xFrac, yFrac }) => ( + onResizeStart(anchor, e)} + /> + ))} + + {/* Rotation handle */} + + + ); +} diff --git a/packages/web/src/editor/connect-flow/connect-flow.tsx b/packages/web/src/editor/connect-flow/connect-flow.tsx new file mode 100644 index 0000000..e0cda28 --- /dev/null +++ b/packages/web/src/editor/connect-flow/connect-flow.tsx @@ -0,0 +1,343 @@ +import { useEffect, useCallback, useRef } from "react"; +import { Bluetooth, X, Check } from "lucide-react"; +import { useEditorV2Store } from "../../store/editor-store.ts"; +import { usePrinterStore } from "../../store/printer-store.ts"; +import { useWebBluetooth } from "../../hooks/use-web-bluetooth.ts"; +import { getDevice } from "@thermoprint/core"; +import { mmToPx } from "../../utils/px-mm.ts"; + +type Step = "idle" | "scanning" | "pairing" | "connected" | "error"; + +function useConnectFlowStep(): Step { + const cfStep = useEditorV2Store((s) => s.connectFlow.step); + return cfStep; +} + +function setFlow(patch: Partial<{ open: boolean; step: Step }>) { + useEditorV2Store.setState((s) => ({ + connectFlow: { ...s.connectFlow, ...patch }, + })); +} + +export function ConnectFlow() { + const open = useEditorV2Store((s) => s.connectFlow.open); + const step = useConnectFlowStep(); + const { scan, connect } = useWebBluetooth(); + + // Bridge printer store state into editor store + const isConnected = usePrinterStore((s) => s.isConnected); + const isConnecting = usePrinterStore((s) => s.isConnecting); + const peripheral = usePrinterStore((s) => s.peripheral); + const battery = usePrinterStore((s) => s.battery); + const error = usePrinterStore((s) => s.error); + + // Sync printer store → editorV2Store (connection + device profile defaults) + useEffect(() => { + if (isConnected && peripheral) { + useEditorV2Store.setState({ + printer: { + connected: true, + name: peripheral.name || "Printer", + battery: battery >= 0 ? battery : 0, + model: peripheral.name?.split(" ")[1] || "", + }, + }); + + // Apply device profile defaults to label size and paper type + const modelId = usePrinterStore.getState().modelId; + if (modelId) { + const profile = getDevice(modelId); + const lc = profile?.labelConfig; + if (lc) { + const def = lc.defaultSize; + useEditorV2Store.setState({ + paperType: lc.defaultPaperType, + label: { + widthMm: def.widthMm, + heightMm: def.heightMm, + widthPx: mmToPx(def.widthMm), + heightPx: mmToPx(def.heightMm), + }, + printSettings: { + ...useEditorV2Store.getState().printSettings, + density: profile.defaults.density, + }, + }); + } + } + + setFlow({ step: "connected" }); + // Auto-dismiss after 1.2s + const t = setTimeout(() => setFlow({ open: false, step: "idle" }), 1200); + return () => clearTimeout(t); + } + }, [isConnected, peripheral, battery]); + + useEffect(() => { + if (isConnecting) { + setFlow({ step: "pairing" }); + } + }, [isConnecting]); + + useEffect(() => { + if (error && step !== "idle") { + setFlow({ step: "error" }); + } + }, [error, step]); + + // Sync disconnection + useEffect(() => { + if (!isConnected) { + useEditorV2Store.setState((s) => ({ + printer: { ...s.printer, connected: false }, + })); + } + }, [isConnected]); + + // Keep battery in sync (it arrives asynchronously after connect) + useEffect(() => { + if (isConnected && battery >= 0) { + useEditorV2Store.setState((s) => ({ + printer: { ...s.printer, battery }, + })); + } + }, [isConnected, battery]); + + const handleScan = useCallback(async () => { + setFlow({ step: "scanning" }); + try { + // requestDevice opens native picker — blocks until user selects or cancels + await scan(); + // After scan resolves, peripheral is set in printer store + // Auto-connect + const p = usePrinterStore.getState().peripheral; + if (p) { + setFlow({ step: "pairing" }); + await connect(p); + } + } catch { + // User cancelled the native picker — close the whole flow + setFlow({ open: false, step: "idle" }); + } + }, [scan, connect]); + + // Auto-trigger scan when the flow opens at "idle" step + const prevStepRef = useRef("idle"); + const didAutoScan = useRef(false); + useEffect(() => { + // Reset guard when flow closes or when returning to idle from error/other steps + if (!open) { + didAutoScan.current = false; + } else if (step === "idle" && prevStepRef.current !== "idle") { + didAutoScan.current = false; + } + prevStepRef.current = step; + + if (open && step === "idle" && !didAutoScan.current) { + didAutoScan.current = true; + handleScan(); + } + }, [open, step, handleScan]); + + const close = () => setFlow({ open: false, step: "idle" }); + + if (!open) return null; + + const printerName = usePrinterStore.getState().peripheral?.name || "Printer"; + const printerBattery = usePrinterStore.getState().battery; + + return ( +
{ + if (e.target === e.currentTarget) close(); + }} + > +
+ {/* Header */} +
+
+
+ +
+
+
+ Connect printer +
+
+ {step === "idle" && + "Pair a thermal printer over Bluetooth to start printing."} + {step === "scanning" && + "Select your printer from the browser's Bluetooth picker…"} + {step === "pairing" && + "Establishing connection. This usually takes a few seconds."} + {step === "connected" && "All set."} + {step === "error" && (error || "Something went wrong.")} +
+
+
+ +
+ + {/* Body: Idle */} + {step === "idle" && ( +
+
+
+ Before you begin +
+
    + {[ + "Power on the printer and hold the power button until the LED blinks blue.", + "Make sure Bluetooth is enabled on this device.", + "Keep the printer within 3 meters of this device.", + ].map((t, i) => ( +
  1. +
    + {i + 1} +
    + {t} +
  2. + ))} +
+
+
+
+ Uses Web Bluetooth. + Chrome, Edge, or Opera. +
+ +
+
+ )} + + {/* Body: Scanning / Pairing */} + {(step === "scanning" || step === "pairing") && ( +
+
+
+
+
+ + +
+ + {step === "pairing" ? "Pairing" : "Waiting for selection"} + +
+
+
+ {step === "scanning" && ( + <> +
+
+
+
+
+
+ Choose your printer from the browser dialog… +
+
+ The native Bluetooth picker should be open +
+ + )} + {step === "pairing" && ( + <> +
+
+ Connecting to{" "} + + {peripheral?.name || "device"} + + … +
+ + )} +
+
+
+ + Tip: hold power for 3s to put the printer in pairing mode. + + BLE · 2.4GHz +
+
+ )} + + {/* Body: Connected */} + {step === "connected" && ( +
+
+
+ +
+
+
+ Connected to {printerName} +
+
+ Ready to print + {printerBattery >= 0 && ` · battery ${printerBattery}%`} +
+
+
+
+ )} + + {/* Body: Error */} + {step === "error" && ( +
+
+
+ Connection failed +
+
+ {error || "Unknown error. Try again."} +
+
+
+ +
+
+ )} + + {/* Footer */} + {step !== "connected" && ( +
+
+ + Web Bluetooth + +
+ +
+ )} +
+
+ ); +} diff --git a/packages/web/src/editor/dock/dock-btn.tsx b/packages/web/src/editor/dock/dock-btn.tsx new file mode 100644 index 0000000..71a3ab4 --- /dev/null +++ b/packages/web/src/editor/dock/dock-btn.tsx @@ -0,0 +1,77 @@ +import type { LucideIcon } from "lucide-react"; + +interface Props { + icon: LucideIcon; + label: string; + shortcut?: string; + active?: boolean; + onClick?: () => void; +} + +export function DockBtn({ + icon: Icon, + label, + shortcut, + active, + onClick, +}: Props) { + return ( + + ); +} diff --git a/packages/web/src/editor/dock/dock-divider.tsx b/packages/web/src/editor/dock/dock-divider.tsx new file mode 100644 index 0000000..2ca4ee8 --- /dev/null +++ b/packages/web/src/editor/dock/dock-divider.tsx @@ -0,0 +1,7 @@ +export function DockDivider() { + return ( +
+
+
+ ); +} diff --git a/packages/web/src/editor/dock/dock-group.tsx b/packages/web/src/editor/dock/dock-group.tsx new file mode 100644 index 0000000..7639509 --- /dev/null +++ b/packages/web/src/editor/dock/dock-group.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +export function DockGroup({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/web/src/editor/dock/dock.tsx b/packages/web/src/editor/dock/dock.tsx new file mode 100644 index 0000000..cb83c72 --- /dev/null +++ b/packages/web/src/editor/dock/dock.tsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from "react"; +import { + Type, + QrCode, + Barcode, + ImageIcon, + Square, + Minus, + Layers, + Folder, + Settings, + MoreHorizontal, +} from "lucide-react"; +import { DockBtn } from "./dock-btn.tsx"; +import { DockGroup } from "./dock-group.tsx"; +import { DockDivider } from "./dock-divider.tsx"; +import { Kbd } from "./kbd.tsx"; +import { + addTextEl, + addQrEl, + addBarcodeEl, + addImageEl, + addRectEl, + addLineEl, +} from "../../lib/keyboard.ts"; +import { LayersFlyout } from "./flyouts/layers-flyout.tsx"; +import { LibraryFlyout } from "./flyouts/library-flyout.tsx"; +import { PrintSettingsFlyout } from "./flyouts/print-settings-flyout.tsx"; + +type FlyoutKey = "layers" | "library" | "print" | "more-tools" | null; + +export function Dock() { + const [openFlyout, setOpenFlyout] = useState(null); + + const toggle = (key: Exclude) => + setOpenFlyout((cur) => (cur === key ? null : key)); + + // Close flyout on Escape + useEffect(() => { + const h = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpenFlyout(null); + }; + window.addEventListener("keydown", h); + return () => window.removeEventListener("keydown", h); + }, []); + + // Listen for "open library" event from file chip menu + useEffect(() => { + const h = () => setOpenFlyout("library"); + window.addEventListener("thermoprint:open-library", h); + return () => window.removeEventListener("thermoprint:open-library", h); + }, []); + + return ( + <> + {openFlyout && ( +
setOpenFlyout(null)} + /> + )} + {openFlyout === "layers" && ( + setOpenFlyout(null)} /> + )} + {openFlyout === "library" && ( + setOpenFlyout(null)} /> + )} + {openFlyout === "print" && ( + setOpenFlyout(null)} /> + )} + {openFlyout === "more-tools" && ( +
+
+ {[ + { icon: Type, label: "Text", fn: addTextEl }, + { icon: QrCode, label: "QR Code", fn: addQrEl }, + { icon: Barcode, label: "Barcode", fn: addBarcodeEl }, + { icon: ImageIcon, label: "Image", fn: addImageEl }, + { icon: Square, label: "Rectangle", fn: addRectEl }, + { icon: Minus, label: "Line", fn: addLineEl }, + ].map((t) => ( + + ))} +
+
+ )} + +
+
+ {/* Ambient cyan glow */} +
+ + {/* Dock surface */} +
+ {/* Desktop: all tools */} +
+ + + + + + + + + +
+ + {/* Mobile: Text + Image + More */} +
+ + + + toggle("more-tools")} + active={openFlyout === "more-tools"} + /> + + +
+ + + toggle("layers")} + active={openFlyout === "layers"} + /> + toggle("library")} + active={openFlyout === "library"} + /> + toggle("print")} + active={openFlyout === "print"} + /> + +
+
+ + {/* Shortcut legend */} +
+ + Space pan + + + + +scroll zoom + + + + ⌘K commands + +
+
+ + ); +} diff --git a/packages/web/src/editor/dock/flyouts/layers-flyout.tsx b/packages/web/src/editor/dock/flyouts/layers-flyout.tsx new file mode 100644 index 0000000..6dd466b --- /dev/null +++ b/packages/web/src/editor/dock/flyouts/layers-flyout.tsx @@ -0,0 +1,115 @@ +import { + Type, + QrCode, + Barcode, + Square, + Minus, + ImageIcon, + Layers, + X, + ChevronUp, + ChevronDown, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useEditorV2Store, type BaseElement } from "../../../store/editor-store.ts"; + +const ICON_FOR_TYPE: Record = { + text: Type, + image: ImageIcon, + qrcode: QrCode, + barcode: Barcode, + rect: Square, + line: Minus, +}; + +function labelFor(el: BaseElement): string { + if (el.type === "text") return (el.props.text as string) || "Text"; + const labels: Record = { + qrcode: "QR Code", + barcode: "Barcode", + rect: "Rectangle", + line: "Line", + image: "Image", + }; + return labels[el.type] || el.type; +} + +interface Props { + onClose: () => void; +} + +export function LayersFlyout({ onClose }: Props) { + const elements = useEditorV2Store((s) => s.elements); + const selectedIds = useEditorV2Store((s) => s.selectedIds); + const selectOnly = useEditorV2Store((s) => s.selectOnly); + const zOrder = useEditorV2Store((s) => s.zOrder); + + const reversed = [...elements].reverse(); + + return ( +
+
+
+ + Layers + + {elements.length} + +
+ +
+
+ {reversed.length === 0 && ( +
+ No elements yet +
+ )} + {reversed.map((el) => { + const Icon = ICON_FOR_TYPE[el.type] || Square; + const sel = selectedIds.includes(el.id); + return ( +
selectOnly([el.id])} + className={`group flex items-center gap-2 px-3 h-7 cursor-pointer ${ + sel + ? "bg-accent/10 text-accent" + : "text-ink-300 hover:bg-white/5" + }`} + > + + + {labelFor(el)} + + + {Math.round(el.width)}×{Math.round(el.height)} + +
+ + +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/web/src/editor/dock/flyouts/library-flyout.tsx b/packages/web/src/editor/dock/flyouts/library-flyout.tsx new file mode 100644 index 0000000..8c0b05d --- /dev/null +++ b/packages/web/src/editor/dock/flyouts/library-flyout.tsx @@ -0,0 +1,501 @@ +import { useState, useEffect, useRef, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { + Folder, + X, + Search, + Plus, + MoreHorizontal, + Download, + Upload, + Copy, + Trash2, +} from "lucide-react"; +import QRCode from "qrcode"; +import JsBarcode from "jsbarcode"; +import { useEditorV2Store, type BaseElement } from "../../../store/editor-store.ts"; +import { + downloadLabelAsJson, + importLabelFromJson, + type SavedLabel, +} from "../../../lib/library.ts"; + +// ---- Thumbnail helpers ---- + +function useQrDataUrl(content: string, ecl: string): string | null { + const [url, setUrl] = useState(null); + const key = `${content}|${ecl}`; + useEffect(() => { + let cancelled = false; + QRCode.toDataURL(content || " ", { + errorCorrectionLevel: (ecl as "L" | "M" | "Q" | "H") || "M", + margin: 1, + scale: 4, + color: { dark: "#000000", light: "#ffffff" }, + }).then((dataUrl) => { + if (!cancelled) setUrl(dataUrl); + }).catch(() => {}); + return () => { cancelled = true; }; + }, [key, content, ecl]); + return url; +} + +function useBarcodeDataUrl(content: string, format: string): string | null { + return useMemo(() => { + try { + const canvas = document.createElement("canvas"); + JsBarcode(canvas, content || "0000", { + format: format || "CODE128", + displayValue: false, + margin: 0, + height: 60, + }); + return canvas.toDataURL(); + } catch { + return null; + } + }, [content, format]); +} + +function QrThumbnail({ el }: { el: BaseElement }) { + const p = el.props as Record; + const url = useQrDataUrl( + (p.content as string) || "", + (p.errorCorrectionLevel as string) || "M", + ); + if (!url) { + return ; + } + return ( + + ); +} + +function BarcodeThumbnail({ el }: { el: BaseElement }) { + const p = el.props as Record; + const url = useBarcodeDataUrl( + (p.content as string) || "0000", + (p.format as string) || "CODE128", + ); + if (!url) { + return ; + } + return ( + + ); +} + +// ---- Thumbnail ---- + +function LabelThumbnail({ + doc, + maxW = 200, + maxH = 92, +}: { + doc: Pick; + maxW?: number; + maxH?: number; +}) { + const { label, elements } = doc; + const s = Math.min(maxW / label.widthPx, maxH / label.heightPx); + const w = label.widthPx * s; + const h = label.heightPx * s; + + return ( + + {elements.map((el: BaseElement) => { + switch (el.type) { + case "text": { + const p = el.props as Record; + const align = (p.align as string) ?? "left"; + const textX = + align === "center" + ? el.x + el.width / 2 + : align === "right" + ? el.x + el.width + : el.x; + const anchor = + align === "center" + ? "middle" + : align === "right" + ? "end" + : "start"; + const weight = (p.fontWeight as number) ?? 400; + const italic = p.italic as boolean | undefined; + const letterSpacing = (p.letterSpacing as number) ?? 0; + return ( + + {p.text as string} + + ); + } + case "qrcode": + return ; + case "barcode": + return ; + case "line": + return ( + + ); + case "rect": + return ( + + ); + case "image": { + const src = el.props.src as string; + return src ? ( + + ) : ( + + ); + } + default: + return null; + } + })} + + ); +} + +// ---- Relative time ---- + +function relTime(ts: number): string { + const diff = Date.now() - ts; + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + if (diff < 2 * 86_400_000) return "yesterday"; + if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)}d ago`; + return new Date(ts).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +// ---- Context menu ---- + +interface MenuState { + id: string; + x: number; + y: number; +} + +// ---- Main ---- + +interface Props { + onClose: () => void; +} + +export function LibraryFlyout({ onClose }: Props) { + const library = useEditorV2Store((s) => s.library); + const currentLabelId = useEditorV2Store((s) => s.currentLabelId); + const openLabel = useEditorV2Store((s) => s.openLabel); + const newLabel = useEditorV2Store((s) => s.newLabel); + const deleteLabel = useEditorV2Store((s) => s.deleteLabel); + const duplicateLabel = useEditorV2Store((s) => s.duplicateLabel); + const importLabel = useEditorV2Store((s) => s.importLabel); + + const [query, setQuery] = useState(""); + const [menu, setMenu] = useState(null); + const menuRef = useRef(null); + + // Close context menu on outside click + useEffect(() => { + if (!menu) return; + const h = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenu(null); + } + }; + // Defer so the opening click doesn't immediately close it + const frame = requestAnimationFrame(() => { + document.addEventListener("mousedown", h); + }); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener("mousedown", h); + }; + }, [menu]); + + const filtered = query.trim() + ? library.labels.filter((l) => + l.name.toLowerCase().includes(query.toLowerCase()), + ) + : library.labels; + + const handleImport = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + try { + const data = await importLabelFromJson(file); + importLabel(data); + } catch (err) { + console.error("Import failed:", err); + } + }; + input.click(); + }; + + return ( +
+ {/* Header */} +
+ {/* Row 1: title + close */} +
+
+ + Library + {library.labels.length} labels +
+ +
+ {/* Row 2 mobile / single row desktop: search + actions */} +
+
+ + Library + {library.labels.length} labels +
+
+
+ + setQuery(e.target.value)} + placeholder="Search labels..." + className="bg-transparent text-ui-sm text-ink-100 placeholder-ink-500 outline-none flex-1 min-w-0" + /> +
+ + + +
+
+ + {/* Grid */} +
+ {filtered.length === 0 ? ( +
+
+ +
+
+ {query ? "No matches" : "No saved labels"} +
+
+ {query + ? "Try a different search" + : 'Click "New label" to create your first label'} +
+
+ ) : ( +
+ {filtered.map((item) => { + const isCurrent = item.id === currentLabelId; + return ( +
+ + + {/* Card actions (visible on hover) */} +
+ + +
+
+ ); + })} +
+ )} +
+ + {/* Context menu — portaled to body so it escapes overflow clipping */} + {menu && createPortal( +
+ + + +
+ +
, + document.body, + )} +
+ ); +} diff --git a/packages/web/src/editor/dock/flyouts/print-settings-flyout.tsx b/packages/web/src/editor/dock/flyouts/print-settings-flyout.tsx new file mode 100644 index 0000000..2c239cf --- /dev/null +++ b/packages/web/src/editor/dock/flyouts/print-settings-flyout.tsx @@ -0,0 +1,365 @@ +import { Settings, X } from "lucide-react"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; +import { usePrinterStore } from "../../../store/printer-store.ts"; +import { getDevice, type LabelSizePreset } from "@thermoprint/core"; +import { mmToPx } from "../../../utils/px-mm.ts"; + +// Fallback sizes when no printer is connected +const FALLBACK_GAP_SIZES: LabelSizePreset[] = [ + { widthMm: 22, heightMm: 12 }, + { widthMm: 30, heightMm: 12 }, + { widthMm: 30, heightMm: 15 }, + { widthMm: 40, heightMm: 12 }, + { widthMm: 40, heightMm: 15 }, + { widthMm: 50, heightMm: 15 }, + { widthMm: 50, heightMm: 30 }, +]; +const FALLBACK_CONTINUOUS_SIZES: LabelSizePreset[] = [ + { widthMm: 22, heightMm: 12 }, + { widthMm: 30, heightMm: 15 }, + { widthMm: 40, heightMm: 12 }, + { widthMm: 40, heightMm: 15 }, + { widthMm: 50, heightMm: 15 }, +]; + +function useLabelConfig(): { + supportedPaperTypes: ("gap" | "continuous")[]; + sizesForPaperType: (pt: "gap" | "continuous") => LabelSizePreset[]; + hasProfile: boolean; +} { + const modelId = usePrinterStore((s) => s.modelId); + const profile = modelId ? getDevice(modelId) : null; + const lc = profile?.labelConfig; + + if (lc) { + return { + supportedPaperTypes: lc.supportedPaperTypes, + sizesForPaperType: (pt) => + pt === "gap" ? lc.gapSizes : lc.continuousSizes, + hasProfile: true, + }; + } + + return { + supportedPaperTypes: ["gap", "continuous"], + sizesForPaperType: (pt) => + pt === "gap" ? FALLBACK_GAP_SIZES : FALLBACK_CONTINUOUS_SIZES, + hasProfile: false, + }; +} + +interface Props { + onClose: () => void; +} + +export function PrintSettingsFlyout({ onClose }: Props) { + const printSettings = useEditorV2Store((s) => s.printSettings); + const paperType = useEditorV2Store((s) => s.paperType); + const label = useEditorV2Store((s) => s.label); + const isConnected = usePrinterStore((s) => s.isConnected); + const modelId = usePrinterStore((s) => s.modelId); + const uiScale = useEditorV2Store((s) => s.uiScale); + const setUiScale = useEditorV2Store((s) => s.setUiScale); + const theme = useEditorV2Store((s) => s.theme); + const mode = useEditorV2Store((s) => s.mode); + const setTheme = useEditorV2Store((s) => s.setTheme); + const setMode = useEditorV2Store((s) => s.setMode); + + const themes = [ + { id: "cyan", name: "Cyan", swatch: "#2ad0ff" }, + { id: "amber", name: "Amber", swatch: "#ff9f40" }, + { id: "graphite", name: "Graphite", swatch: "#e6e6eb" }, + { id: "violet", name: "Violet", swatch: "#a78bfa" }, + { id: "forest", name: "Forest", swatch: "#6ecc93" }, + { id: "paper", name: "Paper", swatch: "#e2bc7a" }, + ]; + + const { supportedPaperTypes, sizesForPaperType, hasProfile } = + useLabelConfig(); + const availableSizes = sizesForPaperType(paperType); + + const updateSettings = (patch: Partial) => + useEditorV2Store.setState((s) => ({ + printSettings: { ...s.printSettings, ...patch }, + })); + + const setPaperType = (pt: "gap" | "continuous") => { + useEditorV2Store.setState({ paperType: pt }); + // If current label size isn't valid for the new paper type, switch to the first valid size + const sizes = sizesForPaperType(pt); + const currentValid = sizes.some( + (s) => s.widthMm === label.widthMm && s.heightMm === label.heightMm, + ); + if (!currentValid && sizes.length > 0) { + const def = sizes[0]; + useEditorV2Store.setState({ + label: { + widthMm: def.widthMm, + heightMm: def.heightMm, + widthPx: mmToPx(def.widthMm), + heightPx: mmToPx(def.heightMm), + }, + }); + } + // Also sync to old printer store + usePrinterStore.getState().updateSettings({ paperType: pt }); + }; + + const setLabelSize = (widthMm: number, heightMm: number) => { + useEditorV2Store.setState({ + label: { + widthMm, + heightMm, + widthPx: mmToPx(widthMm), + heightPx: mmToPx(heightMm), + }, + }); + }; + + return ( +
+
+
+ + + Print settings + +
+ +
+
+ {/* Interface size */} +
+
+
+ Interface size +
+
+ + {Math.round(uiScale * 100)}% + + {uiScale !== 1 && ( + + )} +
+
+ setUiScale(parseFloat(e.target.value))} + className="w-full accent-accent" + /> +
+ 80% + 140% +
+
+ +
+ + {/* Theme */} +
+
+
+ Theme +
+
+ {(["dark", "light"] as const).map((m) => ( + + ))} +
+
+
+ {themes.map((t) => { + const active = theme === t.id; + return ( + + ); + })} +
+
+ +
+ + {/* Paper type */} +
+
+ Paper type +
+
+ {(["gap", "continuous"] as const).map((pt) => { + const supported = supportedPaperTypes.includes(pt); + return ( + + ); + })} +
+ {hasProfile && + supportedPaperTypes.length === 1 && ( +
+ Only {supportedPaperTypes[0]} supported by this printer +
+ )} +
+ + {/* Label size */} +
+
+ Label size +
+
+ {availableSizes.map((s) => { + const active = + s.widthMm === label.widthMm && s.heightMm === label.heightMm; + return ( + + ); + })} +
+
+ + {/* Density */} +
+
+ Density +
+
+ {[1, 2, 3].map((d) => ( + + ))} +
+
+ + {/* Dither */} +
+
+ Dither +
+
+ {[ + { v: "floyd-steinberg", l: "Floyd-S" }, + { v: "threshold", l: "Threshold" }, + { v: "none", l: "None" }, + ].map((o) => ( + + ))} +
+
+ + {/* Threshold */} +
+
+
+ Threshold +
+
+ {printSettings.threshold} +
+
+ updateSettings({ threshold: Number(e.target.value) })} + className="w-full accent-accent" + /> +
+ + {/* Printer info */} +
+
+ Printer + + {isConnected ? (modelId || "Connected") : "Not connected"} + +
+
+ Current label + + {label.widthMm} × {label.heightMm} mm · {paperType} + +
+
+
+
+ ); +} diff --git a/packages/web/src/editor/dock/kbd.tsx b/packages/web/src/editor/dock/kbd.tsx new file mode 100644 index 0000000..09d005e --- /dev/null +++ b/packages/web/src/editor/dock/kbd.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export function Kbd({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/packages/web/src/editor/editor.tsx b/packages/web/src/editor/editor.tsx new file mode 100644 index 0000000..c760fe6 --- /dev/null +++ b/packages/web/src/editor/editor.tsx @@ -0,0 +1,163 @@ +import { useRef, useCallback, useEffect } from "react"; +import type Konva from "konva"; +import { TopChrome } from "./top-chrome/top-chrome.tsx"; +import { Canvas } from "./canvas/canvas.tsx"; +import { Inspector } from "./inspector/inspector.tsx"; +import { StatusBar } from "./status-bar.tsx"; +import { Dock } from "./dock/dock.tsx"; +import { PrintProgressToast } from "./print-progress-toast.tsx"; +import { Palette } from "./palette/palette.tsx"; +import { ConnectFlow } from "./connect-flow/connect-flow.tsx"; +import { useKeyboardShortcuts, setPrintFn } from "../lib/keyboard.ts"; +import { useEditorV2Store } from "../store/editor-store.ts"; +import { usePrinterStore } from "../store/printer-store.ts"; +import { getPrinter } from "../hooks/use-web-bluetooth.ts"; +import type { RawImageData } from "@thermoprint/core"; + +function captureLabel( + stage: Konva.Stage, + widthPx: number, + heightPx: number, +): HTMLCanvasElement { + // The paper+elements layer is the second layer (index 1) + const layer = stage.getLayers()[1]; + const origStageW = stage.width(); + const origStageH = stage.height(); + const origLayerX = layer.x(); + const origLayerY = layer.y(); + const displayScale = layer.scaleX(); + + const displayW = widthPx * displayScale; + const displayH = heightPx * displayScale; + + // Temporarily resize so toCanvas captures only the label + stage.width(displayW); + stage.height(displayH); + layer.x(0); + layer.y(0); + + const canvas = stage.toCanvas({ pixelRatio: 1 / displayScale }); + + // Restore + stage.width(origStageW); + stage.height(origStageH); + layer.x(origLayerX); + layer.y(origLayerY); + stage.batchDraw(); + + return canvas; +} + +function rotateCanvas90CW(src: HTMLCanvasElement): HTMLCanvasElement { + const dst = document.createElement("canvas"); + dst.width = src.height; + dst.height = src.width; + const ctx = dst.getContext("2d")!; + ctx.translate(dst.width, 0); + ctx.rotate(Math.PI / 2); + ctx.drawImage(src, 0, 0); + return dst; +} + +export function Editor() { + const stageRef = useRef(null); + + useKeyboardShortcuts(); + + // Warn on close with unsaved changes + useEffect(() => { + const h = (e: BeforeUnloadEvent) => { + if (useEditorV2Store.getState().currentLabelDirty) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", h); + return () => window.removeEventListener("beforeunload", h); + }, []); + + const print = useCallback(async (copies: number): Promise => { + const printer = getPrinter(); + const stage = stageRef.current; + + // Need both a connected printer and a stage to print for real + if (!printer || !stage) return false; + + const { label } = useEditorV2Store.getState(); + const settings = usePrinterStore.getState().settings; + + // Deselect to avoid selection handles in the capture + useEditorV2Store.getState().clearSelection(); + + // Wait a frame for Konva to re-render without selection handles + await new Promise((r) => requestAnimationFrame(r)); + + // Capture the label region at 1:1 pixel resolution + const raw = captureLabel(stage, label.widthPx, label.heightPx); + const canvas = rotateCanvas90CW(raw); + const rotatedW = canvas.width; + const rotatedH = canvas.height; + + // Pad to printWidth if narrower than the print head + const printWidth = settings.printWidth; + let imageData: RawImageData; + + if (rotatedW < printWidth) { + const padded = document.createElement("canvas"); + padded.width = printWidth; + padded.height = rotatedH; + const pCtx = padded.getContext("2d")!; + pCtx.fillStyle = "#ffffff"; + pCtx.fillRect(0, 0, printWidth, rotatedH); + const offsetX = Math.floor((printWidth - rotatedW) / 2); + pCtx.drawImage(canvas, offsetX, 0); + const imgData = pCtx.getImageData(0, 0, printWidth, rotatedH); + imageData = { data: imgData.data, width: printWidth, height: rotatedH }; + } else { + const ctx = canvas.getContext("2d")!; + const imgData = ctx.getImageData(0, 0, rotatedW, rotatedH); + imageData = { data: imgData.data, width: rotatedW, height: rotatedH }; + } + + // Listen for real progress events from the printer + const offProgress = (p: { bytesSent: number; totalBytes: number }) => { + useEditorV2Store.setState({ printProgress: p }); + }; + printer.on("progress", offProgress); + + try { + // Send to the real printer + await printer.print(imageData, { + density: settings.density, + paperType: settings.paperType, + copies, + dither: settings.ditherMode as "floyd-steinberg" | "threshold" | "none", + threshold: settings.threshold, + }); + } finally { + printer.off("progress", offProgress); + } + + return true; + }, []); + + // Register print fn for keyboard shortcut + useEffect(() => { + setPrintFn(print); + return () => setPrintFn(null); + }, [print]); + + return ( +
+ +
+ + + + + +
+ + +
+ ); +} diff --git a/packages/web/src/editor/element-tree.tsx b/packages/web/src/editor/element-tree.tsx deleted file mode 100644 index db6bd63..0000000 --- a/packages/web/src/editor/element-tree.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useEditorStore } from "../store/editor-store.ts"; -import { Type, Image, QrCode, Barcode, Square, Minus, ChevronUp, ChevronDown, Trash2 } from "lucide-react"; -import type { EditorElement } from "../store/types.ts"; - -const typeIcons: Record = { - text: Type, - image: Image, - qrcode: QrCode, - barcode: Barcode, - rect: Square, - line: Minus, -}; - -function getElementLabel(el: EditorElement): string { - switch (el.type) { - case "text": { - const text = (el.props as { text: string }).text; - return text.length > 20 ? text.slice(0, 20) + "…" : text || "Empty text"; - } - case "image": - return "Image"; - case "qrcode": - return "QR Code"; - case "barcode": - return "Barcode"; - case "rect": - return "Rectangle"; - case "line": - return "Line"; - } -} - -export function ElementTree() { - const elements = useEditorStore((s) => s.elements); - const selectedId = useEditorStore((s) => s.selectedId); - const setSelectedId = useEditorStore((s) => s.setSelectedId); - const moveElement = useEditorStore((s) => s.moveElement); - const removeElement = useEditorStore((s) => s.removeElement); - - if (elements.length === 0) { - return ( -
-

Elements

-

No elements yet

-
- ); - } - - // Show in reverse order (top of z-stack first) - const reversed = [...elements].reverse(); - - return ( -
-

Elements

-
- {reversed.map((el) => { - const Icon = typeIcons[el.type]; - const isSelected = el.id === selectedId; - return ( -
setSelectedId(isSelected ? null : el.id)} - > - - {getElementLabel(el)} -
- - - -
-
- ); - })} -
-
- ); -} diff --git a/packages/web/src/editor/elements/element-wrapper.tsx b/packages/web/src/editor/elements/element-wrapper.tsx deleted file mode 100644 index aaf26f6..0000000 --- a/packages/web/src/editor/elements/element-wrapper.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useEffect, useRef } from "react"; -import { Transformer } from "react-konva"; -import type Konva from "konva"; - - -interface ElementWrapperProps { - nodeRef: React.RefObject; - isSelected: boolean; -} - -export function ElementWrapper({ nodeRef, isSelected }: ElementWrapperProps) { - const trRef = useRef(null); - - useEffect(() => { - if (isSelected && trRef.current && nodeRef.current) { - trRef.current.nodes([nodeRef.current]); - trRef.current.getLayer()?.batchDraw(); - } - }, [isSelected, nodeRef]); - - if (!isSelected) return null; - - return ( - { - if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) { - return oldBox; - } - return newBox; - }} - anchorSize={12} - anchorCornerRadius={6} - anchorStroke="#2563eb" - anchorStrokeWidth={2} - anchorFill="#ffffff" - borderStroke="#2563eb" - borderStrokeWidth={2} - borderDash={[6, 3]} - rotateAnchorOffset={32} - rotateAnchorCursor={"grab"} - anchorStyleFunc={(anchor: Konva.Shape) => { - // Make the rotate anchor circular and distinct - if (anchor.hasName("rotater")) { - (anchor as unknown as Konva.Rect).cornerRadius(10); - anchor.fill("#2563eb"); - anchor.stroke("#1d4ed8"); - anchor.width(14); - anchor.height(14); - anchor.offsetX(7); - anchor.offsetY(7); - } - // Cursor feedback - anchor.on("mouseenter", () => { - const stage = anchor.getStage(); - if (stage) stage.container().style.cursor = anchor.hasName("rotater") ? "grab" : "nwse-resize"; - }); - anchor.on("mouseleave", () => { - const stage = anchor.getStage(); - if (stage) stage.container().style.cursor = "default"; - }); - }} - /> - ); -} diff --git a/packages/web/src/editor/elements/image-element.tsx b/packages/web/src/editor/elements/image-element.tsx deleted file mode 100644 index 46e20cd..0000000 --- a/packages/web/src/editor/elements/image-element.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useRef, useEffect, useState } from "react"; -import { Image } from "react-konva"; -import type Konva from "konva"; -import type { EditorElement, ImageProps } from "../../store/types.ts"; -import { useEditorStore } from "../../store/editor-store.ts"; -import { ElementWrapper } from "./element-wrapper.tsx"; - -interface ImageElementProps { - element: EditorElement; - isSelected: boolean; -} - -export function ImageElement({ element, isSelected }: ImageElementProps) { - const ref = useRef(null); - const props = element.props as ImageProps; - const updateElement = useEditorStore((s) => s.updateElement); - const pushHistory = useEditorStore((s) => s.pushHistory); - const setSelectedId = useEditorStore((s) => s.setSelectedId); - const [image, setImage] = useState(null); - - useEffect(() => { - const img = new window.Image(); - img.src = props.src; - img.onload = () => setImage(img); - }, [props.src]); - - if (!image) return null; - - return ( - <> - setSelectedId(element.id)} - onTap={() => setSelectedId(element.id)} - onDragStart={() => pushHistory()} - onDragEnd={(e) => { - updateElement(element.id, { x: e.target.x(), y: e.target.y() }); - }} - onTransformEnd={() => { - const node = ref.current; - if (!node) return; - pushHistory(); - const scaleX = node.scaleX(); - const scaleY = node.scaleY(); - node.scaleX(1); - node.scaleY(1); - updateElement(element.id, { - x: node.x(), - y: node.y(), - width: Math.max(5, node.width() * scaleX), - height: Math.max(5, node.height() * scaleY), - rotation: node.rotation(), - }); - }} - /> - - - ); -} diff --git a/packages/web/src/editor/elements/shape-element.tsx b/packages/web/src/editor/elements/shape-element.tsx deleted file mode 100644 index baf9e17..0000000 --- a/packages/web/src/editor/elements/shape-element.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useRef } from "react"; -import { Rect, Line } from "react-konva"; -import type Konva from "konva"; -import type { EditorElement, ShapeProps } from "../../store/types.ts"; -import { useEditorStore } from "../../store/editor-store.ts"; -import { ElementWrapper } from "./element-wrapper.tsx"; - -interface ShapeElementProps { - element: EditorElement; - isSelected: boolean; -} - -export function ShapeElement({ element, isSelected }: ShapeElementProps) { - const ref = useRef(null); - const props = element.props as ShapeProps; - const updateElement = useEditorStore((s) => s.updateElement); - const pushHistory = useEditorStore((s) => s.pushHistory); - const setSelectedId = useEditorStore((s) => s.setSelectedId); - - const commonProps = { - id: element.id, - x: element.x, - y: element.y, - rotation: element.rotation, - draggable: true as const, - onClick: () => setSelectedId(element.id), - onTap: () => setSelectedId(element.id), - onDragStart: () => pushHistory(), - onDragEnd: (e: Konva.KonvaEventObject) => { - updateElement(element.id, { x: e.target.x(), y: e.target.y() }); - }, - onTransformEnd: () => { - const node = ref.current; - if (!node) return; - pushHistory(); - const scaleX = node.scaleX(); - const scaleY = node.scaleY(); - node.scaleX(1); - node.scaleY(1); - updateElement(element.id, { - x: node.x(), - y: node.y(), - width: Math.max(5, node.width() * scaleX), - height: Math.max(5, node.height() * scaleY), - rotation: node.rotation(), - }); - }, - }; - - return ( - <> - {props.shapeType === "rect" ? ( - } - {...commonProps} - width={element.width} - height={element.height} - fill={props.fill} - stroke={props.stroke} - strokeWidth={props.strokeWidth} - /> - ) : ( - } - {...commonProps} - points={[0, 0, element.width, element.height]} - stroke={props.stroke} - strokeWidth={props.strokeWidth} - /> - )} - } - isSelected={isSelected} - /> - - ); -} diff --git a/packages/web/src/editor/elements/text-element.tsx b/packages/web/src/editor/elements/text-element.tsx deleted file mode 100644 index 8caac4f..0000000 --- a/packages/web/src/editor/elements/text-element.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useRef, useEffect } from "react"; -import { Text } from "react-konva"; -import type Konva from "konva"; -import type { EditorElement, TextProps } from "../../store/types.ts"; -import { useEditorStore } from "../../store/editor-store.ts"; -import { ElementWrapper } from "./element-wrapper.tsx"; - -interface TextElementProps { - element: EditorElement; - isSelected: boolean; -} - -export function TextElement({ element, isSelected }: TextElementProps) { - const ref = useRef(null); - const props = element.props as TextProps; - const updateElement = useEditorStore((s) => s.updateElement); - const updateElementProps = useEditorStore((s) => s.updateElementProps); - const pushHistory = useEditorStore((s) => s.pushHistory); - const setSelectedId = useEditorStore((s) => s.setSelectedId); - - // Ensure the font is loaded before measuring, then redraw - useEffect(() => { - const node = ref.current; - if (!node) return; - - const measure = () => { - if (!ref.current) return; - const h = ref.current.height(); - if (Math.abs(h - element.height) > 1) { - updateElement(element.id, { height: h }); - } - }; - - document.fonts.load(`16px "${props.fontFamily}"`).then(() => { - node.getLayer()?.batchDraw(); - requestAnimationFrame(measure); - }); - - requestAnimationFrame(measure); - }, [props.text, props.fontSize, props.fontFamily, props.fontStyle, element.width]); - - return ( - <> - setSelectedId(element.id)} - onTap={() => setSelectedId(element.id)} - onDragStart={() => pushHistory()} - onDragEnd={(e) => { - updateElement(element.id, { x: e.target.x(), y: e.target.y() }); - }} - onTransformEnd={() => { - const node = ref.current; - if (!node) return; - pushHistory(); - const scaleX = node.scaleX(); - const scaleY = node.scaleY(); - node.scaleX(1); - node.scaleY(1); - const newFontSize = Math.max(4, Math.round(props.fontSize * scaleY)); - const newWidth = Math.max(5, node.width() * scaleX); - updateElement(element.id, { - x: node.x(), - y: node.y(), - width: newWidth, - height: node.height(), - rotation: node.rotation(), - }); - updateElementProps(element.id, { fontSize: newFontSize }); - }} - /> - - - ); -} diff --git a/packages/web/src/editor/inspector/fields.tsx b/packages/web/src/editor/inspector/fields.tsx new file mode 100644 index 0000000..12ed973 --- /dev/null +++ b/packages/web/src/editor/inspector/fields.tsx @@ -0,0 +1,179 @@ +import type { ReactNode } from "react"; + +// Shared field components used across all inspector sections + +export function Section({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + +export function Field({ + label, + mono, + children, +}: { + label: string; + mono?: boolean; + children: ReactNode; +}) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +export function NumInput({ + value, + onChange, + suffix, + min, + max, + step = 1, +}: { + value: number; + onChange: (v: number) => void; + suffix?: string; + min?: number; + max?: number; + step?: number; +}) { + 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" + /> + {suffix && ( + + {suffix} + + )} +
+ ); +} + +export function TextInput({ + value, + onChange, + placeholder, + autoFocus, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + autoFocus?: boolean; +}) { + return ( + onChange(e.target.value)} + 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" + /> + ); +} + +export function Select({ + value, + onChange, + options, +}: { + value: string; + onChange: (v: string) => void; + options: { value: string; label: string }[]; +}) { + return ( + + ); +} + +export function SegBtn({ + active, + onClick, + children, + title, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; + title?: string; +}) { + return ( + + ); +} + +export function SegGroup({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function ColorInput({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( +
+
+ onChange(e.target.value)} + className="flex-1 bg-transparent text-ui-sm text-ink-100 font-mono outline-none" + /> +
+ ); +} diff --git a/packages/web/src/editor/inspector/inspector.tsx b/packages/web/src/editor/inspector/inspector.tsx new file mode 100644 index 0000000..7575f3f --- /dev/null +++ b/packages/web/src/editor/inspector/inspector.tsx @@ -0,0 +1,123 @@ +import { + Type, + QrCode, + Barcode, + Square, + Minus, + ImageIcon, + Copy, + ArrowUp, + ArrowDown, + Trash2, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useEditorV2Store, type BaseElement } from "../../store/editor-store.ts"; +import { TransformSection } from "./sections/transform-section.tsx"; +import { TextSection } from "./sections/text-section.tsx"; +import { QrSection } from "./sections/qr-section.tsx"; +import { BarcodeSection } from "./sections/barcode-section.tsx"; +import { ShapeSection } from "./sections/shape-section.tsx"; + +const TYPE_LABELS: Record = { + text: "Text", + qrcode: "QR Code", + barcode: "Barcode", + rect: "Rectangle", + line: "Line", + image: "Image", +}; + +const TYPE_ICONS: Record = { + text: Type, + qrcode: QrCode, + barcode: Barcode, + rect: Square, + line: Minus, + image: ImageIcon, +}; + +export function Inspector() { + const elements = useEditorV2Store((s) => s.elements); + const selectedIds = useEditorV2Store((s) => s.selectedIds); + const duplicateSelected = useEditorV2Store((s) => s.duplicateSelected); + const removeSelected = useEditorV2Store((s) => s.removeSelected); + const zOrder = useEditorV2Store((s) => s.zOrder); + + const selected = elements.filter((e) => selectedIds.includes(e.id)); + + // Hidden when selection is empty + 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; + + return ( +
+ {el ? ( + <> + {/* Header */} +
+
+ {TypeIcon && } + + {typeLabel} + +
+
+ + + + +
+
+ + {/* Sections */} +
+ + {el.type === "text" && } + {el.type === "qrcode" && } + {el.type === "barcode" && } + {(el.type === "rect" || el.type === "line") && ( + + )} +
+ + ) : ( +
+ {selected.length} elements selected +
+ )} +
+ ); +} diff --git a/packages/web/src/editor/inspector/sections/barcode-section.tsx b/packages/web/src/editor/inspector/sections/barcode-section.tsx new file mode 100644 index 0000000..2fede65 --- /dev/null +++ b/packages/web/src/editor/inspector/sections/barcode-section.tsx @@ -0,0 +1,53 @@ +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; +import { Section, Field, TextInput, Select } from "../fields.tsx"; + +interface Props { + element: BaseElement; +} + +export function BarcodeSection({ element }: Props) { + const updateElement = useEditorV2Store((s) => s.updateElement); + + const p = element.props as { + content?: string; + format?: string; + displayValue?: boolean; + }; + + const update = (patch: Record) => + updateElement(element.id, { props: patch }); + + return ( +
+ + update({ content: v })} + /> + +
+ + update({ displayValue: e.target.checked })} + className="accent-accent" + /> + + Show value below bars + +
+
+ ); +} diff --git a/packages/web/src/editor/inspector/sections/qr-section.tsx b/packages/web/src/editor/inspector/sections/qr-section.tsx new file mode 100644 index 0000000..7b23ce0 --- /dev/null +++ b/packages/web/src/editor/inspector/sections/qr-section.tsx @@ -0,0 +1,60 @@ +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; +import { Section, Field, TextInput, Select } from "../fields.tsx"; + +interface Props { + element: BaseElement; +} + +export function QrSection({ element }: Props) { + const updateElement = useEditorV2Store((s) => s.updateElement); + + const p = element.props as { + content?: string; + errorCorrectionLevel?: string; + }; + + const update = (patch: Record) => + updateElement(element.id, { props: patch }); + + const eccLevel = p.errorCorrectionLevel || "M"; + const eccWidth = + eccLevel === "H" + ? "100%" + : eccLevel === "Q" + ? "75%" + : eccLevel === "M" + ? "50%" + : "25%"; + + return ( +
+ + update({ content: v })} + /> + +
+ + update({ fontFamily: v })} + options={[ + { value: "Inter", label: "Inter" }, + { value: "JetBrains Mono", label: "JetBrains Mono" }, + { value: "Arial", label: "Arial" }, + { value: "Georgia", label: "Georgia" }, + { value: "Courier New", label: "Courier New" }, + ]} + /> + +
+
+ + = 600} + onClick={() => + update({ fontWeight: (p.fontWeight || 400) >= 600 ? 400 : 700 }) + } + title="Bold" + > + + + update({ italic: !p.italic })} + title="Italic" + > + + + + + update({ align: "left" })} + > + + + update({ align: "center" })} + > + + + update({ align: "right" })} + > + + + +
+
+ + update({ fill: v })} + /> + +
+
+ ); +} diff --git a/packages/web/src/editor/inspector/sections/transform-section.tsx b/packages/web/src/editor/inspector/sections/transform-section.tsx new file mode 100644 index 0000000..0ba65ca --- /dev/null +++ b/packages/web/src/editor/inspector/sections/transform-section.tsx @@ -0,0 +1,76 @@ +import { + AlignHorizontalJustifyCenter, + AlignVerticalJustifyCenter, +} from "lucide-react"; +import type { BaseElement } from "../../../store/editor-store.ts"; +import { useEditorV2Store } from "../../../store/editor-store.ts"; +import { Section, Field, NumInput } from "../fields.tsx"; + +interface Props { + element: BaseElement; +} + +export function TransformSection({ element }: Props) { + const updateElement = useEditorV2Store((s) => s.updateElement); + const label = useEditorV2Store((s) => s.label); + + const update = (patch: Partial) => + updateElement(element.id, patch); + + const alignH = () => + update({ x: Math.round((label.widthPx - element.width) / 2) }); + const alignV = () => + update({ y: Math.round((label.heightPx - element.height) / 2) }); + const alignBoth = () => + update({ + x: Math.round((label.widthPx - element.width) / 2), + y: Math.round((label.heightPx - element.height) / 2), + }); + + return ( +
+
+ + update({ x: v })} suffix="px" /> + + + update({ y: v })} suffix="px" /> + + + update({ width: v })} suffix="px" /> + + + update({ height: v })} suffix="px" /> + +
+
+ + update({ rotation: v })} suffix="°" /> + +
+ + + +
+
+
+ ); +} diff --git a/packages/web/src/editor/label-editor.tsx b/packages/web/src/editor/label-editor.tsx deleted file mode 100644 index a1cb33e..0000000 --- a/packages/web/src/editor/label-editor.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useRef } from "react"; -import type Konva from "konva"; -import { Canvas } from "./canvas.tsx"; -import { Toolbar } from "./toolbar/toolbar.tsx"; -import { PropertiesPanel } from "./properties/properties-panel.tsx"; -import { ElementTree } from "./element-tree.tsx"; -import { PrinterPanel } from "../printer/printer-panel.tsx"; -import { PrintSettingsPanel } from "../printer/print-settings-panel.tsx"; -import { PrintButton } from "../printer/print-button.tsx"; -import { TemplateManager } from "../templates/template-manager.tsx"; -import { DebugLogButton } from "../printer/debug-log-button.tsx"; -import { useKeyboard } from "../hooks/use-keyboard.ts"; -import { usePrinter } from "../hooks/use-printer.ts"; - -export function LabelEditor() { - const stageRef = useRef(null); - const { print } = usePrinter(stageRef); - - useKeyboard(); - - return ( -
- -
-
- -
- -
- -
- -
- -
- -
- -
- -
-
-
- ); -} diff --git a/packages/web/src/editor/palette/commands.ts b/packages/web/src/editor/palette/commands.ts new file mode 100644 index 0000000..0e7beef --- /dev/null +++ b/packages/web/src/editor/palette/commands.ts @@ -0,0 +1,146 @@ +import type { LucideIcon } from "lucide-react"; +import { + Type, + QrCode, + Barcode, + Square, + Minus, + ImageIcon, + Undo2, + Redo2, + Copy, + Trash2, + AlignHorizontalJustifyCenter, + AlignVerticalJustifyCenter, + Grid3x3, + Ruler, + Maximize2, + Printer, + Settings, + LayoutTemplate, +} from "lucide-react"; +import { useEditorV2Store } from "../../store/editor-store.ts"; +import { mmToPx } from "../../utils/px-mm.ts"; +import { + addTextEl, + addQrEl, + addBarcodeEl, + addImageEl, + addRectEl, + addLineEl, +} from "../../lib/keyboard.ts"; + +export interface Command { + id: string; + label: string; + group: string; + icon: LucideIcon; + shortcut?: string; + run: () => void; +} + +function fitToScreen() { + const { label } = useEditorV2Store.getState(); + const cw = window.innerWidth - 160; + const ch = window.innerHeight - 200; + const fit = Math.max( + 0.5, + Math.min(4, Math.min(cw / label.widthPx, ch / label.heightPx)), + ); + useEditorV2Store.getState().setZoom(fit); + useEditorV2Store.getState().setPan(0, 0); +} + +function setLabelSize(widthMm: number, heightMm: number) { + useEditorV2Store.setState({ + label: { + widthMm, + heightMm, + widthPx: mmToPx(widthMm), + heightPx: mmToPx(heightMm), + }, + }); +} + +export const commands: Command[] = [ + // Insert + { id: "add-text", label: "Add text element", group: "Insert", icon: Type, shortcut: "T", run: addTextEl }, + { id: "add-qr", label: "Add QR code", group: "Insert", icon: QrCode, shortcut: "Q", run: addQrEl }, + { id: "add-barcode", label: "Add barcode", group: "Insert", icon: Barcode, shortcut: "B", run: addBarcodeEl }, + { id: "add-rect", label: "Add rectangle", group: "Insert", icon: Square, shortcut: "R", run: addRectEl }, + { id: "add-line", label: "Add line", group: "Insert", icon: Minus, shortcut: "L", run: addLineEl }, + { id: "add-image", label: "Add image", group: "Insert", icon: ImageIcon, shortcut: "I", run: addImageEl }, + + // Edit + { id: "undo", label: "Undo", group: "Edit", icon: Undo2, shortcut: "⌘Z", run: () => useEditorV2Store.temporal.getState().undo() }, + { id: "redo", label: "Redo", group: "Edit", icon: Redo2, shortcut: "⌘⇧Z", run: () => useEditorV2Store.temporal.getState().redo() }, + { id: "dup", label: "Duplicate selection", group: "Edit", icon: Copy, shortcut: "⌘D", run: () => useEditorV2Store.getState().duplicateSelected() }, + { id: "del", label: "Delete selection", group: "Edit", icon: Trash2, shortcut: "⌫", run: () => useEditorV2Store.getState().removeSelected() }, + + // Align + { + id: "center", + label: "Center selection on label", + group: "Align", + icon: AlignHorizontalJustifyCenter, + run: () => { + const { selectedIds, elements, label, updateElement } = useEditorV2Store.getState(); + selectedIds.forEach((id) => { + const el = elements.find((e) => e.id === id); + if (el) { + updateElement(id, { + x: Math.round((label.widthPx - el.width) / 2), + y: Math.round((label.heightPx - el.height) / 2), + }); + } + }); + }, + }, + { + id: "center-h", + label: "Center horizontally", + group: "Align", + icon: AlignHorizontalJustifyCenter, + run: () => { + const { selectedIds, elements, label, updateElement } = useEditorV2Store.getState(); + selectedIds.forEach((id) => { + const el = elements.find((e) => e.id === id); + if (el) updateElement(id, { x: Math.round((label.widthPx - el.width) / 2) }); + }); + }, + }, + { + id: "center-v", + label: "Center vertically", + group: "Align", + icon: AlignVerticalJustifyCenter, + run: () => { + const { selectedIds, elements, label, updateElement } = useEditorV2Store.getState(); + selectedIds.forEach((id) => { + const el = elements.find((e) => e.id === id); + if (el) updateElement(id, { y: Math.round((label.heightPx - el.height) / 2) }); + }); + }, + }, + + // View + { id: "grid", label: "Toggle grid", group: "View", icon: Grid3x3, shortcut: "G", run: () => useEditorV2Store.setState((s) => ({ gridVisible: !s.gridVisible })) }, + { id: "rulers", label: "Toggle rulers", group: "View", icon: Ruler, run: () => useEditorV2Store.setState((s) => ({ rulersVisible: !s.rulersVisible })) }, + { id: "fit", label: "Fit label to screen", group: "View", icon: Maximize2, shortcut: "1", run: fitToScreen }, + + // Label + { id: "label-50x30", label: "Set label size · 50 × 30 mm", group: "Label", icon: Square, run: () => setLabelSize(50, 30) }, + { id: "label-40x12", label: "Set label size · 40 × 12 mm", group: "Label", icon: Square, run: () => setLabelSize(40, 12) }, + { id: "label-40x30", label: "Set label size · 40 × 30 mm", group: "Label", icon: Square, run: () => setLabelSize(40, 30) }, + { id: "label-70x40", label: "Set label size · 70 × 40 mm", group: "Label", icon: Square, run: () => setLabelSize(70, 40) }, + { id: "label-50x50", label: "Set label size · 50 × 50 mm", group: "Label", icon: Square, run: () => setLabelSize(50, 50) }, + + // Print + { id: "print", label: "Print label", group: "Print", icon: Printer, shortcut: "⌘P", run: () => {} }, + { id: "print-settings", label: "Open print settings", group: "Print", icon: Settings, run: () => {} }, + + // Templates + { id: "tpl-asset", label: "Template · Asset tag", group: "Templates", icon: LayoutTemplate, run: () => {} }, + { id: "tpl-shipping", label: "Template · Shipping label", group: "Templates", icon: LayoutTemplate, run: () => {} }, + { id: "tpl-price", label: "Template · Price tag", group: "Templates", icon: LayoutTemplate, run: () => {} }, +]; diff --git a/packages/web/src/editor/palette/palette.tsx b/packages/web/src/editor/palette/palette.tsx new file mode 100644 index 0000000..a5e1d7e --- /dev/null +++ b/packages/web/src/editor/palette/palette.tsx @@ -0,0 +1,148 @@ +import { useState, useEffect, useRef, useMemo } from "react"; +import { Search } from "lucide-react"; +import { useEditorV2Store } from "../../store/editor-store.ts"; +import { commands, type Command } from "./commands.ts"; + +export function Palette() { + const paletteOpen = useEditorV2Store((s) => s.paletteOpen); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(0); + const inputRef = useRef(null); + + const filtered = useMemo(() => { + if (!query.trim()) return commands; + const q = query.toLowerCase(); + return commands.filter( + (c) => + c.label.toLowerCase().includes(q) || + c.group.toLowerCase().includes(q), + ); + }, [query]); + + // Reset cursor on query change + useEffect(() => { + setCursor(0); + }, [query]); + + // Focus input on open, clear on close + useEffect(() => { + if (paletteOpen) { + setTimeout(() => inputRef.current?.focus(), 10); + } else { + setQuery(""); + } + }, [paletteOpen]); + + // Keyboard navigation + useEffect(() => { + if (!paletteOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + useEditorV2Store.setState({ paletteOpen: false }); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setCursor((c) => Math.min(filtered.length - 1, c + 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setCursor((c) => Math.max(0, c - 1)); + } else if (e.key === "Enter") { + e.preventDefault(); + const cmd = filtered[cursor]; + if (cmd) { + cmd.run(); + useEditorV2Store.setState({ paletteOpen: false }); + } + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [paletteOpen, filtered, cursor]); + + if (!paletteOpen) return null; + + // Group commands + const groups: Record = {}; + filtered.forEach((c, i) => { + (groups[c.group] ||= []).push({ ...c, i }); + }); + + return ( +
{ + if (e.target === e.currentTarget) + useEditorV2Store.setState({ paletteOpen: false }); + }} + > +
+ {/* Search input */} +
+ + setQuery(e.target.value)} + placeholder="Type a command, template, or label size…" + className="flex-1 bg-transparent outline-none text-ui-md text-ink-100 placeholder:text-ink-500" + /> + ESC +
+ + {/* Results */} +
+ {filtered.length === 0 && ( +
+ No commands match “{query}” +
+ )} + {Object.entries(groups).map(([group, items]) => ( +
+
+ {group} +
+ {items.map(({ i, icon: Icon, ...cmd }) => ( +
setCursor(i)} + onClick={() => { + cmd.run(); + useEditorV2Store.setState({ paletteOpen: false }); + }} + className={`flex items-center gap-3 px-4 h-9 cursor-pointer ${ + cursor === i ? "bg-accent/10" : "" + }`} + > + + + {cmd.label} + + {cmd.shortcut && {cmd.shortcut}} +
+ ))} +
+ ))} +
+ + {/* Footer */} +
+
+ + ↑↓ navigate + + + run + +
+ {filtered.length} commands +
+
+
+ ); +} diff --git a/packages/web/src/editor/print-progress-toast.tsx b/packages/web/src/editor/print-progress-toast.tsx new file mode 100644 index 0000000..a30fcfd --- /dev/null +++ b/packages/web/src/editor/print-progress-toast.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import { Check } from "lucide-react"; +import { useEditorV2Store } from "../store/editor-store.ts"; + +export function PrintProgressToast() { + const printing = useEditorV2Store((s) => s.printing); + const copies = useEditorV2Store((s) => s.printingCopies); + const startedAt = useEditorV2Store((s) => s.printingStartedAt); + const duration = useEditorV2Store((s) => s.printingDuration); + const progress = useEditorV2Store((s) => s.printProgress); + const printerName = useEditorV2Store((s) => s.printer.name); + const connected = useEditorV2Store((s) => s.printer.connected); + + const [estimatedPct, setEstimatedPct] = useState(0); + const [done, setDone] = useState(false); + + // Time-based fallback animation (used when no real progress events) + useEffect(() => { + if (!printing) return; + setEstimatedPct(0); + setDone(false); + + let raf = 0; + const tick = () => { + const elapsed = Date.now() - startedAt; + const t = Math.max(0, Math.min(1, elapsed / duration)); + const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic + setEstimatedPct(eased * 100); + if (t < 1) { + raf = requestAnimationFrame(tick); + } else { + setEstimatedPct(100); + setDone(true); + } + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [printing, startedAt, duration]); + + if (!printing) return null; + + // Use real progress when available, otherwise fall back to time estimate + const hasRealProgress = progress !== null && progress.totalBytes > 0; + const pct = hasRealProgress + ? (progress.bytesSent / progress.totalBytes) * 100 + : estimatedPct; + const isComplete = hasRealProgress ? progress.bytesSent >= progress.totalBytes : done; + + const n = copies || 1; + const displayPct = Math.round(pct); + const currentLabel = Math.min(n, Math.max(1, Math.ceil((pct / 100) * n))); + const dest = connected && printerName ? printerName : "printer"; + + return ( +
+ {/* Header row */} +
+
+ {isComplete ? ( +
+ +
+ ) : ( + <> +
+
+ + )} +
+ +
+
+ {isComplete ? "Printed to" : "Printing to"} {dest} +
+
+ {isComplete + ? `${n} ${n === 1 ? "label" : "labels"} · complete` + : `Label ${currentLabel} of ${n} · 203 dpi`} +
+
+ +
+
+ {displayPct} + % +
+
+
+ + {/* Progress bar */} +
+
+ {!isComplete && ( +
+ )} +
+ + {/* Per-label ticks (2–50 copies only) */} + {n > 1 && n <= 50 && ( +
+ {Array.from({ length: n }).map((_, i) => { + const perChunk = 100 / n; + const chunkPct = Math.max( + 0, + Math.min(1, (pct - i * perChunk) / perChunk), + ); + return ( +
+
+
+ ); + })} +
+ )} + {(n === 1 || n > 50) &&
} +
+ ); +} diff --git a/packages/web/src/editor/properties/barcode-properties.tsx b/packages/web/src/editor/properties/barcode-properties.tsx deleted file mode 100644 index 3d681a7..0000000 --- a/packages/web/src/editor/properties/barcode-properties.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { EditorElement, BarcodeProps } from "../../store/types.ts"; -import { useEditorStore } from "../../store/editor-store.ts"; - -const FORMATS: BarcodeProps["format"][] = ["CODE128", "EAN13", "UPC", "CODE39", "ITF14"]; - -export function BarcodeProperties({ element }: { element: EditorElement }) { - const props = element.props as BarcodeProps; - const updateElementProps = useEditorStore((s) => s.updateElementProps); - const pushHistory = useEditorStore((s) => s.pushHistory); - - return ( -
-
- - { pushHistory(); updateElementProps(element.id, { content: e.target.value }); }} - className="w-full mt-1 px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700" /> -
-
- - -
-
- { pushHistory(); updateElementProps(element.id, { displayValue: e.target.checked }); }} - className="rounded" /> - -
-
- ); -} diff --git a/packages/web/src/editor/properties/image-properties.tsx b/packages/web/src/editor/properties/image-properties.tsx deleted file mode 100644 index 1384f21..0000000 --- a/packages/web/src/editor/properties/image-properties.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { EditorElement, ImageProps } from "../../store/types.ts"; -import { useEditorStore } from "../../store/editor-store.ts"; - -export function ImageProperties({ element }: { element: EditorElement }) { - const props = element.props as ImageProps; - const updateElementProps = useEditorStore((s) => s.updateElementProps); - const pushHistory = useEditorStore((s) => s.pushHistory); - - const replaceImage = () => { - const input = document.createElement("input"); - input.type = "file"; - input.accept = "image/*"; - input.onchange = () => { - const file = input.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - pushHistory(); - const img = new window.Image(); - img.src = reader.result as string; - img.onload = () => { - updateElementProps(element.id, { - src: reader.result as string, - naturalWidth: img.naturalWidth, - naturalHeight: img.naturalHeight, - }); - }; - }; - reader.readAsDataURL(file); - }; - input.click(); - }; - - return ( -
-
- -
- -
- -
-
Original: {props.naturalWidth} x {props.naturalHeight} px
-
- ); -} diff --git a/packages/web/src/editor/properties/properties-panel.tsx b/packages/web/src/editor/properties/properties-panel.tsx deleted file mode 100644 index c6e5ff8..0000000 --- a/packages/web/src/editor/properties/properties-panel.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useEditorStore } from "../../store/editor-store.ts"; -import { TextProperties } from "./text-properties.tsx"; -import { ImageProperties } from "./image-properties.tsx"; -import { QrCodeProperties } from "./qr-code-properties.tsx"; -import { BarcodeProperties } from "./barcode-properties.tsx"; -import { ShapeProperties } from "./shape-properties.tsx"; -import { Tooltip } from "../../components/tooltip.tsx"; -import { Trash2, ArrowUp, ArrowDown, ArrowUpToLine, ArrowDownToLine, AlignCenterHorizontal, AlignCenterVertical } from "lucide-react"; - -export function PropertiesPanel() { - const elements = useEditorStore((s) => s.elements); - const selectedId = useEditorStore((s) => s.selectedId); - const removeElement = useEditorStore((s) => s.removeElement); - const updateElement = useEditorStore((s) => s.updateElement); - const moveElement = useEditorStore((s) => s.moveElement); - const pushHistory = useEditorStore((s) => s.pushHistory); - const labelConfig = useEditorStore((s) => s.labelConfig); - - const element = elements.find((el) => el.id === selectedId); - - if (!element) { - return ( -
-

Select an element to edit its properties

-
- ); - } - - const iconBtn = "p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-pointer"; - - return ( -
-
-

{element.type}

-
- - - - - - - - - - - - - - - -
-
- -
- {(["x", "y", "width", "height"] as const).map((key) => ( -
- - { pushHistory(); updateElement(element.id, { [key]: Number(e.target.value) }); }} - className="w-full px-1.5 py-0.5 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700" /> -
- ))} -
- - { pushHistory(); updateElement(element.id, { rotation: Number(e.target.value) }); }} - className="w-full px-1.5 py-0.5 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700" /> -
-
- -
- - - - - - - - - -
- -
- {element.type === "text" && } - {element.type === "image" && } - {element.type === "qrcode" && } - {element.type === "barcode" && } - {(element.type === "rect" || element.type === "line") && } -
-
- ); -} diff --git a/packages/web/src/editor/properties/qr-code-properties.tsx b/packages/web/src/editor/properties/qr-code-properties.tsx deleted file mode 100644 index 4f1fe2d..0000000 --- a/packages/web/src/editor/properties/qr-code-properties.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { EditorElement, QrCodeProps } from "../../store/types.ts"; -import { useEditorStore } from "../../store/editor-store.ts"; - -export function QrCodeProperties({ element }: { element: EditorElement }) { - const props = element.props as QrCodeProps; - const updateElementProps = useEditorStore((s) => s.updateElementProps); - const pushHistory = useEditorStore((s) => s.pushHistory); - - return ( -
-
- -