perf: use screen-sized drag previews

This commit is contained in:
Dariusz L
2026-08-18 22:33:45 +02:00
parent 032a55d0c5
commit f99cedb441
3 changed files with 200 additions and 5 deletions
+79 -2
View File
@@ -41,6 +41,7 @@ export class CanvasLayers {
this.renderOrderSourceEntries = [];
this.renderOrderSignature = '';
this.dragSceneCache = null;
this.dragLayerPreviews = new Map();
// Debouncing system for processed image creation
this.processedImageDebounceTimers = new Map();
this.processedImageBuildTimers = new Map();
@@ -148,6 +149,7 @@ export class CanvasLayers {
this.processedImageBuildTimers = new Map();
this.processedImageBuilds = new Map();
this.latestProcessedImageKeys = new Map();
this.dragLayerPreviews = new Map();
this.blendModes = [
{ name: 'normal', label: 'Normal' },
{ name: 'multiply', label: 'Multiply' },
@@ -750,6 +752,42 @@ export class CanvasLayers {
const blendArea = layer.blendArea ?? 0;
return `${layer.id}_${blendArea}_${this.getProcessedImageGeometryKey(layer)}`;
}
clearDragLayerPreviews() {
for (const preview of this.dragLayerPreviews.values()) {
preview.canvas.width = 0;
preview.canvas.height = 0;
}
this.dragLayerPreviews.clear();
}
getOrCreateDragLayerPreview(layer, source, options) {
const displayWidth = Math.max(1, Math.ceil(Math.abs(layer.width * options.viewport.zoom)));
const displayHeight = Math.max(1, Math.ceil(Math.abs(layer.height * options.viewport.zoom)));
const maxPreviewDimension = Math.max(1024, Math.max(options.width, options.height) * 2);
const previewScale = Math.min(1, maxPreviewDimension / displayWidth, maxPreviewDimension / displayHeight);
const previewWidth = Math.max(1, Math.ceil(displayWidth * previewScale));
const previewHeight = Math.max(1, Math.ceil(displayHeight * previewScale));
const cacheKey = `${this.getProcessedImageCacheKey(layer)}|${previewWidth}x${previewHeight}`;
const existing = this.dragLayerPreviews.get(layer.id);
if (existing && existing.source === source && existing.cacheKey === cacheKey) {
return existing.canvas;
}
const surface = createCanvas(previewWidth, previewHeight);
if (!surface.ctx)
return null;
surface.ctx.imageSmoothingEnabled = true;
surface.ctx.imageSmoothingQuality = 'high';
surface.ctx.drawImage(source, 0, 0, previewWidth, previewHeight);
if (existing) {
existing.canvas.width = 0;
existing.canvas.height = 0;
}
this.dragLayerPreviews.set(layer.id, {
source,
cacheKey,
canvas: surface.canvas
});
return surface.canvas;
}
isUserInteractionActive() {
const mode = this.canvas.canvasInteractions?.interaction?.mode;
return mode !== undefined && mode !== 'none';
@@ -1103,6 +1141,7 @@ export class CanvasLayers {
*/
clearProcessedImageCache() {
this.latestProcessedImageKeys.clear();
this.clearDragLayerPreviews();
const images = new Set([
...this.processedImageCache.values(),
...Array.from(this.lastProcessedImageFallbacks.values(), fallback => fallback.image)
@@ -1335,6 +1374,37 @@ export class CanvasLayers {
}
ctx.restore();
}
_drawLayerDuringDrag(ctx, layer, previewOptions) {
if (!layer.image)
return;
const processedImage = this.getProcessedImage(layer, {
cacheOnly: true,
allowStaleCacheWhileDragging: true
});
if (!processedImage) {
this._drawLayer(ctx, layer);
return;
}
const preview = this.getOrCreateDragLayerPreview(layer, processedImage, previewOptions);
if (!preview) {
this._drawLayer(ctx, layer);
return;
}
ctx.save();
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
ctx.translate(centerX, centerY);
ctx.rotate(layer.rotation * Math.PI / 180);
if (layer.flipH || layer.flipV) {
ctx.scale(layer.flipH ? -1 : 1, layer.flipV ? -1 : 1);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(preview, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
ctx.restore();
}
createDragStaticCanvas(segment, viewport, width, height) {
const surface = createCanvas(width, height);
if (!surface.ctx)
@@ -1483,7 +1553,9 @@ export class CanvasLayers {
this.drawDragStaticSegment(ctx, segment);
}
if (part.dynamicLayers.length > 0) {
this._drawLayers(ctx, part.dynamicLayers);
this._drawLayers(ctx, part.dynamicLayers, {
dragPreview: { viewport, width, height }
});
}
}
}
@@ -1491,7 +1563,12 @@ export class CanvasLayers {
const sortedLayers = this.getRenderOrder(layers);
sortedLayers.forEach(layer => {
if (layer.visible) {
this._drawLayer(ctx, layer, options);
if (options.dragPreview) {
this._drawLayerDuringDrag(ctx, layer, options.dragPreview);
}
else {
this._drawLayer(ctx, layer, options);
}
}
});
}
+118 -3
View File
@@ -74,6 +74,18 @@ interface DragSceneCache {
parts: DragScenePart[];
}
interface DragLayerPreview {
source: CanvasImageSource;
cacheKey: string;
canvas: HTMLCanvasElement;
}
interface DragPreviewOptions {
viewport: { x: number; y: number; zoom: number };
width: number;
height: number;
}
export class CanvasLayers {
private canvas: Canvas;
private _canvasMaskCache: WeakMap<HTMLCanvasElement, DistanceFieldCacheEntry> = new WeakMap();
@@ -113,6 +125,7 @@ export class CanvasLayers {
private renderOrderSourceEntries: Layer[] = [];
private renderOrderSignature = '';
private dragSceneCache: DragSceneCache | null = null;
private dragLayerPreviews: Map<string, DragLayerPreview> = new Map();
// Debouncing system for processed image creation
private processedImageDebounceTimers: Map<string, number> = new Map();
@@ -138,6 +151,7 @@ export class CanvasLayers {
this.processedImageBuildTimers = new Map();
this.processedImageBuilds = new Map();
this.latestProcessedImageKeys = new Map();
this.dragLayerPreviews = new Map();
this.blendModes = [
{ name: 'normal', label: 'Normal' },
{name: 'multiply', label: 'Multiply'},
@@ -937,6 +951,54 @@ export class CanvasLayers {
return `${layer.id}_${blendArea}_${this.getProcessedImageGeometryKey(layer)}`;
}
private clearDragLayerPreviews(): void {
for (const preview of this.dragLayerPreviews.values()) {
preview.canvas.width = 0;
preview.canvas.height = 0;
}
this.dragLayerPreviews.clear();
}
private getOrCreateDragLayerPreview(
layer: Layer,
source: CanvasImageSource,
options: DragPreviewOptions
): HTMLCanvasElement | null {
const displayWidth = Math.max(1, Math.ceil(Math.abs(layer.width * options.viewport.zoom)));
const displayHeight = Math.max(1, Math.ceil(Math.abs(layer.height * options.viewport.zoom)));
const maxPreviewDimension = Math.max(1024, Math.max(options.width, options.height) * 2);
const previewScale = Math.min(
1,
maxPreviewDimension / displayWidth,
maxPreviewDimension / displayHeight
);
const previewWidth = Math.max(1, Math.ceil(displayWidth * previewScale));
const previewHeight = Math.max(1, Math.ceil(displayHeight * previewScale));
const cacheKey = `${this.getProcessedImageCacheKey(layer)}|${previewWidth}x${previewHeight}`;
const existing = this.dragLayerPreviews.get(layer.id);
if (existing && existing.source === source && existing.cacheKey === cacheKey) {
return existing.canvas;
}
const surface = createCanvas(previewWidth, previewHeight);
if (!surface.ctx) return null;
surface.ctx.imageSmoothingEnabled = true;
surface.ctx.imageSmoothingQuality = 'high';
surface.ctx.drawImage(source, 0, 0, previewWidth, previewHeight);
if (existing) {
existing.canvas.width = 0;
existing.canvas.height = 0;
}
this.dragLayerPreviews.set(layer.id, {
source,
cacheKey,
canvas: surface.canvas
});
return surface.canvas;
}
private isUserInteractionActive(): boolean {
const mode = this.canvas.canvasInteractions?.interaction?.mode;
return mode !== undefined && mode !== 'none';
@@ -1344,6 +1406,7 @@ export class CanvasLayers {
*/
public clearProcessedImageCache(): void {
this.latestProcessedImageKeys.clear();
this.clearDragLayerPreviews();
const images = new Set<CanvasImageSource>([
...this.processedImageCache.values(),
@@ -1609,6 +1672,48 @@ export class CanvasLayers {
ctx.restore();
}
private _drawLayerDuringDrag(
ctx: CanvasRenderingContext2D,
layer: Layer,
previewOptions: DragPreviewOptions
): void {
if (!layer.image) return;
const processedImage = this.getProcessedImage(layer, {
cacheOnly: true,
allowStaleCacheWhileDragging: true
});
if (!processedImage) {
this._drawLayer(ctx, layer);
return;
}
const preview = this.getOrCreateDragLayerPreview(layer, processedImage, previewOptions);
if (!preview) {
this._drawLayer(ctx, layer);
return;
}
ctx.save();
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
ctx.translate(centerX, centerY);
ctx.rotate(layer.rotation * Math.PI / 180);
if (layer.flipH || layer.flipV) {
ctx.scale(layer.flipH ? -1 : 1, layer.flipV ? -1 : 1);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(preview, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
ctx.restore();
}
private createDragStaticCanvas(
segment: DragSceneStaticSegment,
viewport: { x: number; y: number; zoom: number },
@@ -1800,16 +1905,26 @@ export class CanvasLayers {
this.drawDragStaticSegment(ctx, segment);
}
if (part.dynamicLayers.length > 0) {
this._drawLayers(ctx, part.dynamicLayers);
this._drawLayers(ctx, part.dynamicLayers, {
dragPreview: { viewport, width, height }
});
}
}
}
private _drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
private _drawLayers(
ctx: CanvasRenderingContext2D,
layers: Layer[],
options: { offsetX?: number, offsetY?: number, dragPreview?: DragPreviewOptions } = {}
): void {
const sortedLayers = this.getRenderOrder(layers);
sortedLayers.forEach(layer => {
if (layer.visible) {
this._drawLayer(ctx, layer, options);
if (options.dragPreview) {
this._drawLayerDuringDrag(ctx, layer, options.dragPreview);
} else {
this._drawLayer(ctx, layer, options);
}
}
});
}
@@ -96,4 +96,7 @@ test('drag scene cache groups source-over layers and replays non-normal modes',
assert.match(canvasRendererSource, /canReuseDragFrame/);
assert.match(canvasRendererSource, /getDragDirtyRect/);
assert.match(canvasRendererSource, /ctx\.clip\(\)/);
assert.match(canvasLayersSource, /getOrCreateDragLayerPreview/);
assert.match(canvasLayersSource, /dragPreview: \{ viewport, width, height \}/);
assert.match(canvasLayersSource, /ctx\.drawImage\(preview, -layer\.width \/ 2/);
});