Files
moteva/frontend/src/ui/components/CanvasPanels/index.tsx
T
root 44406b72db Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project;
the project page provides an infinite canvas with node drag, zoom/pan,
a design chat panel, history replay, image asset upload, and project
save/regenerate.

- Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory
  or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage;
  sky-valley/pi agent runtime adapter.
- Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n,
  shadcn/ui components, auth (OTP/Turnstile/Google/WeChat).
- Deploy: Docker Compose and k3s manifests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:15:37 +08:00

585 lines
20 KiB
TypeScript

"use client";
import { ChevronDown, ChevronUp, Download, Eye, EyeOff, Frame, Image as ImageIcon, Layers, Lock, Type, Unlock, X } from "lucide-react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design";
import { useI18n } from "@/i18n/i18n";
import { thumbnailImageUrl } from "@/ui/lib/imageDelivery";
import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
type LayerPanelItem =
| {
type: "node";
node: CanvasNode;
}
| {
type: "booleanGroup";
node: CanvasNode;
layers: CanvasNode[];
}
| {
type: "group";
id: string;
nodes: CanvasNode[];
layers: CanvasNode[];
};
type Translate = (key: string, values?: Record<string, string | number>) => string;
export function CanvasGrid({ viewport }: { viewport: CanvasViewport }) {
const gridSize = 48 * viewport.k;
return (
<div
className="canvas-grid"
style={{
backgroundSize: `${gridSize}px ${gridSize}px`,
backgroundPosition: `${viewport.x % gridSize}px ${viewport.y % gridSize}px`
}}
/>
);
}
export function CanvasGeneratedFilesPanel({
nodes,
messages,
selectedIds,
onSelect,
onDownload,
onClose
}: {
nodes: CanvasNode[];
messages: AgentMessage[];
selectedIds: Set<string>;
onSelect: (nodeId: string) => void;
onDownload: (node: CanvasNode) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const generatedFileURLs = useMemo(() => generatedFileURLSet(messages), [messages]);
const files = useMemo(() => nodes.filter((node) => isGeneratedFileNode(node, generatedFileURLs)).reverse(), [generatedFileURLs, nodes]);
return (
<aside className="canvas-generated-files-panel" onPointerDown={(event) => event.stopPropagation()}>
<header>
<strong>{t("generatedFiles")}</strong>
<button type="button" aria-label={t("close")} onClick={onClose}>
<X size={19} />
</button>
</header>
<div className="generated-files-list">
{files.length === 0 ? (
<div className="generated-files-empty">
<ImageIcon size={54} />
<span>{t("generatedFilesEmpty")}</span>
</div>
) : (
<>
{files.map((node) => (
<div
key={node.id}
className={`generated-file-row ${selectedIds.has(node.id) ? "selected" : ""}`}
onClick={() => onSelect(node.id)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onSelect(node.id);
}}
>
<span className="generated-file-thumb">
<img src={thumbnailImageUrl(node.content)} alt={node.title} draggable={false} />
</span>
<span className="generated-file-name">{layerTitle(node, t)}</span>
<button
type="button"
className="generated-file-download"
aria-label={t("download")}
onClick={(event) => {
event.stopPropagation();
onDownload(node);
}}
>
<Download size={19} />
</button>
</div>
))}
<div className="generated-files-end">{t("generatedFilesEnd")}</div>
</>
)}
</div>
</aside>
);
}
export function CanvasLayerPanel({
nodes,
selectedIds,
hiddenNodeIds,
lockedNodeIds,
onSelect,
onSelectMany,
onRenameNode,
onToggleHidden,
onToggleLocked,
onToggleHiddenMany,
onToggleLockedMany,
onClose
}: {
nodes: CanvasNode[];
selectedIds: Set<string>;
hiddenNodeIds: Set<string>;
lockedNodeIds: Set<string>;
onSelect: (nodeId: string) => void;
onSelectMany: (nodeIds: string[]) => void;
onRenameNode: (nodeId: string, title: string) => void;
onToggleHidden: (nodeId: string) => void;
onToggleLocked: (nodeId: string) => void;
onToggleHiddenMany: (nodeIds: string[]) => void;
onToggleLockedMany: (nodeIds: string[]) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const layers = useMemo(() => buildLayerPanelItems(nodes), [nodes]);
return (
<aside className="canvas-layer-panel" onPointerDown={(event) => event.stopPropagation()}>
<header>
<strong>{t("layers")}</strong>
<button type="button" aria-label={t("close")} onClick={onClose}>
<X size={17} />
</button>
</header>
<section className="layer-history-section">
<button type="button">
<span>{t("layerHistory")}</span>
<ChevronUp size={14} />
</button>
<div className="layer-history-empty">
<ImageIcon size={58} />
<span>{t("layerNoHistory")}</span>
</div>
</section>
<div className="layer-list">
{layers.length === 0 ? (
<div className="layer-list-empty">{t("layerNoLayers")}</div>
) : (
layers.map((item) => {
if (item.type === "booleanGroup") {
const selected = selectedIds.has(item.node.id);
const hidden = hiddenNodeIds.has(item.node.id);
const locked = lockedNodeIds.has(item.node.id);
return (
<Fragment key={item.node.id}>
<div className={`layer-row layer-group-row boolean-layer-group-row ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelect(item.node.id)}>
<ChevronDown size={14} />
<span className="layer-thumb group-thumb boolean-group-thumb">
{layerThumb(item.node)}
</span>
<button
type="button"
className="layer-name"
onClick={(event) => {
event.stopPropagation();
onSelect(item.node.id);
}}
>
<strong>{layerTitle(item.node, t)}</strong>
<small>{t("booleanOperation")}</small>
</button>
<button
type="button"
className={`layer-control ${locked ? "active" : ""}`}
aria-label={locked ? t("contextUnlock") : t("contextLock")}
onClick={(event) => {
event.stopPropagation();
onToggleLocked(item.node.id);
}}
>
{locked ? <Lock size={15} /> : <Unlock size={15} />}
</button>
<button
type="button"
className={`layer-control ${hidden ? "active" : ""}`}
aria-label={hidden ? t("contextShow") : t("contextHide")}
onClick={(event) => {
event.stopPropagation();
onToggleHidden(item.node.id);
}}
>
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
{item.layers.map((node) => (
<LayerNodeRow
key={node.id}
node={node}
nested
selected={selected}
hidden={hiddenNodeIds.has(node.id)}
locked={lockedNodeIds.has(node.id)}
controlsVisible={false}
onSelect={() => onSelect(item.node.id)}
onRenameNode={onRenameNode}
onToggleHidden={onToggleHidden}
onToggleLocked={onToggleLocked}
/>
))}
</Fragment>
);
}
if (item.type === "group") {
const nodeIds = item.nodes.map((node) => node.id);
const selected = nodeIds.some((nodeId) => selectedIds.has(nodeId));
const hidden = nodeIds.every((nodeId) => hiddenNodeIds.has(nodeId));
const locked = nodeIds.every((nodeId) => lockedNodeIds.has(nodeId));
return (
<Fragment key={item.id}>
<div className={`layer-row layer-group-row ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelectMany(nodeIds)}>
<span className="layer-thumb group-thumb">
<Layers size={17} />
</span>
<button
type="button"
className="layer-name"
onClick={(event) => {
event.stopPropagation();
onSelectMany(nodeIds);
}}
>
<strong>{t("layerGroup")}</strong>
<small>{t("layerItemsCount", { count: item.nodes.length })}</small>
</button>
<button
type="button"
className={`layer-control ${locked ? "active" : ""}`}
aria-label={locked ? t("contextUnlock") : t("contextLock")}
onClick={(event) => {
event.stopPropagation();
onToggleLockedMany(nodeIds);
}}
>
{locked ? <Lock size={15} /> : <Unlock size={15} />}
</button>
<button
type="button"
className={`layer-control ${hidden ? "active" : ""}`}
aria-label={hidden ? t("contextShow") : t("contextHide")}
onClick={(event) => {
event.stopPropagation();
onToggleHiddenMany(nodeIds);
}}
>
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
{item.layers.map((node) => (
<LayerNodeRow
key={node.id}
node={node}
nested
selected={selectedIds.has(node.id)}
hidden={hiddenNodeIds.has(node.id)}
locked={lockedNodeIds.has(node.id)}
onSelect={onSelect}
onRenameNode={onRenameNode}
onToggleHidden={onToggleHidden}
onToggleLocked={onToggleLocked}
/>
))}
</Fragment>
);
}
const node = item.node;
return (
<LayerNodeRow
key={node.id}
node={node}
selected={selectedIds.has(node.id)}
hidden={hiddenNodeIds.has(node.id)}
locked={lockedNodeIds.has(node.id)}
onSelect={onSelect}
onRenameNode={onRenameNode}
onToggleHidden={onToggleHidden}
onToggleLocked={onToggleLocked}
/>
);
})
)}
</div>
</aside>
);
}
function LayerNodeRow({
node,
selected,
hidden,
locked,
nested = false,
onSelect,
onRenameNode,
onToggleHidden,
onToggleLocked,
controlsVisible = true
}: {
node: CanvasNode;
selected: boolean;
hidden: boolean;
locked: boolean;
nested?: boolean;
onSelect: (nodeId: string) => void;
onRenameNode: (nodeId: string, title: string) => void;
onToggleHidden: (nodeId: string) => void;
onToggleLocked: (nodeId: string) => void;
controlsVisible?: boolean;
}) {
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement | null>(null);
const [editingName, setEditingName] = useState(false);
const [draftName, setDraftName] = useState(node.title);
const title = layerTitle(node, t);
const canRename = node.type === "image" && !locked;
useEffect(() => {
if (!editingName) setDraftName(node.title);
}, [editingName, node.title]);
useEffect(() => {
if (!editingName) return;
const input = inputRef.current;
if (!input) return;
input.focus();
input.select();
}, [editingName]);
const startEditingName = () => {
if (!canRename) return;
setDraftName(node.title.trim() || title);
setEditingName(true);
};
const cancelEditingName = () => {
setDraftName(node.title);
setEditingName(false);
};
const commitEditingName = () => {
const nextName = draftName.trim();
if (!nextName || nextName === node.title.trim()) {
cancelEditingName();
return;
}
onRenameNode(node.id, nextName);
setEditingName(false);
};
return (
<div className={`layer-row ${nested ? "nested" : ""} ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelect(node.id)}>
<span className="layer-thumb">{layerThumb(node)}</span>
{editingName ? (
<span className="layer-name layer-name-editing" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<input
ref={inputRef}
value={draftName}
placeholder={t("imageTitlePlaceholder")}
aria-label={t("renameImageNode")}
onChange={(event) => setDraftName(event.target.value)}
onBlur={commitEditingName}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitEditingName();
}
if (event.key === "Escape") {
event.preventDefault();
cancelEditingName();
}
}}
/>
</span>
) : (
<button
type="button"
className="layer-name"
aria-label={canRename ? t("renameImageNode") : undefined}
onClick={(event) => {
event.stopPropagation();
onSelect(node.id);
}}
onDoubleClick={(event) => {
event.stopPropagation();
startEditingName();
}}
>
<strong>{title}</strong>
<small>{layerKindLabel(node, t)}</small>
</button>
)}
{controlsVisible ? (
<>
<button
type="button"
className={`layer-control ${locked ? "active" : ""}`}
aria-label={locked ? t("contextUnlock") : t("contextLock")}
onClick={(event) => {
event.stopPropagation();
onToggleLocked(node.id);
}}
>
{locked ? <Lock size={15} /> : <Unlock size={15} />}
</button>
<button
type="button"
className={`layer-control ${hidden ? "active" : ""}`}
aria-label={hidden ? t("contextShow") : t("contextHide")}
onClick={(event) => {
event.stopPropagation();
onToggleHidden(node.id);
}}
>
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</>
) : (
<span className="layer-controls-placeholder" />
)}
</div>
);
}
function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] {
const groups = new Map<string, CanvasNode[]>();
nodes.forEach((node) => {
if (!node.parentId) return;
groups.set(node.parentId, [...(groups.get(node.parentId) ?? []), node]);
});
const emittedGroups = new Set<string>();
const items: LayerPanelItem[] = [];
[...nodes].reverse().forEach((node) => {
if (isBooleanShapeOperandNode(node, nodes)) return;
if (isBooleanShapeGroupNode(node)) {
items.push({
type: "booleanGroup",
node,
layers: [...(groups.get(node.id) ?? [])].reverse()
});
return;
}
if (!node.parentId) {
items.push({ type: "node", node });
return;
}
if (emittedGroups.has(node.parentId)) return;
const groupNodes = groups.get(node.parentId) ?? [node];
emittedGroups.add(node.parentId);
items.push({
type: "group",
id: node.parentId,
nodes: groupNodes,
layers: [...groupNodes].reverse()
});
});
return items;
}
function layerTitle(node: CanvasNode, t: Translate) {
const title = node.title.trim();
if (title) return title;
if (node.type === "text") return node.content.trim().slice(0, 24) || t("textNode");
if (node.type === "frame" && node.layerRole === "shape") return t("shapeTool");
if (node.type === "frame") return t("frameNode");
if (node.type === "image") return t("imageNode");
return t("canvasUntitled");
}
function layerKindLabel(node: CanvasNode, t: Translate) {
if (node.type === "text") return t("textNode");
if (node.type === "frame" && node.layerRole === "shape") return t("shapeTool");
if (node.type === "frame") return t("frameNode");
if (node.type === "image") return t("imageNode");
return node.type;
}
function layerThumb(node: CanvasNode) {
if (isBooleanShapeGroupNode(node) || isShapeNode(node)) {
return <ShapeNodePreview node={node} />;
}
if (node.type === "image" && isImageUrl(node.content)) {
return <img src={thumbnailImageUrl(node.content)} alt="" draggable={false} />;
}
if (node.type === "text") return <Type size={17} />;
if (node.type === "frame") return <Frame size={17} />;
return <ImageIcon size={17} />;
}
function isGeneratedFileNode(node: CanvasNode, generatedFileURLs: Set<string>) {
return (
node.type === "image" &&
isImageUrl(node.content) &&
node.status !== "generating" &&
node.status !== "uploading" &&
node.status !== "error" &&
!nonModelGeneratedLayerRoles.has(node.layerRole ?? "") &&
assetURLSetHas(generatedFileURLs, node.content)
);
}
function isImageUrl(value: string) {
return /^(https?:\/\/|data:image\/|blob:|\/)/.test(value.trim());
}
const nonModelGeneratedLayerRoles = new Set(["pen-stroke", "background-removed", "clean-image", "mockup-print", "merge-layer", "manual-frame", "image-generator"]);
function generatedFileURLSet(messages: AgentMessage[]) {
const urls = new Set<string>();
messages.forEach((message) => {
if (message.name !== "generate_media" && message.title !== "generate_media" && message.type !== "tool_use") return;
try {
const events = JSON.parse(message.content) as Array<{
event_data?: {
artifact?: Array<{
image_url?: string;
}>;
};
}>;
events.forEach((event) => {
event.event_data?.artifact?.forEach((artifact) => {
if (artifact.image_url) addAssetURLKeys(urls, artifact.image_url);
});
});
} catch {
// Tool messages that are not artifact payloads are ignored here.
}
});
return urls;
}
function assetURLSetHas(items: Set<string>, value: string) {
return assetURLKeys(value).some((key) => items.has(key));
}
function addAssetURLKeys(items: Set<string>, value: string) {
assetURLKeys(value).forEach((key) => items.add(key));
}
function assetURLKeys(value: string) {
const trimmed = value.trim();
if (!trimmed || trimmed.startsWith("data:image/") || trimmed.startsWith("blob:")) return [];
const keys = new Set<string>([trimmed]);
const queryIndex = trimmed.indexOf("?");
if (queryIndex > 0) keys.add(trimmed.slice(0, queryIndex));
try {
const url = new URL(trimmed, window.location.origin);
keys.add(url.pathname);
if (url.search) keys.add(`${url.pathname}${url.search}`);
} catch {
// Keep exact and queryless keys above.
}
return Array.from(keys);
}