mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
The canvas now fills the window and the chat floats on its right. draw.io is served from our own origin (public/drawio, downloaded at build time and checked against the release's SHA-256), so the app can drive the editor directly: - every AI change becomes a version card with a thumbnail; compare, restore or undo it, and Ctrl+Z on the canvas takes an AI change back in one step - shapes the AI just changed are highlighted on the canvas - selected shapes appear as a chip and are sent to the model - the app's own canvas toolbar, shape library and page tabs replace draw.io's chrome - new start screen, session rename and switcher, open .drawio files - settings split into Models & keys, Appearance, Advanced and About - phones switch between canvas and chat - dark mode keeps the diagram on a white sheet and no longer flashes With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the app falls back to draw.io's own toolbar and the features above that need same origin are off. The chat logic moved from the 1500-line chat panel into a chat engine plus small UI components; zustand holds canvas, versions, settings and UI state.
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
import {
|
||
Check,
|
||
Download,
|
||
Ellipsis,
|
||
Maximize2,
|
||
Minus,
|
||
MousePointer2,
|
||
Paintbrush,
|
||
PenLine,
|
||
Plus,
|
||
Redo2,
|
||
Shapes,
|
||
Spline,
|
||
Type,
|
||
Undo2,
|
||
} from "lucide-react"
|
||
import { useMemo, useState } from "react"
|
||
import { useChatEngine } from "@/components/chat/chat-engine"
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover"
|
||
import { IconButton, ToolbarDivider } from "@/components/workspace/icon-button"
|
||
import { useDictionary } from "@/hooks/use-dictionary"
|
||
import {
|
||
fitDiagram,
|
||
focusCanvas,
|
||
hasAction,
|
||
keepFocusInCanvas,
|
||
runAction,
|
||
stopFreehand,
|
||
toggleShapesPanel,
|
||
viewToggles,
|
||
} from "@/lib/drawio/editor-bridge"
|
||
import { isMac, modKey } from "@/lib/platform"
|
||
import { cn } from "@/lib/utils"
|
||
import { useCanvasStore } from "@/stores/canvas-store"
|
||
import { useUiStore } from "@/stores/ui-store"
|
||
|
||
interface MenuItem {
|
||
action: string
|
||
label: string
|
||
shortcut?: string
|
||
}
|
||
|
||
function MenuButton({
|
||
children,
|
||
onClick,
|
||
shortcut,
|
||
checked,
|
||
disabled,
|
||
}: {
|
||
children: React.ReactNode
|
||
onClick: () => void
|
||
shortcut?: string
|
||
/** On/off items: a check mark when on, and room for it when off */
|
||
checked?: boolean
|
||
disabled?: boolean
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
{...(checked !== undefined && {
|
||
role: "menuitemcheckbox",
|
||
"aria-checked": checked,
|
||
})}
|
||
className="flex w-full items-center justify-between gap-6 rounded-md px-2.5 py-1.5 text-left text-[13px] text-foreground hover:bg-accent disabled:pointer-events-none disabled:opacity-40"
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
{checked !== undefined && (
|
||
<Check
|
||
className={cn(
|
||
"size-3.5 shrink-0",
|
||
!checked && "invisible",
|
||
)}
|
||
/>
|
||
)}
|
||
{children}
|
||
</span>
|
||
{shortcut && (
|
||
<span className="text-xs text-muted-foreground">
|
||
{shortcut}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* The canvas toolbar. Every button calls an existing draw.io action; a
|
||
* button whose action is missing in this draw.io build is not shown.
|
||
*/
|
||
export function CanvasToolbar({
|
||
compact = false,
|
||
onOpenFile,
|
||
}: {
|
||
compact?: boolean
|
||
onOpenFile: () => void
|
||
}) {
|
||
const dict = useDictionary()
|
||
const t = dict.workspace
|
||
const canUndo = useCanvasStore((s) => s.canUndo)
|
||
const canRedo = useCanvasStore((s) => s.canRedo)
|
||
const zoom = useCanvasStore((s) => s.zoom)
|
||
const isFreehand = useCanvasStore((s) => s.isFreehand)
|
||
const isFormatOpen = useCanvasStore((s) => s.isFormatOpen)
|
||
const isShapesOpen = useCanvasStore((s) => s.isShapesOpen)
|
||
const setSaveDialogOpen = useUiStore((s) => s.setSaveDialogOpen)
|
||
const engine = useChatEngine()
|
||
const [moreOpen, setMoreOpen] = useState(false)
|
||
const [zoomOpen, setZoomOpen] = useState(false)
|
||
|
||
// Checked once per mount; hasAction warns about missing actions
|
||
const available = useMemo(() => {
|
||
const names = [
|
||
"undo",
|
||
"redo",
|
||
"toggleShapes",
|
||
"insertText",
|
||
"insertEdge",
|
||
"insertFreehand",
|
||
"zoomIn",
|
||
"zoomOut",
|
||
"fitWindow",
|
||
"resetView",
|
||
"format",
|
||
"findReplace",
|
||
"editDiagram",
|
||
"layers",
|
||
"outline",
|
||
"grid",
|
||
"pageView",
|
||
"insertImage",
|
||
"insertLink",
|
||
"selectAll",
|
||
"print",
|
||
]
|
||
return new Set(names.filter((name) => hasAction(name)))
|
||
}, [])
|
||
|
||
// Read when the menu opens (draw.io has no event for these)
|
||
const toggles = moreOpen ? viewToggles() : { grid: false, pageView: false }
|
||
const checkedOf = (action: string) =>
|
||
action === "grid"
|
||
? toggles.grid
|
||
: action === "pageView"
|
||
? toggles.pageView
|
||
: undefined
|
||
const moreItems: MenuItem[] = [
|
||
{ action: "findReplace", label: t.findReplace, shortcut: `${modKey}F` },
|
||
{ action: "layers", label: t.layers, shortcut: `${modKey}⇧L` },
|
||
{ action: "outline", label: t.outline, shortcut: `${modKey}⇧O` },
|
||
{ action: "grid", label: t.toggleGrid, shortcut: `${modKey}⇧G` },
|
||
{ action: "pageView", label: t.togglePageView },
|
||
{ action: "insertImage", label: t.insertImage },
|
||
{ action: "insertLink", label: t.insertLink },
|
||
{ action: "selectAll", label: t.selectAll, shortcut: `${modKey}A` },
|
||
{ action: "editDiagram", label: t.editDiagram },
|
||
{ action: "print", label: t.print, shortcut: `${modKey}P` },
|
||
].filter((item) => available.has(item.action))
|
||
|
||
const tool = (action: string) => () => {
|
||
if (isFreehand && action !== "insertFreehand") stopFreehand()
|
||
runAction(action)
|
||
}
|
||
|
||
return (
|
||
<div
|
||
role="toolbar"
|
||
aria-label={t.toolbar}
|
||
className={cn(
|
||
"pointer-events-auto flex items-center gap-0.5 rounded-xl bg-card p-1 shadow-float",
|
||
compact && "max-w-full overflow-x-auto",
|
||
)}
|
||
>
|
||
{available.has("undo") && (
|
||
<IconButton
|
||
label={t.undo}
|
||
shortcut={`${modKey}Z`}
|
||
onClick={tool("undo")}
|
||
disabled={!canUndo}
|
||
data-testid="toolbar-undo"
|
||
>
|
||
<Undo2 />
|
||
</IconButton>
|
||
)}
|
||
{available.has("redo") && (
|
||
<IconButton
|
||
label={t.redo}
|
||
shortcut={isMac ? "⌘⇧Z" : "Ctrl+Y"}
|
||
onClick={tool("redo")}
|
||
disabled={!canRedo}
|
||
>
|
||
<Redo2 />
|
||
</IconButton>
|
||
)}
|
||
<ToolbarDivider />
|
||
{!compact && (
|
||
<IconButton
|
||
label={t.select}
|
||
active={!isFreehand}
|
||
onClick={() => {
|
||
stopFreehand()
|
||
focusCanvas()
|
||
}}
|
||
>
|
||
<MousePointer2 />
|
||
</IconButton>
|
||
)}
|
||
{available.has("toggleShapes") && (
|
||
<IconButton
|
||
label={t.shapes}
|
||
shortcut={`${modKey}⇧K`}
|
||
active={isShapesOpen}
|
||
onClick={() => {
|
||
if (isFreehand) stopFreehand()
|
||
toggleShapesPanel()
|
||
focusCanvas()
|
||
}}
|
||
data-testid="toolbar-shapes"
|
||
>
|
||
<Shapes />
|
||
</IconButton>
|
||
)}
|
||
{!compact && available.has("insertText") && (
|
||
<IconButton label={t.text} onClick={tool("insertText")}>
|
||
<Type />
|
||
</IconButton>
|
||
)}
|
||
{!compact && available.has("insertEdge") && (
|
||
<IconButton label={t.connector} onClick={tool("insertEdge")}>
|
||
<Spline />
|
||
</IconButton>
|
||
)}
|
||
{!compact && available.has("insertFreehand") && (
|
||
<IconButton
|
||
label={t.freehand}
|
||
shortcut="X"
|
||
active={isFreehand}
|
||
onClick={tool("insertFreehand")}
|
||
>
|
||
<PenLine />
|
||
</IconButton>
|
||
)}
|
||
<ToolbarDivider />
|
||
{!compact && available.has("zoomOut") && (
|
||
<IconButton
|
||
label={t.zoomOut}
|
||
shortcut={`${modKey}−`}
|
||
onClick={tool("zoomOut")}
|
||
>
|
||
<Minus />
|
||
</IconButton>
|
||
)}
|
||
<Popover open={zoomOpen} onOpenChange={setZoomOpen}>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
aria-label={t.zoomMenu}
|
||
className="h-8 min-w-12 rounded-lg px-1.5 text-xs tabular-nums text-muted-foreground hover:bg-accent hover:text-foreground"
|
||
>
|
||
{zoom}%
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
className="w-44 p-1"
|
||
align="center"
|
||
onCloseAutoFocus={keepFocusInCanvas}
|
||
>
|
||
{available.has("fitWindow") && (
|
||
<MenuButton
|
||
shortcut={`${modKey}⇧H`}
|
||
onClick={() => {
|
||
runAction("fitWindow")
|
||
setZoomOpen(false)
|
||
}}
|
||
>
|
||
{t.fitWindow}
|
||
</MenuButton>
|
||
)}
|
||
{available.has("resetView") && (
|
||
<MenuButton
|
||
shortcut="Home"
|
||
onClick={() => {
|
||
runAction("resetView")
|
||
setZoomOpen(false)
|
||
}}
|
||
>
|
||
{t.zoomReset}
|
||
</MenuButton>
|
||
)}
|
||
{compact && available.has("zoomIn") && (
|
||
<MenuButton onClick={() => runAction("zoomIn")}>
|
||
{t.zoomIn}
|
||
</MenuButton>
|
||
)}
|
||
{compact && available.has("zoomOut") && (
|
||
<MenuButton onClick={() => runAction("zoomOut")}>
|
||
{t.zoomOut}
|
||
</MenuButton>
|
||
)}
|
||
</PopoverContent>
|
||
</Popover>
|
||
{!compact && available.has("zoomIn") && (
|
||
<IconButton
|
||
label={t.zoomIn}
|
||
shortcut={`${modKey}+`}
|
||
onClick={tool("zoomIn")}
|
||
>
|
||
<Plus />
|
||
</IconButton>
|
||
)}
|
||
{!compact && (
|
||
<IconButton
|
||
label={t.fitWindow}
|
||
onClick={() => {
|
||
fitDiagram()
|
||
focusCanvas()
|
||
}}
|
||
>
|
||
<Maximize2 />
|
||
</IconButton>
|
||
)}
|
||
<ToolbarDivider />
|
||
{available.has("format") && (
|
||
<IconButton
|
||
label={t.style}
|
||
shortcut={`${modKey}⇧P`}
|
||
active={isFormatOpen}
|
||
onClick={tool("format")}
|
||
>
|
||
<Paintbrush />
|
||
</IconButton>
|
||
)}
|
||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||
<PopoverTrigger asChild>
|
||
<IconButton label={t.more} data-testid="toolbar-more">
|
||
<Ellipsis />
|
||
</IconButton>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
className="w-60 p-1"
|
||
align="end"
|
||
onCloseAutoFocus={keepFocusInCanvas}
|
||
>
|
||
<MenuButton
|
||
disabled={engine.isBusy}
|
||
onClick={() => {
|
||
setMoreOpen(false)
|
||
onOpenFile()
|
||
}}
|
||
>
|
||
{t.openFile}
|
||
</MenuButton>
|
||
{compact && available.has("insertText") && (
|
||
<MenuButton
|
||
onClick={() => {
|
||
setMoreOpen(false)
|
||
runAction("insertText")
|
||
}}
|
||
>
|
||
{t.text}
|
||
</MenuButton>
|
||
)}
|
||
{compact && available.has("insertFreehand") && (
|
||
<MenuButton
|
||
onClick={() => {
|
||
setMoreOpen(false)
|
||
runAction("insertFreehand")
|
||
}}
|
||
>
|
||
{t.freehand}
|
||
</MenuButton>
|
||
)}
|
||
<div className="my-1 h-px bg-border" />
|
||
{moreItems.map((item) => (
|
||
<MenuButton
|
||
key={item.action}
|
||
checked={checkedOf(item.action)}
|
||
shortcut={compact ? undefined : item.shortcut}
|
||
onClick={() => {
|
||
setMoreOpen(false)
|
||
runAction(item.action)
|
||
}}
|
||
>
|
||
{item.label}
|
||
</MenuButton>
|
||
))}
|
||
</PopoverContent>
|
||
</Popover>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSaveDialogOpen(true)}
|
||
className="ml-1 inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-primary px-3 text-[13px] font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||
data-testid="toolbar-export"
|
||
>
|
||
<Download className="size-4" />
|
||
{!compact && t.export}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|