Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65c54e7b9e | |||
| cc2172319e | |||
| 5d82655e0e | |||
| 9b24319747 | |||
| b995e659a6 |
@@ -5,7 +5,8 @@
|
||||
import { Suspense } from "react";
|
||||
import GraphPage from "@/components/workspace/graph-page";
|
||||
|
||||
/** params按当前Next版本await;Suspense给客户端画布准备过程提供状态占位,不在此发模型请求。 */
|
||||
/** params按当前Next版本await;Suspense为子树渲染时的挂起提供占位,不在此发模型请求。
|
||||
* params 的 await 在此边界外;GraphPage 在 effect 中加载模型时的进度/错误由自身状态呈现。 */
|
||||
export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) {
|
||||
const { workspace, board } = await params;
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,8 @@ import { ActiveBoardProvider } from "@/components/workspace/active-board";
|
||||
import DashboardSidebar from "./dashboard";
|
||||
import styles from "@/components/workspace/workspace.module.css";
|
||||
|
||||
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。 */
|
||||
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。
|
||||
* 该边界响应侧栏渲染期间的 suspend,不自动等待侧栏在 effect 中发起的普通请求。 */
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ActiveBoardProvider>
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。 */
|
||||
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。
|
||||
* children 作为插槽传入客户端宿主,不会因此把所有子页面模块变成客户端代码;宿主的释放逻辑在自身模块。 */
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN" data-scroll-behavior="smooth">
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function Login() {
|
||||
verify = hash.get("verify_email"),
|
||||
failure = hash.get("error");
|
||||
if (!code && !verify && !failure) return;
|
||||
// 不只删除认证片段,也删除原 query;当前页面没有保留/恢复任意 returnTo 的流程。
|
||||
history.replaceState(null, "", location.pathname);
|
||||
if (failure) {
|
||||
setError("第三方登录未完成,请重新尝试。");
|
||||
@@ -68,6 +69,7 @@ export default function Login() {
|
||||
* HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
// busy 是显示状态,不是函数入口互斥锁;按钮禁用限制常规点击,submit 本身不检查已在执行。
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
@@ -76,6 +78,7 @@ export default function Login() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
// 使用触发本次提交时 render 闭包里的 register;等待期间切换模式不会改变这次请求的后续分支。
|
||||
if (register)
|
||||
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
|
||||
else router.push("/dashboard");
|
||||
@@ -98,6 +101,7 @@ export default function Login() {
|
||||
setError("无法启动登录,请重新尝试。");
|
||||
}
|
||||
}
|
||||
// submit/oauth 同样没有卸载取消或结果代次校验;回调链结束前可继续调用 router/setState。
|
||||
// 模式切换只清提示,已有邮箱/密码和可见性独立保留。
|
||||
// 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。
|
||||
return (
|
||||
|
||||
@@ -21,12 +21,14 @@ export default async function Home() {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
// 按响应状态确定是否已认证,再丢弃不需要的body;此处不解析profile字段。
|
||||
// 先保存 HTTP 成功判定;随后 cancel 若抛错,catch 不会把 authenticated 重置为 false。
|
||||
authenticated = response.ok;
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// An unverified session must not hide the public landing page.
|
||||
}
|
||||
}
|
||||
// redirect 放在上方 try/catch 外,避免把框架用于导航的控制流异常当作 profile 请求失败吞掉。
|
||||
// 仅在确认成功后跳工作台,未认证时沿用同一静态展示组件。
|
||||
if (authenticated) redirect("/dashboard");
|
||||
return <LandingPage />;
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function LandingPage() {
|
||||
const [active, setActive] = useState(0);
|
||||
// null表示全部收起;点击当前问题置null,点击其它问题改索引。hidden与aria-expanded/controls对应同一状态。
|
||||
const [faq, setFaq] = useState<number | null>(null);
|
||||
// FAQ 的 hidden 只切换可见性,问答节点仍保留在 DOM;页面没有请求型加载或异步清理任务。
|
||||
// 页面顺序:导航→首屏交互预览→理念横条→三个功能说明→工作步骤→FAQ→行动入口→页脚。
|
||||
// 功能卡与步骤通过静态内容展示;FAQ以问题文本作key,步骤以序号作key,预览以说明作key。
|
||||
// 状态仅在本次组件挂载期间保留;切换预览不自动轮播,FAQ展开不发网络请求。
|
||||
|
||||
@@ -13,7 +13,8 @@ export const FeedbackContext = createContext(false);
|
||||
|
||||
/** 包住页面一次即可共享提示延迟;400ms首次出现,200ms内切换提示可跳过等待。
|
||||
* Toaster放在右下角,最多展示三条通知,默认4500ms消失并允许关闭;颜色读取主题变量。
|
||||
* children正常透传,本组件不订阅文档、队列或服务端状态。 */
|
||||
* children正常透传,本组件不订阅文档、队列或服务端状态。
|
||||
* visibleToasts 限制同时可见数量,不是业务事件队列容量;通知创建/撤销仍通过通知适配器调用。 */
|
||||
export function AppFeedback({ children }: { children: ReactNode }) {
|
||||
return <FeedbackContext.Provider value={true}>
|
||||
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface ButtonProps
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
// 只选择宿主,不另包一层DOM;cn合并变体和调用方样式,业务逻辑不进入这个封装。
|
||||
// asChild 不把链接转换成原生按钮:disabled 属性本身不保证链接无法导航,调用方须按宿主处理行为。
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -40,7 +40,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
/** 组合Portal、遮罩与居中内容面板,并在children之后固定附带右上角关闭按钮。
|
||||
* 面板ref和剩余属性交给Radix Content,保留焦点及外部交互事件;关闭按钮有读屏文字。
|
||||
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。 */
|
||||
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。
|
||||
* 剩余 props 只传 Content;当前调用没有把 Overlay 的属性入口一并暴露给 DialogContent。 */
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
|
||||
@@ -21,6 +21,7 @@ export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "chi
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
|
||||
// Provider存在时由Radix显示提示,移除原生title以免出现两层提示;图标自身对读屏隐藏。
|
||||
// 后置 ...props 可以覆盖 aria-label 等同名属性;label 是默认名称,最终 DOM 语义由调用者共同负责。
|
||||
const feedback = useContext(FeedbackContext);
|
||||
const button = (
|
||||
<button
|
||||
|
||||
@@ -29,6 +29,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
||||
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
// busy 是本次 render 的状态值,限制后续渲染后的重复提交;不是同步 ref 互斥或持久操作幂等键。
|
||||
if (busy) return;
|
||||
const data = new FormData(event.currentTarget);
|
||||
setBusy(true);
|
||||
@@ -38,6 +39,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
||||
if (settings) {
|
||||
const input = { display_name: String(data.get("display_name")).trim(), username: String(data.get("username")).trim() };
|
||||
const updated = demo ? { ...profile, ...input } : await api<Profile>("profile", { method: "PATCH", body: JSON.stringify(input) });
|
||||
// 宿主回调也在 try 内;若回调抛错,会显示失败,但已成功的服务端修改不会因此回滚。
|
||||
onProfile(updated);
|
||||
} else {
|
||||
const name = String(data.get("name")).trim();
|
||||
@@ -49,6 +51,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// 请求没有本地 AbortController/结果代次标记;受控 Dialog 阻止普通关闭,父级强制卸载仍由宿主管理。
|
||||
// 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
|
||||
|
||||
@@ -17,6 +17,7 @@ const ActiveBoardContext = createContext<{
|
||||
|
||||
/** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */
|
||||
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
|
||||
// Provider 本身不监听路由/登出,也不推断哪条异步结果较新;发布、清空及身份核对由调用方负责。
|
||||
const [board, setBoard] = useState<ActiveBoard>(null);
|
||||
const value = useMemo(() => ({ board, setBoard }), [board]);
|
||||
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
|
||||
|
||||
@@ -13,6 +13,7 @@ import styles from "./workspace.module.css";
|
||||
/** branches只随boards/query重算,closed是当前挂载视图局部状态,宿主切工作区可用key重建。 */
|
||||
export function BoardDirectory({ boards, workspace, active, query, demo }: { boards: Board[]; workspace: string; active?: string; query: string; demo: boolean }) {
|
||||
const [closed, setClosed] = useState(new Set<string>());
|
||||
// memo 按 boards 引用重算;宿主更新条目须提供新数组,不能只原地改标题/父链再期待重新派生。
|
||||
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
|
||||
/** 活动画布或目录刷新时展开其祖先路径,防当前页藏在折叠树下。 */
|
||||
useEffect(() => {
|
||||
@@ -22,6 +23,7 @@ export function BoardDirectory({ boards, workspace, active, query, demo }: { boa
|
||||
/** 递归渲染树,视觉缩进最多8层但不裁掉更深子项;搜索时强制展开匹配路径而不清空用户closed记录。 */
|
||||
function render(items: BoardBranch[], depth = 0) {
|
||||
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
|
||||
// 搜索中的切换只修改 closed 记录,当前仍强制展开;清掉搜索后才看到用户折叠记录的效果。
|
||||
const expanded = !!query.trim() || !closed.has(board.id);
|
||||
return <li key={board.id}><div className={styles.directoryRow} style={{ paddingLeft: Math.min(depth, 8) * 16 }}>
|
||||
{children.length ? <button type="button" className={styles.directoryToggle} aria-label={`${expanded ? "收起" : "展开"}${board.title}的子画布`} aria-expanded={expanded} onClick={() => setClosed((previous) => { const next = new Set(previous); if (expanded) next.add(board.id); else next.delete(board.id); return next; })}><ChevronRight size={13} style={{ transform: expanded ? "rotate(90deg)" : undefined }} /></button> : <span className={styles.directorySpacer} />}
|
||||
|
||||
@@ -23,6 +23,7 @@ const Renderers=createContext(defaultCanvasRenderers);
|
||||
export function useCanvasRenderers(){return useContext(Renderers);}
|
||||
/** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */
|
||||
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
|
||||
// 惰性初始化只装配对象/工厂,不在 render 直接打开项目;实际账户绑定和持久化租约由后续调用取得。
|
||||
const [{host,renderers}] = useState(() => {
|
||||
// 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。
|
||||
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
|
||||
@@ -51,6 +52,7 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN
|
||||
* 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */
|
||||
useEffect(() => {
|
||||
const generation = ++lifetime.current;
|
||||
// cleanup 不等待 close 完成,也不主动重试;异步失败经 host 的错误通道和此处 catch 报告。
|
||||
// Strict Mode replays effects synchronously; only a real detach retires the host.
|
||||
return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); };
|
||||
}, [host]);
|
||||
|
||||
@@ -53,6 +53,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
cancelled.current = true;
|
||||
setError("");
|
||||
setMode("selected");
|
||||
// 不保存帧句柄做卸载取消;执行时通过 ref 可选访问,目标已卸载则不聚焦。
|
||||
requestAnimationFrame(() => button.current?.focus());
|
||||
}
|
||||
/** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。
|
||||
@@ -70,6 +71,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
setMode("saving");
|
||||
setError("");
|
||||
try {
|
||||
// onSave 没有本组件的 signal/代次护栏;切换目标或卸载后的结果隔离需由宿主回调保证。
|
||||
await onSave(name);
|
||||
cancelled.current = true;
|
||||
setMode("idle");
|
||||
@@ -78,6 +80,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
setMode("editing");
|
||||
} finally { pending.current = false; }
|
||||
}
|
||||
// title prop 改变不自动覆盖正在编辑的 draft;同名判断使用本次 save 闭包看到的 title。
|
||||
// saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。
|
||||
return (
|
||||
<div ref={shell} className={`${styles.shell} ${className}`}>
|
||||
|
||||
@@ -61,6 +61,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
// latest合并待采用的预览,pumping串行openPartial/persist,防快流把多个partial打开交错到同一项目。
|
||||
let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|undefined;
|
||||
/** 后台任务失败仅在页面仍活跃时显示,离开页面不再更新本页error。 */
|
||||
// 此错误显示只检查页面 active;它不像结果采用分支那样再核对 currentIdentity。
|
||||
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
|
||||
/** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */
|
||||
const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
|
||||
@@ -68,6 +69,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
function pump(){
|
||||
if(pumping)return pumping;
|
||||
const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&¤tIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
|
||||
// finally 清空运行句柄,再对等待槽续泵;latest 只留最近预览,并非逐块持久操作队列。
|
||||
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
|
||||
}
|
||||
|
||||
@@ -91,6 +93,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
catch(error){reportBackground(error);}
|
||||
}
|
||||
// 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。
|
||||
// 任一次泵拒绝会沿外层 catch 显示错误,不继续把完整结果冒充已经成功采用。
|
||||
while(pumping)await pumping;
|
||||
if(!active||!currentIdentity?.())return;
|
||||
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken});
|
||||
@@ -101,12 +104,14 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
|
||||
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
|
||||
// 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。
|
||||
// route 只包含两个路径参数,不包含 demo/retry/authEpoch;同路由这些值变化时由 effect 清状态和取消护栏处理。
|
||||
const current = loaded?.route === route ? loaded : null;
|
||||
const currentPreview=preview?.route===route?preview:null;
|
||||
const board = current?.board??currentPreview?.board;
|
||||
/** 发布当前board元数据供侧栏标题使用,卸载/换board归还该Context显示状态,不删除服务端board。 */
|
||||
useEffect(() => {
|
||||
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
|
||||
// 这是当前页面的无条件清空,不删除 runtime 或目录记录,也不是按 boardId 比对的条件撤回。
|
||||
return () => setActiveBoard(null);
|
||||
}, [board, workspaceId, setActiveBoard]);
|
||||
// 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
import { useState } from "react";
|
||||
import styles from "./workspace.module.css";
|
||||
|
||||
/** 同一失败URL不反复重载;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 */
|
||||
/** 只记住最近一次失败URL;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。
|
||||
* 换 URL 可以再试,若之后回到较早失败的 URL 也可能重试;不是永久失败地址集合。 */
|
||||
export function UserAvatar({ name, url, large = false }: {
|
||||
name: string;
|
||||
url?: string | null;
|
||||
large?: boolean;
|
||||
}) {
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
||||
// fallback 用 slice(0,1) 取首个 UTF-16 代码单元,并非完整 Unicode 字素;空名字得到空占位。
|
||||
const showImage = Boolean(url && url !== failedUrl);
|
||||
return (
|
||||
<span className={`${styles.avatar} ${large ? styles.avatarLarge : ""}`}>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
/** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
/** 旧上传接口的素材响应形状,原件规范URL与尺寸由返回前校验。 */
|
||||
/** 旧上传接口的素材响应形状;返回前检查 ID、URL 的源站/路径/查询和尺寸,不完整验证所有字段。 */
|
||||
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
|
||||
/** 上传原文件大小上限20MiB,空文件同样拒绝。 */
|
||||
export const ASSET_LIMIT = 20 * 1024 * 1024;
|
||||
@@ -12,6 +12,7 @@ export const ASSET_LIMIT = 20 * 1024 * 1024;
|
||||
* 调用方可提供key以跨尝试复用;默认key只在这次函数调用开始时生成,不是持久恢复记录。 */
|
||||
export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal, key = crypto.randomUUID()): Promise<CanvasAsset> {
|
||||
if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large");
|
||||
// 文件大小是本地发送前检查;不在这里解码图片、计算内容摘要或确认 mime_type。
|
||||
const form = new FormData(); form.append("image", file, name);
|
||||
/** 两次发送共用同一字节、文件名和key,避免丢响应后新建第二份素材。 */
|
||||
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
|
||||
@@ -25,5 +26,6 @@ export async function uploadCanvasImage(request: Request, workspace: string, fil
|
||||
let url: URL;
|
||||
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
|
||||
if (!/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(asset.id) || url.origin !== "https://api.foundry.entroia.com" || url.pathname !== `/projects/${process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"}/workspaces/${workspace}/assets/${asset.id}` || url.search || !Number.isFinite(asset.width) || asset.width < 1 || !Number.isFinite(asset.height) || asset.height < 1) throw new Error("invalid_asset_response");
|
||||
// bytes/mime_type 未在此逐项核验,URL hash/用户名密码也未另设拒绝条件;不能视为新版资源权威证明。
|
||||
return asset;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export function curvePath(segments: Cubic[]) { return `M ${segments[0][0].x},${s
|
||||
export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["path"], point?: XYPosition) {
|
||||
const segments = curveSegments(from, to, path);
|
||||
let selected = Math.floor(segments.length / 2), t = .5, distance = Infinity;
|
||||
// 只采内部参数 .025~.975;距离相同时保留先遇到的段/采样点,不会选择端点 t=0 或1。
|
||||
if (point) segments.forEach((segment, index) => { for (let step = 1; step < 40; step++) {
|
||||
const p = cubicPoint(segment, step / 40), d = Math.hypot(p.x - point.x, p.y - point.y);
|
||||
if (d < distance) { distance = d; selected = index; t = step / 40; }
|
||||
@@ -33,6 +34,7 @@ export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["
|
||||
// 逐层插值求左右子曲线,anchor成为新结点,abc/bcd决定其两侧相对切线。
|
||||
const [a, b, c, d] = segments[selected];
|
||||
const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t), abc = lerp(ab, bc, t), bcd = lerp(bc, cd, t), anchor = lerp(abc, bcd, t);
|
||||
// clone 隔离已有 knots;返回的 knot 同时就是 next.knots 中插入的对象,二者并非独立副本。
|
||||
const next = structuredClone(path || { type: "bezier-chain" as const, knots: [] });
|
||||
const knot = { id: crypto.randomUUID(), position: anchor, in: minus(abc, anchor), out: minus(bcd, anchor), mode: "smooth" as const };
|
||||
// 同时调整相邻旧结点或首尾控制向量,否则插入后无法保留原曲线。
|
||||
|
||||
@@ -25,11 +25,13 @@ export abstract class CanvasElement {
|
||||
readonly src?: string;
|
||||
/** 构造业务创建参数;position在toNode输出时复制,尚未创建节点身份或渲染对象。 */
|
||||
constructor(position: XYPosition, text = "", src?: string) {
|
||||
// 构造器暂借调用者的位置对象;若转换前被外部修改,toNode 会采用修改后的坐标。
|
||||
this.position = position;
|
||||
this.text = text;
|
||||
this.src = src;
|
||||
}
|
||||
/** 每次转换都生成新UUID与独立position/data/style,字体默认随plain-text与卡片类型选择。 */
|
||||
// 同一个业务实例多次 toNode 也会生成不同节点身份,并非按实例 ID 幂等转换。
|
||||
toNode(): CanvasNode {
|
||||
return {
|
||||
id: crypto.randomUUID(), type: this.nodeType, position: { ...this.position },
|
||||
@@ -87,6 +89,7 @@ export class PlainImageElement extends CanvasElement {
|
||||
/** 封闭内置kind→构造器表,是旧元素工厂入口,不接受任意未知kind动态执行。 */
|
||||
export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement, icon: IconElement, frame: FrameElement, "plain-image": PlainImageElement };
|
||||
/** 按kind读取静态能力,调用方需先保证kind属于内置枚举。 */
|
||||
// readonly 只约束静态属性重新赋值;能力对象仍是共享引用,调用方不能原地改动返回值。
|
||||
export function elementCapabilities(kind: ElementKind) { return elementClasses[kind].capabilities; }
|
||||
/** 创建指定内置类型的空内容节点,src仅作为创建参数传入;不自动上传文件或登记history。 */
|
||||
export function createCanvasElement(kind: ElementKind, position: XYPosition, src?: string): CanvasNode {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { nodeCenter, nodeSize } from "./canvas-geometry.ts";
|
||||
/** 非frame节点按中心点找包含它的分区,多个重叠候选选面积最小者;frame本身不成为另一frame成员。 */
|
||||
export function containingFrame(node: CanvasNode, nodes: CanvasNode[]) {
|
||||
if (node.data.kind === "frame") return undefined;
|
||||
// 以未旋转布局矩形判包含,边界算内部;不是对旋转后的可见形状做精确命中。
|
||||
const center = nodeCenter(node);
|
||||
return nodes.filter((frame) => {
|
||||
if (frame.data.kind !== "frame") return false;
|
||||
@@ -44,8 +45,10 @@ export function assignFrameMembership(nodes: CanvasNode[], droppedIds: string[])
|
||||
export function applyFrameNodeChanges(changes: NodeChange<CanvasNode>[], previous: CanvasNode[]) {
|
||||
const nodes = applyNodeChanges(changes, previous);
|
||||
const before = new Map(previous.map((node) => [node.id, node]));
|
||||
// 仅带实际 position 值的变更计入显式移动;dragging 标记变化本身不触发成员平移。
|
||||
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]));
|
||||
// 单次 map 按原 frameId 计算直接父位移,不递归累积嵌套分区;合法旧数据约定 frame 不归另一 frame。
|
||||
return nodes.map((node) => {
|
||||
const frameId = node.data.frameId;
|
||||
if (!frameId) return node;
|
||||
|
||||
@@ -46,10 +46,12 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
|
||||
const rotation = node.data.rotation || 0;
|
||||
// 指针位移先消除节点旋转,才能区分拖动的是本地宽还是高。
|
||||
const local = rotateVector(delta, -rotation);
|
||||
// delta 是手势起点到当前的场景位移,屏幕缩放换算需由调用者提前完成。
|
||||
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" || node.data.kind === "plain-image";
|
||||
// viewScale 只用于尺寸约束;此处不校验正有限值,0 会按 || 回退成1。
|
||||
const viewScale = node.data.viewScale || 1;
|
||||
// 约束随显示viewScale缩放;frame可更大,普通卡片/文字/图标最小尺寸各有不同。
|
||||
const maximum = node.data.kind === "frame" ? 100000 * viewScale : 3000 * viewScale;
|
||||
@@ -84,6 +86,7 @@ export type Connector = { point: XYPosition; normal: XYPosition };
|
||||
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 } };
|
||||
// side 由联合类型限制,不在运行时回退未知端口;这里的法线假设为四个单位轴向量。
|
||||
const local = vectors[side];
|
||||
const angle = node.data.rotation || 0;
|
||||
const normal = rotateVector(local, angle);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createResourceLoader } from "./resource-loader.ts";
|
||||
export type IconPaths = [string, Record<string, string | number>][];
|
||||
/** Tabler统一分片,其它名字用稳定31进制散列落入96个Lucide分片;散列仅用于定位而非安全校验。 */
|
||||
export function iconAsset(name: string) {
|
||||
// 每步 >>>0 约束为无符号32位;名称大小写参与散列,不能擅自归一化后再查分片。
|
||||
let hash = 0;
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
||||
const key = name.startsWith("Tabler") ? "tabler" : `lucide-${hash % 96}`;
|
||||
@@ -31,9 +32,11 @@ export function validateIconShard(value: unknown): Record<string, IconPaths> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 校验通过后原对象直接返回,不复制/冻结路径和属性;消费者必须把共享结果当只读数据。
|
||||
return value as Record<string, IconPaths>;
|
||||
}
|
||||
/** 单例分片加载器使用HTTP强缓存,响应失败或校验失败不写入成功缓存。 */
|
||||
/** 单例加载器以 force-cache 请求浏览器缓存,并在内存缓存校验后的分片;不保证服务端缓存期限。
|
||||
* 响应失败或校验失败不写入成功缓存;先整体读取 JSON,不限制下载字节数或分片图标总数。 */
|
||||
const resources = createResourceLoader(async (url, signal) => {
|
||||
const response = await fetch(url, { signal, cache: "force-cache" });
|
||||
if (!response.ok) throw new Error("图标加载失败");
|
||||
|
||||
@@ -19,6 +19,7 @@ export function subgraphMoveParticipants(documents: Map<string, CanvasDocument>,
|
||||
for (const node of documents.get(board)?.nodes || []) if (node.data.subgraph) addDescendants(node.data.subgraph.boardId, seen);
|
||||
};
|
||||
for (const node of documents.get(source)?.nodes || []) if (ids.includes(node.id) && node.data.subgraph) addDescendants(node.data.subgraph.boardId);
|
||||
// 祖先端口影响只检查 parent 的直接上一层及其一层内端口映射,不是无限上溯的依赖闭包。
|
||||
const ancestor = parents.get(parent);
|
||||
if (ancestor) {
|
||||
const frame = documents.get(ancestor.boardId)?.nodes.find((node) => node.id === ancestor.frameId);
|
||||
@@ -36,6 +37,7 @@ export function subgraphMovePath(documents: Map<string, CanvasDocument>, source:
|
||||
const parents = subgraphParents(documents);
|
||||
/** 包含自身的父链,重复board立即拒绝,避免循环遍历。 */
|
||||
const chain = (board: string) => { const result = [board]; while (parents.has(board)) { board = parents.get(board)!.boardId; if (result.includes(board)) throw new Error("subgraph_cycle"); result.push(board); } return result; };
|
||||
// 相同 source/target 得到空路径;父索引只反映已加载 documents,缺数据不能据此认定全局无共同根。
|
||||
const from = chain(source), to = chain(target), common = from.find((id) => to.includes(id));
|
||||
if (!common) throw new Error("ownership_conflict");
|
||||
const path: { parent: string; frame: string; source: string; target: string }[] = [];
|
||||
@@ -56,6 +58,7 @@ export function exposedConnection(documents: Map<string, CanvasDocument>, source
|
||||
while (boardId !== owner) {
|
||||
const parent = parents.get(boardId)!;
|
||||
const frame = documents.get(parent.boardId)?.nodes.find((node) => node.id === parent.frameId);
|
||||
// handle 按 === 精确匹配,包括 undefined 与 null 的差别;不会在这里规范化端口表示。
|
||||
const port = frame?.data.ports?.find((port) => port.nodeId === id && port.handle === handle && port.direction === direction);
|
||||
if (!port) throw new Error("boundary_port_required");
|
||||
id = parent.frameId; handle = `port:${port.id}`; boardId = parent.boardId;
|
||||
@@ -71,5 +74,6 @@ export function connectionTransactionPlan(documents: Map<string, CanvasDocument>
|
||||
const path = (id: string) => { const result = [id]; while (parents.has(id)) { id = parents.get(id)!.boardId; if (result.includes(id)) throw new Error("subgraph_cycle"); result.push(id); } return result; };
|
||||
const from = path(source.boardId), to = path(target.boardId), owner = from.find((id) => to.includes(id));
|
||||
if (!owner) throw new Error("ownership_conflict");
|
||||
// 参与集按 source→owner、target→owner 首次出现顺序去重;这只是规划,不带服务版本或原子提交证明。
|
||||
return { boardId: owner, participants: [...new Set([...from.slice(0, from.indexOf(owner) + 1), ...to.slice(0, to.indexOf(owner) + 1)])], source: { board_id: source.boardId, node_id: source.id, handle: source.handle }, target: { board_id: target.boardId, node_id: target.id, handle: target.handle } };
|
||||
}
|
||||
|
||||
@@ -73,12 +73,14 @@ export class CanvasSync {
|
||||
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.savedContent = canvasFingerprint(initial.document, false); this.notify = notify;
|
||||
// 保留文档引用,不做构造时解码/深复制;宿主须传入合法快照且避免原地修改在途对象。
|
||||
this.document = initial.document;
|
||||
}
|
||||
/** 只有持久内容变化才需PATCH,单纯平移/缩放视口不主动写;内容保存时payload仍携当前viewport。 */
|
||||
needsSave(document: CanvasDocument) { return canvasFingerprint(document, false) !== this.savedContent; }
|
||||
/** 替换等待槽,有错误时只保留内容而不自动发送;无错误且未运行时启动一个微任务drain。 */
|
||||
enqueue(document: CanvasDocument): Promise<void> {
|
||||
// 等待槽借用传入引用;若宿主随后原地修改,drain 读到的内容也会改变,必须以新对象提交编辑。
|
||||
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); });
|
||||
@@ -99,6 +101,7 @@ export class CanvasSync {
|
||||
private async drain() {
|
||||
while (this.pending) {
|
||||
const document = this.pending; this.pending = null;
|
||||
// saving 通知在 try 外;notify 应不抛错,否则 running 会拒绝且本次 document 不进入失败回填。
|
||||
this.notify({ status: "saving", version: this.version });
|
||||
try {
|
||||
const fingerprint = canvasFingerprint(document);
|
||||
@@ -110,6 +113,7 @@ export class CanvasSync {
|
||||
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${this.version}"` }, body });
|
||||
if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot");
|
||||
// 只有通过版本回执后推进保存基线;在途期间新enqueue留在pending供下一轮处理。
|
||||
// 下方保存状态回调仍在 try 内;它若抛错会走 catch,此时版本和部分保存状态已经推进。
|
||||
this.version = result.version; this.saved = fingerprint; this.savedContent = canvasFingerprint(document, false);
|
||||
this.document = canvasPayload(document);
|
||||
this.notify({ status: "saved", version: this.version });
|
||||
@@ -128,6 +132,7 @@ export class CanvasSync {
|
||||
* 格式错误继续抛出,不将损坏草稿当作空白文档自动覆盖。 */
|
||||
export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string, snapshot?: CanvasSnapshot): CanvasDocument | null {
|
||||
const record = storage.getItem(key + ":recovery");
|
||||
// 新键存在且内容非空便独占此次恢复判断;即使返回 null 也不再尝试旧键。
|
||||
if (record) {
|
||||
const value = JSON.parse(record);
|
||||
const doc = decodeCanvas(JSON.stringify(value.document));
|
||||
@@ -144,5 +149,6 @@ export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string
|
||||
export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) {
|
||||
if (draft.schema_version < remote.schema_version) return false;
|
||||
const relations = (document: CanvasDocument) => document.nodes.filter((node) => node.data.subgraph).map((node) => ({ id: node.id, subgraph: node.data.subgraph, ports: node.data.ports || [] })).sort((a, b) => a.id.localeCompare(b.id));
|
||||
// 端口数组保留顺序,JSON 表示参与比较;不比较正文、几何或所有跨图端点。
|
||||
return JSON.stringify(relations(draft)) === JSON.stringify(relations(remote));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type CanvasDocuments = Map<string, CanvasDocument>;
|
||||
/** 根board保留原ID,其余加board::前缀,避免旧协议不同board里的同名元素显示碰撞。 */
|
||||
export const displayID = (root: string, board: string, id: string) => root === board ? id : `${board}::${id}`;
|
||||
/** 路径位置点应用平移+缩放,切线/首尾控制量是向量只缩放;inverse将显示路径转换回owner空间。
|
||||
* 没有path时复制data,避免调用方修改投影时改变原始对象。 */
|
||||
* 没有path时深复制data;有path时只重建路径几何层,其余扩展对象仍可能与原data共享引用。 */
|
||||
export function transformEdgeData(data: EdgeData | undefined, reference: CanvasReference, inverse = false): EdgeData | undefined {
|
||||
if (!data?.path) return data ? structuredClone(data) : undefined;
|
||||
const { origin, scale } = reference;
|
||||
@@ -51,6 +51,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
for (const edge of document.edges) pending.push({ edge, ref: reference });
|
||||
}
|
||||
visit(root, { x: 0, y: 0 }, 1, new Set());
|
||||
// 仅遍历祖先链防环,不全局去重同一子 board 的多入口;调用方须先保证单父树和显示 ID 合法。
|
||||
const visible = new Set(nodes.map((node) => node.id));
|
||||
/** 展开入口且端口有效时递归寻内层显示端点;找不到已显示内层就保留外侧入口端口。 */
|
||||
function endpoint(board: string, nodeId: string, handle: string | null | undefined, depth = 0): { id: string; handle?: string | null } {
|
||||
@@ -75,6 +76,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
/** A selected child and its selected parent frame must not receive the same drag twice. */
|
||||
/** 父框与子节点同时选中拖动时,先递归取得父框移动后的显示原点,避免同一拖动位移叠加两次。 */
|
||||
export function referenceAfterMoves(root: string, documents: CanvasDocuments, boards: Map<string, CanvasReference>, boardId: string, moves: Map<string, { x: number; y: number }>): CanvasReference {
|
||||
// boards 必须来自合法投影;此递归不另建 seen 或深度限制,不接受任意含环父引用表。
|
||||
const reference = boards.get(boardId);
|
||||
if (!reference) throw new Error("invalid_reference");
|
||||
if (boardId === root || !reference.parentBoard || !reference.parentFrame) return reference;
|
||||
@@ -94,6 +96,7 @@ const equal = (a: unknown, b: unknown) => JSON.stringify(a, (_key, value) => val
|
||||
export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument, remote: CanvasDocument): CanvasDocument {
|
||||
/** 仅普通对象可逐字段递归,数组/标量两侧不同改动不会“最后写入获胜”,而是抛version_conflict。 */
|
||||
const merge = (base: unknown, local: unknown, remote: unknown): unknown => {
|
||||
// 未改变的分支直接借用另一侧对象;本合并不是全图深复制,采用后仍须遵守文档不可变约定。
|
||||
if (equal(local, base)) return remote;
|
||||
if (equal(remote, base) || equal(local, remote)) return local;
|
||||
if ([base, local, remote].every((v) => !!v && typeof v === "object" && !Array.isArray(v))) {
|
||||
@@ -109,6 +112,7 @@ export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument
|
||||
}
|
||||
/** 排除选择/拖动/测量/style这些框架字段,它们不该引发业务字段合并冲突。 */
|
||||
const strip = <T extends { selected?: boolean; dragging?: boolean; measured?: unknown; style?: unknown }>(value: T) => { const { selected, dragging, measured, style, ...clean } = value; return clean; };
|
||||
// schema 取两侧最大值只选择表示版本,不执行格式迁移或最终整图解码验证。
|
||||
const result = {
|
||||
schema_version: Math.max(local.schema_version, remote.schema_version) as 1 | 2,
|
||||
nodes: collection(base.nodes.map(strip), local.nodes.map(strip), remote.nodes.map(strip)) as CanvasNode[],
|
||||
|
||||
@@ -19,9 +19,10 @@ export const pointValid = (value: unknown): value is XYPosition => !!value && ty
|
||||
export function fields(value: unknown, keys: string[]): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => keys.includes(key));
|
||||
}
|
||||
/** 所有v2校验失败统一抛invalid_canvas,不返回半份有效扩展。 */
|
||||
/** 显式条件校验失败统一抛 invalid_canvas;JSON.stringify/structuredClone 等原生异常仍可能直接传播。 */
|
||||
function check(value: boolean) { if (!value) throw new Error("invalid_canvas"); }
|
||||
/** 字段缺失可通过,字段存在时必须满足校验;存在但undefined不等同于缺失。 */
|
||||
// 使用 in 同时识别继承字段;外部 JSON 解码结果通常是普通自有字段对象,勿把此助手视为原型净化。
|
||||
function optional(object: Record<string, unknown>, key: string, valid: (value: unknown) => boolean) { check(!(key in object) || valid(object[key])); }
|
||||
/** 构造封闭枚举校验器,不接受任意扩展字符串。 */
|
||||
const choice = (...values: unknown[]) => (value: unknown) => values.includes(value);
|
||||
@@ -76,6 +77,7 @@ export function decodeNodeExtensions(data: Record<string, unknown>, schema: numb
|
||||
check(["text", "script", "plain-text", "image", "plain-image"].includes(String(data.kind)) && fields(data.ai, ["enabled", "modelId", "prompt", "propagate", "parameters"])); const ai = data.ai as Record<string, unknown>;
|
||||
optional(ai, "enabled", bool); optional(ai, "propagate", bool); optional(ai, "modelId", (v) => typeof v === "string" && v.length > 0 && v.length <= 256 && v.trim() === v);
|
||||
optional(ai, "prompt", (v) => typeof v === "string" && [...v].length <= 16000); optional(ai, "parameters", (v) => !!v && typeof v === "object" && !Array.isArray(v) && new TextEncoder().encode(JSON.stringify(v)).length <= 16384);
|
||||
// parameters 这里只检查对象外壳和 JSON 编码大小;不解析各模型参数 schema 或证明服务端支持。
|
||||
extensions.ai = structuredClone(ai) as AIConfig;
|
||||
}
|
||||
if (["subgraph", "ports", "presentation"].some((key) => key in data)) check(data.kind === "frame");
|
||||
@@ -99,6 +101,7 @@ export function decodeNodeExtensions(data: Record<string, unknown>, schema: numb
|
||||
* 内联、demo或其它地址原样返回,不把任意HTTPS资源都伪装成受管资源。 */
|
||||
export function managedImageSource(src?: string, preview = false) {
|
||||
if (!src) return src;
|
||||
// 此兼容路径固定项目名 endoleap,不读取 NEXT_PUBLIC_FOUNDRY_PROJECT;命中后舍弃原查询/片段。
|
||||
try { const url = new URL(src); const match = url.pathname.match(/^\/projects\/endoleap\/(workspaces\/[A-Za-z0-9_-]+\/assets\/[A-Za-z0-9_-]+)$/);
|
||||
if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1] + (preview ? "?variant=preview" : "");
|
||||
} catch { /* inline and demo images are already browser-readable */ }
|
||||
|
||||
@@ -45,12 +45,14 @@ export function canvasStorageKey(workspace: string, board: string) {
|
||||
return `endoleap_canvas_v1:${workspace}:${board}`;
|
||||
}
|
||||
/** 把旧present压入past,截留最近40项并清空redo;checkpoint可记录相同文档对象作为手势起点。 */
|
||||
// 仅保存文档引用,不深复制或做相等判断;调用方必须保持历史快照不可变,空修改也可能占一个撤销项。
|
||||
function recorded(state: CanvasHistory, present: CanvasDocument): CanvasHistory {
|
||||
return { past: [...state.past, state.present].slice(-40), present, future: [] };
|
||||
}
|
||||
/** 纯快照归约。动作是否产生撤销单元由各分支明确决定,不能假定每次pointermove或字符输入都入栈。 */
|
||||
export function canvasReducer(state: CanvasHistory, action: CanvasAction): CanvasHistory {
|
||||
switch (action.type) {
|
||||
// patch 直接合入 data 并升级表示版本;reducer 不调用 schema 解码,输入合法性由动作发起方保证。
|
||||
case "node-data": return recorded(state, { ...state.present, schema_version: 2, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, ...action.patch } } : node) });
|
||||
// 连线预览可只改present并清redo,最终逻辑编辑则记录旧快照。
|
||||
case "edge-data": {
|
||||
@@ -58,7 +60,9 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
return action.transient ? { ...state, present, future: [] } : recorded(state, present);
|
||||
}
|
||||
// 放下内容才重新算旧frameId归属;拖动/缩放分区本身不在此改变成员关系。
|
||||
// drop 沿用已有 past/future,不清 redo;宿主须将它与实际拖动的 checkpoint/transform 边界配合使用。
|
||||
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) });
|
||||
// 交互起点可显式入栈,后续edit/transform不逐帧增加历史项。
|
||||
@@ -87,6 +91,7 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
case "style": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, textStyle: { ...node.data.textStyle, ...action.style }, ...(action.fontSize !== undefined ? { fontSize: action.fontSize } : {}) } } : node) });
|
||||
// 连续几何变化同步修改width/height、measured及style;子图frame保存对应展开/折叠尺寸。
|
||||
case "transform": {
|
||||
// 宽高必须同时给出才更新尺寸;只给单轴时该轴也不生效,position/rotation/fontSize 各自独立。
|
||||
const patch = action.transform;
|
||||
return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id !== action.id ? node : {
|
||||
...node,
|
||||
@@ -146,6 +151,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction
|
||||
else position.x += 300;
|
||||
}
|
||||
// 仅选中新卡片并分配独立边ID;目标handle取旋转后主方向的反侧,源handle仍保留指定方向名。
|
||||
// at 一旦提供便覆盖上述避让位置;指定落点不会再验证重叠,返回文档也不自动登记 history。
|
||||
const child = { ...makeCanvasNode(kind, at || position, src), selected: true };
|
||||
const opposite: Record<NodeDirection, NodeDirection> = { top: "bottom", bottom: "top", left: "right", right: "left" };
|
||||
return {
|
||||
@@ -158,6 +164,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction
|
||||
/** 接受有限长度的允许格式base64内联图或无用户名/密码的HTTPS源,拒绝其它scheme。 */
|
||||
function validImage(src: unknown): src is string {
|
||||
if (typeof src !== "string") return false;
|
||||
// 只检查字符串表示和长度,不验证 base64 解码字节、图片格式或远端可访问性。
|
||||
if (/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(src)) return src.length < 3_000_000;
|
||||
try { const url = new URL(src); return url.protocol === "https:" && !url.username && !url.password && src.length < 2048; }
|
||||
catch { return false; }
|
||||
@@ -182,6 +189,7 @@ export function decodeTextStyle(value: unknown): import("./canvas-elements.ts").
|
||||
* 校验单board内容和其端口外壳,跨board子树/目标存在性需要上层联合加载与事务校验。 */
|
||||
export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (new TextEncoder().encode(raw).length > 5 * 1024 * 1024) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
|
||||
// JSON.parse 异常直接传播;整个文件先入内存再解释,字节上限不是流式内存预算。
|
||||
const value = JSON.parse(raw);
|
||||
if (!value || ![1, 2].includes(value.schema_version) || !Array.isArray(value.nodes) || !Array.isArray(value.edges) || value.nodes.length > 500 || value.edges.length > 2000)
|
||||
throw new Error("不是有效的啡跃图谱文件。");
|
||||
@@ -221,6 +229,7 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
// frameId仅可引用本board真实frame,不能让旧平面成员关系指向一个普通节点。
|
||||
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("分区归属不存在。");
|
||||
// 以下校验本 board 的端点/端口语法,不检测生成执行图环或加载子 board 验证端口内部目标。
|
||||
const nodeKinds = new Map(nodes.map((node) => [node.id, node.data.kind]));
|
||||
const edgeIds = new Set<string>();
|
||||
// 边身份唯一且两端存在;v2分区必须通过声明的input/output port连接,其它允许节点使用四向handle。
|
||||
|
||||
@@ -9,10 +9,12 @@ import type { Board } from "./workspace";
|
||||
export function readDemoBoards(workspace: string): Board[] {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem("endoleap_demo_boards_" + workspace) || "null");
|
||||
// 合法空数组就是空目录,不触发样例回退;version 用 || 补默认,不验证正安全整数。
|
||||
if (Array.isArray(stored)) return stored.map((board) => ({
|
||||
...board, workspace_id: workspace, kind: "board", version: board.version || 1,
|
||||
}));
|
||||
} catch { /* Fall back to the sample pages when browser storage is invalid. */ }
|
||||
// map 中任一条目读取失败也由外围 catch 回退整份样例,不保留此前已映射的部分结果。
|
||||
return [
|
||||
{ id: "morning", title: "城市清晨" },
|
||||
{ id: "ideas", title: "灵感收集" },
|
||||
|
||||
Reference in New Issue
Block a user