mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29: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.
203 lines
7.9 KiB
TypeScript
203 lines
7.9 KiB
TypeScript
import { useParams } from "next/navigation"
|
|
import { useEffect, useRef, useState } from "react"
|
|
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar"
|
|
import { DrawioFrame } from "@/components/canvas/drawio-frame"
|
|
import { PageTabs } from "@/components/canvas/page-tabs"
|
|
import { SelectionAsk } from "@/components/canvas/selection-ask"
|
|
import { useChatEngine } from "@/components/chat/chat-engine"
|
|
import { useDiagram } from "@/contexts/diagram-context"
|
|
import { useDictionary } from "@/hooks/use-dictionary"
|
|
import { getDrawioCss, getDrawioSrc } from "@/lib/drawio/drawio-config"
|
|
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
|
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
|
import { cn } from "@/lib/utils"
|
|
import { useCanvasStore } from "@/stores/canvas-store"
|
|
import { useSettingsStore } from "@/stores/settings-store"
|
|
|
|
/** File types the "Open .drawio file" picker accepts */
|
|
export const DIAGRAM_FILE_ACCEPT =
|
|
".drawio,.xml,.svg,application/xml,image/svg+xml"
|
|
|
|
/**
|
|
* The draw.io canvas with the app's own toolbar, page tabs and the
|
|
* "ask AI" button laid over it. With an external (cross-origin) draw.io the
|
|
* overlays are hidden and draw.io's own toolbar is used.
|
|
*/
|
|
export function CanvasStage({
|
|
className,
|
|
style,
|
|
compact = false,
|
|
hideOverlays = false,
|
|
}: {
|
|
className?: string
|
|
style?: React.CSSProperties
|
|
compact?: boolean
|
|
/** Hide toolbar and buttons (e.g. while the start screen covers the canvas) */
|
|
hideOverlays?: boolean
|
|
}) {
|
|
const dict = useDictionary()
|
|
const params = useParams<{ lang: string }>()
|
|
const lang = (
|
|
i18n.locales.includes(params.lang as Locale)
|
|
? params.lang
|
|
: i18n.defaultLocale
|
|
) as Locale
|
|
const {
|
|
drawioRef,
|
|
handleDiagramAutoSave,
|
|
handleDiagramExport,
|
|
onDrawioLoad,
|
|
resetDrawioReady,
|
|
isDrawioReady,
|
|
} = useDiagram()
|
|
const engine = useChatEngine()
|
|
const isDark = useSettingsStore((s) => s.isDark)
|
|
const hasEditor = useCanvasStore((s) => s.hasEditor)
|
|
// The model sees the first page only, so asking about shapes elsewhere
|
|
// would change the wrong page
|
|
const onFirstPage = useCanvasStore(
|
|
(s) => s.pages.length === 0 || s.pages[0]?.id === s.currentPageId,
|
|
)
|
|
// Canvas width before the chat panel slides (React passes no
|
|
// transitionstart, so this is the width after the previous slide)
|
|
const settledWidthRef = useRef(0)
|
|
|
|
const [src, setSrc] = useState<string | null>(null)
|
|
// null until draw.io tells us whether we can drive it directly
|
|
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
|
|
// Bumped to reload draw.io when its CSS can only change with a reload
|
|
const [reloadKey, setReloadKey] = useState(0)
|
|
const stageRef = useRef<HTMLDivElement>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const [size, setSize] = useState({ width: 0, height: 0 })
|
|
|
|
useEffect(() => {
|
|
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
|
setSrc(getDrawioSrc({ lang, isElectron }))
|
|
}, [lang])
|
|
|
|
// A new iframe starts over: draw.io reports load and access again
|
|
useEffect(() => {
|
|
resetDrawioReady()
|
|
setEditorAccess(null)
|
|
}, [src, reloadKey])
|
|
|
|
// Cross-origin draw.io only takes CSS when it starts, so reload it when
|
|
// the theme changes. Same-origin updates the CSS in place.
|
|
const lastDarkRef = useRef(isDark)
|
|
useEffect(() => {
|
|
if (lastDarkRef.current === isDark) return
|
|
lastDarkRef.current = isDark
|
|
if (editorAccess === false) setReloadKey((key) => key + 1)
|
|
}, [isDark, editorAccess])
|
|
|
|
useEffect(() => {
|
|
const stage = stageRef.current
|
|
if (!stage) return
|
|
settledWidthRef.current = stage.clientWidth
|
|
const observer = new ResizeObserver(([entry]) => {
|
|
setSize({
|
|
width: entry.contentRect.width,
|
|
height: entry.contentRect.height,
|
|
})
|
|
refitIfRecent()
|
|
})
|
|
observer.observe(stage)
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
const showOverlays = hasEditor && isDrawioReady && !hideOverlays
|
|
// The full toolbar is about 590 px wide; a narrower canvas (small window
|
|
// with the chat panel open) gets the short one
|
|
const compactToolbar = compact || (size.width > 0 && size.width < 640)
|
|
|
|
return (
|
|
<div
|
|
ref={stageRef}
|
|
className={cn("absolute overflow-hidden bg-canvas", className)}
|
|
style={style}
|
|
data-testid="canvas-stage"
|
|
// The chat panel slides in or out (the canvas's right edge moves):
|
|
// once the canvas has its final size, fit a diagram just drawn,
|
|
// or keep the view's middle
|
|
onTransitionEnd={(event) => {
|
|
if (event.target !== event.currentTarget) return
|
|
if (event.propertyName !== "right") return
|
|
const stage = event.currentTarget
|
|
// Let draw.io take the new size first
|
|
setTimeout(() => {
|
|
const before = settledWidthRef.current
|
|
settledWidthRef.current = stage.clientWidth
|
|
if (refitIfRecent(4000)) return
|
|
keepCenter(before, stage.clientWidth)
|
|
}, 60)
|
|
}}
|
|
>
|
|
{src && (
|
|
<DrawioFrame
|
|
key={`${src}#${reloadKey}`}
|
|
ref={drawioRef}
|
|
src={src}
|
|
configCss={getDrawioCss({
|
|
dark: isDark,
|
|
customChrome: false,
|
|
})}
|
|
editorCss={getDrawioCss({
|
|
dark: isDark,
|
|
customChrome: true,
|
|
})}
|
|
onLoad={onDrawioLoad}
|
|
onAutoSave={handleDiagramAutoSave}
|
|
onExport={handleDiagramExport}
|
|
onEditorAccess={setEditorAccess}
|
|
/>
|
|
)}
|
|
|
|
{/* Covers the iframe until draw.io has loaded and been styled */}
|
|
{(!isDrawioReady || editorAccess === null) && (
|
|
<div
|
|
className="absolute inset-0 flex items-center justify-center bg-canvas"
|
|
data-testid="canvas-loading"
|
|
>
|
|
<div className="flex items-center gap-2.5 text-[13px] text-muted-foreground">
|
|
<span className="size-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
|
|
{dict.workspace.canvasLoading}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showOverlays && (
|
|
<>
|
|
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center px-3">
|
|
<CanvasToolbar
|
|
compact={compactToolbar}
|
|
onOpenFile={() => fileInputRef.current?.click()}
|
|
/>
|
|
</div>
|
|
{/* Full width, so the tabs' max width is the canvas's */}
|
|
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-10 flex">
|
|
<PageTabs />
|
|
</div>
|
|
<SelectionAsk
|
|
width={size.width}
|
|
height={size.height}
|
|
hidden={engine.isBusy || !onFirstPage}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={DIAGRAM_FILE_ACCEPT}
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0]
|
|
if (file) engine.openDiagramFile(file)
|
|
event.target.value = ""
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|