4db6d28603
* 提示词:完善组件建议,增加容器背景与节点大小一致的实现 * 支持Delete删除节点 ctrl + -缩放 * 步骤结束弹窗提醒   * UI 树快捷删除  * zoom滑动条样式 before:  after:  Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/278 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
288 lines
8.1 KiB
TypeScript
288 lines
8.1 KiB
TypeScript
import type { CanvasLayer, CanvasSnapItem, SnapCandidate } from './types';
|
|
|
|
export const CANVAS_WORLD_SIZE = 12000;
|
|
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
|
|
export const MIN_SCALE = 0.025;
|
|
export const MAX_SCALE = 3.2;
|
|
export const CANVAS_ZOOM_IN_FACTOR = 1.16;
|
|
export const CANVAS_ZOOM_OUT_FACTOR = 0.86;
|
|
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
|
|
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
|
|
export const FIT_VIEW_PADDING = 10;
|
|
export const MINIMAP_SIZE = { width: 132, height: 84 };
|
|
export const MINIMAP_PADDING = 8;
|
|
export const MINIMAP_DRAG_SENSITIVITY = 0.3;
|
|
export const MAX_HISTORY_STEPS = 60;
|
|
export const SNAP_THRESHOLD_SCREEN_PX = 18;
|
|
export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1;
|
|
const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3;
|
|
|
|
export function clamp(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
export function formatPercent(value: number) {
|
|
return `${Math.round(value * 100)}%`;
|
|
}
|
|
|
|
export function formatCanvasDisplayScalePercent(scale: number) {
|
|
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
|
return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE);
|
|
}
|
|
|
|
export function canvasDisplayScaleToViewportScale(displayScale: number) {
|
|
const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1;
|
|
return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE;
|
|
}
|
|
|
|
export function viewportScaleToCanvasDisplayScale(scale: number) {
|
|
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
|
return safeScale / CANVAS_DISPLAY_SCALE_BASE;
|
|
}
|
|
|
|
export function getLayerBounds(targetLayers: CanvasLayer[]) {
|
|
if (targetLayers.length === 0) {
|
|
return null;
|
|
}
|
|
return targetLayers.reduce(
|
|
(current, layer) => ({
|
|
minX: Math.min(current.minX, layer.x),
|
|
minY: Math.min(current.minY, layer.y),
|
|
maxX: Math.max(current.maxX, layer.x + layer.width),
|
|
maxY: Math.max(current.maxY, layer.y + layer.height),
|
|
}),
|
|
{
|
|
minX: Number.POSITIVE_INFINITY,
|
|
minY: Number.POSITIVE_INFINITY,
|
|
maxX: Number.NEGATIVE_INFINITY,
|
|
maxY: Number.NEGATIVE_INFINITY,
|
|
},
|
|
);
|
|
}
|
|
|
|
export function resolveSnappedItemPosition(
|
|
movingItem: CanvasSnapItem,
|
|
proposedX: number,
|
|
proposedY: number,
|
|
items: CanvasSnapItem[],
|
|
scale: number,
|
|
) {
|
|
const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE);
|
|
const visibleItems = items.filter(
|
|
(item) => item.id !== movingItem.id && !item.hidden,
|
|
);
|
|
const verticalTargets = [
|
|
0,
|
|
CANVAS_WORLD_ORIGIN,
|
|
...visibleItems.flatMap((item) => [
|
|
item.x,
|
|
item.x + item.width / 2,
|
|
item.x + item.width,
|
|
]),
|
|
];
|
|
const horizontalTargets = [
|
|
0,
|
|
CANVAS_WORLD_ORIGIN,
|
|
...visibleItems.flatMap((item) => [
|
|
item.y,
|
|
item.y + item.height / 2,
|
|
item.y + item.height,
|
|
]),
|
|
];
|
|
const xAlignmentSnap = findNearestSnap(
|
|
proposedX,
|
|
[0, movingItem.width / 2, movingItem.width],
|
|
verticalTargets,
|
|
threshold,
|
|
);
|
|
const yAlignmentSnap = findNearestSnap(
|
|
proposedY,
|
|
[0, movingItem.height / 2, movingItem.height],
|
|
horizontalTargets,
|
|
threshold,
|
|
);
|
|
const xDistributionSnap = findNearestEqualSpacingSnap({
|
|
axis: 'x',
|
|
proposedStart: proposedX,
|
|
proposedCrossStart: proposedY,
|
|
movingSize: movingItem.width,
|
|
movingCrossSize: movingItem.height,
|
|
items: visibleItems,
|
|
threshold,
|
|
});
|
|
const yDistributionSnap = findNearestEqualSpacingSnap({
|
|
axis: 'y',
|
|
proposedStart: proposedY,
|
|
proposedCrossStart: proposedX,
|
|
movingSize: movingItem.height,
|
|
movingCrossSize: movingItem.width,
|
|
items: visibleItems,
|
|
threshold,
|
|
});
|
|
const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap);
|
|
const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap);
|
|
return {
|
|
x: xSnap ? xSnap.position : proposedX,
|
|
y: ySnap ? ySnap.position : proposedY,
|
|
guide:
|
|
xSnap || ySnap
|
|
? { vertical: xSnap?.guide, horizontal: ySnap?.guide }
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function chooseNearestSnap(
|
|
first: SnapCandidate | null,
|
|
second: SnapCandidate | null,
|
|
) {
|
|
if (!first) return second;
|
|
if (!second) return first;
|
|
return second.distance < first.distance ? second : first;
|
|
}
|
|
|
|
function findNearestEqualSpacingSnap({
|
|
axis,
|
|
proposedStart,
|
|
proposedCrossStart,
|
|
movingSize,
|
|
movingCrossSize,
|
|
items,
|
|
threshold,
|
|
}: {
|
|
axis: 'x' | 'y';
|
|
proposedStart: number;
|
|
proposedCrossStart: number;
|
|
movingSize: number;
|
|
movingCrossSize: number;
|
|
items: CanvasSnapItem[];
|
|
threshold: number;
|
|
}): SnapCandidate | null {
|
|
const orderedItems = items
|
|
.filter((item) =>
|
|
snapItemsOverlapOnCrossAxis(
|
|
proposedCrossStart,
|
|
movingCrossSize,
|
|
item,
|
|
axis,
|
|
),
|
|
)
|
|
.sort(
|
|
(firstItem, secondItem) =>
|
|
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
|
|
);
|
|
let nearest: SnapCandidate | null = null;
|
|
for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) {
|
|
const firstItem = orderedItems[firstIndex];
|
|
if (!firstItem) continue;
|
|
for (
|
|
let secondIndex = firstIndex + 1;
|
|
secondIndex < orderedItems.length &&
|
|
secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD;
|
|
secondIndex += 1
|
|
) {
|
|
const secondItem = orderedItems[secondIndex];
|
|
if (!secondItem) continue;
|
|
const firstStart = getSnapItemStart(firstItem, axis);
|
|
const firstEnd = getSnapItemEnd(firstItem, axis);
|
|
const secondStart = getSnapItemStart(secondItem, axis);
|
|
const secondEnd = getSnapItemEnd(secondItem, axis);
|
|
const pairGap = secondStart - firstEnd;
|
|
if (pairGap < 0) continue;
|
|
nearest = chooseNearestSnap(
|
|
nearest,
|
|
createDistributionSnapCandidate({
|
|
position: firstStart - pairGap - movingSize,
|
|
proposedStart,
|
|
movingSize,
|
|
threshold,
|
|
}),
|
|
);
|
|
nearest = chooseNearestSnap(
|
|
nearest,
|
|
createDistributionSnapCandidate({
|
|
position: secondEnd + pairGap,
|
|
proposedStart,
|
|
movingSize,
|
|
threshold,
|
|
}),
|
|
);
|
|
const betweenGap = (pairGap - movingSize) / 2;
|
|
if (betweenGap >= 0) {
|
|
nearest = chooseNearestSnap(
|
|
nearest,
|
|
createDistributionSnapCandidate({
|
|
position: firstEnd + betweenGap,
|
|
proposedStart,
|
|
movingSize,
|
|
threshold,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return nearest;
|
|
}
|
|
|
|
function createDistributionSnapCandidate({
|
|
position,
|
|
proposedStart,
|
|
movingSize,
|
|
threshold,
|
|
}: {
|
|
position: number;
|
|
proposedStart: number;
|
|
movingSize: number;
|
|
threshold: number;
|
|
}): SnapCandidate | null {
|
|
const distance = Math.abs(position - proposedStart);
|
|
return distance > threshold
|
|
? null
|
|
: { position, guide: position + movingSize / 2, distance };
|
|
}
|
|
|
|
function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') {
|
|
return axis === 'x' ? item.x : item.y;
|
|
}
|
|
|
|
function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') {
|
|
return axis === 'x' ? item.width : item.height;
|
|
}
|
|
|
|
function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') {
|
|
return getSnapItemStart(item, axis) + getSnapItemSize(item, axis);
|
|
}
|
|
|
|
function snapItemsOverlapOnCrossAxis(
|
|
proposedCrossStart: number,
|
|
movingCrossSize: number,
|
|
item: CanvasSnapItem,
|
|
axis: 'x' | 'y',
|
|
) {
|
|
const itemCrossStart = axis === 'x' ? item.y : item.x;
|
|
const itemCrossSize = axis === 'x' ? item.height : item.width;
|
|
return (
|
|
proposedCrossStart <=
|
|
itemCrossStart + itemCrossSize - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
|
|
proposedCrossStart + movingCrossSize >=
|
|
itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE
|
|
);
|
|
}
|
|
|
|
export function findNearestSnap(
|
|
origin: number,
|
|
offsets: number[],
|
|
targets: number[],
|
|
threshold: number,
|
|
): SnapCandidate | null {
|
|
let nearest: SnapCandidate | null = null;
|
|
for (const offset of offsets) {
|
|
for (const target of targets) {
|
|
const distance = Math.abs(target - (origin + offset));
|
|
if (distance <= threshold && (!nearest || distance < nearest.distance)) {
|
|
nearest = { position: target - offset, guide: target, distance };
|
|
}
|
|
}
|
|
}
|
|
return nearest;
|
|
}
|