Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-leafer-graphics-layer.tsx
T
爱笑的毛毛虫andsikongyue dd820616a4 feat(canvas): 面板层级管理 — 五层Elevation体系+渐进式三区+z-index全量迁移 #98 (#102)
* feat(canvas): 面板层级管理优化 - 五层Elevation+渐进式三区+进度增强 #98 #94

基于 Wayfinder 地图 #98 决议执行面板层级管理优化:

1. 五层 Elevation 体系(决策1)
   - 画布组件 z-index 硬编码值全部迁移到 token
   - L0 画布层 → L1 工具层 → L2 面板层 → L3 对话框层 → L4 通知层
   - 涉及 14 个 .tsx 文件,0 处 z-[N] 硬编码残留

2. prompt 面板渐进式三区(决策2)
   - B区参数区改为手风琴折叠模式(默认折叠)
   - 新增 SlidersHorizontal + ChevronDown 图标切换
   - 点击展开 video 工具参数,收起后释放视觉空间
   - A区核心区(prompt+生成)始终展开

3. 任务进度条增强(决策3)
   - 进度条高度 1px→1.5px,视觉更醒目
   - 新增 shimmer 扫描动画(running 态,1.4s/cycle)
   - 新增百分比数字显示(tabular-nums 等宽数字)
   - 新增 indeterminate 模式(无百分比时循环扫描)
   - 尊重 prefers-reduced-motion(reducedMotion 时禁用动画)

4. CSS 动画
   - 新增 @keyframes canvas-task-progress-shimmer
   - .canvas-task-progress-shimmer 类

Closes #98

* refactor(canvas): z-index 全量迁移到五层Elevation token #98 #94

补全 #98 五层 Elevation 体系的剩余硬编码 z-index 清理:
- 三个设置弹层(图/音/视频)zIndex:1200 → var(--z-popover)
- model-setup-guide z-[900] → var(--z-toast)
- 项目侧栏 z-30 → var(--z-panel),章节预览 z-50 → var(--z-panel-floating)
- shared 页头/缩放控件/访客提示 z-[80]/z-[70] → var(--z-panel-floating),右键菜单 z-[90] → var(--z-modal)
- project 缩放控件容器 z-50 → var(--z-panel),绘图加载遮罩 z-[500] → var(--z-toast)
- 反馈面板 z-[90] → var(--z-panel-floating)

共 16 处全局浮层 z-index 迁移到 token,消除层级冲突隐患。

---------

Co-authored-by: sikongyue <3155157671@qq.com>
2026-08-03 22:16:25 +08:00

328 lines
15 KiB
TypeScript

import { useLayoutEffect, useRef, type RefObject } from "react";
import { Group, Leafer, Path, Rect } from "leafer-ui";
import { activeConnectionPath, canvasConnectionPath } from "@/components/canvas/canvas-connections";
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { calculateCanvasPreviewTransform, sameCanvasViewport, shouldRebaseCanvasRaster } from "@/lib/canvas/canvas-leafer-viewport";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox, ViewportTransform } from "@/types/canvas";
type NodeBounds = { left: number; top: number; width: number; height: number; count: number } | null;
type CanvasLeaferGraphicsLayerProps = {
containerRef: RefObject<HTMLDivElement | null>;
viewport: ViewportTransform;
theme: CanvasTheme;
displayConnections: CanvasDisplayConnection[];
selectedConnectionId: string | null;
relatedConnectionIds: Set<string>;
scriptScrollTopById: Record<string, number>;
connectingParams: ConnectionHandle | null;
mouseWorld: Position;
connectionTargetNodeId: string | null;
connectionTargetAnchorRatio?: number;
nodeById: Map<string, CanvasNodeData>;
selectionBox: SelectionBox | null;
selectedNodeBounds: NodeBounds;
alignmentGuides: { vertical?: number; horizontal?: number };
};
type LeaferScene = {
leafer: Leafer;
world: Group;
host: HTMLDivElement;
};
type UnderlayScene = LeaferScene & {
connections: Group;
};
type OverlayScene = LeaferScene & {
selection: Rect;
selectionBounds: Rect;
guides: Path;
draft: Path;
};
export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps) {
const underlayHostRef = useRef<HTMLDivElement>(null);
const overlayHostRef = useRef<HTMLDivElement>(null);
const underlayRef = useRef<UnderlayScene | null>(null);
const overlayRef = useRef<OverlayScene | null>(null);
const viewportRef = useRef(props.viewport);
const rasterViewportRef = useRef(props.viewport);
const propsRef = useRef(props);
propsRef.current = props;
useLayoutEffect(() => {
const underlayHost = underlayHostRef.current;
const overlayHost = overlayHostRef.current;
// 子组件 layout effect 可能早于父层 ref 对外可见,host 的直接父元素才是此刻最可靠的画布容器。
const container = (props.containerRef.current || underlayHost?.parentElement) as HTMLDivElement | null;
if (!underlayHost || !overlayHost || !container) return;
const underlay = createUnderlayScene(underlayHost);
const overlay = createOverlayScene(overlayHost);
underlayRef.current = underlay;
overlayRef.current = overlay;
const resize = () => {
const rect = container.getBoundingClientRect();
const size = { width: Math.max(1, rect.width), height: Math.max(1, rect.height), pixelRatio: canvasPixelRatio() };
underlay.leafer.resize(size);
overlay.leafer.resize(size);
syncViewport(rasterViewportRef.current, size.width, size.height, underlay, overlay, propsRef.current);
if (isViewportPreview(container, viewportRef.current, rasterViewportRef.current)) {
applyScenePreview(viewportRef.current, rasterViewportRef.current, underlay, overlay);
}
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
window.addEventListener("resize", resize);
const unsubscribe = subscribeCanvasGraphicsViewportPreview(container, (next) => {
viewportRef.current = next;
const rect = container.getBoundingClientRect();
if (isViewportPreview(container, next, rasterViewportRef.current)) {
if (shouldRebaseCanvasRaster(next, rasterViewportRef.current)) {
syncViewport(next, rect.width, rect.height, underlay, overlay, propsRef.current);
rasterViewportRef.current = next;
forceSceneRender(underlay, overlay);
resetScenePreview(underlay, overlay);
return;
}
applyScenePreview(next, rasterViewportRef.current, underlay, overlay);
return;
}
resetScenePreview(underlay, overlay);
if (sameCanvasViewport(next, rasterViewportRef.current)) return;
syncViewport(next, rect.width, rect.height, underlay, overlay, propsRef.current);
rasterViewportRef.current = next;
});
const unsubscribeSelection = subscribeCanvasSelectionPreview(container, (selection) => {
syncSelection(overlay.selection, selection, propsRef.current.theme);
});
resize();
return () => {
unsubscribe();
unsubscribeSelection();
resizeObserver.disconnect();
window.removeEventListener("resize", resize);
underlay.leafer.destroy(true);
overlay.leafer.destroy(true);
underlayRef.current = null;
overlayRef.current = null;
};
}, [props.containerRef]);
useLayoutEffect(() => {
const underlay = underlayRef.current;
if (!underlay) return;
rebuildConnections(underlay, props);
}, [props.displayConnections, props.relatedConnectionIds, props.scriptScrollTopById, props.selectedConnectionId, props.theme]);
useLayoutEffect(() => {
const overlay = overlayRef.current;
if (!overlay) return;
syncOverlayContent(overlay, props, viewportRef.current.k);
}, [props.connectingParams, props.connectionTargetAnchorRatio, props.connectionTargetNodeId, props.mouseWorld, props.nodeById, props.scriptScrollTopById, props.selectedNodeBounds, props.selectionBox, props.theme]);
useLayoutEffect(() => {
const underlay = underlayRef.current;
const overlay = overlayRef.current;
const container = props.containerRef.current;
if (!underlay || !overlay || !container) return;
viewportRef.current = props.viewport;
const rect = container.getBoundingClientRect();
const hadPreview = hasScenePreview(underlay, overlay);
if (hadPreview || !sameCanvasViewport(props.viewport, rasterViewportRef.current)) {
syncViewport(props.viewport, rect.width, rect.height, underlay, overlay, props);
}
rasterViewportRef.current = props.viewport;
// 新视口先同步到真实 DPR backing store,再撤销交互期的合成变换,避免出现跳帧。
if (hadPreview) forceSceneRender(underlay, overlay);
resetScenePreview(underlay, overlay);
}, [props.containerRef, props.viewport]);
useLayoutEffect(() => {
const underlay = underlayRef.current;
const overlay = overlayRef.current;
const container = props.containerRef.current;
if (!underlay || !overlay || !container) return;
const rect = container.getBoundingClientRect();
syncViewport(rasterViewportRef.current, rect.width, rect.height, underlay, overlay, props);
if (isViewportPreview(container, viewportRef.current, rasterViewportRef.current)) {
applyScenePreview(viewportRef.current, rasterViewportRef.current, underlay, overlay);
}
}, [props.alignmentGuides, props.containerRef, props.theme]);
return (
<>
<div ref={underlayHostRef} data-canvas-leafer-underlay className="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden />
<div ref={overlayHostRef} data-canvas-leafer-overlay className="pointer-events-none absolute inset-0 z-[var(--z-canvas-overlay)] overflow-hidden" aria-hidden />
</>
);
}
function createUnderlayScene(host: HTMLDivElement): UnderlayScene {
const leafer = new Leafer({ view: host, width: 1, height: 1, pixelRatio: canvasPixelRatio(), fill: "transparent", hittable: false, smooth: true });
const world = new Group({ hittable: false });
const connections = new Group({ hittable: false });
world.add(connections);
leafer.add(world);
return { leafer, world, host, connections };
}
function createOverlayScene(host: HTMLDivElement): OverlayScene {
const leafer = new Leafer({ view: host, width: 1, height: 1, pixelRatio: canvasPixelRatio(), fill: "transparent", hittable: false, smooth: true });
const world = new Group({ hittable: false });
const selection = new Rect({ visible: false, hittable: false });
const selectionBounds = new Rect({ visible: false, hittable: false, fill: "transparent" });
const guides = new Path({ visible: false, hittable: false });
const draft = new Path({ visible: false, hittable: false });
world.add(selection);
world.add(selectionBounds);
world.add(guides);
world.add(draft);
leafer.add(world);
return { leafer, world, host, selection, selectionBounds, guides, draft };
}
function rebuildConnections(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps) {
scene.connections.removeAll(true);
props.displayConnections.forEach(({ connection, from, to }) => {
const emphasized = props.selectedConnectionId === connection.id || props.relatedConnectionIds.has(connection.id);
const path = new Path({
path: canvasConnectionPath(connection, from, to, props.scriptScrollTopById[from.id] || 0, props.scriptScrollTopById[to.id] || 0).pathD,
stroke: emphasized ? props.theme.accent.primary : props.theme.node.muted,
strokeWidth: emphasized ? 1.6 : 1,
strokeScaleFixed: true,
strokeCap: "round",
opacity: emphasized ? 0.52 : 0.24,
hittable: false,
});
scene.connections.add(path);
});
}
function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLayerProps, viewportScale: number) {
const selection = props.selectionBox;
scene.selection.visible = Boolean(selection);
if (selection) {
syncSelection(scene.selection, selection, props.theme);
}
const bounds = props.selectedNodeBounds;
scene.selectionBounds.visible = Boolean(bounds && !selection);
if (bounds && !selection) {
syncSelectionBounds(scene.selectionBounds, bounds, viewportScale);
scene.selectionBounds.stroke = props.theme.accent.primary;
}
const connecting = props.connectingParams;
scene.draft.visible = Boolean(connecting);
if (connecting) {
scene.draft.set({
path: activeConnectionPath(
props.nodeById.get(connecting.nodeId),
connecting,
props.mouseWorld,
props.connectionTargetNodeId ? props.nodeById.get(props.connectionTargetNodeId) : undefined,
props.scriptScrollTopById[connecting.nodeId] || 0,
props.connectionTargetAnchorRatio,
),
stroke: props.theme.accent.primary,
strokeCap: "round",
opacity: 0.72,
});
}
}
function syncSelection(rect: Rect, selection: SelectionBox, theme: CanvasTheme) {
rect.set({
x: Math.min(selection.startWorldX, selection.currentWorldX),
y: Math.min(selection.startWorldY, selection.currentWorldY),
width: Math.abs(selection.currentWorldX - selection.startWorldX),
height: Math.abs(selection.currentWorldY - selection.startWorldY),
fill: theme.canvas.selectionFill,
stroke: theme.accent.primary,
});
}
function syncViewport(viewport: ViewportTransform, width: number, height: number, underlay: UnderlayScene, overlay: OverlayScene, props: CanvasLeaferGraphicsLayerProps) {
const scale = Math.max(viewport.k, 0.05);
for (const scene of [underlay, overlay]) scene.world.set({ x: viewport.x, y: viewport.y, scaleX: scale, scaleY: scale });
overlay.selection.strokeWidth = 1 / scale;
overlay.selection.cornerRadius = 2 / scale;
if (props.selectedNodeBounds) syncSelectionBounds(overlay.selectionBounds, props.selectedNodeBounds, scale);
overlay.selectionBounds.set({ strokeWidth: 1 / scale, cornerRadius: 12 / scale });
overlay.draft.set({ strokeWidth: 1.4 / scale, dashPattern: [8 / scale, 8 / scale] });
overlay.guides.set({
visible: typeof props.alignmentGuides.vertical === "number" || typeof props.alignmentGuides.horizontal === "number",
path: guidePath(viewport, width, height, props.alignmentGuides),
stroke: props.theme.accent.primary,
strokeWidth: 1 / scale,
dashPattern: [5 / scale, 5 / scale],
opacity: 0.72,
});
}
function isViewportPreview(container: HTMLDivElement, viewport: ViewportTransform, rasterViewport: ViewportTransform) {
return container.dataset.canvasViewportInteracting === "true" && !sameCanvasViewport(viewport, rasterViewport);
}
function applyScenePreview(viewport: ViewportTransform, rasterViewport: ViewportTransform, ...scenes: LeaferScene[]) {
// 将已栅格画面的屏幕坐标映射到实时视口,缩放手势期间不触碰 Leafer 场景树。
const { ratio, x, y } = calculateCanvasPreviewTransform(viewport, rasterViewport);
for (const scene of scenes) {
scene.host.style.transformOrigin = "0 0";
scene.host.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${ratio})`;
scene.host.style.willChange = "transform";
scene.host.dataset.canvasLeaferPreview = "true";
}
}
function hasScenePreview(...scenes: LeaferScene[]) {
return scenes.some((scene) => scene.host.dataset.canvasLeaferPreview === "true");
}
function resetScenePreview(...scenes: LeaferScene[]) {
for (const scene of scenes) {
scene.host.style.transform = "";
scene.host.style.transformOrigin = "";
scene.host.style.willChange = "";
delete scene.host.dataset.canvasLeaferPreview;
}
}
function forceSceneRender(...scenes: LeaferScene[]) {
for (const scene of scenes) scene.leafer.forceRender(undefined, true);
}
function syncSelectionBounds(rect: Rect, bounds: NonNullable<NodeBounds>, viewportScale: number) {
const padding = 12 / Math.max(viewportScale, 0.05);
rect.set({
x: bounds.left - padding,
y: bounds.top - padding,
width: bounds.width + padding * 2,
height: bounds.height + padding * 2,
});
}
function guidePath(viewport: ViewportTransform, width: number, height: number, guides: { vertical?: number; horizontal?: number }) {
const scale = Math.max(viewport.k, 0.05);
const left = -viewport.x / scale;
const top = -viewport.y / scale;
const right = left + width / scale;
const bottom = top + height / scale;
const commands: string[] = [];
if (typeof guides.vertical === "number") commands.push(`M ${guides.vertical} ${top} L ${guides.vertical} ${bottom}`);
if (typeof guides.horizontal === "number") commands.push(`M ${left} ${guides.horizontal} L ${right} ${guides.horizontal}`);
return commands.join(" ");
}
function canvasPixelRatio() {
return Math.min(3, Math.max(1, window.devicePixelRatio || 1));
}