mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
* fix(chat): the AI reads and draws the page the user is viewing The model always got the first page and display_diagram replaced the whole file with one page, so drawing on page 3 of a multi-page file deleted the other pages (#241), and edits went to page 1 while the user looked at another page. Now a turn records the page the user is viewing when the message is sent. That page's model goes to the model (also as previousXml, on regenerate and in edit_diagram error messages), selected shapes are sent on any page, display_diagram and append_diagram replace only that page (a drawing that brings several pages still replaces the file), edit_diagram targets it, and the streaming preview draws on it. Compact cells and default styles take the layer and the edges of that page, in the preview and in editDiagram. The editor bridge replaces the current page in place when the other pages are unchanged, so Ctrl+Z undoes an AI change on a multi-page file in one step; when a full load is needed, the user's page is shown again afterwards. Version summaries and the compare dialog count the page the change was made on. * test: read the failed edit's output without unsafe optional chaining
172 lines
6.7 KiB
TypeScript
172 lines
6.7 KiB
TypeScript
import { useParams } from "next/navigation"
|
|
import { useEffect, useRef, useState } from "react"
|
|
import { DrawioFrame } from "@/components/canvas/drawio-frame"
|
|
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 { DRAWIO_CSS, getDrawioSrc } from "@/lib/drawio/drawio-config"
|
|
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
|
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
|
import { STORAGE_KEYS } from "@/lib/storage"
|
|
import { cn } from "@/lib/utils"
|
|
import { useCanvasStore } from "@/stores/canvas-store"
|
|
import { useSettingsStore } from "@/stores/settings-store"
|
|
|
|
/**
|
|
* The draw.io canvas, with draw.io's own toolbar, menus and page tabs, and
|
|
* the "ask AI" button laid over it (only when the page can drive draw.io
|
|
* directly, not with an external cross-origin draw.io).
|
|
*/
|
|
export function CanvasStage({
|
|
className,
|
|
style,
|
|
hideOverlays = false,
|
|
}: {
|
|
className?: string
|
|
style?: React.CSSProperties
|
|
/** Hide the "ask AI" button (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)
|
|
// Canvas width when the chat panel starts sliding in or out
|
|
const slideStartWidthRef = 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)
|
|
const stageRef = useRef<HTMLDivElement>(null)
|
|
const [size, setSize] = useState({ width: 0, height: 0 })
|
|
|
|
useEffect(() => {
|
|
// The page is about to switch to the language picked last time
|
|
// (useSavedLocale): load draw.io once, in that language
|
|
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
|
if (saved && saved !== lang && i18n.locales.includes(saved as Locale)) {
|
|
return
|
|
}
|
|
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
|
// The theme class is set before the page renders; the settings store
|
|
// may not have read it yet
|
|
const dark = document.documentElement.classList.contains("dark")
|
|
setSrc(getDrawioSrc({ lang, isElectron, dark }))
|
|
}, [lang])
|
|
|
|
// A new iframe starts over: draw.io reports load and access again
|
|
useEffect(() => {
|
|
resetDrawioReady()
|
|
setEditorAccess(null)
|
|
}, [src])
|
|
|
|
// Cross-origin draw.io only takes its dark mode when it starts: reload
|
|
// it when that is not the theme now (also one switched while it
|
|
// loaded). Same-origin switches it in place.
|
|
useEffect(() => {
|
|
if (editorAccess !== false || !src) return
|
|
const startedDark = new URL(src).searchParams.get("dark") === "1"
|
|
if (startedDark === isDark) return
|
|
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
|
setSrc(getDrawioSrc({ lang, isElectron, dark: isDark }))
|
|
}, [isDark, editorAccess, lang, src])
|
|
|
|
useEffect(() => {
|
|
const stage = stageRef.current
|
|
if (!stage) return
|
|
// React passes no transitionstart
|
|
const onSlideStart = (event: TransitionEvent) => {
|
|
if (event.target !== stage || event.propertyName !== "right") return
|
|
slideStartWidthRef.current = stage.clientWidth
|
|
}
|
|
stage.addEventListener("transitionstart", onSlideStart)
|
|
const observer = new ResizeObserver(([entry]) => {
|
|
setSize({
|
|
width: entry.contentRect.width,
|
|
height: entry.contentRect.height,
|
|
})
|
|
refitIfRecent()
|
|
})
|
|
observer.observe(stage)
|
|
return () => {
|
|
observer.disconnect()
|
|
stage.removeEventListener("transitionstart", onSlideStart)
|
|
}
|
|
}, [])
|
|
|
|
const showOverlays = hasEditor && isDrawioReady && !hideOverlays
|
|
|
|
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(() => {
|
|
if (refitIfRecent(4000)) return
|
|
keepCenter(slideStartWidthRef.current, stage.clientWidth)
|
|
}, 60)
|
|
}}
|
|
>
|
|
{src && (
|
|
<DrawioFrame
|
|
key={src}
|
|
ref={drawioRef}
|
|
src={src}
|
|
configCss={DRAWIO_CSS}
|
|
editorCss={DRAWIO_CSS}
|
|
dark={isDark}
|
|
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 && (
|
|
<SelectionAsk
|
|
width={size.width}
|
|
height={size.height}
|
|
hidden={engine.isBusy}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|