Files
omr-desktop/src/App.tsx
T
2026-06-14 03:50:12 +05:30

198 lines
8.0 KiB
TypeScript

import { useEffect, useState, useRef, Component, type ReactNode } 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 { TabBar } from "./components/TabBar"
import { ApiClientPanel } from "./components/ApiClient/ApiClientPanel"
import { useStore } from "./store/useStore"
import { loadTemplateDialog } from "./utils/loadFile"
import type { AppTab } from "./components/TabBar"
const MIN_SIDEBAR = 160
const MAX_SIDEBAR = 500
class TabErrorBoundary extends Component<{ children: ReactNode; tab: string }, { hasError: boolean; error: string }> {
constructor(props: { children: ReactNode; tab: string }) {
super(props)
this.state = { hasError: false, error: "" }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error: error.message || String(error) }
}
render() {
if (this.state.hasError) {
return (
<div className="p-8 text-error bg-error/5 border border-error/20 rounded-lg m-4">
<h2 className="text-title-md font-medium mb-2">{this.props.tab} Tab Crashed</h2>
<pre className="text-code whitespace-pre-wrap">{this.state.error}</pre>
</div>
)
}
return this.props.children
}
}
export default function App() {
const containerRef = useRef<HTMLDivElement>(null)
const [showCheatSheet, setShowCheatSheet] = useState(false)
const [activeTab, setActiveTab] = useState<AppTab>("editor")
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 />
<TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === "editor" ? (
<TabErrorBoundary tab="Editor" key="editor">
<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>
</TabErrorBoundary>
) : (
<TabErrorBoundary tab="API Client" key="api">
<div className="flex-1 overflow-hidden bg-canvas">
<ApiClientPanel />
</div>
</TabErrorBoundary>
)}
<StatusBar />
{showCheatSheet && <KeyboardCheatSheet onClose={() => setShowCheatSheet(false)} />}
</div>
)
}