mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 11:39:50 +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.
176 lines
7.1 KiB
TypeScript
176 lines
7.1 KiB
TypeScript
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { Shimmer } from "@/components/ai-elements/shimmer"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { useDiagram } from "@/contexts/diagram-context"
|
|
import { useDictionary } from "@/hooks/use-dictionary"
|
|
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
|
import { formatMessage } from "@/lib/i18n/utils"
|
|
import { describeChanges } from "@/lib/version-text"
|
|
import { useUiStore } from "@/stores/ui-store"
|
|
import { useVersionsStore } from "@/stores/versions-store"
|
|
import { useChatEngine } from "./chat-engine"
|
|
|
|
function Sheet({
|
|
label,
|
|
svg,
|
|
loadingText,
|
|
emptyText,
|
|
}: {
|
|
label: string
|
|
svg: string | null | undefined
|
|
loadingText: string
|
|
emptyText: string
|
|
}) {
|
|
return (
|
|
<figure className="flex min-w-0 flex-1 flex-col gap-2">
|
|
<figcaption className="text-xs font-medium text-muted-foreground">
|
|
{label}
|
|
</figcaption>
|
|
<div className="sheet-light flex aspect-[16/10] items-center justify-center overflow-hidden rounded-xl border border-border bg-white p-2">
|
|
{svg === "" ? (
|
|
<span className="text-xs text-faint">{emptyText}</span>
|
|
) : svg ? (
|
|
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
|
<img
|
|
src={svg}
|
|
alt=""
|
|
className="max-h-full max-w-full object-contain"
|
|
/>
|
|
) : (
|
|
<Shimmer as="span" className="text-xs">
|
|
{loadingText}
|
|
</Shimmer>
|
|
)}
|
|
</div>
|
|
</figure>
|
|
)
|
|
}
|
|
|
|
/** A saved version next to the current canvas, with restore */
|
|
export function CompareDialog() {
|
|
const dict = useDictionary()
|
|
const t = dict.versions
|
|
const engine = useChatEngine()
|
|
const { getVersionSvg, chartXML } = useDiagram()
|
|
const versionId = useUiStore((s) => s.compareVersionId)
|
|
const openCompare = useUiStore((s) => s.openCompare)
|
|
const closeCompare = useUiStore((s) => s.closeCompare)
|
|
const versions = useVersionsStore((s) => s.versions)
|
|
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
|
|
|
|
const index = versions.findIndex((v) => v.id === versionId)
|
|
const version = index >= 0 ? versions[index] : null
|
|
|
|
// Whether the canvas is this version, on every page
|
|
const same = useMemo(
|
|
() => !version || isSameDocument(version.xml, chartXML),
|
|
[version, chartXML],
|
|
)
|
|
// What the canvas has changed since this version on the first page
|
|
// (null: nothing there)
|
|
const changes = useMemo(() => {
|
|
if (!version || same) return null
|
|
const { summary } = diffDiagrams(version.xml, chartXML)
|
|
return Object.values(summary).some((n) => n > 0) ? summary : null
|
|
}, [version, chartXML, same])
|
|
|
|
// Picture of the canvas as it is now
|
|
useEffect(() => {
|
|
if (!versionId) return
|
|
let cancelled = false
|
|
setCurrentSvg(null)
|
|
getVersionSvg().then((svg) => {
|
|
if (!cancelled) setCurrentSvg(svg)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [versionId])
|
|
|
|
return (
|
|
<Dialog
|
|
open={!!version}
|
|
onOpenChange={(open) => !open && closeCompare()}
|
|
>
|
|
<DialogContent className="gap-5 sm:max-w-4xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{t.compareTitle}</DialogTitle>
|
|
<DialogDescription>
|
|
{!version
|
|
? ""
|
|
: same
|
|
? t.isOnCanvas
|
|
: changes
|
|
? `${formatMessage(t.compareSince, { n: index + 1 })} ${describeChanges(changes, t)}`
|
|
: formatMessage(t.differsElsewhere, {
|
|
n: index + 1,
|
|
})}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{version && (
|
|
<div className="flex flex-col gap-4 sm:flex-row">
|
|
<Sheet
|
|
label={formatMessage(t.versionLabel, {
|
|
n: index + 1,
|
|
})}
|
|
svg={version.svg}
|
|
loadingText={t.rendering}
|
|
emptyText={t.noPreview}
|
|
/>
|
|
<Sheet
|
|
label={t.currentCanvas}
|
|
svg={currentSvg}
|
|
loadingText={t.rendering}
|
|
emptyText={t.noPreview}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={index <= 0}
|
|
onClick={() => openCompare(versions[index - 1].id)}
|
|
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
|
|
aria-label={t.previous}
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={index < 0 || index >= versions.length - 1}
|
|
onClick={() => openCompare(versions[index + 1].id)}
|
|
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
|
|
aria-label={t.next}
|
|
>
|
|
<ChevronRight className="size-4" />
|
|
</button>
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{index + 1} / {versions.length}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
disabled={!version || same || engine.isBusy}
|
|
onClick={() => {
|
|
if (!version) return
|
|
engine.restoreVersion(version.id)
|
|
closeCompare()
|
|
}}
|
|
className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
|
|
data-testid="compare-restore"
|
|
>
|
|
<RotateCcw className="size-4" />
|
|
{formatMessage(t.restoreVersion, { n: index + 1 })}
|
|
</button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|