mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
feat(ui): canvas-first redesign with a self-hosted draw.io
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.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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 { 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 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,
|
||||
onOpenFile,
|
||||
}: {
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
compact?: boolean
|
||||
/** Opens the file picker for a .drawio file */
|
||||
onOpenFile: () => void
|
||||
/** 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 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)
|
||||
// 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 [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
|
||||
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
|
||||
// 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
|
||||
// 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(() => {
|
||||
if (refitIfRecent(4000)) return
|
||||
keepCenter(slideStartWidthRef.current, 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={onOpenFile}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"
|
||||
import {
|
||||
DRAWIO_CONFIG,
|
||||
EMPTY_DRAWIO_DOCUMENT,
|
||||
} from "@/lib/drawio/drawio-config"
|
||||
import {
|
||||
attachEditor,
|
||||
detachEditor,
|
||||
watchForEditorUi,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
|
||||
export interface DrawioExportEvent {
|
||||
event: "export"
|
||||
format: string
|
||||
data: string
|
||||
xml: string
|
||||
message: { message?: string; [key: string]: unknown }
|
||||
}
|
||||
|
||||
export interface DrawioFrameHandle {
|
||||
load: (data: { xml: string }) => void
|
||||
exportDiagram: (data: {
|
||||
format: string
|
||||
message?: string
|
||||
[key: string]: unknown
|
||||
}) => void
|
||||
}
|
||||
|
||||
interface DrawioFrameProps {
|
||||
src: string
|
||||
/** CSS sent with the configure message (works cross-origin too) */
|
||||
configCss: string
|
||||
/** CSS injected once the editor is reachable directly (same origin) */
|
||||
editorCss: string
|
||||
onLoad?: () => void
|
||||
onAutoSave?: (data: { xml?: string }) => void
|
||||
onExport?: (data: DrawioExportEvent) => void
|
||||
/** Called once we know whether the editor can be driven directly */
|
||||
onEditorAccess?: (hasEditor: boolean) => void
|
||||
}
|
||||
|
||||
const STYLE_ID = "next-ai-drawio-theme"
|
||||
|
||||
/**
|
||||
* The draw.io editor in an iframe, speaking draw.io's postMessage embed
|
||||
* protocol (same messages react-drawio used). When the iframe is same-origin
|
||||
* it also hands the editor object to editor-bridge for direct control.
|
||||
*/
|
||||
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
||||
function DrawioFrame(
|
||||
{
|
||||
src,
|
||||
configCss,
|
||||
editorCss,
|
||||
onLoad,
|
||||
onAutoSave,
|
||||
onExport,
|
||||
onEditorAccess,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const editorUiRef = useRef<any>(null)
|
||||
const attachedRef = useRef(false)
|
||||
const configCssRef = useRef(configCss)
|
||||
configCssRef.current = configCss
|
||||
const editorCssRef = useRef(editorCss)
|
||||
editorCssRef.current = editorCss
|
||||
// Handlers change on every render; the message listener reads refs
|
||||
const handlersRef = useRef({
|
||||
onLoad,
|
||||
onAutoSave,
|
||||
onExport,
|
||||
onEditorAccess,
|
||||
})
|
||||
handlersRef.current = { onLoad, onAutoSave, onExport, onEditorAccess }
|
||||
|
||||
// draw.io's origin: messages go only there, and only its messages
|
||||
// count (the frame could be navigated to another site)
|
||||
// (read on use: there is no window while the page renders on the server)
|
||||
const drawioOrigin = () => new URL(src, window.location.href).origin
|
||||
|
||||
const send = (action: string, data: Record<string, unknown> = {}) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
JSON.stringify({ action, ...data }),
|
||||
drawioOrigin(),
|
||||
)
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
load: ({ xml }) => send("load", { xml, autosave: 1 }),
|
||||
exportDiagram: (data) => send("export", data),
|
||||
}))
|
||||
|
||||
// Same-origin only: keep the theme CSS current without a reload
|
||||
const applyThemeCss = () => {
|
||||
if (!attachedRef.current) return
|
||||
try {
|
||||
const doc = iframeRef.current?.contentDocument
|
||||
if (!doc?.head) return
|
||||
let style = doc.getElementById(STYLE_ID)
|
||||
if (!style) {
|
||||
style = doc.createElement("style")
|
||||
style.id = STYLE_ID
|
||||
doc.head.appendChild(style)
|
||||
}
|
||||
style.textContent = editorCssRef.current
|
||||
} catch {
|
||||
// Cross-origin: the configure message already applied it
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeCss()
|
||||
}, [editorCss])
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return
|
||||
editorUiRef.current = null
|
||||
attachedRef.current = false
|
||||
let reported: boolean | null = null
|
||||
const report = (value: boolean) => {
|
||||
if (reported === value) return
|
||||
reported = value
|
||||
handlersRef.current.onEditorAccess?.(value)
|
||||
}
|
||||
|
||||
// Watch for draw.io's scripts so the editor object can be caught
|
||||
// as it starts. Stops once hooked, or when the frame turns out to
|
||||
// be cross-origin.
|
||||
const startedAt = Date.now()
|
||||
const poll = setInterval(() => {
|
||||
const frameWindow = iframe.contentWindow as any
|
||||
if (!frameWindow) return
|
||||
let ready = false
|
||||
try {
|
||||
ready =
|
||||
frameWindow.location.href !== "about:blank" &&
|
||||
!!frameWindow.EditorUi
|
||||
} catch {
|
||||
clearInterval(poll)
|
||||
report(false)
|
||||
return
|
||||
}
|
||||
if (ready) {
|
||||
clearInterval(poll)
|
||||
const ok = watchForEditorUi(frameWindow, (ui) => {
|
||||
editorUiRef.current = ui
|
||||
})
|
||||
if (!ok) report(false)
|
||||
} else if (Date.now() - startedAt > 60_000) {
|
||||
clearInterval(poll)
|
||||
}
|
||||
}, 5)
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.source !== iframe.contentWindow) return
|
||||
if (event.origin !== drawioOrigin()) return
|
||||
if (typeof event.data !== "string") return
|
||||
let data: any
|
||||
try {
|
||||
data = JSON.parse(event.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const handlers = handlersRef.current
|
||||
switch (data.event) {
|
||||
case "init":
|
||||
send("load", {
|
||||
xml: EMPTY_DRAWIO_DOCUMENT,
|
||||
autosave: 1,
|
||||
})
|
||||
break
|
||||
case "configure":
|
||||
send("configure", {
|
||||
config: {
|
||||
...DRAWIO_CONFIG,
|
||||
css: configCssRef.current,
|
||||
},
|
||||
})
|
||||
break
|
||||
case "load": {
|
||||
const tryAttach = () => {
|
||||
const ui = editorUiRef.current
|
||||
if (!ui || !iframe.contentWindow) return false
|
||||
attachEditor(ui, iframe.contentWindow, iframe)
|
||||
attachedRef.current = true
|
||||
applyThemeCss()
|
||||
report(true)
|
||||
return true
|
||||
}
|
||||
// The editor object is usually caught before the
|
||||
// first load; give it a moment, then fall back to
|
||||
// draw.io's own toolbar
|
||||
if (!tryAttach()) {
|
||||
setTimeout(() => {
|
||||
if (!tryAttach()) report(false)
|
||||
}, 800)
|
||||
}
|
||||
handlers.onLoad?.()
|
||||
break
|
||||
}
|
||||
case "autosave":
|
||||
handlers.onAutoSave?.(data)
|
||||
break
|
||||
case "export":
|
||||
handlers.onExport?.(data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => {
|
||||
clearInterval(poll)
|
||||
window.removeEventListener("message", handleMessage)
|
||||
detachEditor()
|
||||
}
|
||||
}, [src])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
title="draw.io"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
className="block h-full w-full border-0"
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ChevronDown, Plus } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { IconButton } from "@/components/workspace/icon-button"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
addPage,
|
||||
canManagePages,
|
||||
duplicatePage,
|
||||
keepFocusInCanvas,
|
||||
removePage,
|
||||
renamePage,
|
||||
selectPage,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
|
||||
/** Page switcher at the bottom of the canvas (replaces draw.io's page tabs) */
|
||||
export function PageTabs() {
|
||||
const dict = useDictionary()
|
||||
const t = dict.workspace
|
||||
const pages = useCanvasStore((s) => s.pages)
|
||||
const currentPageId = useCanvasStore((s) => s.currentPageId)
|
||||
const [menuFor, setMenuFor] = useState<string | null>(null)
|
||||
|
||||
if (!canManagePages() || pages.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto flex max-w-[min(640px,100%)] items-center gap-0.5 overflow-x-auto rounded-xl bg-card p-1 shadow-float"
|
||||
data-testid="page-tabs"
|
||||
>
|
||||
{pages.map((page) => {
|
||||
const isCurrent = page.id === currentPageId
|
||||
if (!isCurrent) {
|
||||
return (
|
||||
<button
|
||||
key={page.id}
|
||||
type="button"
|
||||
onClick={() => selectPage(page.id)}
|
||||
className="h-7 max-w-40 shrink-0 truncate rounded-lg px-2.5 text-[13px] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{page.name}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Popover
|
||||
key={page.id}
|
||||
open={menuFor === page.id}
|
||||
onOpenChange={(open) =>
|
||||
setMenuFor(open ? page.id : null)
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 max-w-44 shrink-0 items-center gap-1 rounded-lg bg-accent px-2.5 text-[13px] font-medium text-foreground"
|
||||
>
|
||||
<span className="truncate">{page.name}</span>
|
||||
<ChevronDown className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-44 p-1"
|
||||
onCloseAutoFocus={keepFocusInCanvas}
|
||||
>
|
||||
{[
|
||||
{
|
||||
label: t.renamePage,
|
||||
run: () => renamePage(page.id),
|
||||
},
|
||||
{
|
||||
label: t.duplicatePage,
|
||||
run: () => duplicatePage(page.id),
|
||||
},
|
||||
...(pages.length > 1
|
||||
? [
|
||||
{
|
||||
label: t.deletePage,
|
||||
run: () => removePage(page.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuFor(null)
|
||||
item.run()
|
||||
}}
|
||||
className="w-full rounded-md px-2.5 py-1.5 text-left text-[13px] hover:bg-accent"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})}
|
||||
<IconButton
|
||||
label={t.addPage}
|
||||
size="sm"
|
||||
tooltipSide="top"
|
||||
onClick={addPage}
|
||||
>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { modKey } from "@/lib/platform"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
const BUTTON_HEIGHT = 32
|
||||
const GAP = 10
|
||||
|
||||
/**
|
||||
* "Ask AI to change this" next to the selected shapes. Clicking it moves
|
||||
* focus to the chat input, where the selection already shows as a chip.
|
||||
*/
|
||||
export function SelectionAsk({
|
||||
width,
|
||||
height,
|
||||
hidden,
|
||||
}: {
|
||||
/** Size of the canvas area, for keeping the button inside it */
|
||||
width: number
|
||||
height: number
|
||||
hidden: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const rect = useCanvasStore((s) => s.selectionRect)
|
||||
const selection = useCanvasStore((s) => s.selection)
|
||||
const isFreehand = useCanvasStore((s) => s.isFreehand)
|
||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||
|
||||
if (hidden || !rect || selection.length === 0 || isFreehand) return null
|
||||
// Selection scrolled out of view
|
||||
if (
|
||||
rect.x + rect.width < 0 ||
|
||||
rect.y + rect.height < 0 ||
|
||||
rect.x > width ||
|
||||
rect.y > height
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Above the selection's top-right corner, kept inside the canvas
|
||||
const approxWidth = 168
|
||||
const left = Math.min(
|
||||
Math.max(8, rect.x + rect.width - approxWidth / 2),
|
||||
width - approxWidth - 8,
|
||||
)
|
||||
const above = rect.y - BUTTON_HEIGHT - GAP
|
||||
const top =
|
||||
above > 64
|
||||
? above
|
||||
: Math.min(rect.y + rect.height + GAP, height - BUTTON_HEIGHT - 72)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={focusComposer}
|
||||
className="pointer-events-auto absolute z-10 flex h-8 items-center gap-1.5 rounded-xl bg-card pr-2.5 pl-2 text-[13px] font-medium text-foreground shadow-pop transition-transform animate-fade-in hover:-translate-y-px"
|
||||
style={{ left, top }}
|
||||
data-testid="selection-ask"
|
||||
>
|
||||
<Sparkles className="size-4 text-marker-ink" />
|
||||
{dict.workspace.askAi}
|
||||
<kbd className="ml-1 font-sans text-[11px] text-muted-foreground">
|
||||
{modKey}/
|
||||
</kbd>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user