feat(canvas): fix rotation, clarify frame membership and load icons on demand

This commit is contained in:
2026-11-06 13:56:15 +08:00
parent 77d1b3ec86
commit 83021d0f6c
120 changed files with 817 additions and 100 deletions
+22 -7
View File
@@ -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>;
}
+18
View File
@@ -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; }
+18
View File
@@ -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>;
}
+10 -3
View File
@@ -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>
</>;
+33 -4
View File
@@ -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
+10 -61
View File
@@ -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));
}
+48 -14
View File
@@ -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);
}
+2 -1
View File
@@ -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}>