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>
This commit is contained in:
2026-07-07 23:15:37 +08:00
commit 44406b72db
349 changed files with 73265 additions and 0 deletions
@@ -0,0 +1,263 @@
import type { CanvasNode } from "@/domain/design";
import { isBooleanShapeGroupNode, isBooleanShapeOperandNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
import type { FrameStylePatch } from "./types";
import { nodeBounds, normalizeRotation, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle } from "./canvasGeometry";
export type NodeReorderAction = "forward" | "backward" | "front" | "back";
export type NodePosition = { id: string; x: number; y: number };
export function visibleCanvasNodes(nodes: CanvasNode[], hiddenNodeIds: Set<string>) {
return nodes.filter((node) => !hiddenNodeIds.has(node.id) && !isBooleanShapeOperandNode(node, nodes));
}
export function updateNodeContent(nodes: CanvasNode[], nodeId: string, content: string) {
return nodes.map((node) => (node.id === nodeId ? { ...node, content } : node));
}
export function moveNodesByDelta(nodes: CanvasNode[], startPositions: NodePosition[], dx: number, dy: number) {
const positions = new Map(startPositions.map((position) => [position.id, position]));
return nodes.map((node) => {
const position = positions.get(node.id);
return position ? { ...node, x: position.x + dx, y: position.y + dy } : node;
});
}
export function resizeVisualNodeByHandle(node: CanvasNode, handle: ResizeHandle, pointerDelta: { dx: number; dy: number }) {
const aspect = node.width / Math.max(node.height, 1);
const horizontalDirection = handle.includes("right") ? 1 : handle.includes("left") ? -1 : 0;
const verticalDirection = handle.includes("bottom") ? 1 : handle.includes("top") ? -1 : 0;
const visual = node.type === "image" || node.type === "frame";
const penStroke = isCanvasPenStrokeNode(node);
const mockupPrint = node.layerRole === "mockup-print";
const minWidth = penStroke || mockupPrint ? 8 : visual ? 96 : 140;
const minHeight = penStroke || mockupPrint ? 8 : visual ? Math.max(96, minWidth / aspect) : 80;
const widthFromPointer = horizontalDirection === 0 ? node.width : node.width + pointerDelta.dx * horizontalDirection;
const heightFromPointer = verticalDirection === 0 ? node.height : node.height + pointerDelta.dy * verticalDirection;
let nextWidth = Math.max(minWidth, widthFromPointer);
let nextHeight = Math.max(minHeight, heightFromPointer);
let nextX = horizontalDirection < 0 ? node.x + node.width - nextWidth : node.x;
let nextY = verticalDirection < 0 ? node.y + node.height - nextHeight : node.y;
if (visual) {
const widthScale = widthFromPointer / node.width;
const heightScale = heightFromPointer / node.height;
const chosenScale =
horizontalDirection !== 0 && verticalDirection !== 0
? Math.abs(widthScale - 1) > Math.abs(heightScale - 1)
? widthScale
: heightScale
: horizontalDirection !== 0
? widthScale
: heightScale;
const nextScale = Math.max(minWidth / node.width, minHeight / node.height, chosenScale);
nextWidth = node.width * nextScale;
nextHeight = node.height * nextScale;
nextX = horizontalDirection < 0 ? node.x + node.width - nextWidth : horizontalDirection > 0 ? node.x : node.x + (node.width - nextWidth) / 2;
nextY = verticalDirection < 0 ? node.y + node.height - nextHeight : verticalDirection > 0 ? node.y : node.y + (node.height - nextHeight) / 2;
}
return { ...node, x: nextX, y: nextY, width: nextWidth, height: nextHeight };
}
export function removeNodeById(nodes: CanvasNode[], nodeId: string) {
return nodes.filter((node) => node.id !== nodeId);
}
export function removeNodesById(nodes: CanvasNode[], nodeIds: Set<string>) {
return nodes.filter((node) => !nodeIds.has(node.id));
}
export function cloneCanvasNode(node: CanvasNode, id: string, offset = 38): CanvasNode {
return {
...node,
id,
title: `${node.title} copy`,
x: node.x + offset,
y: node.y + offset,
status: node.status === "generating" ? "draft" : node.status
};
}
export function reorderNode(nodes: CanvasNode[], nodeId: string, action: NodeReorderAction) {
const index = nodes.findIndex((node) => node.id === nodeId);
if (index < 0) return nodes;
const next = [...nodes];
const [node] = next.splice(index, 1);
if (action === "front") next.push(node);
if (action === "back") next.unshift(node);
if (action === "forward") next.splice(Math.min(index + 1, next.length), 0, node);
if (action === "backward") next.splice(Math.max(index - 1, 0), 0, node);
return next;
}
export function toggleSetValue(current: Set<string>, value: string) {
const next = new Set(current);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
return next;
}
export function toggleSetValues(current: Set<string>, values: string[]) {
const next = new Set(current);
const allEnabled = values.every((value) => next.has(value));
values.forEach((value) => {
if (allEnabled) {
next.delete(value);
} else {
next.add(value);
}
});
return next;
}
export function patchFrameStyleNode(node: CanvasNode, patch: FrameStylePatch): CanvasNode {
if (node.type !== "frame") return node;
const width = patch.width ?? node.width;
const height = patch.height ?? node.height;
return {
...node,
x: patch.width === undefined ? node.x : node.x + (node.width - width) / 2,
y: patch.height === undefined ? node.y : node.y + (node.height - height) / 2,
fillColor: patch.fillColor ?? node.fillColor,
strokeColor: patch.strokeColor ?? node.strokeColor,
strokeWidth: patch.strokeWidth ?? node.strokeWidth,
strokeStyle: patch.strokeStyle ?? node.strokeStyle,
opacity: patch.opacity === undefined ? node.opacity : patch.opacity / 100,
width,
height
};
}
export function updateNodeTransform(nodes: CanvasNode[], nodeId: string, patch: Partial<NodeTransform> | ((transform: NodeTransform) => Partial<NodeTransform>)) {
return nodes.map((node) => {
if (node.id !== nodeId) return node;
const transform: NodeTransform = {
flipX: Boolean(node.flipX),
flipY: Boolean(node.flipY),
rotation: node.rotation ?? 0
};
const nextPatch = typeof patch === "function" ? patch(transform) : patch;
return {
...node,
flipX: nextPatch.flipX ?? transform.flipX,
flipY: nextPatch.flipY ?? transform.flipY,
rotation: normalizeRotation(nextPatch.rotation ?? transform.rotation)
};
});
}
export function autoLayoutAroundNode(nodes: CanvasNode[], anchorNode: CanvasNode) {
const gap = 34;
const others = nodes.filter((node) => node.id !== anchorNode.id);
return [
anchorNode,
...others.map((node, index) => ({
...node,
x: anchorNode.x + anchorNode.width + gap + (index % 2) * (node.width + gap),
y: anchorNode.y + Math.floor(index / 2) * (node.height + gap)
}))
];
}
export function autoLayoutNodes(nodes: CanvasNode[], selectedIds: Set<string>) {
const selected = nodes.filter((node) => selectedIds.has(node.id));
if (selected.length < 2) return nodes;
const bounds = nodeBounds(selected);
const gap = 28;
const columns = Math.ceil(Math.sqrt(selected.length));
const maxWidth = Math.max(...selected.map((node) => node.width));
const maxHeight = Math.max(...selected.map((node) => node.height));
return nodes.map((node) => {
const index = selected.findIndex((item) => item.id === node.id);
if (index < 0) return node;
return {
...node,
x: bounds.x + (index % columns) * (maxWidth + gap),
y: bounds.y + Math.floor(index / columns) * (maxHeight + gap)
};
});
}
export function alignNodes(nodes: CanvasNode[], selectedIds: Set<string>, alignment: NodeAlignment) {
const selected = nodes.filter((node) => selectedIds.has(node.id));
if (selected.length < 2) return nodes;
const bounds = nodeBounds(selected);
return nodes.map((node) => {
if (!selectedIds.has(node.id)) return node;
if (alignment === "left") return { ...node, x: bounds.x };
if (alignment === "center") return { ...node, x: bounds.x + bounds.width / 2 - node.width / 2 };
if (alignment === "right") return { ...node, x: bounds.x + bounds.width - node.width };
if (alignment === "top") return { ...node, y: bounds.y };
if (alignment === "middle") return { ...node, y: bounds.y + bounds.height / 2 - node.height / 2 };
return { ...node, y: bounds.y + bounds.height - node.height };
});
}
export function distributeNodes(nodes: CanvasNode[], selectedIds: Set<string>, direction: NodeDistribution) {
const selected = nodes.filter((node) => selectedIds.has(node.id));
if (selected.length < 3) return nodes;
const bounds = nodeBounds(selected);
const sorted = [...selected].sort((a, b) => (direction === "horizontal" ? a.x - b.x : a.y - b.y));
const positions = new Map<string, number>();
if (direction === "horizontal") {
const totalWidth = sorted.reduce((sum, node) => sum + node.width, 0);
const gap = Math.max(0, (bounds.width - totalWidth) / (sorted.length - 1));
let nextX = bounds.x;
sorted.forEach((node) => {
positions.set(node.id, nextX);
nextX += node.width + gap;
});
} else {
const totalHeight = sorted.reduce((sum, node) => sum + node.height, 0);
const gap = Math.max(0, (bounds.height - totalHeight) / (sorted.length - 1));
let nextY = bounds.y;
sorted.forEach((node) => {
positions.set(node.id, nextY);
nextY += node.height + gap;
});
}
return nodes.map((node) => {
const position = positions.get(node.id);
if (position === undefined) return node;
return direction === "horizontal" ? { ...node, x: position } : { ...node, y: position };
});
}
export function groupNodes(nodes: CanvasNode[], selectedIds: Set<string>, groupId: string) {
return nodes.map((node) => (selectedIds.has(node.id) ? { ...node, parentId: groupId } : node));
}
export function ungroupNodes(nodes: CanvasNode[], groupId: string) {
return nodes.map((node) => (node.parentId === groupId ? { ...node, parentId: undefined } : node));
}
export function isImageUrl(value: string) {
return /^(https?:\/\/|data:image\/|blob:|\/)/.test(value.trim());
}
export function isRealCanvasImageNode(node: CanvasNode) {
return node.type === "image" && node.layerRole !== "pen-stroke" && node.layerRole !== "image-generator" && node.status !== "generating" && node.status !== "error" && isImageUrl(node.content);
}
export function isCanvasFrameNode(node: CanvasNode) {
return node.type === "frame" && node.status !== "generating" && node.status !== "error";
}
export function isCanvasPenStrokeNode(node: CanvasNode) {
return node.type === "image" && node.layerRole === "pen-stroke" && node.status !== "generating" && node.status !== "error" && isImageUrl(node.content);
}
export function canUseNodeContextMenu(node: CanvasNode) {
return isRealCanvasImageNode(node) || isCanvasPenStrokeNode(node) || node.type === "text" || isCanvasFrameNode(node);
}
export function canResizeCanvasNode(node: CanvasNode) {
if (isBooleanShapeGroupNode(node)) return true;
if (node.type !== "image") return true;
return isRealCanvasImageNode(node) || isCanvasPenStrokeNode(node);
}