feat(workspace): integrate persisted canvas, element editing and session renewal

This commit is contained in:
2026-11-06 12:50:43 +08:00
parent 45e78f2e43
commit 373582906d
36 changed files with 1028 additions and 193 deletions
+12 -8
View File
@@ -1,11 +1,12 @@
import { sessionCookie, sessionMaxAge, SESSION_EXPIRY_HEADER } from "@/lib/session-cookie";
import { NextRequest, NextResponse } from "next/server";
const allowed =
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/;
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/;
const bodyLimit = 1024 * 1024;
// Route Handlers do not have the Pages Router bodyParser size limit.
async function readBody(req: NextRequest) {
async function readBody(req: NextRequest, limit = bodyLimit) {
const reader = req.body?.getReader();
if (!reader) return {};
const chunks: Uint8Array[] = [];
@@ -15,7 +16,7 @@ async function readBody(req: NextRequest) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > bodyLimit) {
if (size > limit) {
await reader.cancel();
throw new Error("body_too_large");
}
@@ -49,7 +50,7 @@ async function handler(
let requestBody: unknown;
if (req.method !== "GET") {
try {
requestBody = await readBody(req);
requestBody = await readBody(req, path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit);
} catch (error) {
const tooLarge = error instanceof Error && error.message === "body_too_large";
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
@@ -83,18 +84,21 @@ async function handler(
cache: "no-store",
},
);
const etag = upstream.headers.get("etag");
if (etag) responseHeaders.set("ETag", etag);
const headersReceived = performance.now();
const body = upstream.status === 204 ? null : await upstream.json();
responseHeaders.set(
"Server-Timing",
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
);
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
if (body?.access_token) {
responseHeaders.set(
"Set-Cookie",
`foundry_session=${encodeURIComponent(body.access_token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https" ? "; Secure" : ""}`,
);
responseHeaders.set("Set-Cookie", sessionCookie(body.access_token, sessionMaxAge(body.expires_in), secure));
delete body.access_token;
} else if (session && upstream.status !== 401) {
const renewed = upstream.headers.get(SESSION_EXPIRY_HEADER);
if (renewed !== null && /^\d+$/.test(renewed)) responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewed), secure));
}
if (path === "auth/logout" && upstream.ok)
responseHeaders.set(
+10 -9
View File
@@ -7,7 +7,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronsUpDown, FilePlus2, Home, Network, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings } from "lucide-react";
import { IconButton } from "@/components/ui/icon-button";
import { api, errorText, type Profile } from "@/lib/api";
import { boardHref, createBoard, renameBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace";
import { boardHref, createBoard, renameBoard, matchesRef, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace";
import { useActiveBoard } from "@/components/workspace/active-board";
import toolbar from "@/components/ui/toolbar.module.css";
import { EditableTitle } from "@/components/workspace/editable-title";
@@ -39,7 +39,9 @@ export default function DashboardSidebar() {
const createLock = useRef(false);
const listVersion = useRef(0);
const accountPopup = useRef<HTMLDivElement>(null);
const workspace = params.workspace || searchParams.get("workspace") || selectedWorkspace;
const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace;
const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef));
const workspace = currentWorkspace?.id || (activeBoard?.workspace_ref === workspaceRef ? activeBoard.workspace_id : workspaceRef);
useEffect(() => {
if (demo) {
@@ -101,7 +103,7 @@ export default function DashboardSidebar() {
setBoards((list) => [board, ...list]);
if (demo) localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify([board, ...readDemoBoards(workspace)]));
setQuery("");
router.push(boardHref(board.workspace_id, board.id, demo));
router.push(boardHref(currentWorkspace?.slug || board.workspace_id, board.slug || board.id, demo));
} catch (e) { setError(errorText(e)); }
finally { createLock.current = false; setCreating(false); }
}
@@ -132,16 +134,15 @@ export default function DashboardSidebar() {
} catch (e) { setError(errorText(e)); }
}
const currentTitle = activeBoard && activeBoard.id === params.board && activeBoard.workspace_id === workspace
const currentTitle = activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace
? activeBoard.title
: boards.find((board) => board.id === params.board)?.title || (params.board ? "正在读取图谱…" : "工作空间");
: boards.find((board) => matchesRef(board, params.board))?.title || (params.board ? "正在读取图谱…" : "工作空间");
const name = profile?.display_name || profile?.username || "账户";
const currentWorkspace = workspaces.find((w) => w.id === workspace);
const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
const canCreate = status === "ready" && (!workspace || Boolean(currentWorkspace && currentWorkspace.role !== "viewer"));
async function renameTitle(title: string) {
const id = params.board;
const id = activeBoard?.id || params.board;
if (!id || !workspace) return;
try {
if (demo) {
@@ -175,7 +176,7 @@ export default function DashboardSidebar() {
</Link>
<span className={toolbar.divider} aria-hidden="true" />
<EditableTitle key={workspace + ":" + (params.board || "home")} title={currentTitle}
editable={Boolean(params.board && activeBoard?.id === params.board && activeBoard.workspace_id === workspace && canCreate)}
editable={Boolean(params.board && activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace && canCreate)}
onSave={renameTitle} className={styles.floatingTitle} />
<span className={toolbar.divider} aria-hidden="true" />
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
@@ -206,7 +207,7 @@ export default function DashboardSidebar() {
{status === "loading" && <p className={styles.hint} role="status">正在读取工作空间…</p>}
{status === "ready" && !workspace && <p className={styles.hint}>新建页面时会自动建立个人空间。</p>}
{filtered.map((board) => (
<Link key={board.id} className={styles.pageLink} aria-current={params.board === board.id ? "page" : undefined} href={boardHref(workspace, board.id, demo)}>
<Link key={board.id} className={styles.pageLink} aria-current={matchesRef(board, params.board) ? "page" : undefined} href={boardHref(currentWorkspace?.slug || workspace, board.slug || board.id, demo)}>
<Network size={16} strokeWidth={1.6} aria-hidden="true" />
<span>{board.title}</span>
</Link>
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { BaseEdge, useInternalNode, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { connectorBezier, nodeConnector, type ConnectorSide } from "@/lib/canvas-geometry";
function connector(node: InternalNode<CanvasNode>, handle: string | null | undefined, fallback: ConnectorSide) {
const side = handle === "top" || handle === "right" || handle === "bottom" || handle === "left" ? handle : fallback;
return nodeConnector({ ...node, position: node.internals.positionAbsolute }, side);
}
export function CanvasEdge(props: EdgeProps) {
const source = useInternalNode<CanvasNode>(props.source);
const target = useInternalNode<CanvasNode>(props.target);
if (!source || !target) return null;
const from = connector(source, props.sourceHandleId, "right");
const to = connector(target, props.targetHandleId, "left");
return <BaseEdge id={props.id} path={connectorBezier(from, to)} style={{ ...props.style, strokeLinecap: "round" }} markerStart={props.markerStart} markerEnd={props.markerEnd} interactionWidth={props.interactionWidth} />;
}
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
const from = connector(props.fromNode, props.fromHandle.id, "right");
const to = props.toNode && props.toHandle
? connector(props.toNode, props.toHandle.id, "left")
: { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
return <path fill="none" className="react-flow__connection-path" d={connectorBezier(from, to)} style={{ stroke: "#aa968b", strokeWidth: 1.5, ...props.connectionLineStyle, strokeLinecap: "round" }} />;
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { DynamicIcon } from "lucide-react/dynamic";
import { Star, type LucideProps } from "lucide-react";
import { iconsById } from "./icon-catalog";
export function CanvasIcon({ name, ...props }: LucideProps & { name?: string }) {
const icon = iconsById.get(name || "Star");
if (!icon) return <Star {...props} />;
return <DynamicIcon name={icon.slug} {...props} fallback={() => <Star {...props} />} />;
}
@@ -0,0 +1,37 @@
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import type { CanvasNodeData } from "@/lib/canvas-elements";
import { NodeEditing } from "./transformable-node";
import { textAppearance } from "./text-appearance";
import styles from "./canvas.module.css";
/** Single clicks select; native double-click timing controls entry into editing. */
export function CanvasTextEditor({ id, data, selected, plain = false }: { id: string; data: CanvasNodeData; selected: boolean; plain?: boolean }) {
const actions = useContext(NodeEditing);
const [editing, setEditing] = useState(false);
const textarea = useRef<HTMLTextAreaElement>(null);
const press = useRef({ x: 0, y: 0, moved: false });
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
useEffect(() => {
if (!editing) return;
const element = textarea.current;
element?.focus({ preventScroll: true });
element?.setSelectionRange(element.value.length, element.value.length);
}, [editing]);
function begin() { actions.begin(); setEditing(true); }
const active = editing && selected;
const label = plain ? "纯文本" : data.title;
return <div className={`${styles.textEditor} ${active ? "nodrag" : ""}`}>
<textarea ref={textarea} style={textAppearance(data)} className={`${plain ? styles.plainText : styles.cardText} nodrag nowheel`}
aria-label={label} readOnly={!active} tabIndex={active ? 0 : -1}
placeholder={plain ? "写下文字…" : data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
value={data.text} maxLength={100000} onChange={(event) => actions.edit(id, event.target.value)}
onBlur={() => setEditing(false)} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
{!active && <div className={styles.textSelectLayer} role="button" tabIndex={selected ? 0 : -1} aria-label={`单击选中,双击编辑${label}`}
onPointerDown={(event) => { press.current = { x: event.clientX, y: event.clientY, moved: false }; }}
onPointerMove={(event) => { if (Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > 4) press.current.moved = true; }}
onDoubleClick={(event) => { if (!press.current.moved) { event.preventDefault(); event.stopPropagation(); if (!selected) actions.select(id); begin(); } }}
onKeyDown={(event) => { if (selected && (event.key === "Enter" || event.key === "F2")) { event.preventDefault(); event.stopPropagation(); begin(); } }} />}
</div>;
}
+41 -13
View File
@@ -5,7 +5,7 @@
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
.node { position: relative; padding: 0; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
.selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; }
.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; max-width: 100%; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
.cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; }
.cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; }
@@ -36,16 +36,15 @@
.plainFrame { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
.plainFrame.selectedFrame { border-color: #a47764; }
.plainText { padding: 4px; }
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 2px solid #fff; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; transition: width 160ms ease, height 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; }
.centerHandle svg { opacity: 0; scale: 0.4; pointer-events: none; transition: opacity 140ms ease, scale 160ms ease; }
.node :global(.react-flow__handle).plusHandle { width: 28px; height: 28px; background: #fffefb; border: 1px solid #cbb8a8; box-shadow: 0 2px 6px #32251a0a; }
.plusHandle svg { opacity: 1; scale: 1; }
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 0; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; opacity: 0; pointer-events: none; transition: opacity 160ms ease, background 160ms ease, border-color 160ms ease; }
.selectedNode :global(.react-flow__handle).centerHandle { opacity: 1; }
.node :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; }
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 0; scale: 0.8; pointer-events: none; transition: opacity 160ms ease, scale 160ms ease; }
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 1; scale: 1; pointer-events: auto; transition: opacity 160ms ease, scale 160ms ease; }
.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
.rotatingFrame { cursor: grabbing; touch-action: none; }
.rotatingFrame textarea, .rotatingFrame .centerHandle { pointer-events: none; }
.rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
.rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; }
.styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; }
.styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; }
@@ -71,13 +70,42 @@
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
.handMode .plainText { pointer-events: none; }
.handMode .cornerResize, .handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
.handMode .cornerResize { pointer-events: none; opacity: 0.6; }
.handMode .styleToolbar { display: none; }
@media (prefers-reduced-motion: reduce) {
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle, .centerHandle svg { transition: none; }
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; }
}
.textDragHandle { position: absolute; top: -23px; left: 0; display: grid; place-items: center; width: 20px; height: 20px; color: #92877c; cursor: grab; opacity: 0; pointer-events: none; transition: opacity 160ms ease; }
.selectedFrame .textDragHandle { opacity: 1; pointer-events: auto; }
.handMode .textDragHandle { visibility: hidden; }
@media (prefers-reduced-motion: reduce) { .textDragHandle { transition: none; } }
.frameNode { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; }
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; }
.frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; }
.frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_bottom { bottom: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_left { left: -5px; top: 0; bottom: 0; width: 10px; }
.frameBorder_right { right: -5px; top: 0; bottom: 0; width: 10px; }
.styleToolbar .frameNameInput { width: 150px; height: 28px; padding: 0 6px; border: 1px solid #ded7ce; border-radius: 4px; font-size: 12px; background: transparent; }
.handMode .frameBorderHit, .handMode .frameLabel { pointer-events: none; }
.syncError { position: absolute; left: 50%; top: 70px; translate: -50% 0; z-index: 22; display: flex; flex-wrap: wrap; gap: 8px; max-width: min(580px, calc(100vw - 24px)); padding: 12px; border: 1px solid #e5c7b8; border-radius: 8px; background: #fff9f3; color: #a25439; font-size: 12px; }
.syncError span { flex-basis: 100%; }
.recoveryDialog { max-width: min(460px, calc(100vw - 32px)); padding: 24px; margin: auto; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 12px 50px #32251a20; }
.recoveryDialog::backdrop { background: #30251e33; }
.recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; }
.recoveryDialog p { font-size: 13px; line-height: 1.8; color: #85766b; }
.recoveryDialog > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
.recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; }
.textEditor { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; border-radius: inherit; }
.textEditor textarea[readonly] { pointer-events: none; }
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
.handMode .textSelectLayer { pointer-events: none; }
.selectedFrame .textSelectLayer { cursor: grab; }
.textSelectLayer:active { cursor: grabbing; }
.plainImageFrame { padding: 0; }
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; }
.nodeTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.nodeTitleInput { width: 180px; min-width: 0; height: 22px; padding: 0 4px; border: 1px solid #cbb8a8; border-radius: 4px; background: #fffefb; font: inherit; color: #78523f; }
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import styles from "./canvas.module.css";
export function DraftRecovery({ restore, discard }: { restore: () => void; discard: () => void }) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []);
if (typeof document === "undefined") return null;
return createPortal(<dialog ref={dialog} className={styles.recoveryDialog} aria-labelledby="draft-recovery-title" onCancel={(event) => event.preventDefault()}>
<h2 id="draft-recovery-title">发现未同步的本地草稿</h2><p>服务端画布已读取。恢复草稿会以本地内容替换当前服务端版本,请选择要继续编辑的版本。</p>
<div><button type="button" onClick={restore}>恢复本地草稿</button><button type="button" onClick={discard}>使用服务端版本</button></div>
</dialog>, document.body);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import { NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { CanvasNode } from "@/lib/canvas";
import type { FrameLabelMode } from "@/lib/canvas-elements";
import { NodeEditing, TransformableNode } from "./transformable-node";
import styles from "./canvas.module.css";
export function FrameNode(props: NodeProps<CanvasNode>) {
const { id, data, selected } = props;
const editing = useContext(NodeEditing);
const { zoom } = useViewport();
const reduce = useReducedMotion();
const [title, setTitle] = useState(data.title);
useEffect(() => setTitle(data.title), [data.title]);
const cancelBlur = useRef(false);
const mode = data.labelMode || "fixed";
function saveTitle() {
const name = title.trim() || "未命名分区";
if (name !== data.title) editing.frame(id, { title: name });
setTitle(name);
}
return <>
<NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<AnimatePresence>{selected && <motion.div className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }}
onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<input className={styles.frameNameInput} aria-label="分区名称" value={title} maxLength={300} onChange={(event) => setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }}
onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.currentTarget.blur(); } if (event.key === "Escape") { cancelBlur.current = true; setTitle(data.title); event.currentTarget.blur(); } }} />
<select aria-label="分区名称显示模式" value={mode} onChange={(event) => editing.frame(id, { labelMode: event.target.value as FrameLabelMode })}>
<option value="hidden">隐藏名称</option><option value="fixed">固定字号</option><option value="scaled">随画布缩放</option>
</select>
</motion.div>}</AnimatePresence>
</NodeToolbar>
<TransformableNode {...props} hideStyleToolbar className={styles.frameNode}>
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / zoom : 1 }} title={data.title}>{data.title}</div>}
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
</TransformableNode>
</>;
}
+75 -40
View File
@@ -5,33 +5,41 @@ import {
Background, BackgroundVariant, ConnectionLineType, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
addEdge, useReactFlow, useViewport, type Connection, type OnConnectEnd, type XYPosition,
} from "@xyflow/react";
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Shapes, Type, Undo2, Upload } from "lucide-react";
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Shapes, Frame, Image as ImageIcon, Type, Undo2, Upload } from "lucide-react";
import { useReducedMotion } from "motion/react";
import toolbar from "@/components/ui/toolbar.module.css";
import { IconButton } from "@/components/ui/icon-button";
import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode, type NodeDirection } from "@/lib/canvas";
import { errorText } from "@/lib/api";
import { readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
import { useCanvasPersistence } from "./use-canvas-persistence";
import type { Board } from "@/lib/workspace";
import { CanvasEdge, CanvasConnectionLine } from "./canvas-edge";
import { BranchMenu, type BranchRequest } from "./branch-menu";
import { IconNode } from "./icon-node";
import { IconPicker } from "./icon-picker";
import type { TextStyle } from "@/lib/canvas-elements";
import { DraftRecovery } from "./draft-recovery";
import { FrameNode } from "./frame-node";
import type { FrameLabelMode, TextStyle } from "@/lib/canvas-elements";
import { PlainImageNode } from "./plain-image-node";
import { PlainTextNode } from "./plain-text-node";
import type { NodeTransform } from "@/lib/canvas-geometry";
import { MaterialNode, NodeEditing } from "./material-node";
import styles from "./canvas.module.css";
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode };
const edgeTypes = { default: CanvasEdge };
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
function CanvasEditor({ board }: { board: Board }) {
function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
const flow = useReactFlow<CanvasNode>();
const { zoom } = useViewport();
const reduceMotion = useReducedMotion();
const storageKey = canvasStorageKey(board.workspace_id, board.id);
const [initial] = useState(() => {
try {
const stored = localStorage.getItem(storageKey);
return { document: stored ? decodeCanvas(stored) : emptyCanvas(), error: "" };
} catch { return { document: emptyCanvas(), error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
const local = readLocalRecovery(localStorage, storageKey, snapshot);
return { document: snapshot?.document || local || emptyCanvas(), recovery: snapshot ? local : null, error: "" };
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
});
const [state, dispatch] = useReducer(canvasReducer, { past: [], present: initial.document, future: [] } satisfies CanvasHistory);
const [tool, setTool] = useState<"select" | "hand">("select");
@@ -39,12 +47,16 @@ function CanvasEditor({ board }: { board: Board }) {
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
const [iconPicker, setIconPicker] = useState(false);
const [rotating, setRotating] = useState<string | null>(null);
const dragOrigins = useRef(new Map<string, XYPosition>());
const draggingFrame = useRef(false);
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
const pendingImage = useRef<BranchRequest | null>(null);
const closeBranch = useCallback(() => setBranchRequest(null), []);
const imageKind = useRef<"image" | "plain-image">("image");
const imageInput = useRef<HTMLInputElement>(null);
const projectInput = useRef<HTMLInputElement>(null);
const firstSave = useRef(true);
const [recovery, setRecovery] = useState(initial.recovery);
const sync = useCanvasPersistence(state.present, board.workspace_id, board.id, storageKey, snapshot, Boolean(recovery));
useEffect(() => {
const input = imageInput.current;
const cancel = () => { pendingImage.current = null; };
@@ -54,28 +66,10 @@ function CanvasEditor({ board }: { board: Board }) {
const { nodes, edges } = state.present;
const duration = reduceMotion ? 0 : 180;
useEffect(() => {
if (firstSave.current) { firstSave.current = false; return; }
const timeout = setTimeout(() => {
try {
localStorage.setItem(storageKey, JSON.stringify(decodeCanvas(JSON.stringify(state.present))));
} catch { setError("浏览器存储空间不足或暂不可用,请导出图谱备份。当前内容仍保留在画布中。"); }
}, 400);
return () => clearTimeout(timeout);
}, [state.present, storageKey]);
// Flush the latest view and nodes when navigating away before the debounce runs.
const latest = useRef(state.present);
latest.current = state.present;
useEffect(() => {
const flush = () => {
try { localStorage.setItem(storageKey, JSON.stringify(decodeCanvas(JSON.stringify(latest.current)))); }
catch { /* The editor already reports storage failures while it is open. */ }
};
window.addEventListener("pagehide", flush);
return () => { window.removeEventListener("pagehide", flush); flush(); };
}, [storageKey]);
const select = useCallback((id: string) => dispatch({ type: "nodes", changes: latest.current.nodes.map((node) => ({ id: node.id, type: "select" as const, selected: node.id === id })) }), []);
const beginEdit = useCallback(() => dispatch({ type: "checkpoint" }), []);
const edit = useCallback((id: string, text: string) => dispatch({ type: "edit", id, text }), []);
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => {
@@ -84,7 +78,9 @@ function CanvasEditor({ board }: { board: Board }) {
}, []);
const transform = useCallback((id: string, transform: NodeTransform) => dispatch({ type: "transform", id, transform }), []);
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => dispatch({ type: "style", id, style, fontSize }), []);
const editing = useMemo(() => ({ begin: beginEdit, edit, branch, transform, style, rotating, rotate: setRotating }), [beginEdit, edit, branch, transform, style, rotating]);
const rename = useCallback((id: string, title: string) => dispatch({ type: "rename-node", id, title }), []);
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode }) => dispatch({ type: "frame", id, ...patch }), []);
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, rotating, rotate: setRotating }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating]);
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
try {
const document = branchCanvasNode(state.present, request.id, request.direction, kind, src, request.at);
@@ -96,7 +92,7 @@ function CanvasEditor({ board }: { board: Board }) {
}
function chooseBranch(kind: "text" | "image" | "script") {
if (!branchRequest) return;
if (kind === "image") { pendingImage.current = branchRequest; setBranchRequest(null); imageInput.current?.click(); }
if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); }
else createBranch(branchRequest, kind);
}
const connectionEnd: OnConnectEnd = (event, connection) => {
@@ -116,9 +112,12 @@ function CanvasEditor({ board }: { board: Board }) {
const offset = (nodes.length % 6) * 18;
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
};
function addNode(kind: CanvasNode["data"]["kind"], src?: string) {
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
dispatch({ type: "nodes", changes: [{ type: "add", item: makeCanvasNode(kind, center(), src) }] });
const node = makeCanvasNode(kind, center(), src);
if (size) { node.width = size.width; node.height = size.height; node.style = { ...node.style, ...size }; }
if (title) node.data.title = title.slice(0, 300);
dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
setTool("select");
}
function addTemplate() {
@@ -136,6 +135,20 @@ function CanvasEditor({ board }: { board: Board }) {
] } });
setTool("select");
}
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
beginEdit(); closeBranch(); setRotating(null);
draggingFrame.current = node.data.kind === "frame" || dragged.some((item) => item.data.kind === "frame");
const before = new Map(latest.current.nodes.map((item) => [item.id, item.position]));
dragOrigins.current = new Map((dragged.length ? dragged : [node]).map((item) => [item.id, before.get(item.id) || item.position]));
}
function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (!draggingFrame.current) {
const items = dragged.length ? dragged : [node];
const ids = items.filter((item) => { const start = dragOrigins.current.get(item.id); return item.data.kind !== "frame" && start && (start.x !== item.position.x || start.y !== item.position.y); }).map((item) => item.id);
if (ids.length) dispatch({ type: "drop", ids });
}
dragOrigins.current.clear();
}
function connect(connection: Connection) {
dispatch({ type: "replace", document: { ...state.present, edges: addEdge({ ...connection, id: crypto.randomUUID() }, edges) } });
}
@@ -146,6 +159,8 @@ function CanvasEditor({ board }: { board: Board }) {
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > 2_000_000) {
pendingImage.current = null; setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return;
}
const request = pendingImage.current; pendingImage.current = null;
const kind = imageKind.current;
try {
const src = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -153,8 +168,16 @@ function CanvasEditor({ board }: { board: Board }) {
reader.onerror = () => reject(new Error("无法读取图片。"));
reader.readAsDataURL(file);
});
const request = pendingImage.current; pendingImage.current = null;
if (request) createBranch(request, "image", src); else addNode("image", src);
let size: { width: number; height: number } | undefined;
if (kind === "plain-image" && !request) {
size = await new Promise((resolve, reject) => {
const image = new window.Image();
image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); };
image.onerror = () => reject(new Error("无法读取图片尺寸。"));
image.src = src;
});
}
if (request) createBranch(request, "image", src); else addNode(kind, src, size, kind === "plain-image" ? file.name : undefined);
} catch { pendingImage.current = null; setError("无法读取图片,请重新选择。"); }
}
async function importProject(event: ChangeEvent<HTMLInputElement>) {
@@ -200,11 +223,14 @@ function CanvasEditor({ board }: { board: Board }) {
return (
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
<NodeEditing.Provider value={editing}>
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes}
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
onNodesChange={(changes) => dispatch({ type: "nodes", changes })}
onEdgesChange={(changes) => dispatch({ type: "edges", changes })}
connectionLineType={ConnectionLineType.Bezier}
onNodeDragStart={() => { beginEdit(); closeBranch(); setRotating(null); }} onConnect={connect} connectionMode={ConnectionMode.Loose}
connectionLineType={ConnectionLineType.Bezier} connectionLineComponent={CanvasConnectionLine}
onNodeDragStart={(_, node, dragged) => beginDrag(node, dragged)}
onNodeDragStop={(_, node, dragged) => finishDrag(node, dragged)}
onSelectionDragStart={(_, dragged) => { if (dragged[0]) beginDrag(dragged[0], dragged); }}
onSelectionDragStop={(_, dragged) => { if (dragged[0]) finishDrag(dragged[0], dragged); }} onConnect={connect} connectionMode={ConnectionMode.Loose}
connectOnClick={false} connectionDragThreshold={5}
onConnectStart={(event, params) => { closeBranch(); const point = "touches" in event ? event.touches[0] : event; if (params.nodeId && params.handleId && point) connectionStart.current = { id: params.nodeId, direction: params.handleId as NodeDirection, point: { x: point.clientX, y: point.clientY } }; }}
onConnectEnd={connectionEnd}
@@ -214,6 +240,7 @@ function CanvasEditor({ board }: { board: Board }) {
defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4}
panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]}
nodesDraggable={tool === "select"} nodesConnectable={tool === "select"}
elevateNodesOnSelect={false}
elementsSelectable={tool === "select"} selectionMode={SelectionMode.Partial}
panActivationKeyCode="Space" selectionKeyCode="Shift"
deleteKeyCode={["Backspace", "Delete"]} preventScrolling
@@ -240,8 +267,10 @@ function CanvasEditor({ board }: { board: Board }) {
<span className={toolbar.divider} aria-hidden="true" />
<IconButton icon={Type} label="添加纯文本" onClick={() => addNode("plain-text")} />
<IconButton icon={StickyNote} label="添加文本卡片" onClick={() => addNode("text")} />
<IconButton icon={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageInput.current?.click(); }} />
<IconButton icon={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageKind.current = "image"; imageInput.current?.click(); }} />
<IconButton icon={ImageIcon} label="添加纯图片" onClick={() => { pendingImage.current = null; imageKind.current = "plain-image"; imageInput.current?.click(); }} />
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
<IconButton icon={Frame} label="添加框选区" onClick={() => addNode("frame")} />
<IconButton icon={Shapes} label="添加图标" aria-expanded={iconPicker} onClick={() => setIconPicker(!iconPicker)} />
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
<span className={toolbar.divider} aria-hidden="true" />
@@ -257,7 +286,8 @@ function CanvasEditor({ board }: { board: Board }) {
<p>V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选</p>
<p>拖动右下角调整大小;在样式栏开启旋转,拖动元素旋转,点击外部退出。Shift 吸附 15°。</p>
<p>纯文本可独立调整宽高,文字随宽度换行,字号在样式栏设置;不显示卡片连接点或四向加号。</p>
<p>拖动四边中心点或选中后的加号创建贝塞尔连线;点击加号选择新节点类型。Delete 删除所选内容。</p>
<p>拖动四边中心点创建贝塞尔连线;选中后点击连接点选择新节点类型。Delete 删除所选内容。</p>
<p>添加框选区后拖入内容加入;拖出退出。移动分区只带动已有成员,不收纳被覆盖的内容。选中分区可改名并选择名称显示模式。</p>
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
<p>可通过右上角导出 JSON 备份。</p>
</div>
@@ -269,6 +299,11 @@ function CanvasEditor({ board }: { board: Board }) {
dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
setTool("select");
}} />
{recovery && <DraftRecovery restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
{snapshot && sync.status === "error" && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
{sync.error !== "version_conflict" && <button onClick={() => void sync.retry()}>重试保存</button>}
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
</div>}
{error && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
@@ -276,6 +311,6 @@ function CanvasEditor({ board }: { board: Board }) {
);
}
export default function GraphCanvas({ board }: { board: Board }) {
return <ReactFlowProvider><CanvasEditor board={board} /></ReactFlowProvider>;
export default function GraphCanvas({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} /></ReactFlowProvider>;
}
+65 -19
View File
@@ -1,19 +1,65 @@
import { ArrowRight, BookOpen, Briefcase, Calendar, Camera, Check, Circle, Cloud, Code, Coffee, Compass, Diamond, FileText, Flag, Flame, Folder, Globe, Heart, Image, Layers, Leaf, Lightbulb, Link, MapPin, MessageCircle, Moon, Music, Palette, Play, Rocket, Search, Settings, Shield, Smile, Sparkles, Square, Star, Sun, Target, ThumbsUp, Triangle, Users, Video, WandSparkles, Zap } from "lucide-react";
export const iconCatalog = [
{ name: "Star", label: "星星", Icon: Star }, { name: "Heart", label: "爱心", Icon: Heart }, { name: "Lightbulb", label: "灵感", Icon: Lightbulb },
{ name: "Sparkles", label: "闪光", Icon: Sparkles }, { name: "Zap", label: "闪电", Icon: Zap }, { name: "Flame", label: "火焰", Icon: Flame },
{ name: "Rocket", label: "火箭", Icon: Rocket }, { name: "Target", label: "目标", Icon: Target }, { name: "Flag", label: "旗帜", Icon: Flag },
{ name: "Check", label: "完成", Icon: Check }, { name: "ThumbsUp", label: "点赞", Icon: ThumbsUp }, { name: "Smile", label: "笑脸", Icon: Smile },
{ name: "Coffee", label: "咖啡", Icon: Coffee }, { name: "Leaf", label: "叶子", Icon: Leaf }, { name: "Sun", label: "太阳", Icon: Sun },
{ name: "Moon", label: "月亮", Icon: Moon }, { name: "Cloud", label: "云", Icon: Cloud }, { name: "Globe", label: "地球", Icon: Globe },
{ name: "MapPin", label: "位置", Icon: MapPin }, { name: "Compass", label: "指南针", Icon: Compass }, { name: "ArrowRight", label: "箭头", Icon: ArrowRight },
{ name: "BookOpen", label: "书本", Icon: BookOpen }, { name: "FileText", label: "文件", Icon: FileText }, { name: "Folder", label: "文件夹", Icon: Folder },
{ name: "Calendar", label: "日历", Icon: Calendar }, { name: "Briefcase", label: "公文包", Icon: Briefcase }, { name: "Users", label: "用户", Icon: Users },
{ name: "MessageCircle", label: "对话", Icon: MessageCircle }, { name: "Link", label: "链接", Icon: Link }, { name: "Code", label: "代码", Icon: Code },
{ name: "Settings", label: "设置", Icon: Settings }, { name: "Shield", label: "盾牌", Icon: Shield }, { name: "Search", label: "搜索", Icon: Search },
{ name: "Image", label: "图片", Icon: Image }, { name: "Camera", label: "相机", Icon: Camera }, { name: "Video", label: "视频", Icon: Video },
{ name: "Music", label: "音乐", Icon: Music }, { name: "Play", label: "播放", Icon: Play }, { name: "Palette", label: "调色板", Icon: Palette },
{ name: "WandSparkles", label: "魔法", Icon: WandSparkles }, { name: "Layers", label: "图层", Icon: Layers }, { name: "Circle", label: "圆形", Icon: Circle },
{ name: "Square", label: "方形", Icon: Square }, { name: "Triangle", label: "三角形", Icon: Triangle }, { name: "Diamond", label: "菱形", Icon: Diamond },
];
export function canvasIcon(name?: string) { return iconCatalog.find((icon) => icon.name === name)?.Icon || Star; }
import dynamicIconImports from "lucide-react/dynamicIconImports";
import type { IconName } from "lucide-react/dynamic";
const chinese: Record<string, string> = {
"Star": "星星",
"Heart": "爱心",
"Lightbulb": "灵感",
"Sparkles": "闪光",
"Zap": "闪电",
"Flame": "火焰",
"Rocket": "火箭",
"Target": "目标",
"Flag": "旗帜",
"Check": "完成",
"ThumbsUp": "点赞",
"Smile": "笑脸",
"Coffee": "咖啡",
"Leaf": "叶子",
"Sun": "太阳",
"Moon": "月亮",
"Cloud": "云",
"Globe": "地球",
"MapPin": "位置",
"Compass": "指南针",
"ArrowRight": "箭头",
"BookOpen": "书本",
"FileText": "文件",
"Folder": "文件夹",
"Calendar": "日历",
"Briefcase": "公文包",
"Users": "用户",
"MessageCircle": "对话",
"Link": "链接",
"Code": "代码",
"Settings": "设置",
"Shield": "盾牌",
"Search": "搜索",
"Image": "图片",
"Camera": "相机",
"Video": "视频",
"Music": "音乐",
"Play": "播放",
"Palette": "调色板",
"WandSparkles": "魔法",
"Layers": "图层",
"Circle": "圆形",
"Square": "方形",
"Triangle": "三角形",
"Diamond": "菱形"
};
const keywords: Record<string, string> = { arrow: "箭头 方向", user: "用户 人物", file: "文件 文档", folder: "文件夹 目录", heart: "爱心 喜欢", calendar: "日历 日期", clock: "时间 时钟", image: "图片 图像", camera: "相机 摄影", video: "视频 电影", music: "音乐 音频", cloud: "云 天气", sun: "太阳 天气", moon: "月亮 夜晚", circle: "圆 圆形", square: "方形 正方形", triangle: "三角形", building: "建筑 公司", book: "书 阅读", code: "代码 编程", chart: "图表 数据", mail: "邮件 信封", message: "消息 对话", map: "地图 位置", search: "搜索 查找", settings: "设置", lock: "锁 安全", key: "钥匙 密钥", star: "星星 收藏", check: "确认 完成", chevron: "箭头 展开", laptop: "电脑 笔记本", monitor: "屏幕 显示器", smartphone: "手机", shopping: "购物 商店", dollar: "货币 金钱", euro: "货币 欧元", zodiac: "星座", tree: "树 植物", flower: "花 植物", leaf: "叶子 植物", dog: "狗 动物", cat: "猫 动物", fish: "鱼 动物", coffee: "咖啡 饮料", food: "食物", smile: "表情 笑脸", angry: "表情 生气", frown: "表情 难过", paint: "绘画 颜色", palette: "调色板 颜色", text: "文本 文字", type: "字体 文字", grip: "拖动", move: "移动", zoom: "缩放", eye: "眼睛 显示", accessibility: "无障碍", audio: "音频 声音", battery: "电池", network: "网络 连接", wifi: "无线 网络", globe: "地球 全球", bell: "铃铛 通知", badge: "徽章", shield: "盾牌 安全", pencil: "铅笔 编辑", pen: "笔 写作", trash: "垃圾 删除", undo: "撤销", redo: "重做", rocket: "火箭", sparkles: "闪光 灵感", lightbulb: "灯泡 灵感" };
function identifier(slug: string) {
const pascal = slug.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join("");
return /^[A-Z]/.test(pascal) ? pascal : "Icon" + pascal;
}
const entries = (Object.keys(dynamicIconImports) as IconName[]).map((slug) => {
const name = identifier(slug);
return { name, slug, label: chinese[name] || slug.replaceAll("-", " "), keywords: slug.split("-").map((word) => keywords[word] || "").join(" ") };
});
export const iconCatalog = Array.from(new Map(entries.map((icon) => [icon.name, icon])).values());
export const iconsById = new Map(iconCatalog.map((icon) => [icon.name, icon]));
export function searchIcons(query: string) {
const needle = query.trim().toLowerCase().replace(/[-_\s]/g, "");
return iconCatalog.filter((icon) => (icon.name + icon.label + icon.keywords).toLowerCase().replace(/[-_\s]/g, "").includes(needle));
}
+2 -3
View File
@@ -2,9 +2,8 @@
import type { NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import { canvasIcon } from "./icon-catalog";
import { CanvasIcon } from "./canvas-icon";
import styles from "./canvas.module.css";
export function IconNode(props: NodeProps<CanvasNode>) {
const Icon = canvasIcon(props.data.icon);
return <TransformableNode {...props}><div className={styles.iconContent} style={{ color: props.data.textStyle?.color || "#78523f", background: props.data.textStyle?.background || "transparent" }}><Icon aria-label={props.data.title} width="100%" height="100%" strokeWidth={1.5} /></div></TransformableNode>;
return <TransformableNode {...props}><div className={styles.iconContent} style={{ color: props.data.textStyle?.color || "#78523f", background: props.data.textStyle?.background || "transparent" }}><CanvasIcon name={props.data.icon} aria-label={props.data.title} width="100%" height="100%" strokeWidth={1.5} /></div></TransformableNode>;
}
+12 -7
View File
@@ -1,21 +1,26 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { iconCatalog } from "./icon-catalog";
import { CanvasIcon } from "./canvas-icon";
import { iconCatalog, searchIcons } from "./icon-catalog";
import styles from "./canvas.module.css";
export function IconPicker({ open, close, choose }: { open: boolean; close: () => void; choose: (name: string, label: string) => void }) {
const [query, setQuery] = useState("");
const [limit, setLimit] = useState(80);
const matches = useMemo(() => searchIcons(query), [query]);
const search = useRef<HTMLInputElement>(null);
const reduce = useReducedMotion();
useEffect(() => { if (open) search.current?.focus(); }, [open]);
useEffect(() => { if (open) { search.current?.focus(); setLimit(80); } }, [open]);
if (typeof document === "undefined") return null;
return createPortal(<AnimatePresence>{open && <motion.aside key="icons" initial={{ opacity: 0, x: reduce ? 0 : -12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: reduce ? 0 : -12 }} transition={{ duration: reduce ? 0 : 0.16 }} className={styles.iconPicker} role="dialog" aria-label="添加图标" onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") close(); }}>
<header><strong>图标</strong><button aria-label="关闭图标面板" onClick={close}><X size={16} /></button></header>
<input ref={search} type="search" aria-label="搜索图标" placeholder="搜索图标…" value={query} onChange={(event) => setQuery(event.target.value)} />
<div className={styles.iconGrid}>{iconCatalog.filter((icon) => (icon.name + icon.label).toLowerCase().includes(query.toLowerCase())).map(({ name, label, Icon }) => <button key={name} title={label} aria-label={`添加${label}图标`} onClick={() => choose(name, label)}><Icon size={24} /></button>)}</div>
{!iconCatalog.some((icon) => (icon.name + icon.label).toLowerCase().includes(query.toLowerCase())) && <p>未找到图标</p>}
<footer>Lucide · {iconCatalog.length} 个图标</footer>
<input ref={search} type="search" aria-label="搜索图标" placeholder="搜索图标…" value={query} onChange={(event) => { setQuery(event.target.value); setLimit(80); }} />
<div className={styles.iconGrid}>{matches.slice(0, limit).map(({ name, label }) => <button key={name} title={label} aria-label={`添加${label}图标`} onClick={() => choose(name, label)}><CanvasIcon name={name} size={24} /></button>)}</div>
{!matches.length && <p>未找到图标</p>}
<footer>Lucide · {iconCatalog.length} 个图标 · 已显示 {Math.min(limit, matches.length)} / {matches.length}
{limit < matches.length && <button type="button" onClick={() => setLimit(limit + 80)}>加载更多</button>}
</footer>
</motion.aside>}</AnimatePresence>, document.body);
}
+7 -8
View File
@@ -2,10 +2,11 @@
import { useContext, useRef } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { FileText, Image as ImageIcon, Plus, Type } from "lucide-react";
import { FileText, Image as ImageIcon, Type } from "lucide-react";
import { elementCapabilities } from "@/lib/canvas-elements";
import { nodeDirections, type CanvasNode } from "@/lib/canvas";
import { textAppearance } from "./text-appearance";
import { NodeTitle } from "./node-title";
import { CanvasTextEditor } from "./canvas-text-editor";
import styles from "./canvas.module.css";
import { TransformableNode, NodeEditing } from "./transformable-node";
export { NodeEditing } from "./transformable-node";
@@ -20,16 +21,15 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
return (
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><span>{data.title}</span></header>
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
{data.kind === "image" ? <img className={styles.image} src={data.src} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
<textarea style={textAppearance(data)} className={`${styles.cardText} nodrag nowheel`} aria-label={data.title} placeholder={data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
value={data.text} maxLength={100000} onFocus={editing.begin} onChange={(event) => editing.edit(id, event.target.value)} />
<CanvasTextEditor id={id} data={data} selected={selected} />
)}
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
{nodeDirections.map((direction) => <Handle key={direction} id={direction} type="source" position={positions[direction]}
className={`${styles.centerHandle} ${selected ? styles.plusHandle : ""}`}
aria-label={selected ? `在${labels[direction]}添加或连接节点` : `从${labels[direction]}连接节点`} role="button" tabIndex={selected ? 0 : -1}
className={styles.centerHandle} isConnectableStart={selected}
aria-hidden={!selected} aria-label={`从${labels[direction]}连接节点`} role="button" tabIndex={selected ? 0 : -1}
onPointerDown={(event) => { pointerStart.current = { x: event.clientX, y: event.clientY }; }}
onClick={(event) => {
event.stopPropagation();
@@ -37,7 +37,6 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
editing.branch(id, direction, { x: event.clientX, y: event.clientY });
}}
onKeyDown={(event) => { if (selected && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); editing.branch(id, direction, { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); } }}>
<Plus size={16} strokeWidth={1.6} aria-hidden="true" />
</Handle>)}
</TransformableNode>
);
+4 -4
View File
@@ -15,10 +15,10 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
const style = data.textStyle || {};
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
return <NodeToolbar isVisible={true} position={Position.Top} offset={34}>
return <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<AnimatePresence>{selected && <motion.div initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}>
{data.kind !== "image" && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
{!(["image", "plain-image"].includes(data.kind)) && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
{text && <>
<select aria-label="字体" value={style.fontFamily || "sans"} onChange={(event) => patch({ fontFamily: event.target.value as TextStyle["fontFamily"] })}><option value="sans">正文</option><option value="serif">衬线</option><option value="mono">等宽</option></select>
<input aria-label="字号" title="字号" type="number" min={8} max={160} value={data.fontSize || (data.kind === "plain-text" ? 24 : 13)} onChange={(event) => { const size = Number(event.target.value); if (size >= 8 && size <= 160) patch({}, size); }} />
@@ -26,8 +26,8 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
{([["left", AlignLeft, "左对齐"], ["center", AlignCenter, "居中"], ["right", AlignRight, "右对齐"]] as const).map(([align, Icon, label]) => <button key={align} aria-label={label} aria-pressed={(style.align || "left") === align} title={label} onClick={() => patch({ align })}><Icon size={16} /></button>)}
<select aria-label="行距" title="行距" value={style.lineHeight || 1.5} onChange={(event) => patch({ lineHeight: Number(event.target.value) })}><option value={1}>1</option><option value={1.25}>1.25</option><option value={1.5}>1.5</option><option value={1.8}>1.8</option><option value={2}>2</option></select>
</>}
{data.kind !== "image" && <label className={styles.colorPicker} title="背景色"><span style={{ background: style.background && style.background !== "transparent" ? style.background : "#ffffff" }} /><input aria-label="背景色" type="color" value={style.background && style.background !== "transparent" ? style.background : "#ffffff"} onChange={(event) => patch({ background: event.target.value })} /></label>}
{data.kind !== "image" && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
{!(["image", "plain-image"].includes(data.kind)) && <label className={styles.colorPicker} title="背景色"><span style={{ background: style.background && style.background !== "transparent" ? style.background : "#ffffff" }} /><input aria-label="背景色" type="color" value={style.background && style.background !== "transparent" ? style.background : "#ffffff"} onChange={(event) => patch({ background: event.target.value })} /></label>}
{!(["image", "plain-image"].includes(data.kind)) && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
<button data-rotate-toggle={id} title="旋转模式" aria-label="旋转模式" aria-pressed={editing.rotating === id} onClick={() => editing.rotate(editing.rotating === id ? null : id)}><RotateCw size={16} /></button>
</motion.div>}</AnimatePresence>
</NodeToolbar>;
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import { NodeEditing } from "./transformable-node";
import styles from "./canvas.module.css";
export function NodeTitle({ id, title, selected }: { id: string; title: string; selected: boolean }) {
const actions = useContext(NodeEditing);
const [editing, setEditing] = useState(false), [value, setValue] = useState(title);
const input = useRef<HTMLInputElement>(null), cancelled = useRef(false);
useEffect(() => { if (!editing) setValue(title); }, [title, editing]);
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]);
function save() {
if (!cancelled.current) { const name = value.trim(); if (name && name !== title) actions.rename(id, name); }
cancelled.current = false; setEditing(false);
}
if (editing) return <input ref={input} className={`${styles.nodeTitleInput} nodrag nopan nowheel`} value={value} maxLength={300} aria-label="节点名称" onChange={(event) => setValue(event.target.value)} onBlur={save}
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" && !event.nativeEvent.isComposing) event.currentTarget.blur(); if (event.key === "Escape") { cancelled.current = true; event.currentTarget.blur(); } }} />;
return <span className={styles.nodeTitle} title="双击编辑名称" role="button" tabIndex={selected ? 0 : -1} onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); actions.select(id); setEditing(true); }}
onKeyDown={(event) => { if (event.key === "F2" || event.key === "Enter") { event.preventDefault(); event.stopPropagation(); setEditing(true); } }}>{title}</span>;
}
@@ -0,0 +1,8 @@
"use client";
import type { NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import styles from "./canvas.module.css";
export function PlainImageNode(props: NodeProps<CanvasNode>) {
return <TransformableNode {...props} className={styles.plainImageFrame}><img className={styles.plainImage} src={props.data.src} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
}
+3 -10
View File
@@ -1,21 +1,14 @@
"use client";
import { GripVertical } from "lucide-react";
import { useContext } from "react";
import type { NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { NodeEditing, TransformableNode } from "./transformable-node";
import { textAppearance } from "./text-appearance";
import styles from "./canvas.module.css";
import { TransformableNode } from "./transformable-node";
import { CanvasTextEditor } from "./canvas-text-editor";
export function PlainTextNode(props: NodeProps<CanvasNode>) {
const editing = useContext(NodeEditing);
return (
<TransformableNode {...props}>
<span className={styles.textDragHandle} title="拖动移动纯文本" aria-label="拖动移动纯文本"><GripVertical size={14} /></span>
<textarea className={`${styles.plainText} nodrag nowheel`} aria-label="纯文本" placeholder="写下文字…"
style={textAppearance(props.data)} value={props.data.text} maxLength={100000}
onFocus={editing.begin} onChange={(event) => editing.edit(props.id, event.target.value)} />
<CanvasTextEditor id={props.id} data={props.data} selected={props.selected} plain />
</TransformableNode>
);
}
+10 -7
View File
@@ -10,15 +10,17 @@ import { NodeStyleToolbar } from "./node-style-toolbar";
import styles from "./canvas.module.css";
export const NodeEditing = createContext({
begin: () => {}, edit: (_id: string, _text: string) => {},
begin: () => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
transform: (_id: string, _transform: NodeTransform) => {},
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
rename: (_id: string, _title: string) => {},
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode }) => {},
rotating: null as string | null, rotate: (_id: string | null) => {},
});
type Gesture = { node: CanvasNode; start: XYPosition; direction: "bottom-right" | "rotate" };
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string }) {
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
const { id, data, selected, width, height, children, className = "" } = props;
const editing = useContext(NodeEditing);
const flow = useReactFlow<CanvasNode>();
@@ -26,7 +28,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const frame = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
const rotation = data.rotation || 0;
const plain = data.kind === "plain-text" || data.kind === "icon";
const plain = data.kind === "plain-text" || data.kind === "icon" || data.kind === "plain-image";
const capabilities = elementCapabilities(data.kind);
const rotating = capabilities.rotatable && editing.rotating === id && selected;
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
@@ -47,6 +49,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const node = flow.getNode(id);
if (!node) return;
editing.begin();
if (!node.selected) editing.select(id);
gesture.current = { node, direction, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
event.currentTarget.setPointerCapture(event.pointerId);
}
@@ -64,15 +67,15 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}
return <>
<NodeStyleToolbar {...props} />
{!props.hideStyleToolbar && <NodeStyleToolbar {...props} />}
<div ref={frame} className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${rotating ? styles.rotatingFrame + " nodrag nopan" : ""} ${className}`}
onPointerDownCapture={rotating ? (event) => start(event, "rotate") : undefined}
onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}
onLostPointerCapture={() => { gesture.current = null; }}
style={{ width: width || (data.kind === "icon" ? 80 : plain ? 240 : 260), height: height || (data.kind === "icon" ? 80 : plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
style={{ width: width || (data.kind === "frame" ? 640 : data.kind === "plain-image" ? 320 : data.kind === "icon" ? 80 : plain ? 240 : 260), height: height || (data.kind === "frame" ? 400 : data.kind === "plain-image" ? 240 : data.kind === "icon" ? 80 : plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
{children}
{capabilities.resizable && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={selected && !rotating ? 0 : -1}
aria-hidden={!selected || rotating} aria-label="拖动右下角调整大小" title="拖动调整大小"
{capabilities.resizable && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={rotating ? -1 : 0}
aria-hidden={rotating} aria-label="拖动右下角调整大小" title="拖动调整大小"
onPointerDown={(event) => start(event, "bottom-right")} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}>
<Grip size={14} />
</button>}
@@ -0,0 +1,40 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { canvasFingerprint, canvasPath, CanvasSync, type CanvasSnapshot, type SyncState } from "@/lib/canvas-sync";
import { decodeCanvas, type CanvasDocument } from "@/lib/canvas";
export function useCanvasPersistence(document: CanvasDocument, workspace: string, board: string, key: string, snapshot: CanvasSnapshot | undefined, paused: boolean) {
const [sync, setSync] = useState<SyncState>({ status: "saved", version: snapshot?.version || 1 });
const alive = useRef(false);
const latest = useRef(document); latest.current = document;
const pausedRef = useRef(paused); pausedRef.current = paused;
const [writer] = useState(() => snapshot ? new CanvasSync(api, canvasPath(workspace, board), snapshot, (next) => {
if (alive.current) { setSync(next); backup(); }
}) : null);
function backup() {
if (pausedRef.current) return;
try {
const current = latest.current;
localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(current) !== writer.saved : false }));
} catch { /* Storage failure is surfaced by the effect below. */ }
}
useEffect(() => {
if (paused) return;
const local = setTimeout(() => {
try { localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(latest.current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(latest.current) !== writer.saved : false })); }
catch { setSync({ status: "error", version: writer?.version || 1, error: "local_backup_failed" }); }
}, 250);
const remote = writer ? setTimeout(() => void writer.enqueue(latest.current), 800) : null;
return () => { clearTimeout(local); if (remote) clearTimeout(remote); };
}, [document, writer, key, paused]);
useEffect(() => {
alive.current = true;
const flush = () => backup();
window.addEventListener("pagehide", flush);
return () => { window.removeEventListener("pagehide", flush); backup(); alive.current = false; };
// Writer and key are stable for this board's mounted editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writer, key]);
return { ...sync, retry: () => { backup(); return writer?.retry(); } };
}
+1 -1
View File
@@ -3,7 +3,7 @@
import { createContext, useContext, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import type { Board } from "@/lib/workspace";
type ActiveBoard = Pick<Board, "id" | "workspace_id" | "title"> | null;
type ActiveBoard = (Pick<Board, "id" | "workspace_id" | "title" | "slug"> & { workspace_ref?: string }) | null;
const ActiveBoardContext = createContext<{
board: ActiveBoard;
setBoard: Dispatch<SetStateAction<ActiveBoard>>;
+24 -42
View File
@@ -4,64 +4,46 @@ import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
import type { Board } from "@/lib/workspace";
import { matchesRef, type Board } from "@/lib/workspace";
import { loadBoardCanvas } from "@/lib/canvas-loader";
import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import GraphCanvas from "@/components/canvas/graph-canvas";
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const demo = useSearchParams().get("demo") === "1";
const router = useRouter();
const route = workspaceId + ":" + boardId;
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const [board, setBoard] = useState<Board | null>(null);
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
useEffect(() => {
setBoard(null);
setError("");
setLoaded(null); setError("");
if (demo) {
try {
const list = readDemoBoards(workspaceId);
const item = list.find((b) => b.id === boardId);
if (item) setBoard(item);
else setError("图谱不存在。");
} catch { setError("无法读取本地演示图谱。"); }
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
else setError("图谱不存在。");
return;
}
let active = true;
const controller = new AbortController();
api<Board>(`workspaces/${encodeURIComponent(workspaceId)}/materials/${encodeURIComponent(boardId)}`, { signal: controller.signal })
.then((item) => {
if (!active) return;
if (item.kind !== "board" || item.workspace_id !== workspaceId) {
setError("当前页面不是此工作空间的图谱。");
return;
}
setBoard(item);
})
.catch((e) => {
if (!active) return;
setError(errorText(e));
if (e.message === "invalid_session") router.replace("/login");
});
loadBoardCanvas(api, workspaceId, boardId, controller.signal)
.then((result) => { if (active) setLoaded({ ...result, route }); })
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
return () => { active = false; controller.abort(); };
}, [workspaceId, boardId, demo, router]);
}, [workspaceId, boardId, route, demo, retry, router]);
const current = loaded?.route === route ? loaded : null;
const board = current?.board;
useEffect(() => {
if (board?.id === boardId && board.workspace_id === workspaceId) {
setActiveBoard({ id: board.id, workspace_id: board.workspace_id, title: board.title });
}
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
return () => setActiveBoard(null);
}, [board, boardId, workspaceId, setActiveBoard]);
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === workspaceId
? { ...board, title: activeBoard.title } : board;
return (
<main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
{displayBoard ? <GraphCanvas key={workspaceId + ":" + boardId} board={displayBoard} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <p className={styles.graphStatus} role="alert">{error}</p>
: !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
</main>
);
}, [board, workspaceId, setActiveBoard]);
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
{displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
</main>;
}
+4
View File
@@ -49,6 +49,10 @@ export const errorText = (error: unknown) => {
version_conflict: "图谱已在别处修改,请再次保存以读取最新版本。",
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
invalid_canvas_snapshot: "服务端画布数据格式无效,请重新读取或联系维护者。",
canvas_too_large: "画布超过 5 MiB,请减少图片或拆分画布后重试。",
body_too_large: "画布内容过大,请减少图片或拆分画布。",
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
invalid_reference: "工作空间不存在或当前账户无权访问,请刷新空间列表。",
upstream_timeout: "连接服务超时,请稍后再试。",
+23 -3
View File
@@ -1,6 +1,7 @@
import type { Node, XYPosition } from "@xyflow/react";
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon";
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
export type CanvasNodeData = {
kind: ElementKind;
@@ -11,8 +12,10 @@ export type CanvasNodeData = {
fontSize?: number;
textStyle?: TextStyle;
icon?: string;
frameId?: string;
labelMode?: FrameLabelMode;
};
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon">;
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon" | "frame" | "plainImage">;
export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean };
/** Business inheritance only: React views reuse these capabilities by composition. */
@@ -57,7 +60,24 @@ export class IconElement extends CanvasElement {
override toNode(): CanvasNode { const node = super.toNode(); return { ...node, data: { ...node.data, icon: "Star" } }; }
}
export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement, icon: IconElement };
export class FrameElement extends CanvasElement {
static override readonly capabilities: ElementCapabilities = { ...CanvasElement.capabilities, rotatable: false };
readonly kind = "frame"; readonly title = "未命名分区";
protected override readonly nodeType = "frame" as const;
protected override readonly size = { width: 640, height: 400 };
override toNode(): CanvasNode {
const node = super.toNode();
return { ...node, zIndex: -1, style: { ...node.style, pointerEvents: "none" }, data: { ...node.data, labelMode: "fixed" } };
}
}
export class PlainImageElement extends CanvasElement {
readonly kind = "plain-image"; readonly title = "图片";
protected override readonly nodeType = "plainImage" as const;
protected override readonly size = { width: 320, height: 240 };
}
export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement, icon: IconElement, frame: FrameElement, "plain-image": PlainImageElement };
export function elementCapabilities(kind: ElementKind) { return elementClasses[kind].capabilities; }
export function createCanvasElement(kind: ElementKind, position: XYPosition, src?: string): CanvasNode {
return new elementClasses[kind](position, "", src).toNode();
+41
View File
@@ -0,0 +1,41 @@
import type { NodeChange } from "@xyflow/react";
import { applyNodeChanges } from "@xyflow/react";
import type { CanvasNode } from "./canvas-elements.ts";
import { nodeCenter, nodeSize } from "./canvas-geometry.ts";
/** Membership changes only on content drops, never on frame movement or resize. */
export function assignFrameMembership(nodes: CanvasNode[], droppedIds: string[]): CanvasNode[] {
const dropped = new Set(droppedIds);
const frames = nodes.filter((node) => node.data.kind === "frame");
return nodes.map((node) => {
if (!dropped.has(node.id) || node.data.kind === "frame") return node;
const center = nodeCenter(node);
const candidates = frames.filter((frame) => {
const { width, height } = nodeSize(frame);
return center.x >= frame.position.x && center.x <= frame.position.x + width && center.y >= frame.position.y && center.y <= frame.position.y + height;
}).sort((a, b) => nodeSize(a).width * nodeSize(a).height - nodeSize(b).width * nodeSize(b).height);
const frameId = candidates[0]?.id;
if (frameId === node.data.frameId) return node;
const { frameId: _old, ...data } = node.data;
return { ...node, data: { ...data, ...(frameId ? { frameId } : {}) } };
});
}
export function applyFrameNodeChanges(changes: NodeChange<CanvasNode>[], previous: CanvasNode[]) {
const nodes = applyNodeChanges(changes, previous);
const before = new Map(previous.map((node) => [node.id, node]));
const positioned = new Set(changes.flatMap((change) => change.type === "position" && change.position ? [change.id] : []));
const frames = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node]));
return nodes.map((node) => {
const frameId = node.data.frameId;
if (!frameId) return node;
const frame = frames.get(frameId);
if (!frame) {
const { frameId: _old, ...data } = node.data;
return { ...node, data };
}
const oldFrame = before.get(frameId);
if (!oldFrame || !positioned.has(frameId) || positioned.has(node.id)) return node;
return { ...node, position: { x: node.position.x + frame.position.x - oldFrame.position.x, y: node.position.y + frame.position.y - oldFrame.position.y } };
});
}
+21 -3
View File
@@ -5,8 +5,8 @@ export type ResizeDirection = "top" | "right" | "bottom" | "left" | "top-left" |
export type NodeTransform = { position?: XYPosition; width?: number; height?: number; rotation?: number; fontSize?: number };
export function nodeSize(node: CanvasNode) {
return {
width: node.width || Number(node.style?.width) || node.measured?.width || (node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 240 : 260),
height: node.height || Number(node.style?.height) || node.measured?.height || (node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 72 : 200),
width: node.width || Number(node.style?.width) || node.measured?.width || (node.data.kind === "frame" ? 640 : node.data.kind === "plain-image" ? 320 : node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 240 : 260),
height: node.height || Number(node.style?.height) || node.measured?.height || (node.data.kind === "frame" ? 400 : node.data.kind === "plain-image" ? 240 : node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 72 : 200),
};
}
export function normalizeRotation(angle: number) { return ((angle % 360) + 360) % 360; }
@@ -33,7 +33,7 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
const horizontal = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
const vertical = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
const plain = node.data.kind === "plain-text";
const icon = node.data.kind === "icon";
const icon = node.data.kind === "icon" || node.data.kind === "plain-image";
let nextWidth = Math.max(plain ? 60 : 140, Math.min(3000, width + horizontal * local.x));
let nextHeight = Math.max(plain ? 24 : 100, Math.min(3000, height + vertical * local.y));
if (icon) {
@@ -51,3 +51,21 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
width: nextWidth, height: nextHeight,
};
}
export type ConnectorSide = "top" | "right" | "bottom" | "left";
export type Connector = { point: XYPosition; normal: XYPosition };
/** Attach to the card border itself, independent of the handle's hit area. */
export function nodeConnector(node: CanvasNode, side: ConnectorSide): Connector {
const { width, height } = nodeSize(node);
const vectors = { top: { x: 0, y: -1 }, right: { x: 1, y: 0 }, bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 } };
const local = vectors[side];
const angle = node.data.rotation || 0;
const normal = rotateVector(local, angle);
const offset = rotateVector({ x: local.x * width / 2, y: local.y * height / 2 }, angle);
const center = nodeCenter(node);
return { point: { x: center.x + offset.x, y: center.y + offset.y }, normal };
}
export function connectorBezier(from: Connector, to: Connector) {
const reach = Math.min(240, Math.max(40, Math.hypot(to.point.x - from.point.x, to.point.y - from.point.y) * 0.4));
return `M ${from.point.x},${from.point.y} C ${from.point.x + from.normal.x * reach},${from.point.y + from.normal.y * reach} ${to.point.x + to.normal.x * reach},${to.point.y + to.normal.y * reach} ${to.point.x},${to.point.y}`;
}
+9
View File
@@ -0,0 +1,9 @@
import { decodeSnapshot, type CanvasSnapshot, type CanvasRequest } from "./canvas-sync.ts";
import { matchesRef, type Board } from "./workspace.ts";
export async function loadBoardCanvas(request: CanvasRequest, workspaceRef: string, boardRef: string, signal?: AbortSignal) {
const path = `workspaces/${encodeURIComponent(workspaceRef)}/materials/${encodeURIComponent(boardRef)}`;
const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal })]);
if (board.kind !== "board" || !matchesRef(board, boardRef) || typeof board.workspace_id !== "string" || !board.workspace_id) throw new Error("invalid_reference");
return { board, snapshot: decodeSnapshot(snapshot) };
}
+77
View File
@@ -0,0 +1,77 @@
import { decodeCanvas, type CanvasDocument } from "./canvas.ts";
export type CanvasSnapshot = { version: number; document: CanvasDocument };
export type CanvasRequest = <T>(path: string, options?: RequestInit) => Promise<T>;
export type SyncState = { status: "saved" | "saving" | "error"; version: number; error?: string };
export function canvasPath(workspace: string, board: string) {
return `workspaces/${encodeURIComponent(workspace)}/materials/${encodeURIComponent(board)}/canvas`;
}
export function canvasPayload(document: CanvasDocument) {
const clean = decodeCanvas(JSON.stringify(document));
return { ...clean, nodes: clean.nodes.map(({ id, type, position, width, height, data }) => ({ id, type, position, ...(width !== undefined ? { width, height } : {}), data })) };
}
export function canvasFingerprint(document: CanvasDocument) {
return JSON.stringify(canvasPayload(document), (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
}
export function decodeSnapshot(value: CanvasSnapshot): CanvasSnapshot {
if (!Number.isSafeInteger(value?.version) || value.version < 1) throw new Error("invalid_canvas_snapshot");
return { version: value.version, document: decodeCanvas(JSON.stringify(value.document)) };
}
/** One writer per board. Coalesce pending snapshots; stop on any rejected write. */
export class CanvasSync {
version: number;
saved: string;
error = "";
private pending: CanvasDocument | null = null;
private running: Promise<void> | null = null;
private request: CanvasRequest;
private path: string;
private notify: (state: SyncState) => void;
constructor(request: CanvasRequest, path: string, initial: CanvasSnapshot, notify: (state: SyncState) => void) {
this.request = request; this.path = path; this.version = initial.version;
this.saved = canvasFingerprint(initial.document); this.notify = notify;
}
enqueue(document: CanvasDocument): Promise<void> {
this.pending = document;
if (this.error) return Promise.resolve();
if (!this.running) this.running = Promise.resolve().then(() => this.drain()).finally(() => { this.running = null; if (this.pending && !this.error) void this.enqueue(this.pending); });
return this.running;
}
retry() { this.error = ""; return this.pending ? this.enqueue(this.pending) : Promise.resolve(); }
private async drain() {
while (this.pending) {
const document = this.pending; this.pending = null;
this.notify({ status: "saving", version: this.version });
try {
const fingerprint = canvasFingerprint(document);
if (fingerprint === this.saved) { this.notify({ status: "saved", version: this.version }); continue; }
const body = JSON.stringify({ document: canvasPayload(document) });
if (new TextEncoder().encode(body).length > 5 * 1024 * 1024) throw new Error("canvas_too_large");
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { "If-Match": `"${this.version}"` }, body });
if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot");
this.version = result.version; this.saved = fingerprint;
this.notify({ status: "saved", version: this.version });
} catch (error) {
this.pending ||= document;
this.error = error instanceof Error ? error.message : "request_failed";
this.notify({ status: "error", version: this.version, error: this.error });
return;
}
}
}
}
export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string, snapshot?: CanvasSnapshot): CanvasDocument | null {
const record = storage.getItem(key + ":recovery");
if (record) {
const value = JSON.parse(record);
const doc = decodeCanvas(JSON.stringify(value.document));
if (!snapshot || (value.dirty && canvasFingerprint(doc) !== canvasFingerprint(snapshot.document))) return doc;
return null;
}
const legacy = storage.getItem(key);
if (!legacy) return null;
const doc = decodeCanvas(legacy);
return !snapshot || canvasFingerprint(doc) !== canvasFingerprint(snapshot.document) ? doc : null;
}
+20 -7
View File
@@ -1,6 +1,7 @@
import { applyEdgeChanges, applyNodeChanges } from "@xyflow/react";
import { applyEdgeChanges } from "@xyflow/react";
import type { Edge, EdgeChange, NodeChange, Viewport, XYPosition } from "@xyflow/react";
import { applyFrameNodeChanges, assignFrameMembership } from "./canvas-frames.ts";
import { createCanvasElement, elementCapabilities, type CanvasNode } from "./canvas-elements.ts";
import { nodeSize, rotateVector, nodeCenter, type NodeTransform } from "./canvas-geometry.ts";
export type { CanvasNode } from "./canvas-elements.ts";
@@ -10,6 +11,9 @@ export type CanvasDocument = { schema_version: 1; nodes: CanvasNode[]; edges: Ed
export type CanvasHistory = { past: CanvasDocument[]; present: CanvasDocument; future: CanvasDocument[] };
export type CanvasAction =
| { type: "checkpoint" }
| { type: "drop"; ids: string[] }
| { type: "rename-node"; id: string; title: string }
| { type: "frame"; id: string; title?: string; labelMode?: import("./canvas-elements.ts").FrameLabelMode }
| { type: "replace"; document: CanvasDocument }
| { type: "nodes"; changes: NodeChange<CanvasNode>[] }
| { type: "edges"; changes: EdgeChange[] }
@@ -30,10 +34,13 @@ function recorded(state: CanvasHistory, present: CanvasDocument): CanvasHistory
}
export function canvasReducer(state: CanvasHistory, action: CanvasAction): CanvasHistory {
switch (action.type) {
case "drop": return { ...state, present: { ...state.present, nodes: assignFrameMembership(state.present.nodes, action.ids) } };
case "rename-node": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, title: action.title } } : node) });
case "frame": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id && node.data.kind === "frame" ? { ...node, data: { ...node.data, ...(action.title !== undefined ? { title: action.title } : {}), ...(action.labelMode ? { labelMode: action.labelMode } : {}) } } : node) });
case "checkpoint": return recorded(state, state.present);
case "replace": return recorded(state, action.document);
case "nodes": {
const nodes = applyNodeChanges(action.changes, state.present.nodes);
const nodes = applyFrameNodeChanges(action.changes, state.present.nodes);
const ids = new Set(nodes.map((node) => node.id));
const present = { ...state.present, nodes, edges: state.present.edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target)) };
return action.changes.some((change) => change.type === "remove" || change.type === "add")
@@ -137,8 +144,8 @@ export function decodeCanvas(raw: string): CanvasDocument {
const nodes: CanvasNode[] = value.nodes.map((node: CanvasNode) => {
if (!node || typeof node.id !== "string" || !/^[\w-]{1,128}$/.test(node.id) || ids.has(node.id) || !node.position ||
![node.position.x, node.position.y].every((n) => Number.isFinite(n) && Math.abs(n) <= 1_000_000) ||
!node.data || !["text", "image", "script", "plain-text", "icon"].includes(node.data.kind) || typeof node.data.title !== "string" || node.data.title.length > 300 ||
typeof node.data.text !== "string" || node.data.text.length > 100_000 || (node.data.kind === "image" && !validImage(node.data.src)))
!node.data || !["text", "image", "script", "plain-text", "icon", "frame", "plain-image"].includes(node.data.kind) || typeof node.data.title !== "string" || node.data.title.length > 300 ||
typeof node.data.text !== "string" || node.data.text.length > 100_000 || (["image", "plain-image"].includes(node.data.kind) && !validImage(node.data.src)))
throw new Error("图谱节点格式无效。");
const width = node.width ?? node.style?.width;
const height = node.height ?? node.style?.height;
@@ -148,15 +155,21 @@ export function decodeCanvas(raw: string): CanvasDocument {
(node.data.fontSize !== undefined && (!Number.isFinite(node.data.fontSize) || node.data.fontSize < 8 || node.data.fontSize > 160)))
throw new Error("节点大小或旋转角度无效。");
if (node.data.kind === "icon" && (typeof node.data.icon !== "string" || !/^[A-Z][A-Za-z0-9]{0,63}$/.test(node.data.icon))) throw new Error("图标标识无效。");
if (node.data.frameId !== undefined && (node.data.kind === "frame" || typeof node.data.frameId !== "string" || !/^[\w-]{1,128}$/.test(node.data.frameId))) throw new Error("分区归属无效。");
if (node.data.kind === "frame" && ((node.data.labelMode !== undefined && !["hidden", "fixed", "scaled"].includes(node.data.labelMode)) || (node.data.rotation || 0) !== 0)) throw new Error("分区设置无效。");
const textStyle = decodeTextStyle(node.data.textStyle);
ids.add(node.id);
return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : node.data.kind === "icon" ? "icon" : "material",
...(width !== undefined && height !== undefined ? { width, height, style: { width, height } } : {}), position: { x: node.position.x, y: node.position.y }, data: {
return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : node.data.kind === "icon" ? "icon" : node.data.kind === "frame" ? "frame" : node.data.kind === "plain-image" ? "plainImage" : "material",
...(width !== undefined && height !== undefined ? { width, height, style: { width, height } } : {}), ...(node.data.kind === "frame" ? { zIndex: -1, style: { ...(width !== undefined ? { width, height } : {}), pointerEvents: "none" as const } } : {}),
position: { x: node.position.x, y: node.position.y }, data: {
...(node.data.frameId ? { frameId: node.data.frameId } : {}), ...(node.data.kind === "frame" ? { labelMode: node.data.labelMode || "fixed" } : {}),
...(textStyle ? { textStyle } : {}), ...(node.data.kind === "icon" ? { icon: node.data.icon } : {}),
kind: node.data.kind, title: node.data.title, text: node.data.text,
rotation: node.data.rotation || 0, fontSize: node.data.fontSize || (node.data.kind === "plain-text" ? 24 : 13), ...(node.data.kind === "image" ? { src: node.data.src } : {}),
rotation: node.data.rotation || 0, fontSize: node.data.fontSize || (node.data.kind === "plain-text" ? 24 : 13), ...(["image", "plain-image"].includes(node.data.kind) ? { src: node.data.src } : {}),
} };
});
const frameIds = new Set(nodes.filter((node) => node.data.kind === "frame").map((node) => node.id));
if (nodes.some((node) => node.data.frameId && !frameIds.has(node.data.frameId))) throw new Error("分区归属不存在。");
const nodeKinds = new Map(nodes.map((node) => [node.id, node.data.kind]));
const edgeIds = new Set<string>();
const edges: Edge[] = value.edges.map((edge: Edge) => {
+9
View File
@@ -0,0 +1,9 @@
export const SESSION_IDLE_SECONDS = 30 * 24 * 60 * 60;
export const SESSION_EXPIRY_HEADER = "X-Foundry-Session-Expires-In";
export function sessionMaxAge(value: unknown, fallback = SESSION_IDLE_SECONDS) {
const seconds = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
return Number.isSafeInteger(seconds) && seconds >= 0 ? Math.min(seconds, SESSION_IDLE_SECONDS) : fallback;
}
export function sessionCookie(value: string, maxAge: number, secure: boolean) {
return `foundry_session=${encodeURIComponent(value)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${sessionMaxAge(maxAge)}${secure ? "; Secure" : ""}`;
}
+5 -3
View File
@@ -1,15 +1,17 @@
import type { Profile } from "./api";
export type Workspace = { id: string; name: string; role?: "admin" | "editor" | "viewer" };
export type Workspace = { id: string; slug?: string; name: string; role?: "admin" | "editor" | "viewer" };
export type Board = {
id: string;
workspace_id: string;
slug?: string;
kind: "board";
title: string;
version: number;
content?: { schema_version: number };
description?: string;
};
export function matchesRef(item: { id: string; slug?: string } | null | undefined, ref: string | undefined) { return Boolean(item && ref && (item.id === ref || item.slug === ref)); }
export const DEFAULT_BOARD_TITLE = "未命名图谱";
export function boardHref(workspace: string, board: string, demo = false) {
@@ -47,7 +49,7 @@ export async function createBoard(
onWorkspaceCreated(workspace);
}
const name = title.trim() || DEFAULT_BOARD_TITLE;
const result = await request<{ id: string; workspace_id: string; kind: "board"; version: number }>("workspaces/" + id + "/materials", {
const result = await request<{ id: string; workspace_id: string; kind: "board"; version: number; slug?: string }>("workspaces/" + id + "/materials", {
method: "POST",
body: JSON.stringify({
kind: "board",
@@ -64,7 +66,7 @@ export async function renameBoard(request: Request, workspaceId: string, boardId
if (!name || new TextEncoder().encode(name).length > 300) throw new Error("invalid_title");
const path = `workspaces/${encodeURIComponent(workspaceId)}/materials/${encodeURIComponent(boardId)}`;
const current = await request<Board>(path);
if (current.kind !== "board" || current.id !== boardId || current.workspace_id !== workspaceId) throw new Error("invalid_reference");
if (current.kind !== "board" || !matchesRef(current, boardId) || current.workspace_id !== workspaceId) throw new Error("invalid_reference");
await request(path, {
method: "PATCH",
headers: { "If-Match": `"${current.version}"` },
+84 -5
View File
@@ -6,12 +6,26 @@ import { fileURLToPath } from "node:url";
import { readdir, readFile } from "node:fs/promises";
const host = "http://127.0.0.1:3005";
const graphs = new Map();
let canvasSnapshot = { version: 1, document: { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } };
let mockSessionSeconds = "2592000";
let mockProfile = {
id: "user", username: "test", display_name: "Test User", email: "test@example.com",
avatar_url: "https://lh3.googleusercontent.com/test-avatar=s96-c",
};
const upstream = createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.headers.authorization === "Bearer test-only-session" && !req.url.endsWith("/auth/logout")) res.setHeader("X-Foundry-Session-Expires-In", mockSessionSeconds);
const canonical = req.url.replace("/workspaces/creative-space/", "/workspaces/space/").replace("/materials/my-board", "/materials/foundry-generated-id");
if (canonical === "/projects/endoleap/workspaces/space/materials/foundry-generated-id/canvas") {
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({ error: "invalid_session" })); return; }
if (req.method === "GET") { res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify(canvasSnapshot)); return; }
let raw = ""; for await (const chunk of req) raw += chunk;
if (req.headers["if-match"] !== '"' + canvasSnapshot.version + '"') { res.statusCode = 412; res.end(JSON.stringify({ error: "version_conflict" })); return; }
const { document } = JSON.parse(raw);
assert.equal(document.schema_version, 1); assert(Array.isArray(document.nodes));
canvasSnapshot = { version: canvasSnapshot.version + 1, document };
res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify({ version: canvasSnapshot.version })); return;
}
if (req.url.endsWith("/auth/logout")) {
res.statusCode = 204;
res.end();
@@ -21,11 +35,11 @@ const upstream = createServer(async (req, res) => {
for await (const chunk of req) raw += chunk;
const input = JSON.parse(raw);
assert.deepEqual(input, { kind: "board", title: "未命名图谱", content: { schema_version: 1 } });
const graph = { ...input, id: "foundry-generated-id", workspace_id: "space", version: 1 };
const graph = { ...input, slug: "my-board", id: "foundry-generated-id", workspace_id: "space", version: 1 };
graphs.set(graph.id, graph);
res.statusCode = 201;
res.end(JSON.stringify({ id: graph.id, workspace_id: graph.workspace_id, kind: graph.kind, version: graph.version }));
} else if (req.url === "/projects/endoleap/workspaces/space/materials/foundry-generated-id") {
} else if (canonical === "/projects/endoleap/workspaces/space/materials/foundry-generated-id") {
assert.equal(req.headers.authorization, "Bearer test-only-session");
if (req.method === "PATCH") {
assert.equal(req.headers["if-match"], '"1"');
@@ -43,7 +57,7 @@ const upstream = createServer(async (req, res) => {
res.end(
JSON.stringify({
access_token: "test-only-session",
expires_in: 28800,
expires_in: 2592000,
token_type: "Bearer",
}),
);
@@ -129,8 +143,28 @@ try {
const cookie = res.headers.get("set-cookie");
assert(cookie?.includes("HttpOnly"));
assert(cookie.includes("SameSite=Lax"));
assert(cookie.includes("Max-Age=28800"));
assert(cookie.includes("Max-Age=2592000"));
const session = cookie.split(";")[0];
const signedInHome = await fetch(host + "/", { headers: { Cookie: session }, redirect: "manual" });
assert.equal(signedInHome.status, 307);
assert.equal(signedInHome.headers.get("location"), "/dashboard");
assert.match(signedInHome.headers.get("cache-control") || "", /private|no-store/);
assert(!(await signedInHome.text()).includes("test-only-session"));
const dashboardHome = await fetch(host + "/dashboard", { headers: { Cookie: session } });
assert.equal(dashboardHome.status, 200);
const dashboardHtml = await dashboardHome.text();
assert.match(dashboardHtml, /aria-label="(?:工作空间侧边栏|正在加载侧边栏)"/);
assert(!dashboardHtml.includes('data-testid="rf__wrapper"'));
assert(!dashboardHtml.includes('class="landing"'));
for (const guestHeaders of [{}, { Cookie: "foundry_session=expired-session" }]) {
const guestHome = await fetch(host + "/", { headers: guestHeaders, redirect: "manual" });
assert.equal(guestHome.status, 200);
assert.equal(guestHome.headers.get("location"), null);
const html = await guestHome.text();
assert(html.includes('class="landing"'));
assert(!html.includes('aria-label="工作空间侧边栏"'));
}
assert.equal(
(
await fetch(host + "/api/foundry/profile", {
@@ -140,6 +174,17 @@ try {
200,
);
assert.equal((await fetch(host + "/api/foundry/profile")).status, 401);
mockSessionSeconds = "3600";
const renewed = await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } });
assert.equal(renewed.status, 200); assert.match(renewed.headers.get("set-cookie") || "", /Max-Age=3600/);
assert.match(renewed.headers.get("set-cookie") || "", /HttpOnly/);
mockSessionSeconds = "garbage";
const malformedRenewal = await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } });
assert.equal(malformedRenewal.headers.get("set-cookie"), null);
mockSessionSeconds = "2592000";
const expiredSession = await fetch(host + "/api/foundry/profile", { headers: { Cookie: "foundry_session=expired" } });
assert.equal(expiredSession.status, 401); assert.equal(expiredSession.headers.get("set-cookie"), null);
const profile = await (await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } })).json();
assert.equal(profile.avatar_url, "https://lh3.googleusercontent.com/test-avatar=s96-c");
const updated = await fetch(host + "/api/foundry/profile", {
@@ -176,6 +221,40 @@ try {
assert.equal(renamed.version, 2);
assert.deepEqual(renamed.content, stored.content);
for (const workspace of ["space", "creative-space"]) for (const board of ["foundry-generated-id", "my-board"]) {
const path = `/api/foundry/workspaces/${workspace}/materials/${board}`;
const material = await (await fetch(host + path, { headers: { Cookie: session } })).json();
assert.equal(material.id, "foundry-generated-id"); assert.equal(material.slug, "my-board");
const snapshot = await fetch(host + path + "/canvas", { headers: { Cookie: session } });
assert.equal(snapshot.status, 200); assert.equal(snapshot.headers.get("etag"), '"1"');
assert.equal((await snapshot.json()).version, 1);
}
const canvasPath = "/api/foundry/workspaces/creative-space/materials/my-board/canvas";
const largeDocument = { schema_version: 1, nodes: Array.from({ length: 15 }, (_, index) => ({ id: "text-" + index, type: "material", position: { x: index * 300, y: 100 }, width: 260, height: 200, data: { kind: "text", title: "保存测试", text: "x".repeat(90000), fontSize: 24, rotation: 30, textStyle: { bold: true, color: "#123456" } } })), edges: [], viewport: { x: 20, y: 40, zoom: 0.75 } };
const savedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) });
assert.equal(savedCanvas.status, 200); assert.equal(savedCanvas.headers.get("etag"), '"2"');
assert.equal((await savedCanvas.json()).version, 2);
const restoredCanvas = await (await fetch(host + canvasPath, { headers: { Cookie: session } })).json();
assert.deepEqual(restoredCanvas.document, largeDocument);
const conflict = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) });
assert.equal(conflict.status, 412); assert.equal((await conflict.json()).error, "version_conflict");
const frameDocument = structuredClone(largeDocument);
frameDocument.nodes.unshift({ id: "frame-1", type: "frame", position: { x: 0, y: 0 }, width: 640, height: 400, data: { kind: "frame", title: "参考分区", text: "", rotation: 0, labelMode: "fixed" } });
frameDocument.nodes[1].data.frameId = "frame-1";
for (const [index, mode] of ["hidden", "fixed", "scaled"].entries()) {
frameDocument.nodes[0].data.labelMode = mode;
const response = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"' + (2 + index) + '"' }, body: JSON.stringify({ document: frameDocument }) });
assert.equal(response.status, 200);
const restored = await (await fetch(host + canvasPath, { headers: { Cookie: session } })).json();
assert.equal(restored.document.nodes[0].data.labelMode, mode);
assert.equal(restored.document.nodes[1].data.frameId, "frame-1");
}
assert.equal((await fetch(host + canvasPath)).status, 401);
assert.equal((await fetch(host + "/api/foundry/workspaces/space/search/my-board/canvas")).status, 404);
const oversizedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, "Content-Type": "application/json" }, body: JSON.stringify({ document: "x".repeat(5 * 1024 * 1024) }) });
assert.equal(oversizedCanvas.status, 413);
for (const origin of ["https://example.com", "null"])
assert.equal(
(
@@ -224,7 +303,7 @@ try {
assert.equal(await logout.text(), "");
assert.match(logout.headers.get("set-cookie"), /Max-Age=0/);
console.log(
"PASS: 88 original brand assets, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
"PASS: Canvas GET/PATCH and independent versions, ETag/If-Match, 1+ MiB snapshot restore, conflict and 5 MiB limit, four ID/slug combinations, authenticated home redirect, anonymous and expired-session landing, canvas-free dashboard, 88 original brand assets, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
);
} finally {
const exited = once(app, "exit");
+93
View File
@@ -0,0 +1,93 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { assignFrameMembership } from "../src/lib/canvas-frames.ts";
function seeded() {
const frame = makeCanvasNode("frame", { x: 100, y: 100 });
const member = makeCanvasNode("text", { x: 160, y: 160 });
const outsider = makeCanvasNode("icon", { x: 900, y: 120 });
return { frame, member, outsider, document: { ...emptyCanvas(), nodes: [frame, member, outsider] } };
}
function state(document) { return { past: [], present: document, future: [] }; }
test("only dropped content joins a frame; pre-existing overlapping content stays outside", () => {
const { frame, member, outsider, document } = seeded();
assert.equal(document.nodes[1].data.frameId, undefined);
const nodes = assignFrameMembership(document.nodes, [member.id]);
assert.equal(nodes[1].data.frameId, frame.id);
assert.equal(nodes[2].data.frameId, undefined);
const untouched = assignFrameMembership(document.nodes, [frame.id]);
assert.equal(untouched[1].data.frameId, undefined);
outsider.position = { x: 200, y: 200 };
assert.equal(assignFrameMembership([frame, member, outsider], [member.id])[2].data.frameId, undefined);
});
test("frames carry existing members without collecting newly overlapped content and undo restores positions", () => {
const { frame, member, outsider, document } = seeded();
member.data.frameId = frame.id;
let history = canvasReducer(state(document), { type: "checkpoint" });
history = canvasReducer(history, { type: "nodes", changes: [{ type: "position", id: frame.id, position: { x: 700, y: 100 } }] });
assert.deepEqual(history.present.nodes[1].position, { x: 760, y: 160 });
assert.equal(history.present.nodes[2].data.frameId, undefined);
assert.deepEqual(history.present.nodes[2].position, outsider.position);
history = canvasReducer(history, { type: "undo" });
assert.deepEqual(history.present.nodes[1].position, member.position);
assert.equal(history.present.nodes[1].data.frameId, frame.id);
});
test("dragging out detaches content and dragging between frames reassigns membership", () => {
const { frame, member } = seeded();
member.data.frameId = frame.id;
member.position = { x: 900, y: 900 };
assert.equal(assignFrameMembership([frame, member], [member.id])[1].data.frameId, undefined);
const second = makeCanvasNode("frame", { x: 800, y: 800 });
assert.equal(assignFrameMembership([frame, second, member], [member.id])[2].data.frameId, second.id);
});
test("multi-selection does not move frame members twice", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
const result = canvasReducer(state(document), { type: "nodes", changes: [
{ type: "position", id: frame.id, position: { x: 200, y: 200 } },
{ type: "position", id: member.id, position: { x: 260, y: 260 } },
] });
assert.deepEqual(result.present.nodes[1].position, { x: 260, y: 260 });
});
test("deleting a frame keeps its content and undo restores membership", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
const removed = canvasReducer(state(document), { type: "nodes", changes: [{ type: "remove", id: frame.id }] });
assert.equal(removed.present.nodes.length, 2);
assert.equal(removed.present.nodes[0].data.frameId, undefined);
assert.deepEqual(removed.present.nodes[0].position, member.position);
assert.equal(canvasReducer(removed, { type: "undo" }).present.nodes[1].data.frameId, frame.id);
});
test("all label modes, frame names and memberships survive save/restore and settings undo", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
for (const labelMode of ["hidden", "fixed", "scaled"]) {
const result = canvasReducer(state(document), { type: "frame", id: frame.id, title: "灵感分区", labelMode });
const restored = decodeCanvas(JSON.stringify(result.present));
assert.equal(restored.nodes[0].type, "frame");
assert.equal(restored.nodes[0].data.title, "灵感分区");
assert.equal(restored.nodes[0].data.labelMode, labelMode);
assert.equal(restored.nodes[1].data.frameId, frame.id);
assert.equal(restored.nodes[0].zIndex, -1);
assert.equal(canvasReducer(result, { type: "undo" }).present.nodes[0].data.title, frame.data.title);
}
});
test("invalid frame references, label modes, frame nesting and rotation are rejected", () => {
for (const mutate of [
({ member }) => { member.data.frameId = "missing"; },
({ frame, member }) => { frame.data.frameId = member.id; },
({ frame }) => { frame.data.labelMode = "unexpected"; },
({ frame }) => { frame.data.rotation = 20; },
]) {
const input = seeded(); mutate(input);
assert.throws(() => decodeCanvas(JSON.stringify(input.document)));
}
});
+37 -1
View File
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CanvasElement, CardElement, TextCardElement, ImageCardElement, ScriptCardElement, PlainTextElement, elementCapabilities } from "../src/lib/canvas-elements.ts";
import { nodeCenter, nodeSize, resizeFromPointer, rotateVector, rotationFromPointer } from "../src/lib/canvas-geometry.ts";
import { nodeConnector, connectorBezier, nodeCenter, nodeSize, resizeFromPointer, rotateVector, rotationFromPointer } from "../src/lib/canvas-geometry.ts";
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas } from "../src/lib/canvas.ts";
function close(actual, expected) { assert(Math.abs(actual - expected) < 0.00001, `${actual} != ${expected}`); }
@@ -93,3 +93,39 @@ test("old text-card drafts remain cards and invalid transform values are rejecte
assert.throws(() => decodeCanvas(JSON.stringify({ ...document, nodes: [{ ...old, ...patch }] })));
}
});
test("connection endpoints stay on the exact border centers after resize and rotation", () => {
const node = new TextCardElement({ x: 20, y: 30 }).toNode();
const expected = {
0: { top: [150, 30], right: [280, 130], bottom: [150, 230], left: [20, 130] },
90: { top: [250, 130], right: [150, 260], bottom: [50, 130], left: [150, 0] },
180: { top: [150, 230], right: [20, 130], bottom: [150, 30], left: [280, 130] },
};
for (const [rotation, sides] of Object.entries(expected)) {
node.data.rotation = Number(rotation);
for (const [side, point] of Object.entries(sides)) {
const anchor = nodeConnector(node, side);
close(anchor.point.x, point[0]); close(anchor.point.y, point[1]);
close(Math.hypot(anchor.normal.x, anchor.normal.y), 1);
}
}
node.data.rotation = 0;
const resized = transformed(node, resizeFromPointer(node, "bottom-right", { x: 100, y: 50 }));
assert.deepEqual(nodeConnector(resized, "right").point, { x: 380, y: 155 });
assert.deepEqual(nodeConnector(resized, "bottom").point, { x: 200, y: 280 });
});
test("Bezier paths include both exact card anchors, independent of selected handle sizes", () => {
const source = new TextCardElement({ x: 20, y: 30 }).toNode();
const target = new TextCardElement({ x: 500, y: 180 }).toNode();
for (const angle of [0, 37, 90, 180, 270]) {
source.data.rotation = angle;
target.data.rotation = (angle + 45) % 360;
const from = nodeConnector(source, "right"), to = nodeConnector(target, "left");
const path = connectorBezier(from, to);
assert(path.startsWith(`M ${from.point.x},${from.point.y} C `));
assert(path.endsWith(` ${to.point.x},${to.point.y}`));
source.selected = true; target.selected = true;
assert.equal(connectorBezier(nodeConnector(source, "right"), nodeConnector(target, "left")), path);
}
});
+38
View File
@@ -0,0 +1,38 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { CanvasElement, CardElement, PlainImageElement } from "../src/lib/canvas-elements.ts";
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
import { iconCatalog, iconsById, searchIcons } from "../src/components/canvas/icon-catalog.ts";
test("plain images inherit transforms without card connections and preserve source/name on restore", () => {
const image = new PlainImageElement({ x: 0, y: 0 }, "", "data:image/png;base64,YQ==");
assert(image instanceof CanvasElement); assert.equal(image instanceof CardElement, false);
const node = image.toNode();
assert.equal(node.type, "plainImage"); assert.equal(PlainImageElement.capabilities.connectable, false);
const resized = resizeFromPointer(node, "bottom-right", { x: 320, y: 240 });
assert.equal(resized.width, 640); assert.equal(resized.height, 480);
const renamed = canvasReducer({ past: [], present: { ...emptyCanvas(), nodes: [node] }, future: [] }, { type: "rename-node", id: node.id, title: "参考照片" });
const restored = decodeCanvas(JSON.stringify(renamed.present));
assert.equal(restored.nodes[0].data.title, "参考照片"); assert.equal(restored.nodes[0].data.src, node.data.src);
assert.equal(canvasReducer(renamed, { type: "undo" }).present.nodes[0].data.title, "图片");
});
test("plain image sources are required and pure images cannot acquire card connections", () => {
const image = makeCanvasNode("plain-image", { x: 0, y: 0 }, "data:image/png;base64,YQ==");
const card = makeCanvasNode("text", { x: 300, y: 0 });
const document = { ...emptyCanvas(), nodes: [image, card], edges: [{ id: "edge", source: card.id, target: image.id }] };
assert.throws(() => decodeCanvas(JSON.stringify(document)));
image.data.src = "javascript:bad"; document.edges = [];
assert.throws(() => decodeCanvas(JSON.stringify(document)));
});
test("the complete Lucide catalog has unique backend-compatible identifiers and searchable names", () => {
assert(iconCatalog.length > 2000);
assert.equal(iconsById.size, iconCatalog.length);
for (const icon of iconCatalog) assert.match(icon.name, /^[A-Z][A-Za-z0-9]{0,63}$/);
assert(searchIcons("箭头").length > 45);
assert(searchIcons("arrow left").some((icon) => icon.name === "ArrowLeft"));
assert.equal(iconsById.get("Star").slug, "star");
assert.equal(iconsById.get("WandSparkles").slug, "wand-sparkles");
});
+85
View File
@@ -0,0 +1,85 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts";
import { emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { loadBoardCanvas } from "../src/lib/canvas-loader.ts";
function document(text = "") { const doc = emptyCanvas(); const node = makeCanvasNode("text", { x: 0, y: 0 }); node.id = "text"; node.data.text = text; doc.nodes.push(node); return doc; }
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
const tick = () => new Promise((resolve) => setImmediate(resolve));
test("serialized autosaves use successive versions and coalesce queued documents", async () => {
const calls = [], first = deferred();
const writer = new CanvasSync(async (path, options) => {
calls.push({ path, ...options });
if (calls.length === 1) return first.promise;
return { version: 3 };
}, canvasPath("space", "board"), { version: 1, document: emptyCanvas() }, () => {});
const a = writer.enqueue(document("A")); await tick();
writer.enqueue(document("B")); writer.enqueue(document("C"));
assert.equal(calls.length, 1);
first.resolve({ version: 2 }); await a;
assert.equal(calls.length, 2);
assert.equal(calls[0].headers["If-Match"], '"1"'); assert.equal(calls[1].headers["If-Match"], '"2"');
assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "C"); assert.equal(writer.version, 3);
});
test("version conflicts stop automatic writes and preserve the latest pending content", async () => {
const calls = [];
const writer = new CanvasSync(async (_path, options) => { calls.push(options); throw new Error("version_conflict"); }, "canvas", { version: 7, document: emptyCanvas() }, () => {});
await writer.enqueue(document("A")); await writer.enqueue(document("B"));
assert.equal(calls.length, 1); assert.equal(writer.error, "version_conflict"); assert.equal(writer.version, 7);
});
test("explicit retry sends the newest document after a failed write", async () => {
const calls = [];
const writer = new CanvasSync(async (_path, options) => { calls.push(options); if (calls.length === 1) throw new Error("upstream_unavailable"); return { version: 2 }; }, "canvas", { version: 1, document: emptyCanvas() }, () => {});
await writer.enqueue(document("A")); await writer.enqueue(document("B")); await writer.retry();
assert.equal(calls.length, 2); assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "B"); assert.equal(writer.error, "");
});
test("selection and measured dimensions do not trigger redundant writes or leak UI fields", async () => {
const initial = document("keep"); let calls = 0;
const writer = new CanvasSync(async () => { calls++; return { version: 2 }; }, "canvas", { version: 1, document: initial }, () => {});
const changed = structuredClone(initial); changed.nodes[0].selected = true; changed.nodes[0].measured = { width: 260, height: 200 };
await writer.enqueue(changed); assert.equal(calls, 0);
const payload = canvasPayload(changed);
for (const key of ["selected", "style", "measured", "dragging", "zIndex"]) assert.equal(payload.nodes[0][key], undefined);
const first = writer.enqueue(changed); const second = writer.enqueue(document("new")); await Promise.all([first, second]);
assert.equal(calls, 1);
});
test("all ID/slug combinations load the same canonical board and snapshot", async () => {
for (const workspace of ["space-id", "creative-space"]) for (const board of ["board-id", "my-board"]) {
const calls = [];
const result = await loadBoardCanvas(async (path) => { calls.push(path); return path.endsWith("/canvas") ? { version: 4, document: document("saved") } : { id: "board-id", slug: "my-board", workspace_id: "space-id", kind: "board", title: "Saved" }; }, workspace, board);
assert.equal(result.board.id, "board-id"); assert.equal(result.board.workspace_id, "space-id"); assert.equal(result.snapshot.version, 4);
assert(calls.includes(`workspaces/${workspace}/materials/${board}/canvas`));
}
});
test("failed snapshot loads and mismatched boards prevent editing rather than creating an empty replacement", async () => {
await assert.rejects(loadBoardCanvas(async (path) => { if (path.endsWith("/canvas")) throw new Error("upstream_unavailable"); return { id: "board", workspace_id: "space", kind: "board" }; }, "space", "board"), /upstream/);
await assert.rejects(loadBoardCanvas(async (path) => path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "other", workspace_id: "space", kind: "board" }, "space", "board"), /invalid_reference/);
});
test("dirty local recovery and legacy drafts are offered for explicit migration; clean backups never replace server data", () => {
const remote = { version: 3, document: document("server") };
const local = document("local");
const storage = (values) => ({ getItem: (key) => values[key] || null });
assert.equal(readLocalRecovery(storage({ key: JSON.stringify(local) }), "key", remote).nodes[0].data.text, "local");
assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: local, dirty: true, baseVersion: 2 }) }), "key", remote).nodes[0].data.text, "local");
assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: local, dirty: false }), key: JSON.stringify(local) }), "key", remote), null);
assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: remote.document, dirty: true }) }), "key", remote), null);
});
test("canonical fingerprints ignore server JSON key ordering", async () => {
const remote = document("same"), local = document("same");
remote.nodes[0].data.textStyle = { bold: true, color: "#123456" };
local.nodes[0].data.textStyle = { color: "#123456", bold: true };
let calls = 0;
const writer = new CanvasSync(async () => { calls++; return { version: 2 }; }, "canvas", { version: 1, document: remote }, () => {});
await writer.enqueue(local);
assert.equal(calls, 0);
const storage = { getItem: () => JSON.stringify({ document: local, dirty: true }) };
assert.equal(readLocalRecovery(storage, "key", { version: 1, document: remote }), null);
});
+20
View File
@@ -0,0 +1,20 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { sessionCookie, sessionMaxAge, SESSION_IDLE_SECONDS } from "../src/lib/session-cookie.ts";
test("session cookies respect backend remaining lifetime and a 30-day maximum", () => {
assert.equal(SESSION_IDLE_SECONDS, 2592000);
assert.equal(sessionMaxAge(2592000), 2592000);
assert.equal(sessionMaxAge("3600"), 3600);
assert.equal(sessionMaxAge(90 * 86400), 2592000);
assert.equal(sessionMaxAge(0), 0);
assert.equal(sessionMaxAge(-1), 2592000);
for (const invalid of [Infinity, "garbage", "1.5", NaN, {}, null]) assert.equal(sessionMaxAge(invalid), 2592000);
});
test("renewed cookies retain HttpOnly/SameSite and use the backend-capped deadline", () => {
const cookie = sessionCookie("session-token", 42, true);
assert.match(cookie, /Max-Age=42;/); assert.match(cookie, /HttpOnly/); assert.match(cookie, /SameSite=Lax/); assert.match(cookie, /; Secure$/);
assert(!sessionCookie("session-token", 1000, false).includes("Secure"));
assert.match(sessionCookie("session-token", 0, true), /Max-Age=0/);
});