docs(ui): explain shared controls and canvas display adapters

This commit is contained in:
2026-11-12 01:51:59 +08:00
parent 1306df2b35
commit b51d78b47d
11 changed files with 115 additions and 0 deletions
+14
View File
@@ -1,11 +1,21 @@
/**
* 按名称异步加载画布SVG图标。先读取共享缓存,再订阅图标加载器,展示加载中/失败/已就绪三种图形。
* 组件负责显示和重试入口;网络并发、缓存以及订阅取消的实现位于canvas-icon-loader。
*/
"use client";
import { createElement, useEffect, useState } from "react";
import type { LucideProps } from "lucide-react";
import { loadIcon, peekIcon, type IconPaths } from "@/lib/canvas-icon-loader";
/** 兼容Lucide尺寸与描边属性,name默认Star;width/height未提供时跟随size。
* 成功后按加载器返回的SVG标签与属性创建子元素,避免把整段SVG作为HTML插入。 */
export function CanvasIcon({ name = "Star", size = 24, width = size, height = size, strokeWidth = 2, absoluteStrokeWidth, ...props }: LucideProps & { name?: string }) {
// 结果携带图标名,渲染时只采用当前name对应的内容,换名时不会短暂展示上一个图标。
const [result, setResult] = useState<{ name: string; paths?: IconPaths; error?: boolean }>(() => ({ name, paths: peekIcon(name) }));
// retry仅用于触发同名图标重新订阅;它不是全局缓存版本或网络请求ID。
const [retry, setRetry] = useState(0);
// 换名或点击重试后,先采用可同步取得的缓存,再等待loadIcon。清理时取消本组件订阅;
// AbortError不显示失败,其它错误显示重试占位。底层决定共享请求是否仍有其它使用者。
useEffect(() => {
const controller = new AbortController();
setResult({ name, paths: peekIcon(name) });
@@ -14,8 +24,12 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si
});
return () => controller.abort();
}, [name, retry]);
// effect可能还没处理新name,故读取结果前再次比对名称;失败标记也必须属于当前图标。
const paths = result.name === name ? result.paths : undefined;
const failed = result.name === name && result.error;
// 以24×24为标准坐标;absoluteStrokeWidth在size为数值时按尺寸反向补偿描边宽度。
// 成功时保留调用方onClick;失败时点击改为停止冒泡并重试,避免同时触发外层节点点击。
// 加载中用虚线方框,失败用叉号与title,data-icon-state供样式或检查观察显示状态。
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}>
+11
View File
@@ -1,9 +1,20 @@
/**
* 画布图片显示层:远端图片异步加载时,允许用Provider提供的本地预览覆盖,减轻上传或资源切换时的空白。
* 组件不上传文件、不管理Blob URL寿命;这些职责留给提供sources与loaded回调的上层。
*/
"use client";
import { createContext, useContext } from "react";
/** 按元素id查找预览URL,并在底层图片加载成功时通知上层;默认空Map与空回调支持独立渲染。 */
export const ImagePreviews = createContext<{ sources: Map<string,string>; loaded: (id: string) => void }>({ sources: new Map(), loaded: () => {} });
/** src用于底层正式图片,title作为其替代文本;图片禁止原生拖拽,避免干扰画布手势。
* 底图使用懒加载、异步解码并禁止发送Referer;onLoad只通知上层,不直接删除预览。 */
export function CanvasImage({ id, src, title, className }: { id: string; src?: string; title: string; className: string }) {
// 本地源按元素id而非远端URL索引,同一资源在不同节点中可拥有各自的显示交接状态。
const previews = useContext(ImagePreviews), local = previews.sources.get(id);
const remote = <img loading="lazy" decoding="async" className={className} src={src} alt={title} draggable={false} referrerPolicy="no-referrer" onLoad={() => previews.loaded(id)} />;
// 没有预览或预览已等于正式源时,只绘制一张图片,避免冗余叠层。
if (!local || local === src) return remote;
// 正式图片继续在底层加载,本地预览绝对定位覆盖在上方;Provider清掉该id后自然移除覆盖。
// 覆盖图是装饰性的、忽略指针事件,读屏与节点交互都交给底层正式图片和外层节点。
return <div style={{position:"relative",width:"100%",height:"100%",minHeight:0,flex:1,borderRadius:"inherit"}}>{remote}<img className={className} src={local} alt="" aria-hidden draggable={false} style={{position:"absolute",inset:0,pointerEvents:"none"}} /></div>;
}
@@ -1,3 +1,7 @@
/**
* 内置画布类型到React组件的装配表。把bindings层的renderer描述绑定到实际节点/连线组件,生成默认React渲染注册表。
* 这张表保存可复用的显示定义,不保存每个元素的数据、选择或编辑状态。
*/
"use client";
import type { NodeProps } from '@xyflow/react';
import type { CanvasNode } from '@/lib/canvas-elements';
@@ -9,6 +13,12 @@ import { PlainImageNode } from './plain-image-node';
import { FrameNode } from './frame-node';
import { IconNode } from './icon-node';
import { CanvasEdge } from './canvas-edge';
/** 新版分区复用FrameNode,但开启directAnchors,使它使用该模式的直接连接点。
* 其它NodeProps透传,包装层不再创建一份分区模型。 */
function HeadlessFrameNode(props:NodeProps<CanvasNode>){return <FrameNode {...props} directAnchors />;}
/** rendererId到节点组件的固定映射;这里的键与builtinRendererDefinitions中节点的rendererId配套。 */
const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImageNode,frame:HeadlessFrameNode,icon:IconNode};
/** 每项保留原始类型/结构描述,node按rendererId查表,connection绑定CanvasEdge。
* render与preview目前共用同一个组件;createReactRenderers负责生成供ReactFlow使用的注册结果。
* 本表没有自定义类型的通配组件,兼容占位由投影/渲染选择链路处理。 */
export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>{const render=definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge;return{...definition,render,preview:render} as ReactRendererDefinition;}));
@@ -1,13 +1,22 @@
/**
* 兼容占位组件:显示可理解的公共几何与说明文字,不解析未知类型的业务payload。
* 原始数据由模型保留;这些组件只提供占位绘制,不执行数据迁移,也不自行决定外层是否允许移动或选择。
*/
"use client";
import { BaseEdge, Handle, Position, getBezierPath, type EdgeProps, type NodeProps } from "@xyflow/react";
import type {ReactFlowProjectionNode as ProjectionNode} from "@/lib/canvas-bindings/react-flow";
import type { CanvasEdgeModel } from "@/lib/canvas-v2";
import styles from "./canvas.module.css";
/** 将投影提供的四边方位转换成ReactFlow Handle要求的枚举。 */
const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,left:Position.Left};
/** Render only the known envelope and derived endpoints, never interpret opaque payload fields. */
/** 使用投影已提供的标题、旋转和fallback元数据绘制占位节点,尺寸填满外层节点容器。
* renderer-unavailable明确表示当前缺少显示实现,其它情形提示原内容保留。
* 仍绘制已知连接点供现有连线定位,但禁止从这些Handle发起或接收新连接;不推断未知内容字段。 */
export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
// fallback来自bindings投影;缺失时仍显示通用提示,不尝试从data猜测组件结构。
const fallback=data.__headlessFallback;
return <div className={`${styles.node} ${selected?styles.selectedNode:""}`} style={{width:"100%",height:"100%",padding:14,transform:`rotate(${data.rotation||0}deg)`}} role="note" aria-label="暂不支持编辑的元素">
<span>{data.title}</span><small style={{display:"block"}}>此元素需要兼容的组件版本才能编辑</small>
@@ -15,6 +24,8 @@ export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
{fallback?.handles.map(handle=><Handle key={handle.id} id={handle.id} type="source" position={positions[handle.side]} className={styles.centerHandle} isConnectable={false} isConnectableStart={false} isConnectableEnd={false} aria-hidden />)}
</div>;
}
/** 直接使用ReactFlow推导的端点生成贝塞尔路径,附可访问标题;不调用未知类型的编辑/样式逻辑。
* BaseEdge绘制统一颜色的可见线条,数据保留与版本兼容仍归模型层负责。 */
export function UnsupportedEdge(props:EdgeProps<CanvasEdgeModel>){
const [path]=getBezierPath(props);
return <g role="img" aria-label="暂不支持编辑的连线"><title>原始连线内容已保留</title><BaseEdge id={props.id} path={path} style={{stroke:'#aa968b',strokeWidth:1.5}} /></g>;