Files
ttstd-infinite-canvas/web/src/lib/canvas/canvas-live-viewport.ts
soonstudio-ai 8e82e67cb3 feat(canvas): 多角度贴节点、宫格切分菜单与多选虚线框跟随
把多角度改成贴在图片节点下方的 3D 编辑器并跟随拖动;宫格切分并入图片工具,自定义面板不关菜单;切分后子图按网格排开;多选虚线框随拖动预览平移。
2026-09-14 17:19:56 +08:00

181 lines
8.7 KiB
TypeScript

import type { SelectionBox, ViewportTransform } from "@/types/canvas";
export const CANVAS_VIEWPORT_PREVIEW_EVENT = "canvas:viewport-preview";
export const CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT = "canvas:graphics-viewport-preview";
export const CANVAS_SELECTION_PREVIEW_EVENT = "canvas:selection-preview";
export const CANVAS_NODE_DRAG_PREVIEW_EVENT = "canvas:node-drag-preview";
export type CanvasNodeDragPreview = {
x: number;
y: number;
nodeIds: ReadonlySet<string>;
};
type NodeDragPreviewDomState = {
elementsById: Map<string, HTMLElement>;
previousIds: Set<string>;
selectionBounds: HTMLElement | null;
};
type NodeSelectionPreviewDomState = {
elementsById: Map<string, HTMLElement>;
previousStates: Map<string, "include" | "remove">;
};
export type CanvasNodeSelectionPreview = {
includeNodeIds: ReadonlySet<string>;
removeNodeIds: ReadonlySet<string>;
};
const nodeDragPreviewDomStates = new WeakMap<HTMLDivElement, NodeDragPreviewDomState>();
const nodeSelectionPreviewDomStates = new WeakMap<HTMLDivElement, NodeSelectionPreviewDomState>();
const liveViewportElements = new WeakMap<HTMLDivElement, { worldLayer: HTMLElement | null }>();
export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewport: ViewportTransform, notify = true) {
if (!container) return;
const committedScale = Number(container.style.getPropertyValue("--canvas-committed-scale")) || viewport.k;
let elements = liveViewportElements.get(container);
if (!elements) {
elements = {
worldLayer: container.querySelector<HTMLElement>("[data-canvas-world-layer]"),
};
liveViewportElements.set(container, elements);
}
const worldLayer = elements.worldLayer;
if (worldLayer) {
// 平移期间直接更新合成层,避免修改容器继承变量导致所有节点重新计算样式。
worldLayer.style.transformOrigin = "0 0";
worldLayer.style.transform = `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.k / committedScale})`;
worldLayer.style.willChange = container.dataset.canvasViewportInteracting === "true" ? "transform" : "";
}
container.style.setProperty("--canvas-live-scale", String(viewport.k));
// 外置节点标题用同一帧逆倍率抵消世界层缩放,避免等待 React 提交后再校正尺寸。
container.style.setProperty("--canvas-live-inverse-scale", String(1 / Math.max(viewport.k, 0.05)));
// 图形层必须逐帧跟随 DOM 世界层;浮层和滚动通知仍可按原频率节流。
container.dispatchEvent(new CustomEvent<ViewportTransform>(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport }));
if (notify) {
container.dispatchEvent(new CustomEvent<ViewportTransform>(CANVAS_VIEWPORT_PREVIEW_EVENT, { detail: viewport }));
// Ant Design overlays watch scrollable ancestors, but CSS transforms do not emit layout events.
container.dispatchEvent(new Event("scroll"));
}
}
export function subscribeCanvasGraphicsViewportPreview(container: HTMLDivElement, listener: (viewport: ViewportTransform) => void) {
const handlePreview = (event: Event) => listener((event as CustomEvent<ViewportTransform>).detail);
container.addEventListener(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, handlePreview);
return () => container.removeEventListener(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, handlePreview);
}
export function subscribeCanvasViewportPreview(container: HTMLDivElement, listener: (viewport: ViewportTransform) => void) {
const handlePreview = (event: Event) => listener((event as CustomEvent<ViewportTransform>).detail);
container.addEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, handlePreview);
return () => container.removeEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, handlePreview);
}
/**
* Applies transient node movement without changing React state. Only nodes
* currently mounted in the virtualized world are touched; the committed
* positions are still written by the drag-end path.
*/
export function applyCanvasNodeDragPreview(container: HTMLDivElement | null, preview: CanvasNodeDragPreview | null) {
if (!container) return;
if (preview) container.dataset.canvasNodeDragging = "true";
else delete container.dataset.canvasNodeDragging;
let state = nodeDragPreviewDomStates.get(container);
if (!state) {
state = { elementsById: new Map(), previousIds: new Set(), selectionBounds: null };
nodeDragPreviewDomStates.set(container, state);
}
for (const nodeId of state.previousIds) {
state.elementsById.get(nodeId)?.style.removeProperty("translate");
}
state.selectionBounds?.style.removeProperty("translate");
state.previousIds.clear();
if (preview) {
// Build the lookup once per drag session. A single DOM scan is much
// cheaper than one selector query per selected node on every frame.
if (state.elementsById.size === 0) {
container.querySelectorAll<HTMLElement>("[data-node-id]").forEach((element) => {
const nodeId = element.dataset.nodeId;
if (nodeId) state?.elementsById.set(nodeId, element);
});
state.selectionBounds = container.querySelector<HTMLElement>("[data-canvas-selection-bounds]");
}
for (const nodeId of preview.nodeIds) {
const element = state.elementsById.get(nodeId);
if (!element || !element.isConnected) continue;
element.style.setProperty("translate", `${preview.x}px ${preview.y}px`);
state.previousIds.add(nodeId);
}
if (!state.selectionBounds?.isConnected) {
state.selectionBounds = container.querySelector<HTMLElement>("[data-canvas-selection-bounds]");
}
state.selectionBounds?.style.setProperty("translate", `${preview.x}px ${preview.y}px`);
} else {
state.elementsById.clear();
state.selectionBounds = null;
}
container.dispatchEvent(new CustomEvent<CanvasNodeDragPreview | null>(CANVAS_NODE_DRAG_PREVIEW_EVENT, { detail: preview }));
}
export function subscribeCanvasNodeDragPreview(container: HTMLDivElement, listener: (preview: CanvasNodeDragPreview | null) => void) {
const handlePreview = (event: Event) => listener((event as CustomEvent<CanvasNodeDragPreview | null>).detail);
container.addEventListener(CANVAS_NODE_DRAG_PREVIEW_EVENT, handlePreview);
return () => container.removeEventListener(CANVAS_NODE_DRAG_PREVIEW_EVENT, handlePreview);
}
/**
* Shows the selection delta on mounted nodes without changing React state.
* The controller commits the final Set once on pointer-up.
*/
export function applyCanvasNodeSelectionPreview(container: HTMLDivElement | null, preview: CanvasNodeSelectionPreview | null) {
if (!container) return;
let state = nodeSelectionPreviewDomStates.get(container);
if (!state) {
state = { elementsById: new Map(), previousStates: new Map() };
nodeSelectionPreviewDomStates.set(container, state);
}
const nextStates = new Map<string, "include" | "remove">();
if (preview) {
for (const nodeId of preview.includeNodeIds) nextStates.set(nodeId, "include");
for (const nodeId of preview.removeNodeIds) nextStates.set(nodeId, "remove");
}
if (state.elementsById.size === 0 && nextStates.size > 0) {
container.querySelectorAll<HTMLElement>("[data-node-id]").forEach((element) => {
const nodeId = element.dataset.nodeId;
if (nodeId) state?.elementsById.set(nodeId, element);
});
}
for (const [nodeId, previousState] of state.previousStates) {
if (nextStates.get(nodeId) === previousState) continue;
state.elementsById.get(nodeId)?.removeAttribute("data-canvas-selection-preview");
}
for (const [nodeId, nextState] of nextStates) {
if (state.previousStates.get(nodeId) === nextState) continue;
const element = state.elementsById.get(nodeId);
if (!element || !element.isConnected) continue;
element.dataset.canvasSelectionPreview = nextState;
}
state.previousStates = nextStates;
if (!preview) state.elementsById.clear();
}
export function applyCanvasSelectionPreview(container: HTMLDivElement | null, selection: SelectionBox) {
container?.dispatchEvent(new CustomEvent<SelectionBox>(CANVAS_SELECTION_PREVIEW_EVENT, { detail: selection }));
}
export function subscribeCanvasSelectionPreview(container: HTMLDivElement, listener: (selection: SelectionBox) => void) {
const handlePreview = (event: Event) => listener((event as CustomEvent<SelectionBox>).detail);
container.addEventListener(CANVAS_SELECTION_PREVIEW_EVENT, handlePreview);
return () => container.removeEventListener(CANVAS_SELECTION_PREVIEW_EVENT, handlePreview);
}