initial commit
This commit is contained in:
+161
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { Toolbar } from "./components/Toolbar"
|
||||
import { TitleBar } from "./components/TitleBar"
|
||||
import { LeftSidebar } from "./components/LeftSidebar"
|
||||
import { CanvasView } from "./components/CanvasView"
|
||||
import { PropertiesPanel } from "./components/PropertiesPanel"
|
||||
import { StatusBar } from "./components/StatusBar"
|
||||
import { KeyboardCheatSheet } from "./components/KeyboardCheatSheet"
|
||||
import { ResizeHandle } from "./components/ResizeHandle"
|
||||
import { useStore } from "./store/useStore"
|
||||
import { loadTemplateDialog } from "./utils/loadFile"
|
||||
|
||||
const MIN_SIDEBAR = 160
|
||||
const MAX_SIDEBAR = 500
|
||||
|
||||
export default function App() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [showCheatSheet, setShowCheatSheet] = useState(false)
|
||||
const [leftWidth, setLeftWidth] = useState(280)
|
||||
const [rightWidth, setRightWidth] = useState(320)
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false)
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false)
|
||||
const setToolMode = useStore((s) => s.setToolMode)
|
||||
const undo = useStore((s) => s.undo)
|
||||
const deleteSection = useStore((s) => s.deleteSection)
|
||||
const selectedSectionIdx = useStore((s) => s.selectedSectionIdx)
|
||||
const toolMode = useStore((s) => s.toolMode)
|
||||
const currentBlockName = useStore((s) => s.currentBlockName)
|
||||
const darkMode = useStore((s) => s.darkMode)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", darkMode)
|
||||
window.electronAPI?.setTheme(darkMode)
|
||||
}, [darkMode])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return
|
||||
|
||||
const ctrl = e.ctrlKey || e.metaKey
|
||||
|
||||
if (e.key === "Escape") { setShowCheatSheet(false); return }
|
||||
if (e.key === "?") { e.preventDefault(); setShowCheatSheet((v) => !v); return }
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "s": {
|
||||
e.preventDefault()
|
||||
if (ctrl) {
|
||||
const state = useStore.getState()
|
||||
const errors = state.validateExport()
|
||||
if (errors.length > 0) {
|
||||
alert("Validation errors:\n" + errors.map((e) => ` - ${e}`).join("\n"))
|
||||
return
|
||||
}
|
||||
const data = state.buildExportData()
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.saveFileDialog("metadata.json", [{ name: "JSON", extensions: ["json"] }])
|
||||
.then((path: string) => {
|
||||
if (!path) { state.setStatusMessage("Export cancelled"); return }
|
||||
return window.electronAPI!.writeFile(path, json)
|
||||
})
|
||||
.then(() => state.setStatusMessage("Exported successfully"))
|
||||
.catch((err: Error) => { state.setStatusMessage("Export failed"); console.error(err) })
|
||||
} else {
|
||||
const blob = new Blob([json], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "metadata.json"
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
state.setStatusMessage("Exported metadata.json")
|
||||
}
|
||||
} else {
|
||||
setToolMode("select")
|
||||
}
|
||||
break
|
||||
}
|
||||
case "o":
|
||||
if (ctrl) {
|
||||
e.preventDefault()
|
||||
loadTemplateDialog().then((result) => {
|
||||
if (!result) return
|
||||
useStore.getState().setTemplate(result.dataUrl, result.filePath, result.width, result.height)
|
||||
useStore.getState().setStatusMessage(`Loaded: ${result.name}`)
|
||||
})
|
||||
}
|
||||
break
|
||||
case "z":
|
||||
if (ctrl) {
|
||||
e.preventDefault()
|
||||
undo()
|
||||
}
|
||||
break
|
||||
case "delete":
|
||||
case "backspace":
|
||||
if (selectedSectionIdx !== null) {
|
||||
e.preventDefault()
|
||||
deleteSection(selectedSectionIdx)
|
||||
}
|
||||
break
|
||||
case "d":
|
||||
if (!ctrl) { e.preventDefault(); setToolMode("draw") }
|
||||
break
|
||||
case "p":
|
||||
if (!ctrl) { e.preventDefault(); setToolMode("pan") }
|
||||
break
|
||||
case "arrowup":
|
||||
case "arrowdown":
|
||||
case "arrowleft":
|
||||
case "arrowright":
|
||||
if (toolMode === "select" && selectedSectionIdx !== null && currentBlockName) {
|
||||
e.preventDefault()
|
||||
const step = e.shiftKey ? 10 : 1
|
||||
const state = useStore.getState()
|
||||
const block = state.blocks[currentBlockName]
|
||||
if (!block) break
|
||||
const roi = block.sections[selectedSectionIdx]
|
||||
if (!roi) break
|
||||
const [x1, x2, y1, y2] = roi.cords
|
||||
let nx1 = x1, nx2 = x2, ny1 = y1, ny2 = y2
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "arrowup": ny1 -= step; ny2 -= step; break
|
||||
case "arrowdown": ny1 += step; ny2 += step; break
|
||||
case "arrowleft": nx1 -= step; nx2 -= step; break
|
||||
case "arrowright": nx1 += step; nx2 += step; break
|
||||
}
|
||||
state.pushUndo()
|
||||
state.updateSectionCords(selectedSectionIdx, [nx1, nx2, ny1, ny2])
|
||||
state.setStatusMessage(`Nudged to (${nx1}, ${ny1})`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [setToolMode, undo, deleteSection, selectedSectionIdx, toolMode, currentBlockName])
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<TitleBar />
|
||||
<Toolbar />
|
||||
<div ref={containerRef} className="flex flex-1 overflow-hidden">
|
||||
<LeftSidebar width={leftCollapsed ? 32 : leftWidth} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((v) => !v)} />
|
||||
{!leftCollapsed && <ResizeHandle onResize={(clientX) => setLeftWidth(Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, clientX)))} />}
|
||||
<CanvasView />
|
||||
{!rightCollapsed && <ResizeHandle onResize={(clientX) => {
|
||||
if (!containerRef.current) return
|
||||
const cr = containerRef.current.getBoundingClientRect()
|
||||
setRightWidth(Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, cr.right - clientX)))
|
||||
}} />}
|
||||
<PropertiesPanel width={rightCollapsed ? 32 : rightWidth} collapsed={rightCollapsed} onToggle={() => setRightCollapsed((v) => !v)} />
|
||||
</div>
|
||||
<StatusBar />
|
||||
{showCheatSheet && <KeyboardCheatSheet onClose={() => setShowCheatSheet(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
|
||||
interface BubbleEditorProps {
|
||||
templateDataUrl: string
|
||||
cords: [number, number, number, number]
|
||||
onClose: () => void
|
||||
setStatusMessage: (msg: string) => void
|
||||
}
|
||||
|
||||
export function BubbleEditor({ templateDataUrl, cords, onClose, setStatusMessage }: BubbleEditorProps) {
|
||||
const [x1, x2, y1, y2] = cords
|
||||
const w = x2 - x1
|
||||
const h = y2 - y1
|
||||
|
||||
const displayRef = useRef<HTMLCanvasElement>(null)
|
||||
const offscreenRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const paintingRef = useRef(false)
|
||||
const [color, setColor] = useState<"white" | "black">("black")
|
||||
const [brushSize, setBrushSize] = useState(3)
|
||||
const [zoom, setZoom] = useState(5)
|
||||
const [fileName, setFileName] = useState("bubble_template.jpg")
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const redrawDisplay = useCallback(() => {
|
||||
const offscreen = offscreenRef.current
|
||||
const display = displayRef.current
|
||||
if (!offscreen || !display) return
|
||||
const dCtx = display.getContext("2d")
|
||||
if (!dCtx) return
|
||||
dCtx.imageSmoothingEnabled = false
|
||||
dCtx.clearRect(0, 0, display.width, display.height)
|
||||
dCtx.drawImage(offscreen, 0, 0, offscreen.width, offscreen.height, 0, 0, display.width, display.height)
|
||||
}, [])
|
||||
|
||||
const paint = useCallback((imgX: number, imgY: number) => {
|
||||
const canvas = offscreenRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
const half = Math.floor(brushSize / 2)
|
||||
const r = color === "white" ? 255 : 0
|
||||
const g = color === "white" ? 255 : 0
|
||||
const b = color === "white" ? 255 : 0
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`
|
||||
ctx.fillRect(imgX - half, imgY - half, brushSize, brushSize)
|
||||
redrawDisplay()
|
||||
}, [color, brushSize, redrawDisplay])
|
||||
|
||||
useEffect(() => {
|
||||
const img = new window.Image()
|
||||
img.onload = () => {
|
||||
const offscreen = document.createElement("canvas")
|
||||
offscreen.width = w
|
||||
offscreen.height = h
|
||||
const ctx = offscreen.getContext("2d")
|
||||
if (!ctx) return
|
||||
ctx.drawImage(img, x1, y1, w, h, 0, 0, w, h)
|
||||
offscreenRef.current = offscreen
|
||||
setLoaded(true)
|
||||
}
|
||||
img.src = templateDataUrl
|
||||
}, [templateDataUrl, x1, y1, w, h])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return
|
||||
redrawDisplay()
|
||||
}, [loaded, zoom, redrawDisplay])
|
||||
|
||||
const getImageCoords = (clientX: number, clientY: number): { x: number; y: number } | null => {
|
||||
const canvas = displayRef.current
|
||||
if (!canvas) return null
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const canvasX = (clientX - rect.left) * (canvas.width / rect.width)
|
||||
const canvasY = (clientY - rect.top) * (canvas.height / rect.height)
|
||||
return {
|
||||
x: Math.floor(canvasX / zoom),
|
||||
y: Math.floor(canvasY / zoom),
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
paintingRef.current = true
|
||||
const coords = getImageCoords(e.clientX, e.clientY)
|
||||
if (coords) paint(coords.x, coords.y)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!paintingRef.current) return
|
||||
const coords = getImageCoords(e.clientX, e.clientY)
|
||||
if (coords) paint(coords.x, coords.y)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
paintingRef.current = false
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const offscreen = offscreenRef.current
|
||||
if (!offscreen) return
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
offscreen.toBlob(resolve, "image/jpeg", 0.95)
|
||||
)
|
||||
if (!blob) return
|
||||
|
||||
const toBase64 = (b: Blob): Promise<string> =>
|
||||
new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
resolve(result.split(",")[1])
|
||||
}
|
||||
reader.readAsDataURL(b)
|
||||
})
|
||||
|
||||
if (window.electronAPI) {
|
||||
const path = await window.electronAPI.saveFileDialog(fileName, [
|
||||
{ name: "JPEG Image", extensions: ["jpg", "jpeg"] },
|
||||
])
|
||||
if (!path) { setStatusMessage("Save cancelled"); return }
|
||||
const base64 = await toBase64(blob)
|
||||
await window.electronAPI.writeBinaryFile(path, base64)
|
||||
setStatusMessage(`Saved: ${path.split(/[/\\]/).pop()}`)
|
||||
} else {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setStatusMessage(`Downloaded: ${fileName}`)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm animate-fade-in" onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
|
||||
<div className="bg-canvas rounded-xl shadow-2xl border border-hairline w-[90vw] max-w-[900px] max-h-[90vh] flex flex-col overflow-hidden animate-slide-up">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-hairline shrink-0">
|
||||
<h2 className="text-title-sm text-ink font-medium flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">edit</span>
|
||||
Bubble Template Editor
|
||||
</h2>
|
||||
<span className="text-caption text-muted-soft bg-surface-soft px-2.5 py-1 rounded-md">{w}×{h} px</span>
|
||||
</div>
|
||||
|
||||
{!loaded ? (
|
||||
<div className="flex-1 flex items-center justify-center py-24">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-body-md text-muted">Loading image data...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col gap-3 p-4 overflow-auto">
|
||||
<div className="flex items-start justify-center overflow-auto rounded-lg border border-hairline"
|
||||
style={{
|
||||
backgroundImage: "linear-gradient(45deg, var(--color-hairline) 25%, transparent 25%), linear-gradient(-45deg, var(--color-hairline) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--color-hairline) 75%), linear-gradient(-45deg, transparent 75%, var(--color-hairline) 75%)",
|
||||
backgroundSize: "20px 20px",
|
||||
backgroundPosition: "0 0, 0 10px, 10px -10px, -10px 0px",
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={displayRef}
|
||||
width={w * zoom}
|
||||
height={h * zoom}
|
||||
style={{
|
||||
cursor: "crosshair",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 bg-surface-soft rounded-lg px-4 py-3 border border-hairline">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-caption text-muted font-medium">Brush:</span>
|
||||
<button
|
||||
onClick={() => setColor("white")}
|
||||
className={"w-7 h-7 rounded-full border-2 transition-all " + (color === "white" ? "border-primary ring-2 ring-primary/20" : "border-hairline bg-white")}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setColor("black")}
|
||||
className={"w-7 h-7 rounded-full border-2 transition-all " + (color === "black" ? "border-primary ring-2 ring-primary/20" : "border-hairline")}
|
||||
style={{ backgroundColor: color === "black" ? "#000" : undefined }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-hairline" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-caption text-muted font-medium">Size:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
value={brushSize}
|
||||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-code text-body font-medium w-5 text-center">{brushSize}</span>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-hairline" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-caption text-muted font-medium">Zoom:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={10}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-code text-body font-medium w-7 text-center">{zoom}×</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-caption text-muted font-medium shrink-0">Filename:</span>
|
||||
<input
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
className="flex-1 bg-canvas border border-hairline rounded-md px-3 py-1.5 text-code text-body focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-hairline shrink-0 bg-surface-soft/50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg text-button text-muted hover:text-ink hover:bg-surface-card transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!loaded}
|
||||
className="px-5 py-2 bg-primary text-on-primary rounded-lg text-button font-medium hover:bg-primary-active active:scale-[0.97] disabled:opacity-40 transition-all shadow-sm"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import { useRef, useCallback, useEffect, useState } from "react"
|
||||
import { Stage, Layer, Image, Rect, Text, Line, Group, Circle } from "react-konva"
|
||||
import type Konva from "konva"
|
||||
import { useStore } from "../store/useStore"
|
||||
import type { RoiItem } from "../types"
|
||||
import { loadTemplateDialog, loadMetadataDialog, processDroppedFile } from "../utils/loadFile"
|
||||
|
||||
function useImage(src: string | null): HTMLImageElement | null {
|
||||
const [img, setImg] = useState<HTMLImageElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (!src) { setImg(null); return }
|
||||
const image = new window.Image()
|
||||
image.src = src
|
||||
image.onload = () => setImg(image)
|
||||
}, [src])
|
||||
return img
|
||||
}
|
||||
|
||||
const HANDLE_R = 5
|
||||
const COLORS = ["#cc785c", "#5db8a6", "#e8a55a", "#5db872", "#8e8b82", "#6c6a64", "#a9583e", "#252523"]
|
||||
|
||||
const CURSOR_MAP: Record<string, string> = {
|
||||
tl: "nw-resize", tr: "ne-resize", bl: "sw-resize", br: "se-resize",
|
||||
tc: "n-resize", bc: "s-resize", lc: "w-resize", rc: "e-resize",
|
||||
}
|
||||
|
||||
function renderGrid(w: number, h: number, roi: RoiItem) {
|
||||
const cellW = w / roi.column
|
||||
const cellH = h / roi.row
|
||||
const lines: React.ReactNode[] = []
|
||||
for (let c = 1; c < roi.column; c++) {
|
||||
lines.push(
|
||||
<Line key={`gv${c}`} points={[c * cellW, 0, c * cellW, h]} stroke="#cc785c" strokeWidth={1} dash={[3, 3]} opacity={0.6} />,
|
||||
)
|
||||
}
|
||||
for (let r = 1; r < roi.row; r++) {
|
||||
lines.push(
|
||||
<Line key={`gh${r}`} points={[0, r * cellH, w, r * cellH]} stroke="#cc785c" strokeWidth={1} dash={[3, 3]} opacity={0.6} />,
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function renderPreviewGrid(w: number, h: number, row: number, col: number) {
|
||||
if (w === 0 || h === 0) return []
|
||||
const cellW = w / col
|
||||
const cellH = h / row
|
||||
const lines: React.ReactNode[] = []
|
||||
for (let c = 1; c < col; c++) {
|
||||
lines.push(
|
||||
<Line key={`pgv${c}`} points={[c * cellW, 0, c * cellW, h]} stroke="#cc785c" strokeWidth={1.5} dash={[4, 3]} opacity={0.65} />,
|
||||
)
|
||||
}
|
||||
for (let r = 1; r < row; r++) {
|
||||
lines.push(
|
||||
<Line key={`pgh${r}`} points={[0, r * cellH, w, r * cellH]} stroke="#cc785c" strokeWidth={1.5} dash={[4, 3]} opacity={0.65} />,
|
||||
)
|
||||
}
|
||||
if (row > 1 || col > 1) {
|
||||
const midX = cellW * (col / 2)
|
||||
lines.push(
|
||||
<Text key="gridlabel" x={Math.max(midX - 20, 0)} y={2} text={`${row}×${col}`} fontSize={9} fontFamily="Inter" fill="#cc785c" opacity={0.8} />,
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
interface InteractionState {
|
||||
type: "move" | "resize" | "pan"
|
||||
sectionIdx?: number
|
||||
handle?: string
|
||||
startMouse: { x: number; y: number }
|
||||
startCords?: [number, number, number, number]
|
||||
startOffset?: { x: number; y: number }
|
||||
}
|
||||
|
||||
const HANDLE_DIRS = ["tl", "tc", "tr", "rc", "br", "bc", "bl", "lc"] as const
|
||||
|
||||
export function CanvasView() {
|
||||
const stageRef = useRef<Konva.Stage>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const interactionRef = useRef<InteractionState | null>(null)
|
||||
const liveCordsRef = useRef<Record<number, [number, number, number, number]>>({})
|
||||
const cursorRef = useRef("default")
|
||||
const [, forceUpdate] = useState(0)
|
||||
|
||||
const templateImageData = useStore((s) => s.templateImageData)
|
||||
const imageWidth = useStore((s) => s.imageWidth)
|
||||
const imageHeight = useStore((s) => s.imageHeight)
|
||||
const zoomLevel = useStore((s) => s.zoomLevel)
|
||||
const setZoomLevel = useStore((s) => s.setZoomLevel)
|
||||
const toolMode = useStore((s) => s.toolMode)
|
||||
const blocks = useStore((s) => s.blocks)
|
||||
const blockOrder = useStore((s) => s.blockOrder)
|
||||
const currentBlockName = useStore((s) => s.currentBlockName)
|
||||
const selectedSectionIdx = useStore((s) => s.selectedSectionIdx)
|
||||
const addSection = useStore((s) => s.addSection)
|
||||
const selectSection = useStore((s) => s.selectSection)
|
||||
const setStatusMessage = useStore((s) => s.setStatusMessage)
|
||||
const pushUndo = useStore((s) => s.pushUndo)
|
||||
const updateSectionCords = useStore((s) => s.updateSectionCords)
|
||||
const setTemplate = useStore((s) => s.setTemplate)
|
||||
const restoreFromData = useStore((s) => s.restoreFromData)
|
||||
const defaultRow = useStore((s) => s.defaultRow)
|
||||
const defaultColumn = useStore((s) => s.defaultColumn)
|
||||
const darkMode = useStore((s) => s.darkMode)
|
||||
const setCanvasContainerSize = useStore((s) => s.setCanvasContainerSize)
|
||||
const stageOffset = useStore((s) => s.stageOffset)
|
||||
const setStageOffset = useStore((s) => s.setStageOffset)
|
||||
const img = useImage(templateImageData)
|
||||
|
||||
const sections: RoiItem[] = currentBlockName
|
||||
? blocks[currentBlockName]?.sections ?? []
|
||||
: []
|
||||
|
||||
const [stageSize, setStageSize] = useState({ width: 800, height: 600 })
|
||||
const [drawing, setDrawing] = useState(false)
|
||||
const [drawStart, setDrawStart] = useState({ x: 0, y: 0 })
|
||||
const [drawEnd, setDrawEnd] = useState({ x: 0, y: 0 })
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const panStartRef = useRef({ x: 0, y: 0 })
|
||||
const middlePanRef = useRef(false)
|
||||
const drawingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
setStageSize({ width: rect.width, height: rect.height })
|
||||
setCanvasContainerSize(rect.width, rect.height)
|
||||
}
|
||||
}
|
||||
updateSize()
|
||||
const obs = new ResizeObserver(updateSize)
|
||||
if (containerRef.current) obs.observe(containerRef.current)
|
||||
window.addEventListener("resize", updateSize)
|
||||
return () => { window.removeEventListener("resize", updateSize); obs.disconnect() }
|
||||
}, [setCanvasContainerSize])
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const preventScroll = (e: WheelEvent) => e.preventDefault()
|
||||
el.addEventListener("wheel", preventScroll, { passive: false })
|
||||
return () => el.removeEventListener("wheel", preventScroll)
|
||||
}, [])
|
||||
|
||||
const getCords = useCallback(
|
||||
(idx: number): [number, number, number, number] =>
|
||||
liveCordsRef.current[idx] ?? sections[idx]?.cords ?? [0, 0, 0, 0],
|
||||
[sections],
|
||||
)
|
||||
|
||||
const setCursor = useCallback((c: string) => {
|
||||
if (cursorRef.current !== c && containerRef.current) {
|
||||
cursorRef.current = c
|
||||
containerRef.current.style.cursor = c
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
e.evt.preventDefault()
|
||||
if (!drawingRef.current && !interactionRef.current) {
|
||||
if (e.evt.ctrlKey || e.evt.metaKey) {
|
||||
const delta = -Math.sign(e.evt.deltaY) * 0.05
|
||||
setZoomLevel(zoomLevel + delta)
|
||||
} else {
|
||||
const factor = 1.5
|
||||
const dx = e.evt.shiftKey ? -e.evt.deltaY * factor : 0
|
||||
const dy = e.evt.shiftKey ? 0 : -e.evt.deltaY * factor
|
||||
setStageOffset({ x: stageOffset.x + dx, y: stageOffset.y + dy })
|
||||
}
|
||||
}
|
||||
},
|
||||
[zoomLevel, stageOffset, setZoomLevel, setStageOffset],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (e.evt.button === 1) {
|
||||
middlePanRef.current = true
|
||||
const stage = e.target.getStage()
|
||||
const pointer = stage?.getPointerPosition()
|
||||
if (!pointer) return
|
||||
panStartRef.current = { x: pointer.x - stageOffset.x, y: pointer.y - stageOffset.y }
|
||||
setCursor("grabbing")
|
||||
return
|
||||
}
|
||||
|
||||
const stage = e.target.getStage()
|
||||
const pointer = stage?.getPointerPosition()
|
||||
if (!pointer) return
|
||||
const targetName = e.target.name()
|
||||
|
||||
if (toolMode === "pan") {
|
||||
panStartRef.current = { x: pointer.x - stageOffset.x, y: pointer.y - stageOffset.y }
|
||||
interactionRef.current = { type: "pan", startMouse: { x: (pointer.x - stageOffset.x) / zoomLevel, y: (pointer.y - stageOffset.y) / zoomLevel }, startOffset: { ...stageOffset } }
|
||||
setCursor("grabbing")
|
||||
return
|
||||
}
|
||||
|
||||
if (toolMode === "draw") {
|
||||
pushUndo()
|
||||
drawingRef.current = true
|
||||
setDrawing(true)
|
||||
setDrawStart({ x: (pointer.x - stageOffset.x) / zoomLevel, y: (pointer.y - stageOffset.y) / zoomLevel })
|
||||
setDrawEnd({ x: (pointer.x - stageOffset.x) / zoomLevel, y: (pointer.y - stageOffset.y) / zoomLevel })
|
||||
return
|
||||
}
|
||||
|
||||
if (toolMode === "select") {
|
||||
if (targetName.startsWith("handle::")) {
|
||||
const parts = targetName.split("::")
|
||||
const blockName = parts[1]
|
||||
const idx = parseInt(parts[2])
|
||||
const handle = parts[3]
|
||||
if (!blockName || isNaN(idx) || !handle) return
|
||||
const block = blocks[blockName]
|
||||
if (!block || idx < 0 || idx >= block.sections.length) return
|
||||
const cords = block.sections[idx].cords
|
||||
if (!cords) return
|
||||
if (blockName !== currentBlockName) {
|
||||
useStore.getState().selectBlockByName(blockName)
|
||||
}
|
||||
selectSection(idx)
|
||||
liveCordsRef.current[idx] = [...cords] as [number, number, number, number]
|
||||
pushUndo()
|
||||
interactionRef.current = {
|
||||
type: "resize", sectionIdx: idx, handle,
|
||||
startMouse: { x: (pointer.x - stageOffset.x) / zoomLevel, y: (pointer.y - stageOffset.y) / zoomLevel }, startCords: [...cords] as [number, number, number, number],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (targetName.startsWith("section-body::")) {
|
||||
const parts = targetName.split("::")
|
||||
const blockName = parts[1]
|
||||
const idx = parseInt(parts[2])
|
||||
if (!blockName || isNaN(idx)) return
|
||||
const block = blocks[blockName]
|
||||
if (!block || idx < 0 || idx >= block.sections.length) return
|
||||
const cords = block.sections[idx].cords
|
||||
if (!cords) return
|
||||
if (blockName !== currentBlockName) {
|
||||
useStore.getState().selectBlockByName(blockName)
|
||||
}
|
||||
selectSection(idx)
|
||||
liveCordsRef.current[idx] = [...cords] as [number, number, number, number]
|
||||
pushUndo()
|
||||
interactionRef.current = {
|
||||
type: "move", sectionIdx: idx,
|
||||
startMouse: { x: (pointer.x - stageOffset.x) / zoomLevel, y: (pointer.y - stageOffset.y) / zoomLevel }, startCords: [...cords] as [number, number, number, number],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectSection(null)
|
||||
}
|
||||
},
|
||||
[toolMode, zoomLevel, blocks, currentBlockName, pushUndo, selectSection, stageOffset, setCursor],
|
||||
)
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const stage = e.target.getStage()
|
||||
const pointer = stage?.getPointerPosition()
|
||||
if (!pointer) return
|
||||
|
||||
if (middlePanRef.current) {
|
||||
setStageOffset({
|
||||
x: pointer.x - panStartRef.current.x,
|
||||
y: pointer.y - panStartRef.current.y,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const inter = interactionRef.current
|
||||
if (!inter) {
|
||||
if (drawingRef.current) {
|
||||
setDrawEnd({ x: (pointer.x - stageOffset.x) / zoomLevel, y: (pointer.y - stageOffset.y) / zoomLevel })
|
||||
return
|
||||
}
|
||||
const targetName = e.target.name()
|
||||
let cursor = "default"
|
||||
if (toolMode === "draw") cursor = "crosshair"
|
||||
else if (toolMode === "pan") cursor = "grab"
|
||||
else if (toolMode === "select") {
|
||||
if (targetName.startsWith("handle::")) {
|
||||
const parts = targetName.split("::")
|
||||
cursor = CURSOR_MAP[parts[3]] ?? "default"
|
||||
} else if (targetName.startsWith("section-body::")) {
|
||||
cursor = "move"
|
||||
}
|
||||
}
|
||||
setCursor(cursor)
|
||||
return
|
||||
}
|
||||
|
||||
const mx = (pointer.x - stageOffset.x) / zoomLevel
|
||||
const my = (pointer.y - stageOffset.y) / zoomLevel
|
||||
const dx = mx - inter.startMouse.x
|
||||
const dy = my - inter.startMouse.y
|
||||
|
||||
if (inter.type === "pan") {
|
||||
setStageOffset({
|
||||
x: pointer.x - panStartRef.current.x,
|
||||
y: pointer.y - panStartRef.current.y,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (inter.type === "move" && inter.sectionIdx !== undefined && inter.startCords) {
|
||||
const [x1, x2, y1, y2] = inter.startCords
|
||||
liveCordsRef.current[inter.sectionIdx] = [
|
||||
Math.round(x1 + dx), Math.round(x2 + dx),
|
||||
Math.round(y1 + dy), Math.round(y2 + dy),
|
||||
]
|
||||
forceUpdate((n) => n + 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (inter.type === "resize" && inter.sectionIdx !== undefined && inter.startCords && inter.handle) {
|
||||
const [x1, x2, y1, y2] = inter.startCords
|
||||
let nx1 = x1, nx2 = x2, ny1 = y1, ny2 = y2
|
||||
switch (inter.handle) {
|
||||
case "tl": nx1 = mx; ny1 = my; break
|
||||
case "tr": nx2 = mx; ny1 = my; break
|
||||
case "bl": nx1 = mx; ny2 = my; break
|
||||
case "br": nx2 = mx; ny2 = my; break
|
||||
case "tc": ny1 = my; break
|
||||
case "bc": ny2 = my; break
|
||||
case "lc": nx1 = mx; break
|
||||
case "rc": nx2 = mx; break
|
||||
}
|
||||
if (Math.abs(nx2 - nx1) < 10 || Math.abs(ny2 - ny1) < 10) return
|
||||
liveCordsRef.current[inter.sectionIdx] = [Math.round(nx1), Math.round(nx2), Math.round(ny1), Math.round(ny2)]
|
||||
forceUpdate((n) => n + 1)
|
||||
return
|
||||
}
|
||||
},
|
||||
[toolMode, zoomLevel, stageOffset, setCursor],
|
||||
)
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (middlePanRef.current) {
|
||||
middlePanRef.current = false
|
||||
setCursor(toolMode === "draw" ? "crosshair" : toolMode === "pan" ? "grab" : "default")
|
||||
return
|
||||
}
|
||||
|
||||
const inter = interactionRef.current
|
||||
if (inter) {
|
||||
if (inter.type === "move" || inter.type === "resize") {
|
||||
const idx = inter.sectionIdx!
|
||||
const cords = liveCordsRef.current[idx]
|
||||
if (cords) {
|
||||
updateSectionCords(idx, cords)
|
||||
delete liveCordsRef.current[idx]
|
||||
forceUpdate((n) => n + 1)
|
||||
setStatusMessage(
|
||||
inter.type === "move"
|
||||
? `Moved section #${idx + 1}`
|
||||
: `Resized section #${idx + 1}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
interactionRef.current = null
|
||||
setCursor(toolMode === "draw" ? "crosshair" : toolMode === "pan" ? "grab" : "default")
|
||||
return
|
||||
}
|
||||
|
||||
if (!drawingRef.current) return
|
||||
drawingRef.current = false
|
||||
setDrawing(false)
|
||||
const x1 = Math.min(drawStart.x, drawEnd.x)
|
||||
const y1 = Math.min(drawStart.y, drawEnd.y)
|
||||
const x2 = Math.max(drawStart.x, drawEnd.x)
|
||||
const y2 = Math.max(drawStart.y, drawEnd.y)
|
||||
if (Math.abs(x2 - x1) < 10 || Math.abs(y2 - y1) < 10) return
|
||||
const s = useStore.getState()
|
||||
const roi: RoiItem = {
|
||||
cords: [
|
||||
Math.round(Math.max(0, Math.min(x1, imageWidth))),
|
||||
Math.round(Math.max(0, Math.min(x2, imageWidth))),
|
||||
Math.round(Math.max(0, Math.min(y1, imageHeight))),
|
||||
Math.round(Math.max(0, Math.min(y2, imageHeight))),
|
||||
],
|
||||
row: s.defaultRow,
|
||||
column: s.defaultColumn,
|
||||
options: [],
|
||||
align: "column",
|
||||
questioncount: [],
|
||||
multipleChoice: 1,
|
||||
}
|
||||
addSection(roi)
|
||||
setStatusMessage(`Added section at (${roi.cords[0]},${roi.cords[2]})->(${roi.cords[1]},${roi.cords[3]})`)
|
||||
}, [drawStart, drawEnd, imageWidth, imageHeight, addSection, setStatusMessage, updateSectionCords, toolMode, setCursor])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragOver(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragOver(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragOver(false)
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
for (const file of files) {
|
||||
const result = await processDroppedFile(file)
|
||||
if (!result) continue
|
||||
if ("dataUrl" in result) {
|
||||
setTemplate(result.dataUrl, result.filePath, result.width, result.height)
|
||||
setStatusMessage(`Loaded: ${result.name}`)
|
||||
return
|
||||
}
|
||||
restoreFromData(result)
|
||||
setStatusMessage(`Loaded: ${file.name}`)
|
||||
return
|
||||
}
|
||||
},
|
||||
[setTemplate, setStatusMessage, restoreFromData],
|
||||
)
|
||||
|
||||
return (
|
||||
<main
|
||||
ref={containerRef}
|
||||
className="flex-1 bg-canvas overflow-hidden relative"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(var(--color-canvas-dot) 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 z-40 bg-primary/8 border-2 border-dashed border-primary/40 flex items-center justify-center pointer-events-none">
|
||||
<div className="bg-canvas/90 backdrop-blur-sm px-8 py-7 rounded-xl border border-hairline text-center shadow-lg">
|
||||
<span className="material-symbols-outlined text-5xl text-primary block mb-2">file_upload</span>
|
||||
<p className="text-title-md text-ink font-medium">Drop files here</p>
|
||||
<p className="text-body-sm text-muted mt-1">Image → Template | JSON → Metadata</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,application/json,.json"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const result = await processDroppedFile(file)
|
||||
if (!result) { setStatusMessage("Unsupported file type"); return }
|
||||
if ("dataUrl" in result) {
|
||||
setTemplate(result.dataUrl, result.filePath, result.width, result.height)
|
||||
setStatusMessage(`Loaded: ${result.name}`)
|
||||
} else {
|
||||
restoreFromData(result)
|
||||
setStatusMessage(`Loaded: ${file.name}`)
|
||||
}
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
{templateImageData ? (
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={stageSize.width}
|
||||
height={stageSize.height}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<Layer>
|
||||
<Group x={stageOffset.x} y={stageOffset.y}>
|
||||
{img && (
|
||||
<Image
|
||||
image={img}
|
||||
x={0} y={0}
|
||||
width={imageWidth * zoomLevel}
|
||||
height={imageHeight * zoomLevel}
|
||||
opacity={1.0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{blockOrder.flatMap((blockName) => {
|
||||
const block = blocks[blockName]
|
||||
if (!block) return []
|
||||
const colorIdx = blockOrder.indexOf(blockName) % COLORS.length
|
||||
const blockColor = COLORS[colorIdx]
|
||||
const isActive = blockName === currentBlockName
|
||||
return block.sections.map((roi, idx) => {
|
||||
const cords = isActive ? getCords(idx) : roi.cords
|
||||
const [cx1, cx2, cy1, cy2] = cords
|
||||
const w = cx2 - cx1
|
||||
const h = cy2 - cy1
|
||||
const isSel = isActive && idx === selectedSectionIdx
|
||||
const color = isSel ? "#5db8a6" : blockColor
|
||||
return (
|
||||
<Group key={`${blockName}::${idx}`} name={`sec::${blockName}::${idx}`} x={cx1 * zoomLevel} y={cy1 * zoomLevel}>
|
||||
<Rect
|
||||
name={`section-body::${blockName}::${idx}`}
|
||||
x={0} y={0}
|
||||
width={w * zoomLevel}
|
||||
height={h * zoomLevel}
|
||||
stroke={color}
|
||||
strokeWidth={isSel ? 3 : 2}
|
||||
fill={isSel ? "rgba(93,184,166,0.08)" : "rgba(204,120,92,0.04)"}
|
||||
/>
|
||||
<Text
|
||||
x={0} y={-22}
|
||||
text={blockName}
|
||||
fontSize={10}
|
||||
fontFamily="Inter"
|
||||
fill={color}
|
||||
fontStyle="600"
|
||||
/>
|
||||
<Text
|
||||
x={0} y={h * zoomLevel + 4}
|
||||
text={`Sec #${idx + 1}`}
|
||||
fontSize={9}
|
||||
fontFamily="Inter"
|
||||
fill={color}
|
||||
/>
|
||||
{(roi.row > 1 || roi.column > 1) && renderGrid(w * zoomLevel, h * zoomLevel, roi)}
|
||||
|
||||
{isSel && toolMode === "select" && HANDLE_DIRS.map((dir) => {
|
||||
let hx = 0, hy = 0
|
||||
const r = zoomLevel
|
||||
switch (dir) {
|
||||
case "tl": hx = 0; hy = 0; break
|
||||
case "tc": hx = w * r / 2; hy = 0; break
|
||||
case "tr": hx = w * r; hy = 0; break
|
||||
case "rc": hx = w * r; hy = h * r / 2; break
|
||||
case "br": hx = w * r; hy = h * r; break
|
||||
case "bc": hx = w * r / 2; hy = h * r; break
|
||||
case "bl": hx = 0; hy = h * r; break
|
||||
case "lc": hx = 0; hy = h * r / 2; break
|
||||
}
|
||||
return (
|
||||
<Circle
|
||||
key={dir}
|
||||
name={`handle::${blockName}::${idx}::${dir}`}
|
||||
x={hx} y={hy}
|
||||
radius={HANDLE_R}
|
||||
fill="white"
|
||||
stroke="#5db8a6"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Group>
|
||||
)
|
||||
})
|
||||
})}
|
||||
|
||||
{drawing && (() => {
|
||||
const px = Math.min(drawStart.x, drawEnd.x) * zoomLevel
|
||||
const py = Math.min(drawStart.y, drawEnd.y) * zoomLevel
|
||||
const pw = Math.abs(drawEnd.x - drawStart.x) * zoomLevel
|
||||
const ph = Math.abs(drawEnd.y - drawStart.y) * zoomLevel
|
||||
const s = useStore.getState()
|
||||
return (
|
||||
<Group x={px} y={py}>
|
||||
<Rect
|
||||
x={0} y={0} width={pw} height={ph}
|
||||
stroke="#e8a55a"
|
||||
strokeWidth={2}
|
||||
dash={[4, 2]}
|
||||
/>
|
||||
{renderPreviewGrid(pw, ph, s.defaultRow, s.defaultColumn)}
|
||||
</Group>
|
||||
)
|
||||
})()}
|
||||
</Group>
|
||||
</Layer>
|
||||
</Stage>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center justify-center h-full cursor-pointer group"
|
||||
onClick={() => loadTemplateDialog().then((result) => {
|
||||
if (!result) return
|
||||
setTemplate(result.dataUrl, result.filePath, result.width, result.height)
|
||||
setStatusMessage(`Loaded: ${result.name}`)
|
||||
})}
|
||||
>
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-20 h-20 mx-auto mb-5 rounded-xl bg-surface-card border border-hairline flex items-center justify-center group-hover:border-primary/30 group-hover:bg-surface-cream-strong/20 transition-all">
|
||||
<span className="material-symbols-outlined text-4xl text-muted-soft group-hover:text-primary transition-colors">image</span>
|
||||
</div>
|
||||
<p className="text-title-md text-body font-medium">Drop an image or click to load template</p>
|
||||
<p className="text-body-sm text-muted mt-2">
|
||||
<button className="text-primary hover:text-primary-active underline underline-offset-2 decoration-primary/30 transition-all" onClick={(e) => { e.stopPropagation(); loadTemplateDialog().then((result) => { if (!result) return; setTemplate(result.dataUrl, result.filePath, result.width, result.height); setStatusMessage(`Loaded: ${result.name}`) }) }}>Load Template</button>
|
||||
<span className="mx-3 text-hairline">|</span>
|
||||
<button className="text-primary hover:text-primary-active underline underline-offset-2 decoration-primary/30 transition-all" onClick={(e) => { e.stopPropagation(); loadMetadataDialog().then((result) => { if (!result) { setStatusMessage("Invalid metadata file"); return }; restoreFromData(result); setStatusMessage("Metadata loaded") }) }}>Load metadata.json</button>
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-4 text-caption text-muted-soft">
|
||||
<span className="flex items-center gap-1.5"><kbd className="px-1.5 py-0.5 bg-surface-card border border-hairline rounded text-[11px]">Ctrl+O</kbd> Open</span>
|
||||
<span className="flex items-center gap-1.5"><kbd className="px-1.5 py-0.5 bg-surface-card border border-hairline rounded text-[11px]">Ctrl+S</kbd> Save</span>
|
||||
<span className="flex items-center gap-1.5"><kbd className="px-1.5 py-0.5 bg-surface-card border border-hairline rounded text-[11px]">Ctrl+Z</kbd> Undo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ["S"], desc: "Select mode" },
|
||||
{ keys: ["D"], desc: "Draw mode" },
|
||||
{ keys: ["P"], desc: "Pan mode" },
|
||||
{ keys: ["← ↑ → ↓"], desc: "Nudge section (1px)" },
|
||||
{ keys: ["⇧ + ← ↑ → ↓"], desc: "Nudge section (10px)" },
|
||||
{ keys: ["Del / ⌫"], desc: "Delete selected section" },
|
||||
{ keys: ["?"], desc: "Toggle this cheat sheet" },
|
||||
{ keys: ["Ctrl+O"], desc: "Open template image" },
|
||||
{ keys: ["Ctrl+S"], desc: "Export metadata.json" },
|
||||
{ keys: ["Ctrl+Z"], desc: "Undo" },
|
||||
{ keys: ["Ctrl+Scroll"], desc: "Zoom in/out" },
|
||||
{ keys: ["Scroll"], desc: "Pan canvas" },
|
||||
]
|
||||
|
||||
export function KeyboardCheatSheet({ onClose }: { onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "?" || e.key === "Escape") onClose()
|
||||
}
|
||||
window.addEventListener("keydown", handler)
|
||||
ref.current?.focus()
|
||||
return () => window.removeEventListener("keydown", handler)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-canvas rounded-xl shadow-2xl border border-hairline w-[90vw] max-w-[420px] animate-slide-up overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-hairline">
|
||||
<h2 className="text-title-sm text-ink font-medium flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">keyboard</span>
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<span className="text-caption text-muted-soft bg-surface-soft px-2.5 py-1 rounded-md">14</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-1 max-h-[60vh] overflow-y-auto">
|
||||
{shortcuts.map((sc) => (
|
||||
<div key={sc.keys.join("")} className="flex items-center justify-between px-2 py-2 rounded-lg">
|
||||
<span className="text-body-sm text-body">{sc.desc}</span>
|
||||
<kbd className="px-2 py-1 bg-surface-soft border border-hairline rounded-md text-caption font-mono text-muted whitespace-nowrap ml-4">
|
||||
{sc.keys.join(" ")}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-5 py-3 border-t border-hairline bg-surface-soft/50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg text-button text-muted hover:text-ink hover:bg-surface-card transition-all"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { useStore } from "../store/useStore"
|
||||
|
||||
export function LeftSidebar({ width, collapsed, onToggle }: { width: number; collapsed: boolean; onToggle: () => void }) {
|
||||
const blocks = useStore((s) => s.blocks)
|
||||
const blockOrder = useStore((s) => s.blockOrder)
|
||||
const currentBlockName = useStore((s) => s.currentBlockName)
|
||||
const selectedSectionIdx = useStore((s) => s.selectedSectionIdx)
|
||||
const selectBlockByName = useStore((s) => s.selectBlockByName)
|
||||
const selectSection = useStore((s) => s.selectSection)
|
||||
const addBlock = useStore((s) => s.addBlock)
|
||||
const deleteBlock = useStore((s) => s.deleteBlock)
|
||||
const renameBlock = useStore((s) => s.renameBlock)
|
||||
const deleteSection = useStore((s) => s.deleteSection)
|
||||
const [renaming, setRenaming] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState("")
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming) {
|
||||
renameInputRef.current?.focus()
|
||||
renameInputRef.current?.select()
|
||||
}
|
||||
}, [renaming])
|
||||
|
||||
const totalSections = blockOrder.reduce(
|
||||
(acc, name) => acc + (blocks[name]?.sections.length ?? 0),
|
||||
0,
|
||||
)
|
||||
|
||||
const handleDeleteBlock = (e: React.MouseEvent, name: string) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete block '${name}' and all its sections?`)) {
|
||||
deleteBlock(name)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectSection = (blockName: string, idx: number) => {
|
||||
if (blockName !== currentBlockName) selectBlockByName(blockName)
|
||||
selectSection(idx)
|
||||
}
|
||||
|
||||
const startRename = (e: React.MouseEvent, name: string) => {
|
||||
e.stopPropagation()
|
||||
setRenaming(name)
|
||||
setRenameValue(name)
|
||||
}
|
||||
|
||||
const commitRename = () => {
|
||||
const oldName = renaming
|
||||
const newName = renameValue.trim()
|
||||
if (oldName && newName && newName !== oldName) {
|
||||
renameBlock(oldName, newName)
|
||||
}
|
||||
setRenaming(null)
|
||||
}
|
||||
|
||||
const cancelRename = () => setRenaming(null)
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="bg-surface-card border-r border-hairline flex flex-col shrink-0 items-center py-2" style={{ width }}>
|
||||
<button onClick={onToggle} className="p-1 text-muted hover:text-ink transition-colors cursor-pointer" title="Expand">
|
||||
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
|
||||
</button>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="bg-surface-card border-r border-hairline flex flex-col shrink-0" style={{ width }}>
|
||||
<div className="px-4 py-3 border-b border-hairline flex items-center justify-between gap-2">
|
||||
<h2 className="text-caption-uppercase text-muted tracking-widest flex items-center gap-2 min-w-0">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary shrink-0">layers</span>
|
||||
<span className="truncate">Blocks</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-caption text-muted-soft">{blockOrder.length}</span>
|
||||
<button onClick={onToggle} className="text-muted-soft hover:text-ink transition-colors cursor-pointer" title="Collapse">
|
||||
<span className="material-symbols-outlined text-[16px]">chevron_left</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-3 border-b border-hairline-soft">
|
||||
<button
|
||||
onClick={addBlock}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-primary text-on-primary rounded-lg text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||
New Block
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-1">
|
||||
{blockOrder.map((name) => {
|
||||
const isActive = name === currentBlockName
|
||||
const sections = blocks[name]?.sections ?? []
|
||||
const isRenaming = renaming === name
|
||||
return (
|
||||
<div key={name} className="animate-fade-in group">
|
||||
<div
|
||||
className={
|
||||
isActive
|
||||
? "border-l-[3px] border-primary bg-primary/[0.06]"
|
||||
: "border-l-[3px] border-transparent hover:bg-surface-cream-strong/30 transition-colors"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2.5 cursor-pointer"
|
||||
onClick={() => !isRenaming && selectBlockByName(name)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={"material-symbols-outlined text-[16px] shrink-0 " + (isActive ? "text-primary" : "text-muted-soft")}>
|
||||
checklist
|
||||
</span>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename()
|
||||
if (e.key === "Escape") cancelRename()
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 bg-canvas border border-primary rounded px-1.5 py-0.5 text-title-sm text-ink outline-none min-w-0"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
isActive
|
||||
? "text-title-sm text-ink truncate"
|
||||
: "text-title-sm text-body truncate"
|
||||
}
|
||||
onDoubleClick={(e) => startRename(e, name)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
className={"p-0.5 hover:text-primary transition-all " + (isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100")}
|
||||
onClick={(e) => startRename(e, name)}
|
||||
title="Rename"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">edit</span>
|
||||
</button>
|
||||
<button
|
||||
className={"text-muted-soft/50 hover:text-error transition-all p-0.5 " + (isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100")}
|
||||
onClick={(e) => handleDeleteBlock(e, name)}
|
||||
title="Delete block"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sections.length > 0 && (
|
||||
<div className="ml-4 pb-2 pr-2 space-y-0.5 border-l border-hairline">
|
||||
{sections.map((_, idx) => {
|
||||
const isSecSelected = isActive && idx === selectedSectionIdx
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => handleSelectSection(name, idx)}
|
||||
className={
|
||||
(isSecSelected
|
||||
? "flex items-center gap-2 px-3 py-1.5 text-body-sm cursor-pointer bg-primary/[0.06] rounded-r-md ml-3"
|
||||
: "flex items-center gap-2 px-3 py-1.5 text-body-sm cursor-pointer hover:bg-surface-cream-strong/20 rounded-r-md ml-3") + " group"
|
||||
}
|
||||
>
|
||||
<span className={"material-symbols-outlined text-[14px] shrink-0 " + (isSecSelected ? "text-primary" : "text-muted-soft/50")}>
|
||||
{isSecSelected ? "radio_button_checked" : "radio_button_unchecked"}
|
||||
</span>
|
||||
<span className={"flex-1 truncate " + (isSecSelected ? "text-ink font-medium" : "text-muted")}>
|
||||
Section #{idx + 1}
|
||||
</span>
|
||||
<button
|
||||
className={"text-muted-soft/40 hover:text-error transition-all p-0.5 " + (isSecSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (currentBlockName) {
|
||||
deleteSection(idx)
|
||||
}
|
||||
}}
|
||||
title="Delete section"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-4 py-2.5 border-t border-hairline bg-surface-cream-strong/20 flex justify-between items-center">
|
||||
<span className="text-caption text-muted-soft">
|
||||
<span className="font-medium text-body">{blockOrder.length}</span> blocks
|
||||
</span>
|
||||
<span className="text-caption text-muted-soft">
|
||||
<span className="font-medium text-body">{totalSections}</span> sections
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useStore } from "../store/useStore"
|
||||
import { parseRangeInput } from "../utils/rangeParser"
|
||||
import { BubbleEditor } from "./BubbleEditor"
|
||||
|
||||
export function PropertiesPanel({ width, collapsed, onToggle }: { width: number; collapsed: boolean; onToggle: () => void }) {
|
||||
const currentBlockName = useStore((s) => s.currentBlockName)
|
||||
const selectedSectionIdx = useStore((s) => s.selectedSectionIdx)
|
||||
const blocks = useStore((s) => s.blocks)
|
||||
const config = useStore((s) => s.config)
|
||||
const updateSectionProp = useStore((s) => s.updateSectionProp)
|
||||
const updateConfig = useStore((s) => s.updateConfig)
|
||||
const pushUndo = useStore((s) => s.pushUndo)
|
||||
const setStatusMessage = useStore((s) => s.setStatusMessage)
|
||||
const deleteSection = useStore((s) => s.deleteSection)
|
||||
const templateImageData = useStore((s) => s.templateImageData)
|
||||
|
||||
const roi = (currentBlockName && selectedSectionIdx !== null)
|
||||
? blocks[currentBlockName]?.sections?.[selectedSectionIdx] ?? null
|
||||
: null
|
||||
|
||||
const [x1, setX1] = useState("")
|
||||
const [y1, setY1] = useState("")
|
||||
const [x2, setX2] = useState("")
|
||||
const [y2, setY2] = useState("")
|
||||
const [rows, setRows] = useState("1")
|
||||
const [cols, setCols] = useState("1")
|
||||
const [align, setAlign] = useState("column")
|
||||
const [options, setOptions] = useState("")
|
||||
const [qcount, setQcount] = useState("")
|
||||
const [mc, setMc] = useState("1")
|
||||
const [fillThresh, setFillThresh] = useState(String(config.FillThreshold))
|
||||
const [whitepatch, setWhitepatch] = useState(config.WhitePatchDetectionNeeded)
|
||||
const [xPad, setXPad] = useState(String(config.x_padding))
|
||||
const [yPad, setYPad] = useState(String(config.y_padding))
|
||||
const [tw, setTw] = useState(String(config.TemplateShape.width))
|
||||
const [th, setTh] = useState(String(config.TemplateShape.height))
|
||||
const [showBubbleEditor, setShowBubbleEditor] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (roi) {
|
||||
setX1(String(roi.cords[0]))
|
||||
setY1(String(roi.cords[2]))
|
||||
setX2(String(roi.cords[1]))
|
||||
setY2(String(roi.cords[3]))
|
||||
setRows(String(roi.row))
|
||||
setCols(String(roi.column))
|
||||
setAlign(roi.align)
|
||||
setOptions(JSON.stringify(roi.options))
|
||||
setQcount(JSON.stringify(roi.questioncount))
|
||||
setMc(String(roi.multipleChoice))
|
||||
} else {
|
||||
const st = useStore.getState()
|
||||
setX1(""); setY1(""); setX2(""); setY2("")
|
||||
setRows(String(st.defaultRow)); setCols(String(st.defaultColumn)); setAlign("column")
|
||||
setOptions(""); setQcount(""); setMc("1")
|
||||
}
|
||||
}, [roi])
|
||||
|
||||
useEffect(() => {
|
||||
setFillThresh(String(config.FillThreshold))
|
||||
setWhitepatch(config.WhitePatchDetectionNeeded)
|
||||
setXPad(String(config.x_padding))
|
||||
setYPad(String(config.y_padding))
|
||||
setTw(String(config.TemplateShape.width))
|
||||
setTh(String(config.TemplateShape.height))
|
||||
}, [config])
|
||||
|
||||
const hasSel = roi !== null
|
||||
|
||||
const commitCoord = (field: string, raw: string) => {
|
||||
if (!hasSel || selectedSectionIdx === null || !currentBlockName) {
|
||||
if (roi) restoreCoord(field)
|
||||
return
|
||||
}
|
||||
const num = parseInt(raw)
|
||||
if (isNaN(num)) { restoreCoord(field); return }
|
||||
pushUndo()
|
||||
const r = blocks[currentBlockName].sections[selectedSectionIdx]
|
||||
if (!r) return
|
||||
const newCords: [number, number, number, number] = [...r.cords]
|
||||
switch (field) {
|
||||
case "x1": newCords[0] = num; break
|
||||
case "y1": newCords[2] = num; break
|
||||
case "x2": newCords[1] = num; break
|
||||
case "y2": newCords[3] = num; break
|
||||
}
|
||||
useStore.getState().updateSectionCords(selectedSectionIdx, newCords)
|
||||
setStatusMessage(`Updated ${field.toUpperCase()}`)
|
||||
}
|
||||
|
||||
const restoreCoord = (field: string) => {
|
||||
if (!roi) return
|
||||
const v = String(field === "x1" || field === "x2" ? roi.cords[field === "x1" ? 0 : 1] : roi.cords[field === "y1" ? 2 : 3])
|
||||
switch (field) {
|
||||
case "x1": setX1(v); break
|
||||
case "y1": setY1(v); break
|
||||
case "x2": setX2(v); break
|
||||
case "y2": setY2(v); break
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerate = (field: "options" | "qcount") => {
|
||||
const raw = field === "options" ? options : qcount
|
||||
if (!raw.trim()) return
|
||||
try {
|
||||
const result = parseRangeInput(raw)
|
||||
if (result !== null) {
|
||||
const jsonStr = JSON.stringify(result)
|
||||
if (field === "options") {
|
||||
setOptions(jsonStr)
|
||||
updateSectionProp(selectedSectionIdx!, "options", result)
|
||||
} else {
|
||||
setQcount(jsonStr)
|
||||
updateSectionProp(selectedSectionIdx!, "questioncount", result)
|
||||
}
|
||||
setStatusMessage(`Generated ${result.length} ${field}`)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
alert(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="bg-surface-card border-l border-hairline flex flex-col shrink-0 items-center py-2" style={{ width }}>
|
||||
<button onClick={onToggle} className="p-1 text-muted hover:text-ink transition-colors cursor-pointer" title="Expand">
|
||||
<span className="material-symbols-outlined text-[18px]">chevron_left</span>
|
||||
</button>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="bg-surface-card border-l border-hairline flex flex-col shrink-0" style={{ width }}>
|
||||
<div className="px-4 py-3 border-b border-hairline flex items-center justify-between gap-2">
|
||||
<span className="text-caption-uppercase text-muted tracking-widest flex items-center gap-2 min-w-0">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary shrink-0">tune</span>
|
||||
<span className="truncate">Properties</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-caption text-muted-soft truncate">
|
||||
{currentBlockName ? `${currentBlockName}` : "No selection"}
|
||||
</span>
|
||||
<button onClick={onToggle} className="text-muted-soft hover:text-ink transition-colors cursor-pointer" title="Collapse">
|
||||
<span className="material-symbols-outlined text-[16px]">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-5">
|
||||
{/* Coordinates */}
|
||||
<div className="space-y-3">
|
||||
<SectionHeader>Coordinates</SectionHeader>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ label: "X1", value: x1, setter: setX1, field: "x1" },
|
||||
{ label: "Y1", value: y1, setter: setY1, field: "y1" },
|
||||
{ label: "X2", value: x2, setter: setX2, field: "x2" },
|
||||
{ label: "Y2", value: y2, setter: setY2, field: "y2" },
|
||||
].map((f) => (
|
||||
<FieldBox key={f.label} label={f.label}>
|
||||
<Input
|
||||
type="number"
|
||||
value={f.value}
|
||||
onChange={(e) => f.setter(e.target.value)}
|
||||
onBlur={(e) => commitCoord(f.field, e.currentTarget.value)}
|
||||
disabled={!hasSel}
|
||||
/>
|
||||
</FieldBox>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Config */}
|
||||
<div className="space-y-3">
|
||||
<SectionHeader>Grid Config</SectionHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<FieldBox label="Rows">
|
||||
<Input
|
||||
type="number"
|
||||
value={rows}
|
||||
onChange={(e) => setRows(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.currentTarget.value)
|
||||
if (!isNaN(v) && hasSel) {
|
||||
pushUndo()
|
||||
updateSectionProp(selectedSectionIdx!, "row", v)
|
||||
} else if (!isNaN(v)) {
|
||||
useStore.getState().setDefaultRow(v)
|
||||
} else if (roi) {
|
||||
setRows(String(roi.row))
|
||||
} else {
|
||||
setRows(String(useStore.getState().defaultRow))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FieldBox>
|
||||
<FieldBox label="Cols">
|
||||
<Input
|
||||
type="number"
|
||||
value={cols}
|
||||
onChange={(e) => setCols(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.currentTarget.value)
|
||||
if (!isNaN(v) && hasSel) {
|
||||
pushUndo()
|
||||
updateSectionProp(selectedSectionIdx!, "column", v)
|
||||
} else if (!isNaN(v)) {
|
||||
useStore.getState().setDefaultColumn(v)
|
||||
} else if (roi) {
|
||||
setCols(String(roi.column))
|
||||
} else {
|
||||
setCols(String(useStore.getState().defaultColumn))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FieldBox>
|
||||
</div>
|
||||
<FieldBox label="Align Type">
|
||||
<Select
|
||||
value={align}
|
||||
onChange={(e) => { setAlign(e.target.value); onPropChange("align", e.target.value) }}
|
||||
disabled={!hasSel}
|
||||
>
|
||||
<option value="column">column</option>
|
||||
<option value="row">row</option>
|
||||
<option value="matrix">matrix</option>
|
||||
</Select>
|
||||
</FieldBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options & Questions */}
|
||||
<div className="space-y-3 border-t border-hairline pt-4">
|
||||
<SectionHeader>Options & Questions</SectionHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-caption text-muted font-medium">Options (e.g. A,B,C)</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="A,B,C,D"
|
||||
value={options}
|
||||
onChange={(e) => setOptions(e.target.value)}
|
||||
disabled={!hasSel}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleGenerate("options")}
|
||||
className="shrink-0 px-3 py-1.5 bg-primary/10 text-primary rounded-md text-caption font-medium hover:bg-primary/20 transition-all disabled:opacity-30"
|
||||
disabled={!hasSel}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-caption text-muted font-medium">Question Count</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="e.g. 1-5,8,10-12"
|
||||
value={qcount}
|
||||
onChange={(e) => setQcount(e.target.value)}
|
||||
disabled={!hasSel}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleGenerate("qcount")}
|
||||
className="shrink-0 px-3 py-1.5 bg-primary/10 text-primary rounded-md text-caption font-medium hover:bg-primary/20 transition-all disabled:opacity-30"
|
||||
disabled={!hasSel}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FieldBox label="MC Limit">
|
||||
<Input
|
||||
type="number"
|
||||
value={mc}
|
||||
onChange={(e) => setMc(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.currentTarget.value)
|
||||
if (!isNaN(v) && hasSel) {
|
||||
pushUndo()
|
||||
updateSectionProp(selectedSectionIdx!, "multipleChoice", v)
|
||||
} else if (roi) {
|
||||
setMc(String(roi.multipleChoice))
|
||||
}
|
||||
}}
|
||||
disabled={!hasSel}
|
||||
/>
|
||||
</FieldBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Config */}
|
||||
<div className="space-y-3 border-t border-hairline pt-4">
|
||||
<SectionHeader>Advanced Config</SectionHeader>
|
||||
<div className="space-y-3">
|
||||
<FieldBox label="Fill Threshold (Int)">
|
||||
<Input
|
||||
type="number"
|
||||
value={fillThresh}
|
||||
onChange={(e) => setFillThresh(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.currentTarget.value)
|
||||
if (!isNaN(v)) { pushUndo(); updateConfig({ FillThreshold: v }) }
|
||||
else setFillThresh(String(config.FillThreshold))
|
||||
}}
|
||||
/>
|
||||
</FieldBox>
|
||||
<div className="flex items-center justify-between px-3 py-2.5 bg-surface-cream-strong/20 border border-hairline rounded-lg">
|
||||
<span className="text-body-sm text-body">WhitePatch Correction</span>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={whitepatch}
|
||||
onChange={(e) => {
|
||||
setWhitepatch(e.target.checked)
|
||||
updateConfig({ WhitePatchDetectionNeeded: e.target.checked })
|
||||
pushUndo()
|
||||
}}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-hairline rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<FieldBox label="X Padding (Float)">
|
||||
<Input
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={xPad}
|
||||
onChange={(e) => setXPad(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const v = parseFloat(e.currentTarget.value)
|
||||
if (!isNaN(v)) updateConfig({ x_padding: v })
|
||||
else setXPad(String(config.x_padding))
|
||||
}}
|
||||
/>
|
||||
</FieldBox>
|
||||
<FieldBox label="Y Padding (Float)">
|
||||
<Input
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={yPad}
|
||||
onChange={(e) => setYPad(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const v = parseFloat(e.currentTarget.value)
|
||||
if (!isNaN(v)) updateConfig({ y_padding: v })
|
||||
else setYPad(String(config.y_padding))
|
||||
}}
|
||||
/>
|
||||
</FieldBox>
|
||||
</div>
|
||||
<FieldBox label="Template Shape">
|
||||
<div className="flex items-center gap-2 text-code text-muted">
|
||||
<Input
|
||||
className="w-16 text-center"
|
||||
type="number"
|
||||
value={tw}
|
||||
onChange={(e) => setTw(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.currentTarget.value)
|
||||
if (!isNaN(v)) updateConfig({ TemplateShape: { width: v, height: config.TemplateShape.height } })
|
||||
else setTw(String(config.TemplateShape.width))
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-soft">×</span>
|
||||
<Input
|
||||
className="w-16 text-center"
|
||||
type="number"
|
||||
value={th}
|
||||
onChange={(e) => setTh(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.currentTarget.value)
|
||||
if (!isNaN(v)) updateConfig({ TemplateShape: { width: config.TemplateShape.width, height: v } })
|
||||
else setTh(String(config.TemplateShape.height))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FieldBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-hairline space-y-2 bg-surface-cream-strong/10">
|
||||
<button
|
||||
onClick={() => setShowBubbleEditor(true)}
|
||||
className="w-full py-2.5 bg-accent-teal/10 text-accent-teal border border-accent-teal/20 rounded-lg flex items-center justify-center gap-2 hover:bg-accent-teal/20 active:scale-[0.97] transition-all text-button font-medium"
|
||||
disabled={selectedSectionIdx === null || !templateImageData}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">edit</span>
|
||||
Create Bubble Template
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedSectionIdx !== null && deleteSection(selectedSectionIdx)}
|
||||
className="w-full py-2.5 bg-error/5 text-error border border-error/15 rounded-lg flex items-center justify-center gap-2 hover:bg-error/10 active:scale-[0.97] transition-all text-button font-medium"
|
||||
disabled={selectedSectionIdx === null}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
Delete Section
|
||||
</button>
|
||||
</div>
|
||||
{showBubbleEditor && roi && templateImageData && (
|
||||
<BubbleEditor
|
||||
templateDataUrl={templateImageData}
|
||||
cords={roi.cords}
|
||||
onClose={() => setShowBubbleEditor(false)}
|
||||
setStatusMessage={setStatusMessage}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function onPropChange(field: string, value: string | number) {
|
||||
const state = useStore.getState()
|
||||
if (state.selectedSectionIdx === null || !state.currentBlockName) return
|
||||
state.pushUndo()
|
||||
state.updateSectionProp(state.selectedSectionIdx, field, value)
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return <h3 className="text-caption-uppercase text-muted tracking-widest flex items-center gap-2">{children}</h3>
|
||||
}
|
||||
|
||||
function FieldBox({ label, children, className = "" }: { label: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={"bg-surface-cream-strong/20 border border-hairline rounded-lg p-3 " + className}>
|
||||
<label className="text-caption text-muted block mb-1.5 font-medium">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Input({ value, onChange, onBlur, disabled, type = "text", step, placeholder, className = "" }: {
|
||||
value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void; disabled?: boolean; type?: string; step?: number; placeholder?: string; className?: string
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
className={"w-full bg-canvas border border-hairline rounded-md px-2.5 py-1.5 text-code text-body focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none transition-all disabled:opacity-40 " + className}
|
||||
type={type}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Select({ value, onChange, disabled, children, className = "" }: {
|
||||
value: string; onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void; disabled?: boolean; children: React.ReactNode; className?: string
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className={"w-full bg-canvas border border-hairline rounded-md px-2.5 py-1.5 text-body-sm text-body focus:border-primary focus:ring-1 focus:ring-primary/20 outline-none transition-all disabled:opacity-40 " + className}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useRef, useEffect } from "react"
|
||||
|
||||
interface Props {
|
||||
onResize: (clientX: number) => void
|
||||
}
|
||||
|
||||
export function ResizeHandle({ onResize }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const onResizeRef = useRef(onResize)
|
||||
onResizeRef.current = onResize
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
const dragging = { active: false }
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging.active) return
|
||||
onResizeRef.current(e.clientX)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
dragging.active = false
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
document.body.style.cursor = ""
|
||||
document.body.style.userSelect = ""
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
dragging.active = true
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
document.body.style.cursor = "col-resize"
|
||||
document.body.style.userSelect = "none"
|
||||
}
|
||||
|
||||
el.addEventListener("mousedown", handleMouseDown)
|
||||
return () => {
|
||||
el.removeEventListener("mousedown", handleMouseDown)
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-[5px] shrink-0 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useStore } from "../store/useStore"
|
||||
|
||||
export function StatusBar() {
|
||||
const statusMessage = useStore((s) => s.statusMessage)
|
||||
const zoomLevel = useStore((s) => s.zoomLevel)
|
||||
const currentBlockName = useStore((s) => s.currentBlockName)
|
||||
const selectedSectionIdx = useStore((s) => s.selectedSectionIdx)
|
||||
|
||||
return (
|
||||
<footer className="h-8 bg-surface-dark border-t border-surface-dark-elevated flex items-center justify-between px-4 z-50 shrink-0 select-none">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-success shadow-[0_0_6px_rgba(93,184,114,0.3)]" />
|
||||
<span className="text-caption text-on-dark-soft font-medium">Ready</span>
|
||||
</div>
|
||||
<div className="h-3 w-px bg-surface-dark-elevated" />
|
||||
<span className="text-code text-on-dark-soft/70">{statusMessage}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{currentBlockName && selectedSectionIdx !== null && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-on-dark-soft/60">
|
||||
<span className="material-symbols-outlined text-[14px] text-primary">lens</span>
|
||||
<span className="text-code text-on-dark-soft/70">
|
||||
{currentBlockName} / Sec #{selectedSectionIdx + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-px bg-surface-dark-elevated" />
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-on-dark-soft/60">
|
||||
<span className="material-symbols-outlined text-[14px] text-primary">zoom_in</span>
|
||||
<span className="text-code font-medium text-on-dark-soft/80 tabular-nums">{Math.round(zoomLevel * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
;(function injectStyles() {
|
||||
const style = document.createElement("style")
|
||||
style.textContent = `.tb-drag{-webkit-app-region:drag}.tb-no-drag{-webkit-app-region:no-drag}`
|
||||
document.head.appendChild(style)
|
||||
})()
|
||||
|
||||
export function TitleBar() {
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
const api = window.electronAPI
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return
|
||||
api.isMaximized().then(setMaximized)
|
||||
api.onMaximizeChange(setMaximized)
|
||||
}, [api])
|
||||
|
||||
return (
|
||||
<div className="h-[32px] flex items-center shrink-0 select-none bg-[#cc785c]">
|
||||
<div className="flex items-center h-full flex-1 tb-drag">
|
||||
<span className="px-3 text-[13px] font-medium text-white">OMR Metadata Creator</span>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full tb-no-drag">
|
||||
<button
|
||||
onClick={() => api?.minimizeWindow()}
|
||||
className="w-[46px] h-full flex items-center justify-center text-white/80 hover:text-white hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><rect x="0" y="5.5" width="12" height="1" fill="currentColor" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => api?.maximizeWindow()}
|
||||
className="w-[46px] h-full flex items-center justify-center text-white/80 hover:text-white hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
{maximized ? (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect x="3.5" y="0.5" width="8" height="8" rx="0.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||
<rect x="0.5" y="3.5" width="8" height="8" rx="0.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect x="0.5" y="0.5" width="11" height="11" rx="1" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => api?.closeWindow()}
|
||||
className="w-[46px] h-full flex items-center justify-center text-white/80 hover:text-white hover:bg-red-600 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from "react"
|
||||
import { useStore } from "../store/useStore"
|
||||
import { loadTemplateDialog, loadMetadataDialog } from "../utils/loadFile"
|
||||
import { KeyboardCheatSheet } from "./KeyboardCheatSheet"
|
||||
|
||||
const tools = [
|
||||
{ mode: "select" as const, icon: "near_me", label: "Select (S)" },
|
||||
{ mode: "draw" as const, icon: "crop_square", label: "Draw (D)" },
|
||||
{ mode: "pan" as const, icon: "pan_tool", label: "Pan (P)" },
|
||||
]
|
||||
|
||||
export function Toolbar() {
|
||||
const [showCheatSheet, setShowCheatSheet] = useState(false)
|
||||
const toolMode = useStore((s) => s.toolMode)
|
||||
const setToolMode = useStore((s) => s.setToolMode)
|
||||
const zoomLevel = useStore((s) => s.zoomLevel)
|
||||
const setZoomLevel = useStore((s) => s.setZoomLevel)
|
||||
const setStatusMessage = useStore((s) => s.setStatusMessage)
|
||||
const clearWorkspace = useStore((s) => s.clearWorkspace)
|
||||
const undo = useStore((s) => s.undo)
|
||||
const setTemplate = useStore((s) => s.setTemplate)
|
||||
const restoreFromData = useStore((s) => s.restoreFromData)
|
||||
const darkMode = useStore((s) => s.darkMode)
|
||||
const toggleDarkMode = useStore((s) => s.toggleDarkMode)
|
||||
const zoomToFit = useStore((s) => s.zoomToFit)
|
||||
const templateImageData = useStore((s) => s.templateImageData)
|
||||
|
||||
const handleLoadTemplate = () => {
|
||||
loadTemplateDialog().then((result) => {
|
||||
if (!result) return
|
||||
setTemplate(result.dataUrl, result.filePath, result.width, result.height)
|
||||
setStatusMessage(`Loaded: ${result.name}`)
|
||||
})
|
||||
}
|
||||
|
||||
const handleLoadMetadata = () => {
|
||||
loadMetadataDialog().then((result) => {
|
||||
if (!result) { setStatusMessage("Invalid metadata file"); return }
|
||||
restoreFromData(result)
|
||||
setStatusMessage("Metadata loaded")
|
||||
})
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const state = useStore.getState()
|
||||
const errors = state.validateExport()
|
||||
if (errors.length > 0) {
|
||||
alert("Validation errors:\n" + errors.map((e) => ` - ${e}`).join("\n"))
|
||||
return
|
||||
}
|
||||
const data = state.buildExportData()
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.saveFileDialog("metadata.json", [{ name: "JSON", extensions: ["json"] }])
|
||||
.then((path: string) => {
|
||||
if (!path) { setStatusMessage("Export cancelled"); return }
|
||||
return window.electronAPI!.writeFile(path, json)
|
||||
})
|
||||
.then(() => setStatusMessage("Exported successfully"))
|
||||
.catch((err: Error) => { setStatusMessage("Export failed"); console.error(err) })
|
||||
} else {
|
||||
const blob = new Blob([json], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "metadata.json"
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setStatusMessage("Exported metadata.json")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-toolbar-h bg-canvas border-b border-hairline flex items-center z-50 shrink-0 select-none">
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
{tools.map((t) => (
|
||||
<button
|
||||
key={t.mode}
|
||||
onClick={() => setToolMode(t.mode)}
|
||||
title={t.label}
|
||||
className={
|
||||
toolMode === t.mode
|
||||
? "flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary/10 text-primary border border-primary/20 transition-all"
|
||||
: "flex items-center gap-1.5 px-3 py-1.5 rounded-md text-muted hover:text-ink hover:bg-surface-soft transition-all"
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{t.icon}</span>
|
||||
<span className="text-nav hidden sm:inline">{t.mode.charAt(0).toUpperCase() + t.mode.slice(1)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 bg-surface-soft px-3 py-1 rounded-lg border border-hairline ml-3">
|
||||
<button
|
||||
onClick={() => setZoomLevel(zoomLevel - 0.1)}
|
||||
className="p-1 text-muted hover:text-ink transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">remove</span>
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={15}
|
||||
max={500}
|
||||
value={Math.round(zoomLevel * 100)}
|
||||
onChange={(e) => setZoomLevel(Number(e.target.value) / 100)}
|
||||
className="w-28"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setZoomLevel(zoomLevel + 0.1)}
|
||||
className="p-1 text-muted hover:text-ink transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">add</span>
|
||||
</button>
|
||||
<div className="h-4 w-px bg-hairline" />
|
||||
<button
|
||||
onClick={() => setZoomLevel(1.0)}
|
||||
className="text-caption font-medium px-2 py-0.5 text-muted hover:text-ink hover:bg-surface-card rounded transition-all"
|
||||
title="Reset zoom to 1:1"
|
||||
>
|
||||
1:1
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomToFit}
|
||||
disabled={!templateImageData}
|
||||
className="text-caption font-medium px-2 py-0.5 text-muted hover:text-ink hover:bg-surface-card rounded transition-all disabled:opacity-30"
|
||||
title="Zoom to fit canvas"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
<span className="text-code font-medium text-primary w-12 text-center tabular-nums">
|
||||
{Math.round(zoomLevel * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 rounded-md text-muted hover:text-ink hover:bg-surface-soft transition-all"
|
||||
title={darkMode ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{darkMode ? "light_mode" : "dark_mode"}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCheatSheet(true)}
|
||||
className="p-2 rounded-md text-muted hover:text-ink hover:bg-surface-soft transition-all"
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">question_mark</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleLoadTemplate} className="px-3 py-1.5 rounded-md text-nav text-muted hover:text-ink hover:bg-surface-soft transition-all">
|
||||
Load Template
|
||||
</button>
|
||||
<button onClick={handleLoadMetadata} className="px-3 py-1.5 rounded-md text-nav text-muted hover:text-ink hover:bg-surface-soft transition-all">
|
||||
Load metadata.json
|
||||
</button>
|
||||
<div className="h-5 w-px bg-hairline mx-1" />
|
||||
<button onClick={handleExport}
|
||||
className="px-4 py-1.5 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm">
|
||||
Export
|
||||
</button>
|
||||
<button onClick={undo}
|
||||
className="p-2 rounded-md text-muted hover:text-ink hover:bg-surface-soft transition-all"
|
||||
title="Undo (Ctrl+Z)">
|
||||
<span className="material-symbols-outlined text-[18px]">undo</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={clearWorkspace}
|
||||
className="p-2 rounded-md text-muted hover:text-error hover:bg-error/5 transition-all"
|
||||
title="Clear Workspace"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete_sweep</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCheatSheet && <KeyboardCheatSheet onClose={() => setShowCheatSheet(false)} />}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource/inter/400.css";
|
||||
@import "@fontsource/inter/500.css";
|
||||
@import "@fontsource/inter/600.css";
|
||||
@import "@fontsource/inter/700.css";
|
||||
@import "@fontsource/cormorant-garamond/400.css";
|
||||
@import "@fontsource/cormorant-garamond/500.css";
|
||||
@import "@fontsource/cormorant-garamond/600.css";
|
||||
@import "@fontsource/jetbrains-mono/400.css";
|
||||
@import "@fontsource/jetbrains-mono/500.css";
|
||||
@import "@fontsource/jetbrains-mono/700.css";
|
||||
|
||||
@theme {
|
||||
--color-canvas: #faf9f5;
|
||||
--color-surface-soft: #f5f0e8;
|
||||
--color-surface-card: #efe9de;
|
||||
--color-surface-cream-strong: #e8e0d2;
|
||||
--color-surface-dark: #181715;
|
||||
--color-surface-dark-elevated: #252320;
|
||||
--color-surface-dark-soft: #1f1e1b;
|
||||
--color-primary: #cc785c;
|
||||
--color-primary-active: #a9583e;
|
||||
--color-primary-disabled: #e6dfd8;
|
||||
--color-ink: #141413;
|
||||
--color-body: #3d3d3a;
|
||||
--color-body-strong: #252523;
|
||||
--color-muted: #6c6a64;
|
||||
--color-muted-soft: #8e8b82;
|
||||
--color-hairline: #e6dfd8;
|
||||
--color-hairline-soft: #ebe6df;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-on-dark: #faf9f5;
|
||||
--color-on-dark-soft: #a09d96;
|
||||
--color-accent-teal: #5db8a6;
|
||||
--color-accent-amber: #e8a55a;
|
||||
--color-success: #5db872;
|
||||
--color-warning: #d4a017;
|
||||
--color-error: #c64545;
|
||||
|
||||
--font-serif: "Cormorant Garamond", "Times New Roman", serif;
|
||||
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
|
||||
--text-display-xl: 64px;
|
||||
--text-display-xl--line-height: 1.05;
|
||||
--text-display-xl--letter-spacing: -1.5px;
|
||||
--text-display-xl--font-weight: 400;
|
||||
--text-display-xl--font-family: var(--font-serif);
|
||||
--text-display-lg: 48px;
|
||||
--text-display-lg--line-height: 1.1;
|
||||
--text-display-lg--letter-spacing: -1px;
|
||||
--text-display-lg--font-weight: 400;
|
||||
--text-display-lg--font-family: var(--font-serif);
|
||||
--text-display-md: 36px;
|
||||
--text-display-md--line-height: 1.15;
|
||||
--text-display-md--letter-spacing: -0.5px;
|
||||
--text-display-md--font-weight: 400;
|
||||
--text-display-md--font-family: var(--font-serif);
|
||||
--text-display-sm: 28px;
|
||||
--text-display-sm--line-height: 1.2;
|
||||
--text-display-sm--letter-spacing: -0.3px;
|
||||
--text-display-sm--font-weight: 400;
|
||||
--text-display-sm--font-family: var(--font-serif);
|
||||
--text-title-lg: 22px;
|
||||
--text-title-lg--line-height: 1.3;
|
||||
--text-title-lg--font-weight: 500;
|
||||
--text-title-lg--font-family: var(--font-sans);
|
||||
--text-title-md: 18px;
|
||||
--text-title-md--line-height: 1.4;
|
||||
--text-title-md--font-weight: 500;
|
||||
--text-title-md--font-family: var(--font-sans);
|
||||
--text-title-sm: 16px;
|
||||
--text-title-sm--line-height: 1.4;
|
||||
--text-title-sm--font-weight: 500;
|
||||
--text-title-sm--font-family: var(--font-sans);
|
||||
--text-body-md: 16px;
|
||||
--text-body-md--line-height: 1.55;
|
||||
--text-body-md--font-weight: 400;
|
||||
--text-body-md--font-family: var(--font-sans);
|
||||
--text-body-sm: 14px;
|
||||
--text-body-sm--line-height: 1.55;
|
||||
--text-body-sm--font-weight: 400;
|
||||
--text-body-sm--font-family: var(--font-sans);
|
||||
--text-caption: 13px;
|
||||
--text-caption--line-height: 1.4;
|
||||
--text-caption--font-weight: 500;
|
||||
--text-caption--font-family: var(--font-sans);
|
||||
--text-caption-uppercase: 12px;
|
||||
--text-caption-uppercase--line-height: 1.4;
|
||||
--text-caption-uppercase--letter-spacing: 1.5px;
|
||||
--text-caption-uppercase--font-weight: 500;
|
||||
--text-caption-uppercase--font-family: var(--font-sans);
|
||||
--text-code: 14px;
|
||||
--text-code--line-height: 1.6;
|
||||
--text-code--font-weight: 400;
|
||||
--text-code--font-family: var(--font-mono);
|
||||
--text-button: 14px;
|
||||
--text-button--line-height: 1;
|
||||
--text-button--font-weight: 500;
|
||||
--text-button--font-family: var(--font-sans);
|
||||
--text-nav: 14px;
|
||||
--text-nav--line-height: 1.4;
|
||||
--text-nav--font-weight: 500;
|
||||
--text-nav--font-family: var(--font-sans);
|
||||
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
--spacing-unit: 4px;
|
||||
--spacing-toolbar-h: 56px;
|
||||
--spacing-sidebar-left-w: 280px;
|
||||
--spacing-sidebar-right-w: 320px;
|
||||
--spacing-panel-padding: 16px;
|
||||
--spacing-section: 96px;
|
||||
|
||||
--animate-fade-in: fade-in 0.2s ease-out;
|
||||
--animate-slide-up: slide-up 0.2s ease-out;
|
||||
|
||||
--color-canvas-dot: rgba(108,106,100,0.08);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-hairline);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-muted-soft);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--color-body);
|
||||
background-color: var(--color-canvas);
|
||||
overscroll-behavior: none;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.dark {
|
||||
--color-canvas: #171612;
|
||||
--color-surface-soft: #1f1e1b;
|
||||
--color-surface-card: #22211d;
|
||||
--color-surface-cream-strong: #2a2824;
|
||||
--color-surface-dark: #181715;
|
||||
--color-surface-dark-elevated: #252320;
|
||||
--color-surface-dark-soft: #1f1e1b;
|
||||
--color-ink: #ede8e0;
|
||||
--color-body: #c5c0b5;
|
||||
--color-body-strong: #e0dcd2;
|
||||
--color-muted: #8e8b82;
|
||||
--color-muted-soft: #6c6a64;
|
||||
--color-hairline: #35322d;
|
||||
--color-hairline-soft: #2d2b27;
|
||||
--color-primary-disabled: #4a4440;
|
||||
--color-canvas-dot: rgba(200,195,185,0.07);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-hairline);
|
||||
border-radius: 2px;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
margin-top: -5px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import "./index.css"
|
||||
import App from "./App"
|
||||
|
||||
if (window.electronAPI) {
|
||||
const origAlert = window.alert.bind(window)
|
||||
const origConfirm = window.confirm.bind(window)
|
||||
window.alert = (msg) => { origAlert(msg); window.electronAPI!.focusWindow() }
|
||||
window.confirm = (msg) => {
|
||||
const result = origConfirm(msg)
|
||||
window.electronAPI!.focusWindow()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,365 @@
|
||||
import { create } from "zustand"
|
||||
import type { AppState, ToolMode, GlobalConfig, RoiItem, Block, ExportData } from "../types"
|
||||
import { buildExportData, validateExport } from "../utils/export"
|
||||
|
||||
export interface StoreActions {
|
||||
pushUndo: () => void
|
||||
undo: () => void
|
||||
setToolMode: (mode: ToolMode) => void
|
||||
setZoomLevel: (level: number) => void
|
||||
setStatusMessage: (msg: string) => void
|
||||
|
||||
addBlock: () => void
|
||||
selectBlockByName: (name: string | null) => void
|
||||
renameBlock: (oldName: string, newName: string) => void
|
||||
deleteBlock: (name: string) => void
|
||||
|
||||
addSection: (roi: RoiItem) => void
|
||||
selectSection: (idx: number | null) => void
|
||||
deleteSection: (idx: number) => void
|
||||
updateSectionProp: (idx: number, field: string, value: string | number | unknown[]) => void
|
||||
updateSectionCords: (idx: number, cords: [number, number, number, number]) => void
|
||||
|
||||
updateConfig: (partial: Partial<GlobalConfig>) => void
|
||||
setTemplate: (data: string | null, path: string | null, w: number, h: number) => void
|
||||
clearWorkspace: () => void
|
||||
|
||||
buildExportData: () => ExportData
|
||||
validateExport: () => string[]
|
||||
restoreFromData: (data: ExportData) => void
|
||||
snapshotAppState: () => string
|
||||
setDefaultRow: (v: number) => void
|
||||
setDefaultColumn: (v: number) => void
|
||||
toggleDarkMode: () => void
|
||||
setCanvasContainerSize: (w: number, h: number) => void
|
||||
setStageOffset: (offset: { x: number; y: number }) => void
|
||||
zoomToFit: () => void
|
||||
}
|
||||
|
||||
export type Store = AppState & StoreActions
|
||||
|
||||
const initialState: AppState = {
|
||||
blocks: {},
|
||||
blockOrder: [],
|
||||
currentBlockName: null,
|
||||
selectedSectionIdx: null,
|
||||
toolMode: "select",
|
||||
zoomLevel: 1.0,
|
||||
config: {
|
||||
TemplateShape: { width: 0, height: 0 },
|
||||
FillThreshold: 30,
|
||||
WhitePatchDetectionNeeded: false,
|
||||
x_padding: 0.25,
|
||||
y_padding: 0.25,
|
||||
},
|
||||
templateImageData: null,
|
||||
templatePath: null,
|
||||
imageWidth: 0,
|
||||
imageHeight: 0,
|
||||
undoStack: [],
|
||||
statusMessage: "System Ready",
|
||||
drawing: false,
|
||||
drawStartX: 0,
|
||||
drawStartY: 0,
|
||||
resizing: false,
|
||||
resizeHandleName: null,
|
||||
resizeOrigCords: null,
|
||||
defaultRow: 5,
|
||||
defaultColumn: 4,
|
||||
darkMode: false,
|
||||
canvasContainerWidth: 800,
|
||||
canvasContainerHeight: 600,
|
||||
stageOffset: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
const undoLimit = 50
|
||||
|
||||
function cloneState(s: AppState): AppState {
|
||||
return JSON.parse(JSON.stringify(s))
|
||||
}
|
||||
|
||||
export const useStore = create<Store>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
snapshotAppState: () =>
|
||||
JSON.stringify({ blocks: get().blocks, blockOrder: get().blockOrder, config: get().config }),
|
||||
|
||||
pushUndo: () => {
|
||||
set((s) => {
|
||||
const stack = [...s.undoStack, s.snapshotAppState()]
|
||||
if (stack.length > undoLimit) stack.shift()
|
||||
return { undoStack: stack }
|
||||
})
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const state = get()
|
||||
if (state.undoStack.length === 0) {
|
||||
state.setStatusMessage("Nothing to undo")
|
||||
return
|
||||
}
|
||||
const stack = [...state.undoStack]
|
||||
const snapshot = stack.pop()!
|
||||
const data: { blocks: Record<string, Block>; blockOrder: string[]; config: GlobalConfig } =
|
||||
JSON.parse(snapshot)
|
||||
set({
|
||||
blocks: data.blocks,
|
||||
blockOrder: data.blockOrder,
|
||||
config: data.config,
|
||||
currentBlockName: null,
|
||||
selectedSectionIdx: null,
|
||||
undoStack: stack,
|
||||
drawing: false,
|
||||
resizing: false,
|
||||
})
|
||||
state.setStatusMessage("Undo applied")
|
||||
},
|
||||
|
||||
setToolMode: (mode) => set({ toolMode: mode }),
|
||||
setZoomLevel: (level) => set({ zoomLevel: Math.round(Math.max(0.15, Math.min(5.0, level)) * 100) / 100 }),
|
||||
setStatusMessage: (msg) => {
|
||||
const current = get().statusMessage
|
||||
if (current !== msg) set({ statusMessage: msg })
|
||||
},
|
||||
|
||||
addBlock: () => {
|
||||
const state = get()
|
||||
state.pushUndo()
|
||||
let name = `Block ${state.blockOrder.length + 1}`
|
||||
const base = name
|
||||
let counter = 2
|
||||
while (name in state.blocks) {
|
||||
name = `${base}_${counter}`
|
||||
counter++
|
||||
}
|
||||
set((s) => ({
|
||||
blocks: { ...s.blocks, [name]: { blocktype: "answer", sections: [] } },
|
||||
blockOrder: [...s.blockOrder, name],
|
||||
}))
|
||||
get().selectBlockByName(name)
|
||||
},
|
||||
|
||||
selectBlockByName: (name) => {
|
||||
set((s) => {
|
||||
if (name && !(name in s.blocks)) return {}
|
||||
return { currentBlockName: name, selectedSectionIdx: name ? null : null }
|
||||
})
|
||||
},
|
||||
|
||||
renameBlock: (oldName, newName) => {
|
||||
if (!newName.trim() || newName === oldName || get().blocks[newName]) return
|
||||
get().pushUndo()
|
||||
set((s) => {
|
||||
const blocks = { ...s.blocks }
|
||||
blocks[newName] = blocks[oldName]
|
||||
delete blocks[oldName]
|
||||
const blockOrder = s.blockOrder.map((n) => (n === oldName ? newName : n))
|
||||
return {
|
||||
blocks,
|
||||
blockOrder,
|
||||
currentBlockName: s.currentBlockName === oldName ? newName : s.currentBlockName,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
deleteBlock: (name) => {
|
||||
get().pushUndo()
|
||||
set((s) => {
|
||||
const blocks = { ...s.blocks }
|
||||
delete blocks[name]
|
||||
const blockOrder = s.blockOrder.filter((n) => n !== name)
|
||||
return {
|
||||
blocks,
|
||||
blockOrder,
|
||||
currentBlockName: s.currentBlockName === name ? null : s.currentBlockName,
|
||||
selectedSectionIdx: s.currentBlockName === name ? null : s.selectedSectionIdx,
|
||||
}
|
||||
})
|
||||
get().setStatusMessage(`Deleted block '${name}'`)
|
||||
},
|
||||
|
||||
addSection: (roi) => {
|
||||
const state = get()
|
||||
if (!state.currentBlockName) return
|
||||
state.pushUndo()
|
||||
set((s) => {
|
||||
const block = s.blocks[s.currentBlockName!]
|
||||
if (!block) return {}
|
||||
return {
|
||||
blocks: {
|
||||
...s.blocks,
|
||||
[s.currentBlockName!]: {
|
||||
...block,
|
||||
sections: [...block.sections, roi],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
const sections = get().blocks[state.currentBlockName]?.sections ?? []
|
||||
get().selectSection(sections.length - 1)
|
||||
},
|
||||
|
||||
selectSection: (idx) => set({ selectedSectionIdx: idx }),
|
||||
|
||||
deleteSection: (idx) => {
|
||||
const state = get()
|
||||
if (!state.currentBlockName) return
|
||||
state.pushUndo()
|
||||
set((s) => {
|
||||
const block = s.blocks[s.currentBlockName!]
|
||||
if (!block || idx < 0 || idx >= block.sections.length) return {}
|
||||
const sections = block.sections.filter((_, i) => i !== idx)
|
||||
let selIdx = s.selectedSectionIdx
|
||||
if (selIdx === idx) selIdx = null
|
||||
else if (selIdx !== null && selIdx > idx) selIdx--
|
||||
return {
|
||||
blocks: {
|
||||
...s.blocks,
|
||||
[s.currentBlockName!]: { ...block, sections },
|
||||
},
|
||||
selectedSectionIdx: selIdx,
|
||||
}
|
||||
})
|
||||
state.setStatusMessage(`Deleted section #${idx + 1}`)
|
||||
},
|
||||
|
||||
updateSectionProp: (idx, field, value) => {
|
||||
const state = get()
|
||||
if (!state.currentBlockName) return
|
||||
set((s) => {
|
||||
const block = s.blocks[s.currentBlockName!]
|
||||
if (!block || idx < 0 || idx >= block.sections.length) return {}
|
||||
const sections = block.sections.map((roi, i) => {
|
||||
if (i !== idx) return roi
|
||||
const updated = { ...roi }
|
||||
switch (field) {
|
||||
case "row":
|
||||
updated.row = Math.max(1, value as number)
|
||||
break
|
||||
case "column":
|
||||
updated.column = Math.max(1, value as number)
|
||||
break
|
||||
case "align":
|
||||
updated.align = value as "column" | "row" | "matrix"
|
||||
break
|
||||
case "options":
|
||||
updated.options = value as unknown[]
|
||||
break
|
||||
case "questioncount":
|
||||
updated.questioncount = value as unknown[]
|
||||
break
|
||||
case "multipleChoice":
|
||||
updated.multipleChoice = Math.max(1, value as number)
|
||||
break
|
||||
}
|
||||
return updated
|
||||
})
|
||||
return {
|
||||
blocks: {
|
||||
...s.blocks,
|
||||
[s.currentBlockName!]: { ...block, sections },
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
updateSectionCords: (idx, cords) => {
|
||||
const state = get()
|
||||
if (!state.currentBlockName) return
|
||||
set((s) => {
|
||||
const block = s.blocks[s.currentBlockName!]
|
||||
if (!block || idx < 0 || idx >= block.sections.length) return {}
|
||||
const sections = block.sections.map((roi, i) =>
|
||||
i === idx ? { ...roi, cords } : roi,
|
||||
)
|
||||
return {
|
||||
blocks: {
|
||||
...s.blocks,
|
||||
[s.currentBlockName!]: { ...block, sections },
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
updateConfig: (partial) =>
|
||||
set((s) => ({ config: { ...s.config, ...partial } })),
|
||||
|
||||
setTemplate: (data, path, w, h) =>
|
||||
set({
|
||||
templateImageData: data,
|
||||
templatePath: path,
|
||||
imageWidth: w,
|
||||
imageHeight: h,
|
||||
config: {
|
||||
...get().config,
|
||||
TemplateShape: { width: w, height: h },
|
||||
},
|
||||
}),
|
||||
|
||||
clearWorkspace: () => {
|
||||
const state = get()
|
||||
if (state.blockOrder.length > 0) state.pushUndo()
|
||||
set({
|
||||
...cloneState(initialState),
|
||||
undoStack: state.undoStack,
|
||||
statusMessage: "Workspace cleared.",
|
||||
})
|
||||
},
|
||||
|
||||
buildExportData: () => buildExportData(get()),
|
||||
validateExport: () => validateExport(get()),
|
||||
|
||||
restoreFromData: (data) => {
|
||||
const state = get()
|
||||
state.pushUndo()
|
||||
const config = data.Config
|
||||
const blocks: Record<string, Block> = {}
|
||||
const blockOrder: string[] = []
|
||||
for (const b of data.Blocks) {
|
||||
const sections: RoiItem[] = b.section.map((s) => ({
|
||||
cords: s.cords as [number, number, number, number],
|
||||
row: s.row,
|
||||
column: s.column,
|
||||
options: s.options,
|
||||
align: s.align as "column" | "row" | "matrix",
|
||||
questioncount: s.questioncount,
|
||||
multipleChoice: s.Multiple_Choice,
|
||||
}))
|
||||
blocks[b.name] = { blocktype: b.blocktype, sections }
|
||||
blockOrder.push(b.name)
|
||||
}
|
||||
set({
|
||||
blocks,
|
||||
blockOrder,
|
||||
config: {
|
||||
TemplateShape: config.TemplateShape,
|
||||
FillThreshold: config.FillThreshold,
|
||||
WhitePatchDetectionNeeded: config.WhitePatchDetectionNeeded,
|
||||
x_padding: config.x_padding,
|
||||
y_padding: config.y_padding,
|
||||
},
|
||||
currentBlockName: null,
|
||||
selectedSectionIdx: null,
|
||||
})
|
||||
if (blockOrder.length > 0) state.selectBlockByName(blockOrder[0])
|
||||
},
|
||||
|
||||
setDefaultRow: (v) => set({ defaultRow: Math.max(1, v) }),
|
||||
setDefaultColumn: (v) => set({ defaultColumn: Math.max(1, v) }),
|
||||
|
||||
toggleDarkMode: () => set((s) => ({ darkMode: !s.darkMode })),
|
||||
|
||||
setCanvasContainerSize: (w, h) => set({ canvasContainerWidth: w, canvasContainerHeight: h }),
|
||||
|
||||
setStageOffset: (offset) => set({ stageOffset: offset }),
|
||||
|
||||
zoomToFit: () => {
|
||||
const s = get()
|
||||
if (s.imageWidth === 0 || s.imageHeight === 0) return
|
||||
const pad = 0.88
|
||||
const zx = (s.canvasContainerWidth * pad) / s.imageWidth
|
||||
const zy = (s.canvasContainerHeight * pad) / s.imageHeight
|
||||
const z = Math.min(zx, zy, 2.0)
|
||||
set({ zoomLevel: Math.round(z * 100) / 100, stageOffset: { x: 0, y: 0 } })
|
||||
},
|
||||
}))
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
export interface ElectronAPI {
|
||||
focusWindow: () => void
|
||||
setTheme: (dark: boolean) => void
|
||||
minimizeWindow: () => void
|
||||
maximizeWindow: () => void
|
||||
closeWindow: () => void
|
||||
isMaximized: () => Promise<boolean>
|
||||
onMaximizeChange: (callback: (maximized: boolean) => void) => void
|
||||
openFileDialog: (filters: { name: string; extensions: string[] }[]) => Promise<string[]>
|
||||
saveFileDialog: (defaultName: string, filters: { name: string; extensions: string[] }[]) => Promise<string>
|
||||
readFile: (path: string) => Promise<string>
|
||||
writeFile: (path: string, data: string) => Promise<void>
|
||||
readImage: (path: string) => Promise<string>
|
||||
writeBinaryFile: (path: string, base64: string) => Promise<void>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export type ToolMode = "select" | "draw" | "pan"
|
||||
|
||||
export interface RoiItem {
|
||||
cords: [number, number, number, number]
|
||||
row: number
|
||||
column: number
|
||||
options: unknown[]
|
||||
align: "column" | "row" | "matrix"
|
||||
questioncount: unknown[]
|
||||
multipleChoice: number
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
blocktype: string
|
||||
sections: RoiItem[]
|
||||
}
|
||||
|
||||
export interface GlobalConfig {
|
||||
TemplateShape: { width: number; height: number }
|
||||
FillThreshold: number
|
||||
WhitePatchDetectionNeeded: boolean
|
||||
x_padding: number
|
||||
y_padding: number
|
||||
}
|
||||
|
||||
export interface ExportSection {
|
||||
cords: number[]
|
||||
row: number
|
||||
column: number
|
||||
options: unknown[]
|
||||
align: string
|
||||
questioncount: unknown[]
|
||||
Multiple_Choice: number
|
||||
}
|
||||
|
||||
export interface ExportBlock {
|
||||
blocktype: string
|
||||
name: string
|
||||
section: ExportSection[]
|
||||
}
|
||||
|
||||
export interface ExportData {
|
||||
Config: {
|
||||
TemplateShape: { width: number; height: number }
|
||||
FillThreshold: number
|
||||
WhitePatchDetectionNeeded: boolean
|
||||
x_padding: number
|
||||
y_padding: number
|
||||
}
|
||||
Blocks: ExportBlock[]
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
blocks: Record<string, Block>
|
||||
blockOrder: string[]
|
||||
currentBlockName: string | null
|
||||
selectedSectionIdx: number | null
|
||||
toolMode: ToolMode
|
||||
zoomLevel: number
|
||||
config: GlobalConfig
|
||||
templateImageData: string | null
|
||||
templatePath: string | null
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
undoStack: string[]
|
||||
statusMessage: string
|
||||
drawing: boolean
|
||||
drawStartX: number
|
||||
drawStartY: number
|
||||
resizing: boolean
|
||||
resizeHandleName: string | null
|
||||
resizeOrigCords: [number, number, number, number] | null
|
||||
defaultRow: number
|
||||
defaultColumn: number
|
||||
darkMode: boolean
|
||||
canvasContainerWidth: number
|
||||
canvasContainerHeight: number
|
||||
stageOffset: { x: number; y: number }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { RoiItem, ExportData } from "../types"
|
||||
import type { Store } from "../store/useStore"
|
||||
|
||||
export function buildExportData(state: Partial<Store>): ExportData {
|
||||
const blocksOut: ExportData["Blocks"] = []
|
||||
for (const name of state.blockOrder ?? []) {
|
||||
const b = state.blocks?.[name]
|
||||
if (!b) continue
|
||||
const sectionsOut = b.sections.map((roi: RoiItem) => ({
|
||||
cords: [...roi.cords],
|
||||
row: roi.row,
|
||||
column: roi.column,
|
||||
options: roi.options,
|
||||
align: roi.align,
|
||||
questioncount: roi.questioncount,
|
||||
Multiple_Choice: roi.multipleChoice,
|
||||
}))
|
||||
blocksOut.push({ blocktype: b.blocktype, name, section: sectionsOut })
|
||||
}
|
||||
return {
|
||||
Config: {
|
||||
TemplateShape: { ...state.config!.TemplateShape },
|
||||
FillThreshold: state.config!.FillThreshold,
|
||||
WhitePatchDetectionNeeded: state.config!.WhitePatchDetectionNeeded,
|
||||
x_padding: state.config!.x_padding,
|
||||
y_padding: state.config!.y_padding,
|
||||
},
|
||||
Blocks: blocksOut,
|
||||
}
|
||||
}
|
||||
|
||||
export function validateExport(state: Partial<Store>): string[] {
|
||||
const errors: string[] = []
|
||||
const tc = state.config!.TemplateShape
|
||||
if (tc.width <= 0 || tc.height <= 0) {
|
||||
errors.push("Template dimensions are not set. Load a template image.")
|
||||
}
|
||||
if (!state.blockOrder || state.blockOrder.length === 0) {
|
||||
errors.push("No blocks defined.")
|
||||
return errors
|
||||
}
|
||||
let total = 0
|
||||
for (const name of state.blockOrder) {
|
||||
const sections = state.blocks?.[name]?.sections ?? []
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const roi = sections[i]
|
||||
total++
|
||||
const [x1, x2, y1, y2] = roi.cords
|
||||
if (x2 <= x1 || y2 <= y1) {
|
||||
errors.push(`Block '${name}', section #${i + 1}: invalid coordinates (zero area).`)
|
||||
}
|
||||
if (roi.row < 1) errors.push(`Block '${name}', section #${i + 1}: row must be >= 1.`)
|
||||
if (roi.column < 1) errors.push(`Block '${name}', section #${i + 1}: column must be >= 1.`)
|
||||
if (!["column", "row", "matrix"].includes(roi.align)) {
|
||||
errors.push(`Block '${name}', section #${i + 1}: align must be 'column', 'row', or 'matrix'.`)
|
||||
}
|
||||
if (roi.align === "row" && roi.options.length !== roi.column) {
|
||||
errors.push(`Block '${name}', section #${i + 1}: row align requires options count == columns.`)
|
||||
} else if (roi.align === "column" && roi.options.length !== roi.row) {
|
||||
errors.push(`Block '${name}', section #${i + 1}: column align requires options count == rows.`)
|
||||
} else if (roi.align === "matrix") {
|
||||
if (!Array.isArray(roi.options) || roi.options.length !== roi.row) {
|
||||
errors.push(`Block '${name}', section #${i + 1}: matrix align requires options row count == ${roi.row}.`)
|
||||
} else if (roi.options.length > 0 && (roi.options as unknown[][]).some((o) => o.length !== roi.column)) {
|
||||
errors.push(`Block '${name}', section #${i + 1}: each options row must have ${roi.column} entries.`)
|
||||
}
|
||||
if (roi.questioncount.length !== 1) {
|
||||
errors.push(`Block '${name}', section #${i + 1}: matrix requires exactly 1 questioncount entry.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (total === 0) errors.push("No sections defined. Draw at least one rectangle on the template.")
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ExportData } from "../types"
|
||||
|
||||
export interface LoadedTemplate {
|
||||
dataUrl: string
|
||||
filePath: string
|
||||
name: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function imageFromDataUrl(dataUrl: string, filePath: string): Promise<LoadedTemplate> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new window.Image()
|
||||
img.onload = () =>
|
||||
resolve({
|
||||
dataUrl,
|
||||
filePath,
|
||||
name: filePath.split(/[/\\]/).pop() ?? "image",
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
})
|
||||
img.src = dataUrl
|
||||
})
|
||||
}
|
||||
|
||||
export function loadTemplateDialog(): Promise<LoadedTemplate | null> {
|
||||
if (window.electronAPI) {
|
||||
return window.electronAPI
|
||||
.openFileDialog([{ name: "Images", extensions: ["jpg", "jpeg", "png"] }])
|
||||
.then(async (paths) => {
|
||||
if (!paths?.[0]) return null
|
||||
const dataUrl = await window.electronAPI!.readImage(paths[0])
|
||||
return imageFromDataUrl(dataUrl, paths[0])
|
||||
})
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "image/png,image/jpeg"
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) { resolve(null); return }
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string
|
||||
imageFromDataUrl(dataUrl, file.name).then(resolve)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
export function loadMetadataDialog(): Promise<ExportData | null> {
|
||||
if (window.electronAPI) {
|
||||
return window.electronAPI
|
||||
.openFileDialog([{ name: "JSON", extensions: ["json"] }])
|
||||
.then(async (paths) => {
|
||||
if (!paths?.[0]) return null
|
||||
const content = await window.electronAPI!.readFile(paths[0])
|
||||
return JSON.parse(content) as ExportData
|
||||
})
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "application/json,.json"
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) { resolve(null); return }
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
resolve(JSON.parse(reader.result as string))
|
||||
} catch {
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
export function processDroppedFile(file: File): Promise<LoadedTemplate | ExportData | null> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const content = reader.result as string
|
||||
if (file.type.startsWith("image/") || /\.(jpg|jpeg|png)$/i.test(file.name)) {
|
||||
imageFromDataUrl(content, file.name).then(resolve)
|
||||
} else if (file.type === "application/json" || /\.json$/i.test(file.name)) {
|
||||
try {
|
||||
resolve(JSON.parse(content) as ExportData)
|
||||
} catch {
|
||||
resolve(null)
|
||||
}
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
reader.onerror = () => resolve(null)
|
||||
if (file.type.startsWith("image/")) {
|
||||
reader.readAsDataURL(file)
|
||||
} else {
|
||||
reader.readAsText(file)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export function parseSingleRange(text: string): string[] | null {
|
||||
const t = text.trim()
|
||||
if (!t) return null
|
||||
const letterRange = /^([A-Za-z])-([A-Za-z])$/.exec(t)
|
||||
if (letterRange) {
|
||||
const a = letterRange[1], b = letterRange[2]
|
||||
if (a.toUpperCase() === a !== (b.toUpperCase() === b)) {
|
||||
throw new Error("Letter range must use consistent case")
|
||||
}
|
||||
const start = a.charCodeAt(0), end = b.charCodeAt(0)
|
||||
if (start <= end) {
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => String.fromCharCode(start + i))
|
||||
}
|
||||
return Array.from({ length: start - end + 1 }, (_, i) => String.fromCharCode(start - i))
|
||||
}
|
||||
const numRange = /^(-?\d+)-(-?\d+)$/.exec(t)
|
||||
if (numRange) {
|
||||
const start = parseInt(numRange[1]), end = parseInt(numRange[2])
|
||||
if (start <= end) {
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => String(start + i))
|
||||
}
|
||||
return Array.from({ length: start - end + 1 }, (_, i) => String(start - i))
|
||||
}
|
||||
if (/^[A-Za-z]-/.test(t) || /^.-[A-Za-z]/.test(t)) {
|
||||
throw new Error("Invalid option range: mixed types")
|
||||
}
|
||||
return [t]
|
||||
}
|
||||
|
||||
export function parseRangeInput(text: string): string[] | null {
|
||||
text = text.trim()
|
||||
if (!text) return null
|
||||
try {
|
||||
const result = JSON.parse(text)
|
||||
if (Array.isArray(result)) return result
|
||||
} catch { /* not JSON */ }
|
||||
const parts = text.split(",").map((p) => p.trim())
|
||||
if (parts.length > 1) {
|
||||
const result: string[] = []
|
||||
for (const p of parts) {
|
||||
const sub = parseSingleRange(p)
|
||||
if (sub === null) throw new Error(`Unrecognized range: '${p}'`)
|
||||
result.push(...sub)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return parseSingleRange(text)
|
||||
}
|
||||
Reference in New Issue
Block a user