feat(canvas): fix rotation, clarify frame membership and load icons on demand
This commit is contained in:
@@ -1,9 +1,24 @@
|
||||
"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} />} />;
|
||||
import { createElement, useEffect, useState } from "react";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import { loadIcon, peekIcon, type IconPaths } from "@/lib/canvas-icon-loader";
|
||||
|
||||
export function CanvasIcon({ name = "Star", size = 24, width = size, height = size, strokeWidth = 2, absoluteStrokeWidth, ...props }: LucideProps & { name?: string }) {
|
||||
const [result, setResult] = useState<{ name: string; paths?: IconPaths; error?: boolean }>(() => ({ name, paths: peekIcon(name) }));
|
||||
const [retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setResult({ name, paths: peekIcon(name) });
|
||||
loadIcon(name, controller.signal).then((paths) => setResult({ name, paths }), (error) => {
|
||||
if (error.name !== "AbortError") setResult({ name, error: true });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [name, retry]);
|
||||
const paths = result.name === name ? result.paths : undefined;
|
||||
const failed = result.name === name && result.error;
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={width} height={height} fill="none" stroke="currentColor" strokeWidth={absoluteStrokeWidth && typeof size === "number" ? Number(strokeWidth) * 24 / size : strokeWidth} strokeLinecap="round" strokeLinejoin="round"
|
||||
{...props} data-icon-id={name} data-icon-state={paths ? "loaded" : failed ? "error" : "loading"}
|
||||
onClick={failed ? (event) => { event.stopPropagation(); setRetry((value) => value + 1); } : props.onClick}>
|
||||
{paths ? paths.map(([tag, attributes], index) => createElement(tag, { ...attributes, key: attributes.key ?? index })) : failed ? <><title>图标加载失败,点击重试</title><path d="m8 8 8 8m0-8-8 8" opacity=".5" /></> : <rect x="5" y="5" width="14" height="14" rx="3" opacity=".2" strokeDasharray="2 3" />}
|
||||
</svg>;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,24 @@
|
||||
.iconGrid button { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; }
|
||||
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
|
||||
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
|
||||
.iconSource { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 11px; }
|
||||
.iconSource select { min-width: 0; max-width: 175px; padding: 4px; border: 1px solid #ded7ce; border-radius: 5px; background: transparent; }
|
||||
.iconCategories { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
|
||||
.iconCategories button { padding: 3px 6px; border-radius: 4px; color: #92877c; font-size: 10px; }
|
||||
.iconCategories button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||
.virtualIconGrid { flex: 1; min-height: 96px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.virtualIconGrid button { display: grid; place-items: center; border: 3px solid transparent; border-radius: 8px; background-clip: padding-box; color: #6d6054; }
|
||||
.virtualIconGrid button:hover { background-color: #f1e6df; }
|
||||
.virtualIconGrid button:focus-visible, .iconCategories button:focus-visible { outline: 2px solid #a47764; outline-offset: -2px; }
|
||||
.iconLoading { position: absolute; left: 12px; top: 70px; z-index: 60; padding: 14px; border-radius: 10px; background: #fffefb; font-size: 12px; }
|
||||
.membershipBadge { position: absolute; bottom: -25px; left: 0; display: flex; align-items: center; gap: 4px; max-width: 280px; padding: 3px 6px; border-radius: 4px; background: #e9f1e9; color: #3e684b; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
|
||||
.rotatingFrame .membershipBadge { display: none; }
|
||||
.frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; }
|
||||
.frameNode.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; }
|
||||
.frameNode.frameDropExit { border-style: dashed; }
|
||||
.frameDropHint { position: absolute; top: 12px; left: 12px; transform-origin: top left; border: 1px solid #9db8a4; border-radius: 6px; padding: 6px 9px; background: #f3f8f3; color: #3e684b; font-size: 12px; white-space: nowrap; pointer-events: none; }
|
||||
.membershipNotice { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 20; max-width: min(540px, calc(100vw - 32px)); padding: 8px 12px; border: 1px solid #c8d9ca; border-radius: 8px; background: #f3f8f3; color: #3e684b; font-size: 12px; pointer-events: none; }
|
||||
.membershipNotice:empty { display: none; }
|
||||
.handMode .plainText { pointer-events: none; }
|
||||
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
|
||||
.handMode .cornerResize { pointer-events: none; opacity: 0.6; }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { Frame } from "lucide-react";
|
||||
import type { frameDropFeedback } from "@/lib/canvas-frames";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export const FrameFeedback = createContext({ names: new Map<string, string>(), counts: new Map<string, number>(), drops: [] as ReturnType<typeof frameDropFeedback> });
|
||||
|
||||
export function MembershipBadge({ id, frameId, selected }: { id: string; frameId?: string; selected: boolean }) {
|
||||
const { names, drops } = useContext(FrameFeedback);
|
||||
const drop = drops.find((item) => item.id === id);
|
||||
const target = drop ? drop.to : frameId;
|
||||
const name = target ? names.get(target) : undefined;
|
||||
if (!drop && !selected) return null;
|
||||
const label = name ? `${drop && drop.to !== drop.from ? "松开加入" : "属于"}:${name}` : drop?.from ? `松开移出:${names.get(drop.from) || "分区"}` : null;
|
||||
return label && <div className={styles.membershipBadge} title={label}><Frame size={11} />{label}</div>;
|
||||
}
|
||||
@@ -6,11 +6,16 @@ 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 { FrameFeedback } from "./frame-feedback";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function FrameNode(props: NodeProps<CanvasNode>) {
|
||||
const { id, data, selected } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const feedback = useContext(FrameFeedback);
|
||||
const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length;
|
||||
const outgoing = feedback.drops.filter((drop) => drop.from === id && drop.to !== id).length;
|
||||
const count = feedback.counts.get(id) || 0;
|
||||
const { zoom } = useViewport();
|
||||
const reduce = useReducedMotion();
|
||||
const [title, setTitle] = useState(data.title);
|
||||
@@ -26,16 +31,18 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
|
||||
<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()}>
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(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>
|
||||
<span className={styles.frameMemberCount}>{count} 项内容</span>
|
||||
</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>}
|
||||
<TransformableNode {...props} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
|
||||
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / zoom : 1 }} title={data.title}>{data.title}<span className={styles.frameMemberCount}> · {count} 项</span></div>}
|
||||
{(incoming > 0 || outgoing > 0) && <div className={styles.frameDropHint} style={{ scale: 1 / zoom }}>{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}</div>}
|
||||
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
|
||||
</TransformableNode>
|
||||
</>;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@xyflow/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 dynamic from "next/dynamic";
|
||||
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";
|
||||
@@ -17,7 +18,8 @@ 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 { FrameFeedback } from "./frame-feedback";
|
||||
import { frameDropFeedback } from "@/lib/canvas-frames";
|
||||
import { DraftRecovery } from "./draft-recovery";
|
||||
import { FrameNode } from "./frame-node";
|
||||
import type { FrameLabelMode, TextStyle } from "@/lib/canvas-elements";
|
||||
@@ -28,6 +30,7 @@ import { MaterialNode, NodeEditing } from "./material-node";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const edgeTypes = { default: CanvasEdge };
|
||||
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
|
||||
|
||||
function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
|
||||
@@ -47,6 +50,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
|
||||
const [iconPicker, setIconPicker] = useState(false);
|
||||
const [rotating, setRotating] = useState<string | null>(null);
|
||||
const [draggedIds, setDraggedIds] = useState<string[]>([]);
|
||||
const [membershipNotice, setMembershipNotice] = useState("");
|
||||
const dragOrigins = useRef(new Map<string, XYPosition>());
|
||||
const draggingFrame = useRef(false);
|
||||
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
||||
@@ -64,6 +69,17 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return () => input?.removeEventListener("cancel", cancel);
|
||||
}, []);
|
||||
const { nodes, edges } = state.present;
|
||||
const frameFeedback = useMemo(() => {
|
||||
const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title]));
|
||||
const counts = new Map<string, number>();
|
||||
for (const node of nodes) if (node.data.frameId) counts.set(node.data.frameId, (counts.get(node.data.frameId) || 0) + 1);
|
||||
return { names, counts, drops: frameDropFeedback(nodes, draggedIds) };
|
||||
}, [nodes, draggedIds]);
|
||||
useEffect(() => {
|
||||
if (!membershipNotice) return;
|
||||
const timer = setTimeout(() => setMembershipNotice(""), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [membershipNotice]);
|
||||
const duration = reduceMotion ? 0 : 180;
|
||||
|
||||
const latest = useRef(state.present);
|
||||
@@ -140,13 +156,23 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
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]));
|
||||
setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
|
||||
setMembershipNotice("");
|
||||
}
|
||||
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 });
|
||||
if (ids.length) {
|
||||
const positions = new Map(items.map((item) => [item.id, item.position]));
|
||||
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item);
|
||||
const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to);
|
||||
const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`);
|
||||
if (messages.length) setMembershipNotice(`${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}`);
|
||||
dispatch({ type: "drop", ids });
|
||||
}
|
||||
}
|
||||
setDraggedIds([]);
|
||||
dragOrigins.current.clear();
|
||||
}
|
||||
function connect(connection: Connection) {
|
||||
@@ -223,6 +249,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return (
|
||||
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
||||
<NodeEditing.Provider value={editing}>
|
||||
<FrameFeedback.Provider value={frameFeedback}>
|
||||
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
|
||||
onNodesChange={(changes) => dispatch({ type: "nodes", changes })}
|
||||
onEdgesChange={(changes) => dispatch({ type: "edges", changes })}
|
||||
@@ -247,6 +274,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
|
||||
</ReactFlow>
|
||||
</FrameFeedback.Provider>
|
||||
</NodeEditing.Provider>
|
||||
<div className={styles.topRight}>
|
||||
<div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
@@ -292,13 +320,14 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<p>可通过右上角导出 JSON 备份。</p>
|
||||
</div>
|
||||
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
|
||||
<IconPicker open={iconPicker} close={() => setIconPicker(false)} choose={(name, label) => {
|
||||
{iconPicker && <IconPicker close={() => setIconPicker(false)} choose={(name, label) => {
|
||||
if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; }
|
||||
const node = makeCanvasNode("icon", center());
|
||||
node.data = { ...node.data, icon: name, title: label };
|
||||
dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
|
||||
setTool("select");
|
||||
}} />
|
||||
}} />}
|
||||
<div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>
|
||||
{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>}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,65 +1,14 @@
|
||||
import dynamicIconImports from "lucide-react/dynamicIconImports";
|
||||
import type { IconName } from "lucide-react/dynamic";
|
||||
import records from "./icon-catalog-data.json" with { type: "json" };
|
||||
|
||||
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 iconCategories = [
|
||||
["common", "常用"], ["all", "全部"], ["arrows", "箭头与导航"], ["people", "人物与表情"], ["office", "文件与办公"], ["media", "媒体与设计"], ["tech", "科技与设备"], ["business", "图表与商业"], ["nature", "自然与天气"], ["travel", "出行与地点"], ["shapes", "形状与符号"], ["other", "其他"],
|
||||
] as const;
|
||||
export const iconCatalog = records;
|
||||
export const iconsById = new Map(iconCatalog.map((icon) => [icon.name, icon]));
|
||||
export function searchIcons(query: string) {
|
||||
export type IconSource = "all" | "lucide" | "tabler";
|
||||
export function searchIcons(query: string, category = "all", source: IconSource = "all") {
|
||||
const needle = query.trim().toLowerCase().replace(/[-_\s]/g, "");
|
||||
return iconCatalog.filter((icon) => (icon.name + icon.label + icon.keywords).toLowerCase().replace(/[-_\s]/g, "").includes(needle));
|
||||
return iconCatalog.filter((icon) => (source === "all" || icon.source === source)
|
||||
&& (category === "all" || (category === "common" ? icon.common : category === "other" ? !icon.categories.length : icon.categories.includes(category)))
|
||||
&& (icon.name + icon.label + icon.keywords).toLowerCase().replace(/[-_\s]/g, "").includes(needle));
|
||||
}
|
||||
|
||||
@@ -1,26 +1,60 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { CanvasIcon } from "./canvas-icon";
|
||||
import { iconCatalog, searchIcons } from "./icon-catalog";
|
||||
import { iconCatalog, iconCategories, searchIcons, type IconSource } 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 columns = 5, rowHeight = 48;
|
||||
export function IconPicker({ close, choose }: { close: () => void; choose: (name: string, label: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [limit, setLimit] = useState(80);
|
||||
const matches = useMemo(() => searchIcons(query), [query]);
|
||||
const [category, setCategory] = useState("common");
|
||||
const [source, setSource] = useState<IconSource>("all");
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [height, setHeight] = useState(240);
|
||||
const [focused, setFocused] = useState(0);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const grid = useRef<HTMLDivElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
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(); }}>
|
||||
const matches = useMemo(() => searchIcons(query, query.trim() ? "all" : category, source), [query, category, source]);
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
search.current?.focus();
|
||||
const observer = new ResizeObserver(([entry]) => setHeight(entry.contentRect.height));
|
||||
if (grid.current) observer.observe(grid.current);
|
||||
return () => { observer.disconnect(); previous?.focus(); };
|
||||
}, []);
|
||||
useEffect(() => { if (grid.current) grid.current.scrollTop = 0; setScrollTop(0); setFocused(0); }, [query, category, source]);
|
||||
const rows = Math.ceil(matches.length / columns);
|
||||
const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1);
|
||||
const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1);
|
||||
const visible = matches.slice(firstRow * columns, lastRow * columns);
|
||||
function focusIcon(index: number) {
|
||||
const next = Math.max(0, Math.min(matches.length - 1, index));
|
||||
setFocused(next);
|
||||
const top = Math.floor(next / columns) * rowHeight;
|
||||
if (grid.current) {
|
||||
if (top < grid.current.scrollTop) grid.current.scrollTop = top;
|
||||
else if (top + rowHeight > grid.current.scrollTop + height) grid.current.scrollTop = top + rowHeight - height;
|
||||
setScrollTop(grid.current.scrollTop);
|
||||
}
|
||||
requestAnimationFrame(() => grid.current?.querySelector<HTMLButtonElement>(`[data-index="${next}"]`)?.focus());
|
||||
}
|
||||
return createPortal(<motion.aside initial={{ opacity: 0, x: reduce ? 0 : -12 }} animate={{ opacity: 1, x: 0 }} 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); 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>
|
||||
<label className={styles.iconSource}>图标库<select aria-label="图标库" value={source} onChange={(event) => setSource(event.target.value as IconSource)}><option value="all">全部图标库</option><option value="lucide">Lucide · 2313</option><option value="tabler">Tabler · 24 个试用</option></select></label>
|
||||
<input ref={search} type="search" aria-label="搜索图标" placeholder="搜索当前库的全部图标…" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<div className={styles.iconCategories} aria-label="图标分类">{iconCategories.map(([id, label]) => <button key={id} aria-pressed={!query.trim() && category === id} onClick={() => { setCategory(id); setQuery(""); }}>{label}</button>)}</div>
|
||||
<div ref={grid} className={styles.virtualIconGrid} aria-label="图标列表" onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}>
|
||||
<div style={{ height: rows * rowHeight, position: "relative" }}>{visible.map(({ name, label, source }, offset) => {
|
||||
const index = firstRow * columns + offset;
|
||||
return <button key={name} type="button" data-index={index} tabIndex={focused === index ? 0 : -1} title={`${label} · ${source === "tabler" ? "Tabler" : "Lucide"}`} aria-label={`添加${label}图标`} style={{ position: "absolute", width: "20%", height: rowHeight, left: `${index % columns * 20}%`, top: Math.floor(index / columns) * rowHeight }} onFocus={() => setFocused(index)} onClick={() => choose(name, label)}
|
||||
onKeyDown={(event) => { const delta = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns, Home: -index, End: matches.length - 1 - index }[event.key]; if (delta !== undefined) { event.preventDefault(); focusIcon(index + delta); } }}><CanvasIcon name={name} size={24} /></button>;
|
||||
})}</div>
|
||||
</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);
|
||||
<footer>{matches.length} 个结果 · 共 {iconCatalog.length} 个 · 随滚动加载</footer>
|
||||
</motion.aside>, document.body);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
|
||||
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
|
||||
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(); }}>
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}>
|
||||
{!(["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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements";
|
||||
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
|
||||
import { resizeFromPointer, rotationFromPointer, type NodeTransform } from "@/lib/canvas-geometry";
|
||||
import { NodeStyleToolbar } from "./node-style-toolbar";
|
||||
import { MembershipBadge } from "./frame-feedback";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export const NodeEditing = createContext({
|
||||
@@ -74,6 +75,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
onLostPointerCapture={() => { gesture.current = null; }}
|
||||
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}
|
||||
{data.kind !== "frame" && <MembershipBadge id={id} frameId={data.frameId} selected={selected} />}
|
||||
{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}>
|
||||
|
||||
@@ -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