feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验
统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
1 parent
434f63e153
commit
3f4a9ea3d0
45 files changed
+1348
-286
No files matched your search
@@ -0,0 +1,123 @@
|
||||
export type CanvasSpatialBounds = {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
export type CanvasSpatialIndexEntry<T> = {
|
||||
id: string;
|
||||
bounds: CanvasSpatialBounds;
|
||||
value: T;
|
||||
};
|
||||
|
||||
type Bucket = number[];
|
||||
|
||||
export type CanvasSpatialIndex<T> = {
|
||||
query: (bounds: CanvasSpatialBounds, limit?: number) => T[];
|
||||
};
|
||||
|
||||
const DEFAULT_CELL_SIZE = 1024;
|
||||
const MAX_BUCKET_COVERAGE = 256;
|
||||
|
||||
/**
|
||||
* A uniform grid keeps viewport queries proportional to nearby content. The
|
||||
* original entry order is restored after bucket de-duplication so stacking
|
||||
* behavior remains deterministic.
|
||||
*/
|
||||
export function buildCanvasSpatialIndex<T>(entries: CanvasSpatialIndexEntry<T>[], cellSize = DEFAULT_CELL_SIZE): CanvasSpatialIndex<T> {
|
||||
const normalizedCellSize = Number.isFinite(cellSize) && cellSize > 0 ? cellSize : DEFAULT_CELL_SIZE;
|
||||
const buckets = new Map<string, Bucket>();
|
||||
const largeEntryIndexes: number[] = [];
|
||||
const validEntries: CanvasSpatialIndexEntry<T>[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const bounds = normalizeBounds(entry.bounds);
|
||||
if (!bounds || !entry.id) return;
|
||||
const index = validEntries.push({ ...entry, bounds }) - 1;
|
||||
const minColumn = Math.floor(bounds.left / normalizedCellSize);
|
||||
const maxColumn = Math.floor((bounds.right - Number.EPSILON) / normalizedCellSize);
|
||||
const minRow = Math.floor(bounds.top / normalizedCellSize);
|
||||
const maxRow = Math.floor((bounds.bottom - Number.EPSILON) / normalizedCellSize);
|
||||
if ((maxColumn - minColumn + 1) * (maxRow - minRow + 1) > MAX_BUCKET_COVERAGE) {
|
||||
largeEntryIndexes.push(index);
|
||||
return;
|
||||
}
|
||||
for (let row = minRow; row <= maxRow; row += 1) {
|
||||
for (let column = minColumn; column <= maxColumn; column += 1) {
|
||||
const key = `${column}:${row}`;
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) bucket.push(index);
|
||||
else buckets.set(key, [index]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
query(bounds, limit) {
|
||||
const normalizedBounds = normalizeBounds(bounds);
|
||||
if (!normalizedBounds || validEntries.length === 0) return [];
|
||||
const resultLimit = Number.isFinite(limit) && (limit || 0) > 0 ? Math.floor(limit as number) : null;
|
||||
const minColumn = Math.floor(normalizedBounds.left / normalizedCellSize);
|
||||
const maxColumn = Math.floor((normalizedBounds.right - Number.EPSILON) / normalizedCellSize);
|
||||
const minRow = Math.floor(normalizedBounds.top / normalizedCellSize);
|
||||
const maxRow = Math.floor((normalizedBounds.bottom - Number.EPSILON) / normalizedCellSize);
|
||||
if (resultLimit) {
|
||||
const candidateIndexes = new Set<number>();
|
||||
const resultIndexes: number[] = [];
|
||||
const addBucket = (bucket: Bucket | undefined) => {
|
||||
if (!bucket) return false;
|
||||
for (const index of bucket) {
|
||||
if (candidateIndexes.has(index)) continue;
|
||||
candidateIndexes.add(index);
|
||||
const entry = validEntries[index];
|
||||
if (!intersects(entry.bounds, normalizedBounds)) continue;
|
||||
resultIndexes.push(index);
|
||||
if (resultIndexes.length >= resultLimit) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (let row = minRow; row <= maxRow; row += 1) {
|
||||
for (let column = minColumn; column <= maxColumn; column += 1) {
|
||||
if (addBucket(buckets.get(`${column}:${row}`))) return resultIndexes.sort((left, right) => left - right).map((index) => validEntries[index].value);
|
||||
}
|
||||
}
|
||||
for (const index of largeEntryIndexes) {
|
||||
if (candidateIndexes.has(index)) continue;
|
||||
const entry = validEntries[index];
|
||||
if (!intersects(entry.bounds, normalizedBounds)) continue;
|
||||
resultIndexes.push(index);
|
||||
if (resultIndexes.length >= resultLimit) break;
|
||||
}
|
||||
return resultIndexes.sort((left, right) => left - right).map((index) => validEntries[index].value);
|
||||
}
|
||||
const candidateIndexes = new Set<number>();
|
||||
for (let row = minRow; row <= maxRow; row += 1) {
|
||||
for (let column = minColumn; column <= maxColumn; column += 1) {
|
||||
buckets.get(`${column}:${row}`)?.forEach((index) => candidateIndexes.add(index));
|
||||
}
|
||||
}
|
||||
largeEntryIndexes.forEach((index) => candidateIndexes.add(index));
|
||||
return [...candidateIndexes]
|
||||
.sort((left, right) => left - right)
|
||||
.map((index) => validEntries[index])
|
||||
.filter((entry) => intersects(entry.bounds, normalizedBounds))
|
||||
.map((entry) => entry.value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasNodeBounds(node: { position: { x: number; y: number }; width: number; height: number }): CanvasSpatialBounds {
|
||||
return { left: node.position.x, top: node.position.y, right: node.position.x + node.width, bottom: node.position.y + node.height };
|
||||
}
|
||||
|
||||
function normalizeBounds(bounds: CanvasSpatialBounds | undefined): CanvasSpatialBounds | null {
|
||||
if (!bounds) return null;
|
||||
const { left, top, right, bottom } = bounds;
|
||||
if (![left, top, right, bottom].every(Number.isFinite) || right <= left || bottom <= top) return null;
|
||||
return { left, top, right, bottom };
|
||||
}
|
||||
|
||||
function intersects(left: CanvasSpatialBounds, right: CanvasSpatialBounds) {
|
||||
return left.right > right.left && left.left < right.right && left.bottom > right.top && left.top < right.bottom;
|
||||
}
|
||||
Reference in new issue
Block a user