docs(canvas): explain legacy resources and geometry ownership

This commit is contained in:
2026-11-12 16:04:32 +08:00
parent b995e659a6
commit 9b24319747
6 changed files with 18 additions and 2 deletions
+3 -1
View File
@@ -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;
}
+2
View File
@@ -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 };
// 同时调整相邻旧结点或首尾控制向量,否则插入后无法保留原曲线。
+3
View File
@@ -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 {
+3
View File
@@ -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;
+3
View File
@@ -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);
+4 -1
View File
@@ -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("图标加载失败");