perf: cache composited scene during zoom
This commit is contained in:
@@ -7,6 +7,7 @@ export class CanvasInteractions {
|
||||
constructor(canvas) {
|
||||
this.pendingTransformMove = null;
|
||||
this.transformMoveAnimationFrame = null;
|
||||
this.zoomEndTimer = null;
|
||||
// Bound event handlers to enable proper removeEventListener and avoid leaks
|
||||
this.onMouseDown = (e) => this.handleMouseDown(e);
|
||||
this.onMouseMove = (e) => this.handleMouseMove(e);
|
||||
@@ -68,7 +69,18 @@ export class CanvasInteractions {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
scheduleZoomInteractionEnd() {
|
||||
if (this.zoomEndTimer !== null) {
|
||||
window.clearTimeout(this.zoomEndTimer);
|
||||
}
|
||||
this.zoomEndTimer = window.setTimeout(() => {
|
||||
this.zoomEndTimer = null;
|
||||
this.canvas.canvasRenderer.endZoomInteraction();
|
||||
this.canvas.render();
|
||||
}, 140);
|
||||
}
|
||||
performZoomOperation(worldCoords, zoomFactor) {
|
||||
this.canvas.canvasRenderer.beginZoomInteraction();
|
||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
|
||||
@@ -80,6 +92,7 @@ export class CanvasInteractions {
|
||||
this.canvas.maskTool.handleViewportChange();
|
||||
}
|
||||
this.canvas.onViewportChange?.();
|
||||
this.scheduleZoomInteractionEnd();
|
||||
}
|
||||
renderAndSave(shouldSave = false) {
|
||||
this.canvas.render();
|
||||
@@ -193,6 +206,11 @@ export class CanvasInteractions {
|
||||
}
|
||||
teardownEventListeners() {
|
||||
this.cancelPendingTransformMove();
|
||||
if (this.zoomEndTimer !== null) {
|
||||
window.clearTimeout(this.zoomEndTimer);
|
||||
this.zoomEndTimer = null;
|
||||
}
|
||||
this.canvas.canvasRenderer.endZoomInteraction();
|
||||
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown);
|
||||
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove);
|
||||
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp);
|
||||
|
||||
+174
-37
@@ -1,5 +1,5 @@
|
||||
import { createModuleLogger } from "../log_system/log_funcs.js";
|
||||
import { getBoundsFromPoints, getLayerWorldBounds, isPointInRotatedLayer, localToWorld, worldToLocal } from "../utils/common_utils.js";
|
||||
import { createCanvas, getBoundsFromPoints, getLayerWorldBounds, isPointInRotatedLayer, localToWorld, worldToLocal } from "../utils/common_utils.js";
|
||||
const log = createModuleLogger('CanvasRenderer');
|
||||
export class CanvasRenderer {
|
||||
constructor(canvas) {
|
||||
@@ -9,10 +9,154 @@ export class CanvasRenderer {
|
||||
this.renderInterval = 1000 / 60;
|
||||
this.isDirty = false;
|
||||
this.dragRenderState = null;
|
||||
this.zoomInteractionActive = false;
|
||||
this.zoomSnapshot = null;
|
||||
// Initialize overlay canvases
|
||||
this.initOverlay();
|
||||
this.initStrokeOverlay();
|
||||
}
|
||||
/**
|
||||
* Starts a short-lived zoom interaction cache. The cache contains the
|
||||
* already composited scene, so rapid wheel events can scale one bitmap
|
||||
* instead of recompositing every blend layer on every frame.
|
||||
*/
|
||||
beginZoomInteraction() {
|
||||
if (this.zoomInteractionActive)
|
||||
return;
|
||||
// Mask drawing and other active transforms need live rendering because
|
||||
// their scene can change while the viewport is moving.
|
||||
const interactionMode = this.canvas.canvasInteractions?.interaction?.mode;
|
||||
if (this.canvas.maskTool?.isActive || (interactionMode && interactionMode !== 'none')) {
|
||||
return;
|
||||
}
|
||||
this.zoomInteractionActive = true;
|
||||
this.dragRenderState = null;
|
||||
this.releaseZoomSnapshot();
|
||||
this.zoomSnapshot = this.createZoomSnapshot();
|
||||
}
|
||||
/**
|
||||
* Ends the zoom cache and forces the next frame through the normal,
|
||||
* full-quality compositor.
|
||||
*/
|
||||
endZoomInteraction() {
|
||||
if (!this.zoomInteractionActive && !this.zoomSnapshot)
|
||||
return;
|
||||
this.zoomInteractionActive = false;
|
||||
this.dragRenderState = null;
|
||||
this.releaseZoomSnapshot();
|
||||
}
|
||||
releaseZoomSnapshot() {
|
||||
if (this.zoomSnapshot) {
|
||||
this.zoomSnapshot.canvas.width = 0;
|
||||
this.zoomSnapshot.canvas.height = 0;
|
||||
}
|
||||
this.zoomSnapshot = null;
|
||||
}
|
||||
getCanvasFill() {
|
||||
if (typeof getComputedStyle === 'function') {
|
||||
const canvasFill = getComputedStyle(this.canvas.canvas)
|
||||
.getPropertyValue('--lf-canvas-fill')
|
||||
.trim();
|
||||
if (canvasFill)
|
||||
return canvasFill;
|
||||
}
|
||||
return 'rgba(96, 96, 96, 0.72)';
|
||||
}
|
||||
getZoomSnapshotScale(displayWidth, displayHeight) {
|
||||
// A modest overscan keeps zooming out cached for several wheel steps.
|
||||
// Cap the backing surface so a large editor widget cannot allocate an
|
||||
// unexpectedly large temporary bitmap.
|
||||
const requestedScale = 1.5;
|
||||
const maxSnapshotPixels = 16000000;
|
||||
const requestedPixels = displayWidth * displayHeight * requestedScale * requestedScale;
|
||||
if (requestedPixels <= maxSnapshotPixels)
|
||||
return requestedScale;
|
||||
return Math.max(1, Math.sqrt(maxSnapshotPixels / (displayWidth * displayHeight)));
|
||||
}
|
||||
createZoomSnapshot() {
|
||||
const displayWidth = Math.max(1, this.canvas.offscreenCanvas.width || this.canvas.canvas.clientWidth || 1);
|
||||
const displayHeight = Math.max(1, this.canvas.offscreenCanvas.height || this.canvas.canvas.clientHeight || 1);
|
||||
const zoom = this.canvas.viewport.zoom;
|
||||
const snapshotScale = this.getZoomSnapshotScale(displayWidth, displayHeight);
|
||||
const snapshotWidth = Math.max(1, Math.ceil(displayWidth * snapshotScale));
|
||||
const snapshotHeight = Math.max(1, Math.ceil(displayHeight * snapshotScale));
|
||||
const extraWorldWidth = (snapshotWidth - displayWidth) / zoom;
|
||||
const extraWorldHeight = (snapshotHeight - displayHeight) / zoom;
|
||||
const snapshotViewport = {
|
||||
x: this.canvas.viewport.x - extraWorldWidth / 2,
|
||||
y: this.canvas.viewport.y - extraWorldHeight / 2,
|
||||
zoom,
|
||||
};
|
||||
const surface = createCanvas(snapshotWidth, snapshotHeight, '2d', {
|
||||
alpha: false,
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (!surface.ctx)
|
||||
return null;
|
||||
const ctx = surface.ctx;
|
||||
ctx.clearRect(0, 0, snapshotWidth, snapshotHeight);
|
||||
ctx.fillStyle = this.getCanvasFill();
|
||||
ctx.fillRect(0, 0, snapshotWidth, snapshotHeight);
|
||||
ctx.save();
|
||||
ctx.scale(snapshotViewport.zoom, snapshotViewport.zoom);
|
||||
ctx.translate(-snapshotViewport.x, -snapshotViewport.y);
|
||||
this.drawGrid(ctx, snapshotViewport, snapshotWidth, snapshotHeight);
|
||||
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
||||
this.drawVisibleMask(ctx);
|
||||
ctx.restore();
|
||||
return {
|
||||
canvas: surface.canvas,
|
||||
viewport: snapshotViewport,
|
||||
displayWidth,
|
||||
displayHeight,
|
||||
};
|
||||
}
|
||||
canUseZoomSnapshot(viewport, displayWidth, displayHeight) {
|
||||
const snapshot = this.zoomSnapshot;
|
||||
const interactionMode = this.canvas.canvasInteractions?.interaction?.mode;
|
||||
if (!this.zoomInteractionActive || !snapshot || interactionMode !== 'none')
|
||||
return false;
|
||||
if (snapshot.displayWidth !== displayWidth || snapshot.displayHeight !== displayHeight)
|
||||
return false;
|
||||
const visibleLeft = viewport.x;
|
||||
const visibleTop = viewport.y;
|
||||
const visibleRight = viewport.x + displayWidth / viewport.zoom;
|
||||
const visibleBottom = viewport.y + displayHeight / viewport.zoom;
|
||||
const snapshotRight = snapshot.viewport.x + snapshot.canvas.width / snapshot.viewport.zoom;
|
||||
const snapshotBottom = snapshot.viewport.y + snapshot.canvas.height / snapshot.viewport.zoom;
|
||||
const epsilon = 1 / Math.max(viewport.zoom, snapshot.viewport.zoom, 0.0001);
|
||||
return visibleLeft >= snapshot.viewport.x - epsilon &&
|
||||
visibleTop >= snapshot.viewport.y - epsilon &&
|
||||
visibleRight <= snapshotRight + epsilon &&
|
||||
visibleBottom <= snapshotBottom + epsilon;
|
||||
}
|
||||
drawZoomSnapshot(ctx, viewport) {
|
||||
const snapshot = this.zoomSnapshot;
|
||||
if (!snapshot)
|
||||
return;
|
||||
const scale = viewport.zoom / snapshot.viewport.zoom;
|
||||
const offsetX = (snapshot.viewport.x - viewport.x) * viewport.zoom;
|
||||
const offsetY = (snapshot.viewport.y - viewport.y) * viewport.zoom;
|
||||
ctx.save();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(snapshot.canvas, offsetX, offsetY, snapshot.canvas.width * scale, snapshot.canvas.height * scale);
|
||||
ctx.restore();
|
||||
}
|
||||
drawVisibleMask(ctx) {
|
||||
const maskImage = this.canvas.maskTool?.getMask();
|
||||
if (!maskImage || !this.canvas.maskTool.isOverlayVisible)
|
||||
return;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = this.canvas.maskTool.isActive
|
||||
? this.canvas.maskTool.previewOpacity
|
||||
: 1.0;
|
||||
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
|
||||
ctx.restore();
|
||||
}
|
||||
getDragSelectionSignature(selectedLayers) {
|
||||
return selectedLayers
|
||||
.map(layer => layer.id)
|
||||
@@ -210,9 +354,7 @@ export class CanvasRenderer {
|
||||
// Keep the canvas readable while allowing the node's native color to
|
||||
// tint the editor underneath it. Clear first so alpha does not build
|
||||
// up on every render frame.
|
||||
const canvasFill = typeof getComputedStyle === 'function'
|
||||
? getComputedStyle(this.canvas.canvas).getPropertyValue('--lf-canvas-fill').trim()
|
||||
: '';
|
||||
const canvasFill = this.getCanvasFill();
|
||||
if (canReuseDragFrame) {
|
||||
// Preserve unchanged pixels from the previous drag frame. Every
|
||||
// layer, mask, grid line, and interaction overlay is redrawn only
|
||||
@@ -224,47 +366,42 @@ export class CanvasRenderer {
|
||||
});
|
||||
ctx.clip();
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
ctx.fillStyle = canvasFill || 'rgba(96, 96, 96, 0.72)';
|
||||
ctx.fillStyle = canvasFill;
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
else {
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
ctx.fillStyle = canvasFill || 'rgba(96, 96, 96, 0.72)';
|
||||
ctx.fillStyle = canvasFill;
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
const canUseZoomSnapshot = this.canUseZoomSnapshot(this.canvas.viewport, canvasWidth, canvasHeight);
|
||||
ctx.save();
|
||||
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
|
||||
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
|
||||
this.drawGrid(ctx);
|
||||
// Use a screen-space cache for stationary layer groups while moving
|
||||
// selected layers. The cache replays non-source-over modes in z-order.
|
||||
if (isDraggingLayers && selectedLayers.length > 0) {
|
||||
this.canvas.canvasLayers.drawLayersDuringDrag(ctx, this.canvas.layers, selectedLayers, this.canvas.viewport, canvasWidth, canvasHeight);
|
||||
if (canUseZoomSnapshot) {
|
||||
// The snapshot already contains the background, grid, mask, and
|
||||
// all composited layers. Restore the screen transform for one
|
||||
// inexpensive bitmap scale, then restore the world transform so
|
||||
// selection and interaction overlays remain live.
|
||||
ctx.restore();
|
||||
this.drawZoomSnapshot(ctx, this.canvas.viewport);
|
||||
ctx.save();
|
||||
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
|
||||
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
|
||||
}
|
||||
else {
|
||||
this.canvas.canvasLayers.clearDragSceneCache();
|
||||
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
||||
}
|
||||
// Draw mask AFTER layers but BEFORE all preview outlines
|
||||
const maskImage = this.canvas.maskTool.getMask();
|
||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||
ctx.save();
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
// In draw mask mode, use the previewOpacity value from the slider
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||
this.drawGrid(ctx);
|
||||
// Use a screen-space cache for stationary layer groups while moving
|
||||
// selected layers. The cache replays non-source-over modes in z-order.
|
||||
if (isDraggingLayers && selectedLayers.length > 0) {
|
||||
this.canvas.canvasLayers.drawLayersDuringDrag(ctx, this.canvas.layers, selectedLayers, this.canvas.viewport, canvasWidth, canvasHeight);
|
||||
}
|
||||
else {
|
||||
// When not in draw mask mode, show mask at full opacity
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1.0;
|
||||
this.canvas.canvasLayers.clearDragSceneCache();
|
||||
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
||||
}
|
||||
// Renderuj maskę w jej pozycji światowej (bez przesunięcia względem bounds)
|
||||
const maskWorldX = this.canvas.maskTool.x;
|
||||
const maskWorldY = this.canvas.maskTool.y;
|
||||
ctx.drawImage(maskImage, maskWorldX, maskWorldY);
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.restore();
|
||||
// Draw mask AFTER layers but BEFORE all preview outlines.
|
||||
this.drawVisibleMask(ctx);
|
||||
}
|
||||
// Draw selection frames for selected layers
|
||||
const sortedLayers = this.canvas.canvasLayers.getRenderOrder(this.canvas.layers);
|
||||
@@ -409,13 +546,13 @@ export class CanvasRenderer {
|
||||
});
|
||||
}
|
||||
}
|
||||
drawGrid(ctx) {
|
||||
drawGrid(ctx, viewport = this.canvas.viewport, width = this.canvas.offscreenCanvas.width, height = this.canvas.offscreenCanvas.height) {
|
||||
const gridSize = 64;
|
||||
const lineWidth = 0.5 / this.canvas.viewport.zoom;
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom;
|
||||
const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom;
|
||||
const lineWidth = 0.5 / viewport.zoom;
|
||||
const viewLeft = viewport.x;
|
||||
const viewTop = viewport.y;
|
||||
const viewRight = viewport.x + width / viewport.zoom;
|
||||
const viewBottom = viewport.y + height / viewport.zoom;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#707070';
|
||||
ctx.lineWidth = lineWidth;
|
||||
|
||||
@@ -62,6 +62,7 @@ export class CanvasInteractions {
|
||||
private originalLayerPositions: Map<Layer, Point>;
|
||||
private pendingTransformMove: { world: Point; isShiftPressed: boolean } | null = null;
|
||||
private transformMoveAnimationFrame: number | null = null;
|
||||
private zoomEndTimer: number | null = null;
|
||||
|
||||
// Bound event handlers to enable proper removeEventListener and avoid leaks
|
||||
private onMouseDown = (e: MouseEvent) => this.handleMouseDown(e);
|
||||
@@ -131,7 +132,21 @@ export class CanvasInteractions {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
private scheduleZoomInteractionEnd(): void {
|
||||
if (this.zoomEndTimer !== null) {
|
||||
window.clearTimeout(this.zoomEndTimer);
|
||||
}
|
||||
|
||||
this.zoomEndTimer = window.setTimeout(() => {
|
||||
this.zoomEndTimer = null;
|
||||
this.canvas.canvasRenderer.endZoomInteraction();
|
||||
this.canvas.render();
|
||||
}, 140);
|
||||
}
|
||||
|
||||
private performZoomOperation(worldCoords: Point, zoomFactor: number): void {
|
||||
this.canvas.canvasRenderer.beginZoomInteraction();
|
||||
|
||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
@@ -147,6 +162,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
this.canvas.onViewportChange?.();
|
||||
this.scheduleZoomInteractionEnd();
|
||||
}
|
||||
|
||||
private renderAndSave(shouldSave: boolean = false): void {
|
||||
@@ -278,6 +294,11 @@ export class CanvasInteractions {
|
||||
|
||||
teardownEventListeners(): void {
|
||||
this.cancelPendingTransformMove();
|
||||
if (this.zoomEndTimer !== null) {
|
||||
window.clearTimeout(this.zoomEndTimer);
|
||||
this.zoomEndTimer = null;
|
||||
}
|
||||
this.canvas.canvasRenderer.endZoomInteraction();
|
||||
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown as EventListener);
|
||||
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove as EventListener);
|
||||
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp as EventListener);
|
||||
|
||||
+232
-47
@@ -1,5 +1,5 @@
|
||||
import {createModuleLogger} from "../log_system/log_funcs.js";
|
||||
import { getBoundsFromPoints, getLayerWorldBounds, isPointInRotatedLayer, localToWorld, worldToLocal } from "../utils/common_utils.js";
|
||||
import { createCanvas, getBoundsFromPoints, getLayerWorldBounds, isPointInRotatedLayer, localToWorld, worldToLocal } from "../utils/common_utils.js";
|
||||
|
||||
const log = createModuleLogger('CanvasRenderer');
|
||||
|
||||
@@ -18,6 +18,13 @@ interface DragRenderState {
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ZoomSnapshotState {
|
||||
canvas: HTMLCanvasElement;
|
||||
viewport: { x: number; y: number; zoom: number };
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
}
|
||||
|
||||
export class CanvasRenderer {
|
||||
canvas: any;
|
||||
isDirty: any;
|
||||
@@ -25,6 +32,8 @@ export class CanvasRenderer {
|
||||
renderAnimationFrame: any;
|
||||
renderInterval: any;
|
||||
private dragRenderState: DragRenderState | null;
|
||||
private zoomInteractionActive: boolean;
|
||||
private zoomSnapshot: ZoomSnapshotState | null;
|
||||
// Overlay used to preview in-progress mask strokes (separate from cursor overlay)
|
||||
strokeOverlayCanvas!: HTMLCanvasElement;
|
||||
strokeOverlayCtx!: CanvasRenderingContext2D;
|
||||
@@ -35,12 +44,188 @@ export class CanvasRenderer {
|
||||
this.renderInterval = 1000 / 60;
|
||||
this.isDirty = false;
|
||||
this.dragRenderState = null;
|
||||
this.zoomInteractionActive = false;
|
||||
this.zoomSnapshot = null;
|
||||
|
||||
// Initialize overlay canvases
|
||||
this.initOverlay();
|
||||
this.initStrokeOverlay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a short-lived zoom interaction cache. The cache contains the
|
||||
* already composited scene, so rapid wheel events can scale one bitmap
|
||||
* instead of recompositing every blend layer on every frame.
|
||||
*/
|
||||
public beginZoomInteraction(): void {
|
||||
if (this.zoomInteractionActive) return;
|
||||
|
||||
// Mask drawing and other active transforms need live rendering because
|
||||
// their scene can change while the viewport is moving.
|
||||
const interactionMode = this.canvas.canvasInteractions?.interaction?.mode;
|
||||
if (this.canvas.maskTool?.isActive || (interactionMode && interactionMode !== 'none')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.zoomInteractionActive = true;
|
||||
this.dragRenderState = null;
|
||||
this.releaseZoomSnapshot();
|
||||
this.zoomSnapshot = this.createZoomSnapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the zoom cache and forces the next frame through the normal,
|
||||
* full-quality compositor.
|
||||
*/
|
||||
public endZoomInteraction(): void {
|
||||
if (!this.zoomInteractionActive && !this.zoomSnapshot) return;
|
||||
|
||||
this.zoomInteractionActive = false;
|
||||
this.dragRenderState = null;
|
||||
this.releaseZoomSnapshot();
|
||||
}
|
||||
|
||||
private releaseZoomSnapshot(): void {
|
||||
if (this.zoomSnapshot) {
|
||||
this.zoomSnapshot.canvas.width = 0;
|
||||
this.zoomSnapshot.canvas.height = 0;
|
||||
}
|
||||
this.zoomSnapshot = null;
|
||||
}
|
||||
|
||||
private getCanvasFill(): string {
|
||||
if (typeof getComputedStyle === 'function') {
|
||||
const canvasFill = getComputedStyle(this.canvas.canvas)
|
||||
.getPropertyValue('--lf-canvas-fill')
|
||||
.trim();
|
||||
if (canvasFill) return canvasFill;
|
||||
}
|
||||
return 'rgba(96, 96, 96, 0.72)';
|
||||
}
|
||||
|
||||
private getZoomSnapshotScale(displayWidth: number, displayHeight: number): number {
|
||||
// A modest overscan keeps zooming out cached for several wheel steps.
|
||||
// Cap the backing surface so a large editor widget cannot allocate an
|
||||
// unexpectedly large temporary bitmap.
|
||||
const requestedScale = 1.5;
|
||||
const maxSnapshotPixels = 16_000_000;
|
||||
const requestedPixels = displayWidth * displayHeight * requestedScale * requestedScale;
|
||||
if (requestedPixels <= maxSnapshotPixels) return requestedScale;
|
||||
|
||||
return Math.max(1, Math.sqrt(maxSnapshotPixels / (displayWidth * displayHeight)));
|
||||
}
|
||||
|
||||
private createZoomSnapshot(): ZoomSnapshotState | null {
|
||||
const displayWidth = Math.max(
|
||||
1,
|
||||
this.canvas.offscreenCanvas.width || this.canvas.canvas.clientWidth || 1
|
||||
);
|
||||
const displayHeight = Math.max(
|
||||
1,
|
||||
this.canvas.offscreenCanvas.height || this.canvas.canvas.clientHeight || 1
|
||||
);
|
||||
const zoom = this.canvas.viewport.zoom;
|
||||
const snapshotScale = this.getZoomSnapshotScale(displayWidth, displayHeight);
|
||||
const snapshotWidth = Math.max(1, Math.ceil(displayWidth * snapshotScale));
|
||||
const snapshotHeight = Math.max(1, Math.ceil(displayHeight * snapshotScale));
|
||||
const extraWorldWidth = (snapshotWidth - displayWidth) / zoom;
|
||||
const extraWorldHeight = (snapshotHeight - displayHeight) / zoom;
|
||||
const snapshotViewport = {
|
||||
x: this.canvas.viewport.x - extraWorldWidth / 2,
|
||||
y: this.canvas.viewport.y - extraWorldHeight / 2,
|
||||
zoom,
|
||||
};
|
||||
|
||||
const surface = createCanvas(snapshotWidth, snapshotHeight, '2d', {
|
||||
alpha: false,
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (!surface.ctx) return null;
|
||||
|
||||
const ctx = surface.ctx;
|
||||
ctx.clearRect(0, 0, snapshotWidth, snapshotHeight);
|
||||
ctx.fillStyle = this.getCanvasFill();
|
||||
ctx.fillRect(0, 0, snapshotWidth, snapshotHeight);
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(snapshotViewport.zoom, snapshotViewport.zoom);
|
||||
ctx.translate(-snapshotViewport.x, -snapshotViewport.y);
|
||||
this.drawGrid(ctx, snapshotViewport, snapshotWidth, snapshotHeight);
|
||||
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
||||
this.drawVisibleMask(ctx);
|
||||
ctx.restore();
|
||||
|
||||
return {
|
||||
canvas: surface.canvas,
|
||||
viewport: snapshotViewport,
|
||||
displayWidth,
|
||||
displayHeight,
|
||||
};
|
||||
}
|
||||
|
||||
private canUseZoomSnapshot(
|
||||
viewport: { x: number; y: number; zoom: number },
|
||||
displayWidth: number,
|
||||
displayHeight: number
|
||||
): boolean {
|
||||
const snapshot = this.zoomSnapshot;
|
||||
const interactionMode = this.canvas.canvasInteractions?.interaction?.mode;
|
||||
if (!this.zoomInteractionActive || !snapshot || interactionMode !== 'none') return false;
|
||||
if (snapshot.displayWidth !== displayWidth || snapshot.displayHeight !== displayHeight) return false;
|
||||
|
||||
const visibleLeft = viewport.x;
|
||||
const visibleTop = viewport.y;
|
||||
const visibleRight = viewport.x + displayWidth / viewport.zoom;
|
||||
const visibleBottom = viewport.y + displayHeight / viewport.zoom;
|
||||
const snapshotRight = snapshot.viewport.x + snapshot.canvas.width / snapshot.viewport.zoom;
|
||||
const snapshotBottom = snapshot.viewport.y + snapshot.canvas.height / snapshot.viewport.zoom;
|
||||
const epsilon = 1 / Math.max(viewport.zoom, snapshot.viewport.zoom, 0.0001);
|
||||
|
||||
return visibleLeft >= snapshot.viewport.x - epsilon &&
|
||||
visibleTop >= snapshot.viewport.y - epsilon &&
|
||||
visibleRight <= snapshotRight + epsilon &&
|
||||
visibleBottom <= snapshotBottom + epsilon;
|
||||
}
|
||||
|
||||
private drawZoomSnapshot(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
viewport: { x: number; y: number; zoom: number }
|
||||
): void {
|
||||
const snapshot = this.zoomSnapshot;
|
||||
if (!snapshot) return;
|
||||
|
||||
const scale = viewport.zoom / snapshot.viewport.zoom;
|
||||
const offsetX = (snapshot.viewport.x - viewport.x) * viewport.zoom;
|
||||
const offsetY = (snapshot.viewport.y - viewport.y) * viewport.zoom;
|
||||
|
||||
ctx.save();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(
|
||||
snapshot.canvas,
|
||||
offsetX,
|
||||
offsetY,
|
||||
snapshot.canvas.width * scale,
|
||||
snapshot.canvas.height * scale
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawVisibleMask(ctx: CanvasRenderingContext2D): void {
|
||||
const maskImage = this.canvas.maskTool?.getMask();
|
||||
if (!maskImage || !this.canvas.maskTool.isOverlayVisible) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = this.canvas.maskTool.isActive
|
||||
? this.canvas.maskTool.previewOpacity
|
||||
: 1.0;
|
||||
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private getDragSelectionSignature(selectedLayers: any[]): string {
|
||||
return selectedLayers
|
||||
.map(layer => layer.id)
|
||||
@@ -290,9 +475,7 @@ export class CanvasRenderer {
|
||||
// Keep the canvas readable while allowing the node's native color to
|
||||
// tint the editor underneath it. Clear first so alpha does not build
|
||||
// up on every render frame.
|
||||
const canvasFill = typeof getComputedStyle === 'function'
|
||||
? getComputedStyle(this.canvas.canvas).getPropertyValue('--lf-canvas-fill').trim()
|
||||
: '';
|
||||
const canvasFill = this.getCanvasFill();
|
||||
if (canReuseDragFrame) {
|
||||
// Preserve unchanged pixels from the previous drag frame. Every
|
||||
// layer, mask, grid line, and interaction overlay is redrawn only
|
||||
@@ -304,58 +487,55 @@ export class CanvasRenderer {
|
||||
});
|
||||
ctx.clip();
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
ctx.fillStyle = canvasFill || 'rgba(96, 96, 96, 0.72)';
|
||||
ctx.fillStyle = canvasFill;
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
ctx.fillStyle = canvasFill || 'rgba(96, 96, 96, 0.72)';
|
||||
ctx.fillStyle = canvasFill;
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
const canUseZoomSnapshot = this.canUseZoomSnapshot(
|
||||
this.canvas.viewport,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
|
||||
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
|
||||
|
||||
this.drawGrid(ctx);
|
||||
|
||||
// Use a screen-space cache for stationary layer groups while moving
|
||||
// selected layers. The cache replays non-source-over modes in z-order.
|
||||
if (isDraggingLayers && selectedLayers.length > 0) {
|
||||
this.canvas.canvasLayers.drawLayersDuringDrag(
|
||||
ctx,
|
||||
this.canvas.layers,
|
||||
selectedLayers,
|
||||
this.canvas.viewport,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
} else {
|
||||
this.canvas.canvasLayers.clearDragSceneCache();
|
||||
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
||||
}
|
||||
|
||||
// Draw mask AFTER layers but BEFORE all preview outlines
|
||||
const maskImage = this.canvas.maskTool.getMask();
|
||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||
if (canUseZoomSnapshot) {
|
||||
// The snapshot already contains the background, grid, mask, and
|
||||
// all composited layers. Restore the screen transform for one
|
||||
// inexpensive bitmap scale, then restore the world transform so
|
||||
// selection and interaction overlays remain live.
|
||||
ctx.restore();
|
||||
this.drawZoomSnapshot(ctx, this.canvas.viewport);
|
||||
ctx.save();
|
||||
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
|
||||
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
|
||||
} else {
|
||||
this.drawGrid(ctx);
|
||||
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
// In draw mask mode, use the previewOpacity value from the slider
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||
// Use a screen-space cache for stationary layer groups while moving
|
||||
// selected layers. The cache replays non-source-over modes in z-order.
|
||||
if (isDraggingLayers && selectedLayers.length > 0) {
|
||||
this.canvas.canvasLayers.drawLayersDuringDrag(
|
||||
ctx,
|
||||
this.canvas.layers,
|
||||
selectedLayers,
|
||||
this.canvas.viewport,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
} else {
|
||||
// When not in draw mask mode, show mask at full opacity
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1.0;
|
||||
this.canvas.canvasLayers.clearDragSceneCache();
|
||||
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
||||
}
|
||||
|
||||
// Renderuj maskę w jej pozycji światowej (bez przesunięcia względem bounds)
|
||||
const maskWorldX = this.canvas.maskTool.x;
|
||||
const maskWorldY = this.canvas.maskTool.y;
|
||||
ctx.drawImage(maskImage, maskWorldX, maskWorldY);
|
||||
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.restore();
|
||||
// Draw mask AFTER layers but BEFORE all preview outlines.
|
||||
this.drawVisibleMask(ctx);
|
||||
}
|
||||
|
||||
// Draw selection frames for selected layers
|
||||
@@ -527,14 +707,19 @@ export class CanvasRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
drawGrid(ctx: any) {
|
||||
drawGrid(
|
||||
ctx: any,
|
||||
viewport: { x: number; y: number; zoom: number } = this.canvas.viewport,
|
||||
width: number = this.canvas.offscreenCanvas.width,
|
||||
height: number = this.canvas.offscreenCanvas.height
|
||||
) {
|
||||
const gridSize = 64;
|
||||
const lineWidth = 0.5 / this.canvas.viewport.zoom;
|
||||
const lineWidth = 0.5 / viewport.zoom;
|
||||
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom;
|
||||
const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom;
|
||||
const viewLeft = viewport.x;
|
||||
const viewTop = viewport.y;
|
||||
const viewRight = viewport.x + width / viewport.zoom;
|
||||
const viewBottom = viewport.y + height / viewport.zoom;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#707070';
|
||||
|
||||
Reference in New Issue
Block a user