Merge pull request #65 from jhbruhn/fix/konva-canvas-performance-optimization
Some checks failed
Build, Test, and Lint / Build, Test, and Lint (push) Has been cancelled
Draft Release / Draft Release (push) Has been cancelled
Draft Release / Build Web App (push) Has been cancelled
Draft Release / Build Release - macos-latest (push) Has been cancelled
Draft Release / Build Release - ubuntu-latest (push) Has been cancelled
Draft Release / Build Release - windows-latest (push) Has been cancelled
Draft Release / Upload to GitHub Release (push) Has been cancelled

fix: Optimize Konva canvas performance during drag and zoom operations
This commit is contained in:
Jan-Henrik Bruhn 2025-12-28 14:09:58 +01:00 committed by GitHub
commit 3ba87ba192
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 116 additions and 38 deletions

View file

@ -46,7 +46,7 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
const gridColor = canvasColors.grid(); const gridColor = canvasColors.grid();
return ( return (
<Group name="grid"> <Group name="grid" listening={false}>
{lines.verticalLines.map((points, i) => ( {lines.verticalLines.map((points, i) => (
<Line <Line
key={`v-${i}`} key={`v-${i}`}
@ -73,7 +73,7 @@ export const Origin = memo(() => {
const originColor = canvasColors.origin(); const originColor = canvasColors.origin();
return ( return (
<Group name="origin"> <Group name="origin" listening={false}>
<Line points={[-10, 0, 10, 0]} stroke={originColor} strokeWidth={2} /> <Line points={[-10, 0, 10, 0]} stroke={originColor} strokeWidth={2} />
<Line points={[0, -10, 0, 10]} stroke={originColor} strokeWidth={2} /> <Line points={[0, -10, 0, 10]} stroke={originColor} strokeWidth={2} />
</Group> </Group>
@ -93,7 +93,7 @@ export const Hoop = memo(({ machineInfo }: HoopProps) => {
const hoopColor = canvasColors.hoop(); const hoopColor = canvasColors.hoop();
return ( return (
<Group name="hoop"> <Group name="hoop" listening={false}>
<Rect <Rect
x={hoopLeft} x={hoopLeft}
y={hoopTop} y={hoopTop}

View file

@ -74,6 +74,8 @@ export function PatternCanvas() {
handleZoomIn, handleZoomIn,
handleZoomOut, handleZoomOut,
handleZoomReset, handleZoomReset,
handleStageDragStart,
handleStageDragEnd,
} = useCanvasViewport({ } = useCanvasViewport({
containerRef, containerRef,
pesData, pesData,
@ -165,16 +167,8 @@ export function PatternCanvas() {
scaleY={stageScale} scaleY={stageScale}
draggable draggable
onWheel={handleWheel} onWheel={handleWheel}
onDragStart={() => { onDragStart={handleStageDragStart}
if (stageRef.current) { onDragEnd={handleStageDragEnd}
stageRef.current.container().style.cursor = "grabbing";
}
}}
onDragEnd={() => {
if (stageRef.current) {
stageRef.current.container().style.cursor = "grab";
}
}}
ref={(node) => { ref={(node) => {
stageRef.current = node; stageRef.current = node;
if (node) { if (node) {
@ -182,8 +176,8 @@ export function PatternCanvas() {
} }
}} }}
> >
{/* Background layer: grid, origin, hoop */} {/* Background layer: grid, origin, hoop - static, no event listening */}
<Layer> <Layer listening={false}>
{displayPattern && ( {displayPattern && (
<> <>
<Grid <Grid

View file

@ -2,10 +2,16 @@
* useCanvasViewport Hook * useCanvasViewport Hook
* *
* Manages canvas viewport state including zoom, pan, and container size * Manages canvas viewport state including zoom, pan, and container size
* Handles wheel zoom and button zoom operations * Handles wheel zoom, button zoom operations, and stage drag cursor updates
*/ */
import { useState, useEffect, useCallback, type RefObject } from "react"; import {
useState,
useEffect,
useCallback,
useRef,
type RefObject,
} from "react";
import type Konva from "konva"; import type Konva from "konva";
import type { PesPatternData } from "../../formats/import/pesImporter"; import type { PesPatternData } from "../../formats/import/pesImporter";
import type { MachineInfo } from "../../types/machine"; import type { MachineInfo } from "../../types/machine";
@ -87,7 +93,12 @@ export function useCanvasViewport({
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 }); setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
} }
// Wheel zoom handler // Wheel zoom handler with RAF throttling and delta accumulation
const wheelThrottleRef = useRef<number | null>(null);
const accumulatedDeltaRef = useRef<number>(0);
const lastPointerRef = useRef<{ x: number; y: number } | null>(null);
const lastStageRef = useRef<Konva.Stage | null>(null);
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => { const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault(); e.evt.preventDefault();
@ -97,8 +108,30 @@ export function useCanvasViewport({
const pointer = stage.getPointerPosition(); const pointer = stage.getPointerPosition();
if (!pointer) return; if (!pointer) return;
// Accumulate deltaY from all events during throttle period
accumulatedDeltaRef.current += e.evt.deltaY;
lastPointerRef.current = pointer;
lastStageRef.current = stage;
// Skip if throttle already in progress
if (wheelThrottleRef.current !== null) {
return;
}
// Schedule update on next animation frame (~16ms)
wheelThrottleRef.current = requestAnimationFrame(() => {
const accumulatedDelta = accumulatedDeltaRef.current;
const pointer = lastPointerRef.current;
const stage = lastStageRef.current;
if (!pointer || !stage || accumulatedDelta === 0) {
wheelThrottleRef.current = null;
accumulatedDeltaRef.current = 0;
return;
}
const scaleBy = 1.1; const scaleBy = 1.1;
const direction = e.evt.deltaY > 0 ? -1 : 1; const direction = accumulatedDelta > 0 ? -1 : 1;
setStageScale((oldScale) => { setStageScale((oldScale) => {
const newScale = Math.max( const newScale = Math.max(
@ -113,6 +146,20 @@ export function useCanvasViewport({
return newScale; return newScale;
}); });
// Reset accumulator and throttle
wheelThrottleRef.current = null;
accumulatedDeltaRef.current = 0;
});
}, []);
// Cleanup wheel throttle on unmount
useEffect(() => {
return () => {
if (wheelThrottleRef.current !== null) {
cancelAnimationFrame(wheelThrottleRef.current);
}
};
}, []); }, []);
// Zoom control handlers // Zoom control handlers
@ -155,6 +202,27 @@ export function useCanvasViewport({
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 }); setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}, [initialScale, containerSize]); }, [initialScale, containerSize]);
// Stage drag handlers - cursor updates immediately for better UX
const handleStageDragStart = useCallback(
(e: Konva.KonvaEventObject<DragEvent>) => {
const stage = e.target.getStage();
if (stage) {
stage.container().style.cursor = "grabbing";
}
},
[],
);
const handleStageDragEnd = useCallback(
(e: Konva.KonvaEventObject<DragEvent>) => {
const stage = e.target.getStage();
if (stage) {
stage.container().style.cursor = "grab";
}
},
[],
);
return { return {
// State // State
stagePos, stagePos,
@ -166,5 +234,7 @@ export function useCanvasViewport({
handleZoomIn, handleZoomIn,
handleZoomOut, handleZoomOut,
handleZoomReset, handleZoomReset,
handleStageDragStart,
handleStageDragEnd,
}; };
} }

View file

@ -113,7 +113,8 @@ export function usePatternTransform({
setPatternOffset(centerOffset.x, centerOffset.y); setPatternOffset(centerOffset.x, centerOffset.y);
}, [pesData, setPatternOffset]); }, [pesData, setPatternOffset]);
// Pattern drag handlers // Pattern drag handler - only updates state when drag is complete
// Konva handles the visual drag internally, no need to update React state during drag
const handlePatternDragEnd = useCallback( const handlePatternDragEnd = useCallback(
(e: Konva.KonvaEventObject<DragEvent>) => { (e: Konva.KonvaEventObject<DragEvent>) => {
const newOffset = { const newOffset = {

View file

@ -5,6 +5,9 @@ import { onPatternDeleted } from "./storeEvents";
import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers"; import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers";
import { calculateRotatedBounds } from "../utils/rotationUtils"; import { calculateRotatedBounds } from "../utils/rotationUtils";
// Conditional logging for development only
const isDev = import.meta.env.DEV;
interface PatternState { interface PatternState {
// Original pattern (pre-upload) // Original pattern (pre-upload)
pesData: PesPatternData | null; pesData: PesPatternData | null;
@ -79,13 +82,17 @@ export const usePatternStore = create<PatternState>((set) => ({
// Update pattern offset (for original pattern only) // Update pattern offset (for original pattern only)
setPatternOffset: (x: number, y: number) => { setPatternOffset: (x: number, y: number) => {
set({ patternOffset: { x, y } }); set({ patternOffset: { x, y } });
if (isDev) {
console.log("[PatternStore] Pattern offset changed:", { x, y }); console.log("[PatternStore] Pattern offset changed:", { x, y });
}
}, },
// Set pattern rotation (for original pattern only) // Set pattern rotation (for original pattern only)
setPatternRotation: (rotation: number) => { setPatternRotation: (rotation: number) => {
set({ patternRotation: rotation % 360 }); set({ patternRotation: rotation % 360 });
if (isDev) {
console.log("[PatternStore] Pattern rotation changed:", rotation); console.log("[PatternStore] Pattern rotation changed:", rotation);
}
}, },
// Set uploaded pattern data (called after upload completes) // Set uploaded pattern data (called after upload completes)
@ -101,13 +108,17 @@ export const usePatternStore = create<PatternState>((set) => ({
// Optionally set filename if provided (for resume/reconnect scenarios) // Optionally set filename if provided (for resume/reconnect scenarios)
...(fileName && { currentFileName: fileName }), ...(fileName && { currentFileName: fileName }),
}); });
if (isDev) {
console.log("[PatternStore] Uploaded pattern set"); console.log("[PatternStore] Uploaded pattern set");
}
}, },
// Clear uploaded pattern (called when deleting from machine) // Clear uploaded pattern (called when deleting from machine)
// This reverts to pre-upload state, keeping pesData so user can re-adjust and re-upload // This reverts to pre-upload state, keeping pesData so user can re-adjust and re-upload
clearUploadedPattern: () => { clearUploadedPattern: () => {
if (isDev) {
console.log("[PatternStore] CLEARING uploaded pattern..."); console.log("[PatternStore] CLEARING uploaded pattern...");
}
set({ set({
uploadedPesData: null, uploadedPesData: null,
uploadedPatternOffset: { x: 0, y: 0 }, uploadedPatternOffset: { x: 0, y: 0 },
@ -115,9 +126,11 @@ export const usePatternStore = create<PatternState>((set) => ({
// Keep pesData, currentFileName, patternOffset, patternRotation // Keep pesData, currentFileName, patternOffset, patternRotation
// so user can adjust and re-upload // so user can adjust and re-upload
}); });
if (isDev) {
console.log( console.log(
"[PatternStore] Uploaded pattern cleared - back to editable mode", "[PatternStore] Uploaded pattern cleared - back to editable mode",
); );
}
}, },
// Reset pattern offset to default // Reset pattern offset to default