initial commit

This commit is contained in:
2026-06-13 21:09:11 +05:30
commit 4c901980b2
34 changed files with 7884 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-electron
dist-ssr
*.txt
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# AI Agents
.agents
.claude
.design
.opencode
# Files
ARCHITECTURE.md
DESIGN.md
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+108
View File
@@ -0,0 +1,108 @@
import { app, BrowserWindow, ipcMain, dialog, Menu, nativeTheme } from "electron"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { readFileSync, writeFileSync } from "node:fs"
const __dirname = dirname(fileURLToPath(import.meta.url))
process.env.DIST = join(__dirname, "../dist")
process.env.VITE_PUBLIC = app.isPackaged
? process.env.DIST
: join(process.env.DIST, "../public")
let win: BrowserWindow | null
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]
app.disableHardwareAcceleration()
app.commandLine.appendSwitch("disable-direct-composition")
Menu.setApplicationMenu(null)
function createWindow() {
win = new BrowserWindow({
width: 1480,
height: 880,
minWidth: 1024,
minHeight: 600,
frame: false,
webPreferences: {
preload: join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
show: false,
})
win.on("ready-to-show", () => {
win?.show()
win?.focus()
})
win.on("maximize", () => win?.webContents?.send("window:maximize-change", true))
win.on("unmaximize", () => win?.webContents?.send("window:maximize-change", false))
win.on("enter-full-screen", () => win?.webContents?.send("window:maximize-change", true))
win.on("leave-full-screen", () => win?.webContents?.send("window:maximize-change", false))
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL)
} else {
win.loadFile(join(process.env.DIST!, "index.html"))
}
}
// ── IPC Handlers ──
ipcMain.on("window:focus", () => win?.focus())
ipcMain.on("window:setTheme", (_event, dark: boolean) => { nativeTheme.themeSource = dark ? "dark" : "light" })
ipcMain.on("window:minimize", () => win?.minimize())
ipcMain.on("window:maximize", () => { if (win?.isMaximized()) win.unmaximize(); else win?.maximize() })
ipcMain.on("window:close", () => win?.close())
ipcMain.handle("window:isMaximized", () => win?.isMaximized())
ipcMain.handle("dialog:openFile", async (_event, filters: { name: string; extensions: string[] }[]) => {
const result = await dialog.showOpenDialog(win!, {
properties: ["openFile"],
filters,
})
win?.webContents?.focus()
return result.filePaths
})
ipcMain.handle("dialog:saveFile", async (_event, defaultName: string, filters: { name: string; extensions: string[] }[]) => {
const result = await dialog.showSaveDialog(win!, {
defaultPath: defaultName,
filters,
})
win?.webContents?.focus()
return result.filePath ?? ""
})
ipcMain.handle("file:read", async (_event, path: string) => {
return readFileSync(path, "utf-8")
})
ipcMain.handle("file:write", async (_event, path: string, data: string) => {
writeFileSync(path, data, "utf-8")
})
ipcMain.handle("file:readImage", async (_event, path: string) => {
const buffer = readFileSync(path)
const ext = path.split(".").pop()?.toLowerCase() ?? "png"
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png"
return `data:${mime};base64,${buffer.toString("base64")}`
})
ipcMain.handle("file:writeBinary", async (_event, path: string, base64: string) => {
writeFileSync(path, Buffer.from(base64, "base64"))
})
app.whenReady().then(createWindow)
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit()
})
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
+22
View File
@@ -0,0 +1,22 @@
import { contextBridge, ipcRenderer } from "electron"
contextBridge.exposeInMainWorld("electronAPI", {
focusWindow: () => ipcRenderer.send("window:focus"),
setTheme: (dark: boolean) => ipcRenderer.send("window:setTheme", dark),
minimizeWindow: () => ipcRenderer.send("window:minimize"),
maximizeWindow: () => ipcRenderer.send("window:maximize"),
closeWindow: () => ipcRenderer.send("window:close"),
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
onMaximizeChange: (callback: (maximized: boolean) => void) => {
ipcRenderer.on("window:maximize-change", (_event, value) => callback(value))
},
openFileDialog: (filters: { name: string; extensions: string[] }[]) =>
ipcRenderer.invoke("dialog:openFile", filters),
saveFileDialog: (defaultName: string, filters: { name: string; extensions: string[] }[]) =>
ipcRenderer.invoke("dialog:saveFile", defaultName, filters),
readFile: (path: string) => ipcRenderer.invoke("file:read", path),
writeFile: (path: string, data: string) => ipcRenderer.invoke("file:write", path, data),
readImage: (path: string) => ipcRenderer.invoke("file:readImage", path),
writeBinaryFile: (path: string, base64: string) => ipcRenderer.invoke("file:writeBinary", path, base64),
getAppPath: () => ipcRenderer.invoke("app:getPath"),
})
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+48
View File
@@ -0,0 +1,48 @@
# OMR Metadata Creator — Features
## Core
- **Load template image** (JPG/PNG) via dialog, drag-and-drop, or click placeholder
- **Load existing metadata.json** to edit previous work
- **Manual ROI (Section) drawing** via click-and-drag on canvas with live preview grid
- **Multiple blocks**, each with multiple sections
- **Section move** — drag any section on canvas (manual stage-level interaction, no Konva artifacts)
- **Section resize** — 8 circular handles (4 corners + 4 edge midpoints) with directional cursors
- **Default rows/cols** — set before drawing so the preview grid appears during draw
- **Live grid preview** (dashed red) inside section boxes during draw and resize
- **Grid lines** (dashed red) shown inside sections based on rows×columns
- **Zoom**: 15%500% via toolbar buttons, Ctrl+scroll, or 1:1 reset
- **Scroll/pan**: scroll wheel (vertical), Shift+scroll (horizontal), middle-mouse drag (any tool mode), Pan tool (left-click drag)
- **Undo** (Ctrl+Z) — full state snapshots up to 50 deep
- **Quick range generation** for Options/QCount using `parseRangeInput`: `1-9`, `A-D`, comma-separated like `1-5,8,10-12`
- **Keyboard shortcuts**: S/D/P (tools), Del/Backspace (delete section), Ctrl+O (load template), Ctrl+S (export), Ctrl+Z (undo)
- **Clear workspace** with undo support
## Validation & Export
- **Validation**: checks template dimensions, block/section existence, coordinate validity, row/col/align consistency, matrix options shape
- **Export metadata.json** — valid JSON for the OMR pipeline (Electron native dialog or browser download)
- **Web-compatible** — export works in both Electron mode (native dialog) and browser dev mode (Blob download)
## UI / Layout (React/Electron — Stitch design system dark theme)
- **Dark theme** with full Material 3 Stitch token palette (surface, primary, secondary, error, outline variants)
- **Toolbar** (56px): tool buttons (Select/Draw/Pan) with active highlight, centered zoom slider in pill container, Load Template/Load metadata/Export, +Block, Undo, Clear
- **Left sidebar** (280px): blocks listed with 3px active left border, block delete on hover, sections indented with radio icons, stats footer
- **Properties panel** (320px): live-editable fields with commit-on-blur for coords, block type selector, rows/cols, align type, Options/QCount with Generate buttons, MC Limit, Advanced Config (fill threshold, whitepatch, padding, shape)
- **Canvas**: Konva-based with template image, section overlays (per-block colors), grid preview, 8 circular resize handles, dynamic cursors, middle-mouse pan, scroll-to-pan
- **Status bar** (32px): green status dot, system message, zoom percentage, block/section indicator
- **Selection sync** between sidebar ↔ canvas (click block → highlight; click section → select)
- **File drop overlay** — drag-and-drop files onto canvas area (image→template, JSON→metadata)
## Range Parser
- Supports ranges: `A-D`, `1-10`, `-1--9` (negative numbers)
- Comma-separated mixed: `A-D, E, F-H`
- JSON array: `["A","B","C"]`
- Used by both Options and Question Count Generate buttons
## Architecture
- **Electron** main process with IPC handlers for file dialogs, read/write, image base64
- **React 18** + **TypeScript** — strict typing, no `any`
- **Zustand** for state management with undo stack
- **react-konva** for canvas interactions
- **Tailwind CSS v4** with 40+ Stitch design tokens
- **vite-plugin-electron** for dev hot-reload
- **Web-compatible** — most features work in browser mode (`pnpm dev:web`) via `<input>` fallbacks and Blob download
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet" />
<title>OMR Metadata Creator</title>
</head>
<body class="bg-canvas text-body antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
{
"name": "omr-desktop",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"dev:web": "vite",
"dev:electron": "electron .",
"build": "vite build",
"build:electron": "vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@fontsource/cormorant-garamond": "5.2.8",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@tailwindcss/vite": "^4.3.0",
"konva": "^10.3.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-konva": "^19.2.4",
"tailwindcss": "^4.3.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"electron": "^42.3.0",
"electron-builder": "^26.8.1",
"esbuild": "^0.28.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vite-plugin-electron": "^0.29.1"
}
}
+4328
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#131313"/><rect x="6" y="8" width="20" height="3" rx="1.5" fill="#adc6ff"/><rect x="6" y="14" width="14" height="3" rx="1.5" fill="#adc6ff"/><rect x="6" y="20" width="20" height="3" rx="1.5" fill="#adc6ff"/><circle cx="24" cy="15.5" r="3" fill="#4edea3"/></svg>

After

Width:  |  Height:  |  Size: 364 B

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+23
View File
@@ -0,0 +1,23 @@
{
"version": 1,
"skills": {
"fastapi": {
"source": "fastapi/fastapi",
"sourceType": "github",
"skillPath": "fastapi/.agents/skills/fastapi/SKILL.md",
"computedHash": "9feb517089c269af4fdab5031dea1100a92fd50081e906ccfaf8a971858e39f9"
},
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "github",
"skillPath": "skills/frontend-design/SKILL.md",
"computedHash": "516bd2154eb843a8240e43d5b285229129853114ad7075a5e141e1c08e408c84"
},
"tailwind-4-docs": {
"source": "Lombiq/Tailwind-Agent-Skills",
"sourceType": "github",
"skillPath": "skills/tailwind-4-docs/SKILL.md",
"computedHash": "4900f575f24641b1af459cda37cbb8438ecf3f8f1a63148384197cb730895093"
}
}
}
+161
View File
@@ -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>
)
}
+250
View File
@@ -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}&times;{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}&times;</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>
)
}
+622
View File
@@ -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 &rarr; Template &nbsp;|&nbsp; JSON &rarr; 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>
)
}
+71
View File
@@ -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>
)
}
+214
View File
@@ -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>
)
}
+468
View File
@@ -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">&times;</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>
)
}
+54
View File
@@ -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"
/>
)
}
+39
View File
@@ -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>
)
}
+58
View File
@@ -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>
)
}
+183
View File
@@ -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
View File
@@ -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);
}
+21
View File
@@ -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>,
)
+365
View File
@@ -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 } })
},
}))
+21
View File
@@ -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
}
}
+79
View File
@@ -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 }
}
+75
View File
@@ -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
}
+109
View File
@@ -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)
}
})
}
+48
View 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)
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import electron from "vite-plugin-electron"
export default defineConfig({
plugins: [
react(),
tailwindcss(),
electron([
{ entry: "electron/main.ts" },
{
entry: "electron/preload.ts",
vite: {
build: { lib: { formats: ["cjs"] } },
},
onstart(args) {
args.reload()
},
},
]),
],
})