From f99cedb441093cdb5932aaf89b4aa504b63b7f21 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 18 Aug 2026 22:33:45 +0200 Subject: [PATCH] perf: use screen-sized drag previews --- js/canvas/canvas_layers.js | 81 +++++++++++- src/canvas/canvas_layers.ts | 121 +++++++++++++++++- ...test_drag_scene_cache_characterization.mjs | 3 + 3 files changed, 200 insertions(+), 5 deletions(-) diff --git a/js/canvas/canvas_layers.js b/js/canvas/canvas_layers.js index 1ab56a2..de4a05a 100644 --- a/js/canvas/canvas_layers.js +++ b/js/canvas/canvas_layers.js @@ -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); + } } }); } diff --git a/src/canvas/canvas_layers.ts b/src/canvas/canvas_layers.ts index 78e73d9..180ccef 100644 --- a/src/canvas/canvas_layers.ts +++ b/src/canvas/canvas_layers.ts @@ -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 = new WeakMap(); @@ -113,6 +125,7 @@ export class CanvasLayers { private renderOrderSourceEntries: Layer[] = []; private renderOrderSignature = ''; private dragSceneCache: DragSceneCache | null = null; + private dragLayerPreviews: Map = new Map(); // Debouncing system for processed image creation private processedImageDebounceTimers: Map = 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([ ...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); + } } }); } diff --git a/tests/test_drag_scene_cache_characterization.mjs b/tests/test_drag_scene_cache_characterization.mjs index bd6b5d7..e4b084f 100644 --- a/tests/test_drag_scene_cache_characterization.mjs +++ b/tests/test_drag_scene_cache_characterization.mjs @@ -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/); });