feat(canvas): fix rotation, clarify frame membership and load icons on demand
This commit is contained in:
@@ -4,17 +4,28 @@ 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 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;
|
||||
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)[0];
|
||||
}
|
||||
|
||||
export function frameDropFeedback(nodes: CanvasNode[], droppedIds: string[]) {
|
||||
const ids = new Set(droppedIds);
|
||||
return nodes.filter((node) => ids.has(node.id) && node.data.kind !== "frame").map((node) => ({
|
||||
id: node.id, from: node.data.frameId, to: containingFrame(node, nodes)?.id,
|
||||
}));
|
||||
}
|
||||
|
||||
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;
|
||||
const frameId = containingFrame(node, nodes)?.id;
|
||||
if (frameId === node.data.frameId) return node;
|
||||
const { frameId: _old, ...data } = node.data;
|
||||
return { ...node, data: { ...data, ...(frameId ? { frameId } : {}) } };
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"root":"/canvas-icons/lucide-1.69.0-tabler-3.50.0","assets":{"lucide-42":"lucide-42-e6334d103160.json","lucide-67":"lucide-67-40bbf3f4998d.json","lucide-78":"lucide-78-460a41bb0e98.json","lucide-15":"lucide-15-8e1ee37c7ef3.json","lucide-12":"lucide-12-eb6ad5652041.json","lucide-3":"lucide-3-a7d48b9a5f38.json","lucide-30":"lucide-30-465d7c2432a0.json","lucide-95":"lucide-95-db811944f2e7.json","lucide-13":"lucide-13-de95c1f09bfe.json","lucide-23":"lucide-23-9c5beb3e5845.json","lucide-61":"lucide-61-9c9a684a8a03.json","lucide-75":"lucide-75-900c95ddb9f2.json","lucide-19":"lucide-19-361ec2b087de.json","lucide-50":"lucide-50-93885a6e5d50.json","lucide-55":"lucide-55-d1c614057db0.json","lucide-63":"lucide-63-efa956c78ad5.json","lucide-43":"lucide-43-3d88a508c4cf.json","lucide-62":"lucide-62-c4c29bf5d751.json","lucide-47":"lucide-47-b2b81499037a.json","lucide-76":"lucide-76-1c40d012831f.json","lucide-73":"lucide-73-76bf9835d452.json","lucide-36":"lucide-36-3a7394410d07.json","lucide-58":"lucide-58-2de9abec34f2.json","lucide-94":"lucide-94-5bea60ca0337.json","lucide-80":"lucide-80-40e316df13dd.json","lucide-26":"lucide-26-c85c8413a666.json","lucide-44":"lucide-44-ff06b3d96c4a.json","lucide-31":"lucide-31-ff472b14f003.json","lucide-49":"lucide-49-7309b805d644.json","lucide-56":"lucide-56-86b7d4f71ecf.json","lucide-28":"lucide-28-2823eadc56bd.json","lucide-52":"lucide-52-c9c4eafa5970.json","lucide-27":"lucide-27-008a7e197251.json","lucide-74":"lucide-74-5c962a977bee.json","lucide-33":"lucide-33-9d4c290af3aa.json","lucide-70":"lucide-70-092d8aa63855.json","lucide-88":"lucide-88-6457befc7c94.json","lucide-29":"lucide-29-583952ff9f09.json","lucide-53":"lucide-53-0435c6cc8b17.json","lucide-21":"lucide-21-8442b4ccc903.json","lucide-35":"lucide-35-fed3ac341243.json","lucide-16":"lucide-16-4cb67b6e2ab9.json","lucide-90":"lucide-90-3ea0cfebecd6.json","lucide-17":"lucide-17-3579b4e7f03d.json","lucide-2":"lucide-2-99a8ddad26db.json","lucide-41":"lucide-41-11478920d7a5.json","lucide-34":"lucide-34-d1376aedac27.json","lucide-54":"lucide-54-0120f5392ee0.json","lucide-57":"lucide-57-beaafcf852c6.json","lucide-69":"lucide-69-e8f8d03e8784.json","lucide-87":"lucide-87-ee48480c8452.json","lucide-18":"lucide-18-91a4a347e2f9.json","lucide-68":"lucide-68-2759a00e3b27.json","lucide-4":"lucide-4-abee98f88fb2.json","lucide-59":"lucide-59-c002e7b75a7a.json","lucide-9":"lucide-9-02cf0bc9b45e.json","lucide-46":"lucide-46-519558f5d7e4.json","lucide-72":"lucide-72-eff5c8c77352.json","lucide-25":"lucide-25-65d740c1d8f8.json","lucide-82":"lucide-82-64b55abe08ce.json","lucide-48":"lucide-48-115fbf3ab151.json","lucide-0":"lucide-0-9465a0d0a0d1.json","lucide-14":"lucide-14-8bbb9dad57c4.json","lucide-51":"lucide-51-32bb5fbe689c.json","lucide-81":"lucide-81-c9f178b9187a.json","lucide-66":"lucide-66-ea1fc0da96d9.json","lucide-37":"lucide-37-9581d38bc201.json","lucide-8":"lucide-8-aff9868119d7.json","lucide-24":"lucide-24-cbdeb6f9d8ad.json","lucide-65":"lucide-65-7e9254fc80b9.json","lucide-11":"lucide-11-e851935014bd.json","lucide-6":"lucide-6-36286bf6f231.json","lucide-83":"lucide-83-2e7cf63eed9e.json","lucide-93":"lucide-93-efedb0dc4269.json","lucide-22":"lucide-22-7973c23aa03a.json","lucide-32":"lucide-32-6bfe07e2b3d8.json","lucide-5":"lucide-5-0da38be20d5a.json","lucide-60":"lucide-60-f55da6f4b89b.json","lucide-45":"lucide-45-53fd8cb40183.json","lucide-86":"lucide-86-0d58b9dd2633.json","lucide-40":"lucide-40-074f236c8ab4.json","lucide-79":"lucide-79-db4e84166063.json","lucide-10":"lucide-10-ceefe7053e63.json","lucide-7":"lucide-7-f9e47bc3f4df.json","lucide-92":"lucide-92-eda5fa53d59c.json","lucide-1":"lucide-1-a6ac944b19f1.json","lucide-39":"lucide-39-d7b27970e5e2.json","lucide-84":"lucide-84-c3471f06e084.json","lucide-89":"lucide-89-af19bdeab3b6.json","lucide-64":"lucide-64-c4e3a612a96b.json","lucide-71":"lucide-71-e496b54d07e5.json","lucide-20":"lucide-20-a1b5e79d2626.json","lucide-38":"lucide-38-045b6f0a48c3.json","lucide-85":"lucide-85-c58cda325b5a.json","lucide-77":"lucide-77-2c998e395272.json","lucide-91":"lucide-91-699bc9d1b7e2.json","tabler":"tabler-ab5a387fb923.json"}}
|
||||
@@ -0,0 +1,36 @@
|
||||
import manifest from "./canvas-icon-assets.json" with { type: "json" };
|
||||
import { createResourceLoader } from "./resource-loader.ts";
|
||||
|
||||
export type IconPaths = [string, Record<string, string | number>][];
|
||||
export function iconAsset(name: string) {
|
||||
let hash = 0;
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
||||
const key = name.startsWith("Tabler") ? "tabler" : `lucide-${hash % 96}`;
|
||||
return `${manifest.root}/${manifest.assets[key as keyof typeof manifest.assets]}`;
|
||||
}
|
||||
const allowedTags = new Set(["path", "circle", "ellipse", "rect", "line", "polyline", "polygon"]);
|
||||
const allowedAttributes = new Set(["key", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "x2", "y1", "y2", "width", "height", "points", "fill", "fillRule", "clipRule", "stroke", "strokeWidth", "strokeLinecap", "strokeLinejoin", "opacity", "transform"]);
|
||||
export function validateIconShard(value: unknown): Record<string, IconPaths> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid icon data");
|
||||
for (const [id, paths] of Object.entries(value)) {
|
||||
if (!/^[A-Z][A-Za-z0-9]{0,63}$/.test(id) || !Array.isArray(paths) || !paths.length || paths.length > 100) throw new Error("Invalid icon paths");
|
||||
for (const path of paths) {
|
||||
if (!Array.isArray(path) || path.length !== 2 || !allowedTags.has(path[0]) || !path[1] || typeof path[1] !== "object" || Array.isArray(path[1])) throw new Error("Invalid icon shape");
|
||||
for (const [key, attribute] of Object.entries(path[1])) {
|
||||
if (!allowedAttributes.has(key) || !["string", "number"].includes(typeof attribute) || /url\s*\(|javascript:|data:/i.test(String(attribute))) throw new Error("Invalid icon attribute");
|
||||
}
|
||||
}
|
||||
}
|
||||
return value as Record<string, IconPaths>;
|
||||
}
|
||||
const resources = createResourceLoader(async (url, signal) => {
|
||||
const response = await fetch(url, { signal, cache: "force-cache" });
|
||||
if (!response.ok) throw new Error("图标加载失败");
|
||||
return validateIconShard(await response.json());
|
||||
});
|
||||
export function peekIcon(name: string) { return resources.peek(iconAsset(name))?.[name]; }
|
||||
export async function loadIcon(name: string, signal?: AbortSignal) {
|
||||
const paths = (await resources.request(iconAsset(name), signal))[name];
|
||||
if (!paths) throw new Error("未找到图标");
|
||||
return paths;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Shared, bounded queue; unused requests are removed or aborted. */
|
||||
export function createResourceLoader<T>(load: (key: string, signal: AbortSignal) => Promise<T>, concurrency = 6, cacheSize = 16) {
|
||||
type Subscriber = { resolve: (value: T) => void; reject: (error: unknown) => void; cleanup: () => void };
|
||||
type Job = { key: string; controller: AbortController; subscribers: Set<Subscriber> };
|
||||
const cache = new Map<string, T>();
|
||||
const jobs = new Map<string, Job>();
|
||||
const queue: Job[] = [];
|
||||
let active = 0;
|
||||
function peek(key: string) {
|
||||
const value = cache.get(key);
|
||||
if (value !== undefined) { cache.delete(key); cache.set(key, value); }
|
||||
return value;
|
||||
}
|
||||
function pump() {
|
||||
while (active < concurrency && queue.length) {
|
||||
const job = queue.shift()!;
|
||||
if (!job.subscribers.size) continue;
|
||||
active++;
|
||||
Promise.resolve().then(() => {
|
||||
if (job.controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return load(job.key, job.controller.signal);
|
||||
}).then((value) => {
|
||||
if (!job.controller.signal.aborted) {
|
||||
cache.set(job.key, value);
|
||||
while (cache.size > cacheSize) cache.delete(cache.keys().next().value!);
|
||||
for (const subscriber of job.subscribers) { subscriber.cleanup(); subscriber.resolve(value); }
|
||||
}
|
||||
}, (error) => {
|
||||
for (const subscriber of job.subscribers) { subscriber.cleanup(); subscriber.reject(error); }
|
||||
}).finally(() => {
|
||||
if (jobs.get(job.key) === job) jobs.delete(job.key);
|
||||
active--; pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
function request(key: string, signal?: AbortSignal): Promise<T> {
|
||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
const cached = peek(key);
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
let job = jobs.get(key);
|
||||
if (!job) { job = { key, controller: new AbortController(), subscribers: new Set() }; jobs.set(key, job); queue.push(job); }
|
||||
const current = job;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abort = () => {
|
||||
current.subscribers.delete(subscriber); subscriber.cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
if (!current.subscribers.size) {
|
||||
if (jobs.get(key) === current) jobs.delete(key);
|
||||
const index = queue.indexOf(current);
|
||||
if (index >= 0) queue.splice(index, 1);
|
||||
current.controller.abort();
|
||||
}
|
||||
};
|
||||
const subscriber: Subscriber = { resolve, reject, cleanup: () => signal?.removeEventListener("abort", abort) };
|
||||
current.subscribers.add(subscriber);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
pump();
|
||||
});
|
||||
}
|
||||
return { request, peek };
|
||||
}
|
||||
Reference in New Issue
Block a user