198 lines
8.0 KiB
TypeScript
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>
|
|
)
|
|
}
|