diff --git a/src/lib/api.ts b/src/lib/api.ts index dca8ffa..66e3189 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,3 +1,9 @@ +/** + * 应用通用JSON/FormData请求与登录跳转辅助。请求默认经过同源Foundry代理,错误封装为ApiError, + * 界面通过errorText映射中文说明;业务schema和账户代次等更强校验由各领域适配器处理。 + * 本函数本身不设置超时或自动重试,取消依赖传入signal,代理传输时限在对应路由/响应封装中管理。 + */ +/** 账户资料响应的静态类型,不能用它代替登录会话有效性或权限校验。 */ export type Profile = { id: string; username: string; @@ -5,20 +11,25 @@ export type Profile = { email: string | null; avatar_url?: string | null; }; +/** 保留HTTP状态、服务details和原Retry-After头,message使用稳定错误码,供上层决定显示/重试。 */ export class ApiError extends Error { readonly status: number; readonly details?: unknown; readonly retryAfter?: string | null; + /** 以Error兼容方式封装服务错误,不把details自动拼进面向用户文案。 */ constructor(code: string, status: number, details?: unknown, retryAfter?: string | null) { super(code); this.name = "ApiError"; this.status = status; this.details = details; this.retryAfter = retryAfter; } } +/** 泛型T只是调用方的响应形状声明,不执行运行时schema检查。 + * 204返回null;其它响应必须可解析JSON,非2xx统一抛ApiError,成功body直接交业务层验证。 */ export async function api( path: string, options: RequestInit = {}, ): Promise { const res = await fetch("/api/foundry/" + path, { ...options, + // FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。 headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers }, }); let body: unknown; @@ -26,21 +37,27 @@ export async function api( else { try { body = await res.json(); } catch (error) { + // 消费body失败时优先传播明确取消原因;语法坏JSON与传输中断使用不同错误码。 if (options.signal?.aborted) throw options.signal.reason; const malformed = error instanceof SyntaxError; throw new ApiError(malformed ? "invalid_api_response" : "response_interrupted", res.ok ? 502 : res.status); } } const payload = body as { error?: string | { code?: string }; details?: unknown } | null; + // 服务error兼容字符串或{code},无可用码则request_failed,并保留Retry-After原值供上层解释。 if (!res.ok) { const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error; throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after")); } return body as T; } +/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */ export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"; +/** 创建PKCE verifier和SHA-256 challenge:verifier留在当前标签页sessionStorage, + * 只把challenge带到同源OAuth入口,后续交换流程负责取回verifier。此处不直接取得令牌。 */ export async function startOAuth(provider: "google" | "slack") { const bytes = crypto.getRandomValues(new Uint8Array(32)); + // 32随机字节编码为无填充base64url,避免普通base64字符影响URL参数。 const verifier = btoa(String.fromCharCode(...bytes)) .replace(/\+/g, "-") .replace(/\//g, "_") @@ -53,11 +70,14 @@ export async function startOAuth(provider: "google" | "slack") { .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); + // 页面跳转前保存原verifier;使用单一固定键,同标签页后一次发起会替换上一次值。 sessionStorage.setItem("foundry_verifier", verifier); window.location.assign("/api/oauth/" + provider + "?challenge=" + challenge); } +/** 应用错误码→用户文案适配,不改变任务状态或执行恢复;未知错误给通用提示,不直接泄漏内部异常文本。 */ export const errorText = (error: unknown) => { const code = error instanceof Error ? error.message : ""; + // 文案只解释既有领域状态;“保留/暂停”等实际行为须由触发错误的模块保证,不能由映射表证明。 const messages: Record = { invalid_model_stream: "画布返回的数据无法解析,请重新加载。", incomplete_model_stream: "画布数据未加载完整,请重新加载。", diff --git a/src/lib/canvas-curves.ts b/src/lib/canvas-curves.ts index 513abe5..da5211f 100644 --- a/src/lib/canvas-curves.ts +++ b/src/lib/canvas-curves.ts @@ -1,15 +1,28 @@ +/** + * 旧贝塞尔连线绘制与插入控制结点。基础分段复用core几何实现,插入用de Casteljau细分保持原曲线形状, + * 最近位置用有限采样近似,不把编辑辅助采样当作严格命中查询或精确最近点解算。 + */ import {bezierChainSegments} from './canvas-core/geometry.ts'; import type { XYPosition } from "@xyflow/react"; import type { Connector } from "./canvas-geometry"; import type { EdgeData } from "./canvas-v2"; +/** 一段三次贝塞尔的起点、两控制点和终点,全部处在同一显示坐标空间。 */ export type Cubic = [XYPosition, XYPosition, XYPosition, XYPosition]; +/** 点/向量相加,用于把相对控制向量还原成绝对控制点。 */ export const plus = (a: XYPosition, b: XYPosition) => ({ x: a.x + b.x, y: a.y + b.y }); +/** 差向量,用于把细分后的控制点转换回结点相对切线。 */ export const minus = (a: XYPosition, b: XYPosition) => ({ x: a.x - b.x, y: a.y - b.y }); +/** 线性插值,供de Casteljau逐层构造等价三次子曲线。 */ const lerp = (a: XYPosition, b: XYPosition, t: number) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }); +/** 从端点法线和可选贝塞尔链获得统一三次段列表,不单独复制另一套路径解释规则。 */ export function curveSegments(from:Connector,to:Connector,path?:EdgeData["path"]):Cubic[]{return bezierChainSegments(from,to,path) as Cubic[];} +/** 在参数t处用三层线性插值求曲线上一点,不修改原控制点。 */ export function cubicPoint([a, b, c, d]: Cubic, t: number) { const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t); return lerp(lerp(ab, bc, t), lerp(bc, cd, t), t); } +/** 将非空段列表拼成一个M及多个C命令;调用方负责提供有效几何和至少一段。 */ export function curvePath(segments: Cubic[]) { return `M ${segments[0][0].x},${segments[0][0].y} ` + segments.map(([, b, c, d]) => `C ${b.x},${b.y} ${c.x},${c.y} ${d.x},${d.y}`).join(" "); } /** Split the closest segment without altering its shape. */ +/** 给定指针时对每段内部39个采样点选最近者,无指针默认中间段t=.5。 + * 选点是近似,按所选t进行的曲线细分本身保持形状;返回新path与新UUID结点,不直接改history。 */ 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; @@ -17,13 +30,16 @@ export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData[" 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; } } }); + // 逐层插值求左右子曲线,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); 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 }; + // 同时调整相邻旧结点或首尾控制向量,否则插入后无法保留原曲线。 if (selected) next.knots[selected - 1].out = minus(ab, a); else next.startControl = minus(ab, a); if (selected < next.knots.length) next.knots[selected].in = minus(cd, d); else next.endControl = minus(cd, d); // Splitting one side of an existing symmetric knot can make its handle lengths differ. + // 仅细分一侧会破坏旧对称结点等长约束,降为smooth保留共线而不强行改变曲线。 for (const index of [selected - 1, selected]) if (next.knots[index]?.mode === "symmetric") next.knots[index].mode = "smooth"; next.knots.splice(selected, 0, knot); return { path: next, knot }; } diff --git a/src/lib/canvas-geometry.ts b/src/lib/canvas-geometry.ts index 04a9826..43c50c2 100644 --- a/src/lib/canvas-geometry.ts +++ b/src/lib/canvas-geometry.ts @@ -1,23 +1,36 @@ +/** + * 旧卡片/分区的二维交互几何。输入是显示节点及其起始几何,纯函数返回位置/尺寸/角度或锚点, + * 不修改节点、history或DOM;新版registry几何协议与旧布局约定分开管理。 + */ import type {CanvasGeometryNode as CanvasNode} from "./canvas-bindings/compat-types.ts"; +/** 旧几何计算的二维点/向量,具体坐标空间由调用者保证一致。 */ type XYPosition={x:number;y:number}; +/** 八向缩放手柄,边手柄只影响一轴,角手柄同时影响两轴。 */ export type ResizeDirection = "top" | "right" | "bottom" | "left" | "top-left" | "top-right" | "bottom-left" | "bottom-right"; +/** 返回的几何修改提案,可包含位置、尺寸、旋转或字号,不直接应用到文档。 */ export type NodeTransform = { position?: XYPosition; width?: number; height?: number; rotation?: number; fontSize?: number }; +/** 旧尺寸优先级:显式width/height→可转数字style尺寸→measured→按kind默认值。 + * 使用||回退,零值视作未就绪;不是完整的几何有效性验证器。 */ export function nodeSize(node: CanvasNode) { return { 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), }; } +/** 把有限角度按360度周期规约到[0,360),负数也归入正向区间;输入合法性由调用方负责。 */ export function normalizeRotation(angle: number) { return ((angle % 360) + 360) % 360; } +/** 围绕原点旋转向量,角度为度,公式内换弧度;平移量不能混进此函数。 */ export function rotateVector(point: XYPosition, angle: number): XYPosition { const radians = angle * Math.PI / 180; return { x: point.x * Math.cos(radians) - point.y * Math.sin(radians), y: point.x * Math.sin(radians) + point.y * Math.cos(radians) }; } +/** 用未旋转布局矩形求中心,旋转仍围绕这个中心,不改其位置。 */ export function nodeCenter(node: CanvasNode): XYPosition { const { width, height } = nodeSize(node); return { x: node.position.x + width / 2, y: node.position.y + height / 2 }; } +/** 比较起始/当前指针相对中心的极角并叠加原旋转;snap时吸附到15度,最后归一化。 */ export function rotationFromPointer(node: CanvasNode, start: XYPosition, current: XYPosition, snap = false) { const center = nodeCenter(node); const startAngle = Math.atan2(start.y - center.y, start.x - center.x); @@ -26,20 +39,25 @@ export function rotationFromPointer(node: CanvasNode, start: XYPosition, current if (snap) rotation = Math.round(rotation / 15) * 15; return normalizeRotation(rotation); } +/** 把与节点同坐标空间的delta反旋到节点局部轴,按手柄方向缩放,再旋回中心位移保持对侧锚定。 + * 原节点应是手势起点快照,不能每次用上一帧结果继续累计同一个总delta。 */ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, delta: XYPosition, lockAspect?: boolean): NodeTransform { const { width, height } = nodeSize(node); const rotation = node.data.rotation || 0; + // 指针位移先消除节点旋转,才能区分拖动的是本地宽还是高。 const local = rotateVector(delta, -rotation); 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"; const viewScale = node.data.viewScale || 1; + // 约束随显示viewScale缩放;frame可更大,普通卡片/文字/图标最小尺寸各有不同。 const maximum = node.data.kind === "frame" ? 100000 * viewScale : 3000 * viewScale; const minWidth = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 60 : 140) * viewScale; const minHeight = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 24 : 100) * viewScale; let nextWidth = Math.max(minWidth, Math.min(maximum, width + horizontal * local.x)); let nextHeight = Math.max(minHeight, Math.min(maximum, height + vertical * local.y)); + // icon和纯图片默认锁比例,调用方可显式开关;取偏离1更大的缩放轴,结合双边范围算候选,最终尺寸仍经命令/格式校验。 if (lockAspect ?? icon) { const sx = 1 + horizontal * local.x / width; const sy = 1 + vertical * local.y / height; @@ -49,6 +67,7 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, nextHeight = height * scale; } const center = nodeCenter(node); + // 半尺寸差在局部轴上移动中心,再旋回场景,避免只改宽高导致固定对边漂移。 const shift = rotateVector({ x: horizontal * (nextWidth - width) / 2, y: vertical * (nextHeight - height) / 2 }, rotation); return { position: { x: center.x + shift.x - nextWidth / 2, y: center.y + shift.y - nextHeight / 2 }, @@ -56,9 +75,12 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, }; } +/** 卡片四边连接位置名,与模型中可扩展端口ID不是同一概念。 */ 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. */ +/** 在卡片真实边框中点取点并随节点旋转法线/偏移,忽略DOM手柄较大的点击热区。 */ 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 } }; @@ -69,6 +91,7 @@ export function nodeConnector(node: CanvasNode, side: ConnectorSide): Connector const center = nodeCenter(node); return { point: { x: center.x + offset.x, y: center.y + offset.y }, normal }; } +/** 用两端朝外法线构造单段SVG三次曲线,控制伸展按端距40%并夹到40~240显示单位。 */ 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}`; diff --git a/src/lib/demo-workspace.ts b/src/lib/demo-workspace.ts index f65e5a3..ce4922c 100644 --- a/src/lib/demo-workspace.ts +++ b/src/lib/demo-workspace.ts @@ -1,5 +1,11 @@ +/** + * 本地演示模式的画布目录读取,与真实账户工作区服务分开。 + * 这里只为可用演示数据补目录外壳,不进行真实board/schema/权限验证,也不把示例写入服务端。 + */ import type { Board } from "./workspace"; +/** 优先读workspace对应localStorage数组,补工作区/kind和默认version;解析或读取失败回退三个样例页。 + * 仅判定顶层数组,条目本身不做完整Board校验,不能复用于真实服务响应解码。 */ export function readDemoBoards(workspace: string): Board[] { try { const stored = JSON.parse(localStorage.getItem("endoleap_demo_boards_" + workspace) || "null"); diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 24bdf40..9530b52 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -1,10 +1,17 @@ +/** + * 应用层Sonner通知适配。领域/editor包不依赖具体toast库,宿主在收到状态后选择调用此模块。 + * use client界定通知只在客户端使用,函数不修改画布任务事实。 + */ "use client"; import { toast } from "sonner"; /** Application adapter. Domain/editor packages must not import Sonner. */ +/** 稳定id用于更新/关闭同一通知,level决定显示样式,description是可选补充文案。 */ export type Notification = { id: string; title: string; description?: string; level?: "info" | "success" | "warning" | "error" }; +/** 按指定level调用Sonner,默认info;相同id的合并/更新行为交给通知库。 */ export function notify({ id, title, description, level = "info" }: Notification) { return toast[level](title, { id, description }); } +/** 按id关闭通知,不表示对应业务任务已取消或错误已恢复。 */ export function dismissNotification(id: string) { toast.dismiss(id); } diff --git a/src/lib/session-cookie.ts b/src/lib/session-cookie.ts index 97da4e5..d6f20a8 100644 --- a/src/lib/session-cookie.ts +++ b/src/lib/session-cookie.ts @@ -1,9 +1,18 @@ +/** + * 服务端会话Cookie的纯字符串与寿命辅助,不读取或写入浏览器Cookie。 + * 调用方决定何时采用上游过期提示、是否Secure,并把返回字符串放进响应Set-Cookie。 + */ +/** 本应用默认空闲期上限30天,单位秒。 */ export const SESSION_IDLE_SECONDS = 30 * 24 * 60 * 60; +/** 上游向代理传递剩余会话寿命的响应头名,不是Cookie名。 */ export const SESSION_EXPIRY_HEADER = "X-Foundry-Session-Expires-In"; +/** 数字或纯数字字符串可采用,非负安全整数封顶30天,0可用于清除。 + * 不合法输入原样退回fallback;自定义fallback由调用方提供有效值,不在此再次封顶。 */ 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; } +/** URL编码会话值,固定HttpOnly/SameSite=Lax/根路径并规范Max-Age;secure参数为true才添加Secure。 */ export function sessionCookie(value: string, maxAge: number, secure: boolean) { return `foundry_session=${encodeURIComponent(value)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${sessionMaxAge(maxAge)}${secure ? "; Secure" : ""}`; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 109c4e2..b8a3697 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,9 @@ +/** + * 应用样式类名组合的单一辅助入口,先将条件/数组形式归成字符串,再解决Tailwind类冲突。 + */ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; +/** clsx负责布尔条件和嵌套类名,twMerge按其规则处理同类覆盖;不解析业务状态或生成CSS。 */ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index a82847a..2980c86 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -1,6 +1,13 @@ +/** + * 工作区和画布目录的应用模型与CRUD辅助。这里保存路由/目录元数据约定, + * 不拥有画布内容、ProjectEngine或本地history;服务响应更强schema校验由相应调用路径负责。 + */ import type { Profile } from "./api"; +/** 工作区目录项,slug可用于路由,role供UI显示/能力提示,最终权限仍由服务端决定。 */ export type Workspace = { id: string; slug?: string; name: string; role?: "admin" | "editor" | "viewer" }; +/** 画布目录记录:id与workspace是真实身份,parent_board_id/frame_id表示旧子图入口关系。 + * version是material元数据版本,不能自动当作canonical canvas版本或本地rootRevision。 */ export type Board = { id: string; workspace_id: string; @@ -13,14 +20,19 @@ export type Board = { parent_board_id?: string; frame_id?: string; }; +/** 路由可用id或slug,空item/ref返回false;匹配本身不证明账户有访问权限。 */ 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 = "未命名图谱"; +/** 分别编码workspace和board路径段,demo只追加明确查询开关。 */ export function boardHref(workspace: string, board: string, demo = false) { return `/dashboard/${encodeURIComponent(workspace)}/${encodeURIComponent(board)}${demo ? "?demo=1" : ""}`; } +/** 注入通用API能力,不在该库固定fetch、凭证或错误展示。 */ type Request = (path: string, options?: RequestInit) => Promise; +/** 并行读profile和工作区目录,兼容直接数组与{items}外壳;仅返回数据,不选择当前工作区。 */ export async function loadWorkspaceHome( request: Request, signal?: AbortSignal, @@ -35,6 +47,8 @@ export async function loadWorkspaceHome( }; } +/** 无workspaceId时先创建个人空间并回调宿主,再创建schema1旧画布目录项。 + * 两个POST不是一笔原子事务,后一步失败不会自动删除已创建空间;标题trim后允许默认名。 */ export async function createBoard( request: Request, title: string, @@ -51,6 +65,7 @@ export async function createBoard( onWorkspaceCreated(workspace); } const name = title.trim() || DEFAULT_BOARD_TITLE; + // 创建响应可缺workspace_id时补用本次目标id,返回已确定的标题供界面更新目录。 const result = await request<{ id: string; workspace_id: string; kind: "board"; version: number; slug?: string }>("workspaces/" + id + "/materials", { method: "POST", body: JSON.stringify({ @@ -63,10 +78,13 @@ export async function createBoard( } +/** trim并限制标题UTF-8字节≤300,先读取匹配的board,再用If-Match条件PATCH元数据。 + * PATCH保留现有description/content;返回version按接口+1约定推导,不解析PATCH回执验证该版本。 */ export async function renameBoard(request: Request, workspaceId: string, boardId: string, title: string): Promise { const name = title.trim(); if (!name || new TextEncoder().encode(name).length > 300) throw new Error("invalid_title"); const path = `workspaces/${encodeURIComponent(workspaceId)}/materials/${encodeURIComponent(boardId)}`; + // 校验读取的kind/id或slug/workspace,避免路由引用指向另一空间记录时误写。 const current = await request(path); if (current.kind !== "board" || !matchesRef(current, boardId) || current.workspace_id !== workspaceId) throw new Error("invalid_reference"); await request(path, {