mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
refactor(canvas): decouple the canvas from the chat engine
The canvas components will be reused by the MCP server's browser shell, which has no chat and no Next.js. They now read everything they need from small contexts and the canvas store instead of the chat engine: - components/canvas/versions-context.tsx: VersionsProvider and useVersionsContext give the version cards, the strip and the compare dialog a VersionsSource (versions, onCanvasId, undoneId, isBusy, canUndo, canRedo, restore, undo, redo). The chat fills it from the versions store and the engine in components/chat/chat-versions.tsx. - stores/canvas-store.ts: isBusy and busyReason; the chat engine sets them while a turn runs, SelectionAsk and the version UI read them. - components/canvas/version-card.tsx: the visual version card and the thumbnail, out of tool-activity.tsx; the chat's card composes it and adds its "Show XML" link and code panel. - compare-dialog.tsx and version-strip.tsx move to components/canvas; the strip takes the minimum number of versions to show as a prop, the chat panel computes it from the cards it has. - components/canvas/locale-context.tsx: LocaleProvider and useLocale, fed by the [lang] layout; CanvasStage no longer uses next/navigation. The overlay (SelectionAsk) is a slot and the wait for the saved language is a prop, so the shell can leave both out. - lib/version-text.ts: describeChanges is now describeChangeSummary, so it can be imported next to the MCP core's describeChanges.
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
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 { useLocale } from "@/components/canvas/locale-context"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { DRAWIO_CSS, getDrawioSrc } from "@/lib/drawio/drawio-config"
|
||||
@@ -13,28 +11,38 @@ import { cn } from "@/lib/utils"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
|
||||
/** Size of the canvas area, for overlays that stay inside it */
|
||||
export interface CanvasSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* an overlay laid over it (the web app's "ask AI" button; only when the
|
||||
* page can drive draw.io directly, not with an external cross-origin
|
||||
* draw.io).
|
||||
*/
|
||||
export function CanvasStage({
|
||||
className,
|
||||
style,
|
||||
hideOverlays = false,
|
||||
overlay,
|
||||
waitForSavedLocale = false,
|
||||
}: {
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
/** Hide the "ask AI" button (e.g. while the start screen covers the canvas) */
|
||||
/** Hide the overlay (e.g. while the start screen covers the canvas) */
|
||||
hideOverlays?: boolean
|
||||
/** Rendered over the canvas once draw.io is ready */
|
||||
overlay?: (size: CanvasSize) => React.ReactNode
|
||||
/** Web app: the page is about to switch to the language picked last
|
||||
* time (useSavedLocale in the workspace); do not load draw.io in the
|
||||
* URL's language meanwhile */
|
||||
waitForSavedLocale?: 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 lang = useLocale()
|
||||
const {
|
||||
drawioRef,
|
||||
handleDiagramAutoSave,
|
||||
@@ -43,7 +51,6 @@ export function CanvasStage({
|
||||
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
|
||||
@@ -53,14 +60,19 @@ export function CanvasStage({
|
||||
// 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 })
|
||||
const [size, setSize] = useState<CanvasSize>({ 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
|
||||
// Load draw.io once, in the language the page will have
|
||||
if (waitForSavedLocale) {
|
||||
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
|
||||
@@ -159,13 +171,7 @@ export function CanvasStage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOverlays && (
|
||||
<SelectionAsk
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
hidden={engine.isBusy}
|
||||
/>
|
||||
)}
|
||||
{showOverlays && overlay?.(size)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { VersionThumb } from "@/components/canvas/version-card"
|
||||
import { useVersionsContext } from "@/components/canvas/versions-context"
|
||||
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 { describeChangeSummary } from "@/lib/version-text"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
function Sheet({
|
||||
label,
|
||||
svg,
|
||||
}: {
|
||||
label: string
|
||||
svg: string | null | undefined
|
||||
}) {
|
||||
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">
|
||||
<VersionThumb svg={svg} className="max-h-full max-w-full" />
|
||||
</div>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
/** A saved version next to the current canvas, with restore */
|
||||
export function CompareDialog() {
|
||||
const dict = useDictionary()
|
||||
const t = dict.versions
|
||||
const { versions, isBusy, restore } = useVersionsContext()
|
||||
const { getVersionSvg, chartXML } = useDiagram()
|
||||
const versionId = useUiStore((s) => s.compareVersionId)
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const closeCompare = useUiStore((s) => s.closeCompare)
|
||||
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 page it was
|
||||
// made on (null: nothing there)
|
||||
const changes = useMemo(() => {
|
||||
if (!version || same) return null
|
||||
const { summary } = diffDiagrams(version.xml, chartXML, version.pageId)
|
||||
return Object.values(summary).some((n) => n > 0) ? summary : null
|
||||
}, [version, chartXML, same])
|
||||
|
||||
// The version went away (dropped as the oldest, another chat): the
|
||||
// dialog closes, and nothing waits for its Escape
|
||||
useEffect(() => {
|
||||
if (versionId && !version) closeCompare()
|
||||
}, [versionId, version, closeCompare])
|
||||
|
||||
// Picture of the canvas as it is now, taken again once an answer that
|
||||
// was running is done ("" when none could be made)
|
||||
useEffect(() => {
|
||||
if (!versionId) return
|
||||
let cancelled = false
|
||||
setCurrentSvg(null)
|
||||
getVersionSvg().then((svg) => {
|
||||
if (!cancelled) setCurrentSvg(svg ?? "")
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [versionId, isBusy])
|
||||
|
||||
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: version.number })} ${describeChangeSummary(changes, t)}`
|
||||
: formatMessage(t.differsElsewhere, {
|
||||
n: version.number,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{version && (
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Sheet
|
||||
label={formatMessage(t.versionLabel, {
|
||||
n: version.number,
|
||||
})}
|
||||
svg={version.svg}
|
||||
/>
|
||||
<Sheet label={t.currentCanvas} svg={currentSvg} />
|
||||
</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 || isBusy}
|
||||
onClick={() => {
|
||||
if (!version) return
|
||||
restore(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: version?.number ?? 0,
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
import type { Locale } from "@/lib/i18n/config"
|
||||
|
||||
const LocaleContext = createContext<Locale | null>(null)
|
||||
|
||||
/** The page's language; the web layout feeds it from its route param */
|
||||
export function LocaleProvider({
|
||||
locale,
|
||||
children,
|
||||
}: {
|
||||
locale: Locale
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<LocaleContext.Provider value={locale}>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLocale(): Locale {
|
||||
const locale = useContext(LocaleContext)
|
||||
if (!locale) {
|
||||
throw new Error("useLocale must be used within a LocaleProvider")
|
||||
}
|
||||
return locale
|
||||
}
|
||||
@@ -10,25 +10,25 @@ 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.
|
||||
* Hidden while an answer is changing the canvas.
|
||||
*/
|
||||
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 popupOpen = useCanvasStore((s) => s.isDrawioPopupOpen)
|
||||
const isBusy = useCanvasStore((s) => s.isBusy)
|
||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||
|
||||
if (hidden || popupOpen || !rect || selection.length === 0 || isFreehand) {
|
||||
if (isBusy || popupOpen || !rect || selection.length === 0 || isFreehand) {
|
||||
return null
|
||||
}
|
||||
// Selection scrolled out of view
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Redo2, RotateCcw, Undo2 } from "lucide-react"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import {
|
||||
useVersionsContext,
|
||||
type VersionItem,
|
||||
} from "@/components/canvas/versions-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
/** Picture of a version (SVG data URL), or what stands in for it */
|
||||
export function VersionThumb({
|
||||
svg,
|
||||
dimmed,
|
||||
className,
|
||||
}: {
|
||||
/** undefined or null: still being made; "": none could be made */
|
||||
svg: string | null | undefined
|
||||
dimmed?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const t = useDictionary().versions
|
||||
if (svg) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={svg}
|
||||
alt=""
|
||||
className={cn(
|
||||
"object-contain transition-opacity",
|
||||
dimmed && "opacity-40",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (svg === "") {
|
||||
return <span className="text-xs text-faint">{t.noPreview}</span>
|
||||
}
|
||||
return (
|
||||
<Shimmer as="span" className="text-xs">
|
||||
{t.rendering}
|
||||
</Shimmer>
|
||||
)
|
||||
}
|
||||
|
||||
interface VersionCardProps extends React.ComponentProps<"div"> {
|
||||
version: VersionItem
|
||||
/** The newest version shows its picture; older ones fold into one row */
|
||||
isLatest: boolean
|
||||
/** What happened ("Drew the diagram"); an undone change says so instead */
|
||||
label: string
|
||||
/** Goes after the summary text (the chat adds its "Show XML" link) */
|
||||
afterSummary?: React.ReactNode
|
||||
/** Panel below the card (the chat shows the AI's XML there) */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for one diagram version: picture, number, label, what changed, and
|
||||
* the action (undo or redo on the latest, restore on older ones). The
|
||||
* picture opens Compare. Other attributes go on the card's element.
|
||||
*/
|
||||
export function VersionCard({
|
||||
version,
|
||||
isLatest,
|
||||
label,
|
||||
afterSummary,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: VersionCardProps) {
|
||||
const t = useDictionary().versions
|
||||
const source = useVersionsContext()
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
|
||||
const isUndone = source.undoneId === version.id
|
||||
const isOnCanvas = source.onCanvasId === version.id
|
||||
const title = isUndone ? t.undone : label
|
||||
const summary = version.fromScratch
|
||||
? describeTotals(version.summary, t)
|
||||
: describeChangeSummary(version.summary, t)
|
||||
const badge = (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
|
||||
isOnCanvas
|
||||
? "bg-marker-soft text-marker-ink"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
v{version.number}
|
||||
</span>
|
||||
)
|
||||
const actionClass =
|
||||
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
const restoreButton = (
|
||||
<button
|
||||
type="button"
|
||||
disabled={source.isBusy || isOnCanvas}
|
||||
onClick={() => source.restore(version.id)}
|
||||
className={actionClass}
|
||||
data-testid="version-restore"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{formatMessage(t.restoreVersion, { n: version.number })}
|
||||
</button>
|
||||
)
|
||||
const summaryLine = (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{summary}
|
||||
{afterSummary}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!isLatest) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-card",
|
||||
className,
|
||||
)}
|
||||
data-testid="version-card"
|
||||
{...rest}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 p-1.5 pr-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
|
||||
aria-label={t.compare}
|
||||
title={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
className="max-h-8 max-w-[52px]"
|
||||
/>
|
||||
</button>
|
||||
{badge}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{summaryLine}
|
||||
</div>
|
||||
{restoreButton}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Undo and redo belong to the newest change while it is (or was) on
|
||||
// the canvas; after an older version was restored, this one is
|
||||
// restored too
|
||||
const canUndo = source.canUndo || source.canRedo
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-card",
|
||||
isOnCanvas && "border-foreground/15",
|
||||
className,
|
||||
)}
|
||||
data-testid="version-card"
|
||||
{...rest}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
|
||||
aria-label={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
dimmed={isUndone}
|
||||
className="max-h-20 max-w-[88%]"
|
||||
/>
|
||||
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
|
||||
{t.compare}
|
||||
</span>
|
||||
</button>
|
||||
{/* Title row with the action; the summary runs under both */}
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
|
||||
{badge}
|
||||
<div className="truncate text-[13px] font-medium text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{canUndo ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={source.isBusy}
|
||||
onClick={() =>
|
||||
source.canRedo ? source.redo() : source.undo()
|
||||
}
|
||||
className={actionClass}
|
||||
data-testid="version-undo"
|
||||
>
|
||||
{source.canRedo ? (
|
||||
<Redo2 className="size-3.5" />
|
||||
) : (
|
||||
<Undo2 className="size-3.5" />
|
||||
)}
|
||||
{source.canRedo ? t.redo : t.undo}
|
||||
</button>
|
||||
) : (
|
||||
restoreButton
|
||||
)}
|
||||
<div className="col-span-2 col-start-2 mt-0.5">
|
||||
{summaryLine}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useVersionsContext } from "@/components/canvas/versions-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
/** Row of version thumbnails under the panel header; click to compare */
|
||||
export function VersionStrip({
|
||||
minVersions = 2,
|
||||
}: {
|
||||
/** Hidden below this many versions: one version alone adds nothing to
|
||||
* its card, unless the caller knows there is no card for it */
|
||||
minVersions?: number
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const { versions, onCanvasId } = useVersionsContext()
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Keep the newest version in view
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
|
||||
}, [versions.length])
|
||||
|
||||
if (versions.length < minVersions) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{dict.versions.strip}
|
||||
</span>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
|
||||
>
|
||||
{versions.map((version) => {
|
||||
const isCurrent = version.id === onCanvasId
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
title={`v${version.number}${isCurrent ? ` · ${dict.versions.currentCanvas}` : ""}`}
|
||||
className={cn(
|
||||
"sheet-light relative flex h-12 w-[72px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white transition-colors hover:border-foreground/30",
|
||||
// An outline with a gap reads on the white
|
||||
// picture in both themes
|
||||
isCurrent &&
|
||||
"outline-2 outline-offset-2 outline-foreground",
|
||||
)}
|
||||
data-testid="version-thumb"
|
||||
>
|
||||
{version.svg && (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={version.svg}
|
||||
alt=""
|
||||
className="max-h-9 max-w-[62px] object-contain"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0.5 left-1 text-[10px] font-medium tabular-nums text-neutral-500",
|
||||
isCurrent &&
|
||||
"font-semibold text-neutral-900",
|
||||
)}
|
||||
>
|
||||
v{version.number}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
import type { ChangeSummary } from "@/lib/diagram-diff"
|
||||
|
||||
/** One saved state of the diagram, as the version card and the compare
|
||||
* dialog show it */
|
||||
export interface VersionItem {
|
||||
id: string
|
||||
/** Shown as v{number} */
|
||||
number: number
|
||||
/** Full multi-page document */
|
||||
xml: string
|
||||
/** The page the change was made on; none: the first page */
|
||||
pageId?: string
|
||||
/** Document before the change, when it is known */
|
||||
beforeXml?: string
|
||||
/** SVG data URL thumbnail; undefined: still being made; "": none could
|
||||
* be made */
|
||||
svg?: string
|
||||
summary: ChangeSummary
|
||||
/** Drawn on an empty canvas (the card shows totals, not changes) */
|
||||
fromScratch?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the versions come from and what can be done with them. The web app
|
||||
* fills it from the chat's version store; the MCP shell from the server's
|
||||
* history.
|
||||
*/
|
||||
export interface VersionsSource {
|
||||
versions: VersionItem[]
|
||||
/** The version the canvas shows now, or null (hand edits, undone) */
|
||||
onCanvasId: string | null
|
||||
/** The latest version, when its change was undone */
|
||||
undoneId: string | null
|
||||
/** Something is changing the canvas: version actions wait */
|
||||
isBusy: boolean
|
||||
/** The latest version's change can be undone */
|
||||
canUndo: boolean
|
||||
/** The latest version's change was undone and can be put back */
|
||||
canRedo: boolean
|
||||
restore: (id: string) => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
}
|
||||
|
||||
const VersionsContext = createContext<VersionsSource | null>(null)
|
||||
|
||||
export function VersionsProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: VersionsSource
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<VersionsContext.Provider value={value}>
|
||||
{children}
|
||||
</VersionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useVersionsContext(): VersionsSource {
|
||||
const source = useContext(VersionsContext)
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
"useVersionsContext must be used within a VersionsProvider",
|
||||
)
|
||||
}
|
||||
return source
|
||||
}
|
||||
Reference in New Issue
Block a user