@@ -39,9 +39,9 @@ type CanvasStageModuleConfig = {
39
39
const DEFAULT_CONFIG : CanvasStageModuleConfig = {
40
40
MIN_SCALE : 0.1 ,
41
41
MAX_SCALE : 20 ,
42
- SCALE_FACTOR : 0.9995 ,
42
+ SCALE_FACTOR : 0.999 ,
43
43
FIT_LAYERS_TO_STAGE_PADDING_PX : 48 ,
44
- SCALE_SNAP_POINTS : [ 0.25 , 0.5 , 0.75 , 1.5 , 2 , 3 , 4 , 5 ] ,
44
+ SCALE_SNAP_POINTS : [ 0.25 , 0.5 , 0.75 , 1 , 1 .5, 2 , 3 , 4 , 5 ] ,
45
45
SCALE_SNAP_TOLERANCE : 0.05 ,
46
46
} ;
47
47
@@ -53,6 +53,11 @@ export class CanvasStageModule extends CanvasModuleBase {
53
53
readonly manager : CanvasManager ;
54
54
readonly log : Logger ;
55
55
56
+ // State for scale snapping logic
57
+ private _intendedScale : number = 1 ;
58
+ private _activeSnapPoint : number | null = null ;
59
+ private _snapTimeout : number | null = null ;
60
+
56
61
container : HTMLDivElement ;
57
62
konva : { stage : Konva . Stage } ;
58
63
@@ -87,6 +92,9 @@ export class CanvasStageModule extends CanvasModuleBase {
87
92
container,
88
93
} ) ,
89
94
} ;
95
+
96
+ // Initialize intended scale to the default stage scale
97
+ this . _intendedScale = this . konva . stage . scaleX ( ) ;
90
98
}
91
99
92
100
setContainer = ( container : HTMLDivElement ) => {
@@ -206,6 +214,10 @@ export class CanvasStageModule extends CanvasModuleBase {
206
214
- rect . y * scale + this . config . FIT_LAYERS_TO_STAGE_PADDING_PX + ( availableHeight - rect . height * scale ) / 2
207
215
) ;
208
216
217
+ // When fitting the stage, we update the intended scale and reset any active snap.
218
+ this . _intendedScale = scale ;
219
+ this . _activeSnapPoint = null ;
220
+
209
221
this . konva . stage . setAttrs ( {
210
222
x,
211
223
y,
@@ -245,18 +257,32 @@ export class CanvasStageModule extends CanvasModuleBase {
245
257
} ;
246
258
247
259
/**
248
- * Sets the scale of the stage. If center is provided, the stage will zoom in/out on that point.
249
- * @param scale The new scale to set
250
- * @param center The center of the stage to zoom in/out on
260
+ * Programmatically sets the scale of the stage, overriding any active snapping.
261
+ * If a center point is provided, the stage will zoom on that point.
262
+ * @param scale The new scale to set.
263
+ * @param center The center point for the zoom.
251
264
*/
252
265
setScale = ( scale : number , center ?: Coordinate ) : void => {
253
- this . log . trace ( 'Setting scale' ) ;
254
- const _center = center ?? this . getCenter ( true ) ;
266
+ this . log . trace ( { scale } , 'Programmatically setting scale' ) ;
255
267
const newScale = this . constrainScale ( scale ) ;
256
268
257
- const { x, y } = this . getPosition ( ) ;
269
+ // When scale is set programmatically, update the intended scale and reset any active snap.
270
+ this . _intendedScale = newScale ;
271
+ this . _activeSnapPoint = null ;
272
+
273
+ this . _applyScale ( newScale , center ) ;
274
+ } ;
275
+
276
+ /**
277
+ * Applies a scale to the stage, adjusting the position to keep the given center point stationary.
278
+ * This internal method does NOT modify snapping state.
279
+ */
280
+ private _applyScale = ( newScale : number , center ?: Coordinate ) : void => {
258
281
const oldScale = this . getScale ( ) ;
259
282
283
+ const _center = center ?? this . getCenter ( true ) ;
284
+ const { x, y } = this . getPosition ( ) ;
285
+
260
286
const deltaX = ( _center . x - x ) / oldScale ;
261
287
const deltaY = ( _center . y - y ) / oldScale ;
262
288
@@ -275,6 +301,7 @@ export class CanvasStageModule extends CanvasModuleBase {
275
301
276
302
onStageMouseWheel = ( e : KonvaEventObject < WheelEvent > ) => {
277
303
e . evt . preventDefault ( ) ;
304
+ this . _snapTimeout && window . clearTimeout ( this . _snapTimeout ) ;
278
305
279
306
if ( e . evt . ctrlKey || e . evt . metaKey ) {
280
307
return ;
@@ -289,8 +316,53 @@ export class CanvasStageModule extends CanvasModuleBase {
289
316
290
317
// When wheeling on trackpad, e.evt.ctrlKey is true - in that case, let's reverse the direction
291
318
const delta = e . evt . ctrlKey ? - e . evt . deltaY : e . evt . deltaY ;
292
- const scale = this . manager . stage . getScale ( ) * this . config . SCALE_FACTOR ** delta ;
293
- this . manager . stage . setScale ( scale , cursorPos ) ;
319
+
320
+ // Update the intended scale based on the last intended scale, creating a continuous zoom feel
321
+ const newIntendedScale = this . _intendedScale * this . config . SCALE_FACTOR ** delta ;
322
+ this . _intendedScale = this . constrainScale ( newIntendedScale ) ;
323
+
324
+ // Pass control to the snapping logic
325
+ this . _updateScaleWithSnapping ( cursorPos ) ;
326
+
327
+ this . _snapTimeout = window . setTimeout ( ( ) => {
328
+ // After a short delay, we can reset the intended scale to the current scale
329
+ // This allows for continuous zooming without snapping back to the last snapped scale
330
+ this . _intendedScale = this . getScale ( ) ;
331
+ } , 100 ) ;
332
+ } ;
333
+
334
+ /**
335
+ * Implements "sticky" snap logic.
336
+ * - If not snapped, checks if the intended scale is close enough to a snap point to engage the snap.
337
+ * - If snapped, checks if the intended scale has moved far enough away to break the snap.
338
+ * - Applies the resulting scale to the stage.
339
+ */
340
+ private _updateScaleWithSnapping = ( center : Coordinate ) => {
341
+ // If we are currently snapped, check if we should break out
342
+ if ( this . _activeSnapPoint !== null ) {
343
+ const threshold = this . _activeSnapPoint * this . config . SCALE_SNAP_TOLERANCE ;
344
+ if ( Math . abs ( this . _intendedScale - this . _activeSnapPoint ) > threshold ) {
345
+ // User has scrolled far enough to break the snap
346
+ this . _activeSnapPoint = null ;
347
+ this . _applyScale ( this . _intendedScale , center ) ;
348
+ }
349
+ // Else, do nothing - we remain snapped at the current scale, creating a "dead zone"
350
+ return ;
351
+ }
352
+
353
+ // If we are not snapped, check if we should snap to a point
354
+ for ( const snapPoint of this . config . SCALE_SNAP_POINTS ) {
355
+ const threshold = snapPoint * this . config . SCALE_SNAP_TOLERANCE ;
356
+ if ( Math . abs ( this . _intendedScale - snapPoint ) < threshold ) {
357
+ // Engage the snap
358
+ this . _activeSnapPoint = snapPoint ;
359
+ this . _applyScale ( snapPoint , center ) ;
360
+ return ;
361
+ }
362
+ }
363
+
364
+ // If we are not snapping and not breaking a snap, just update to the intended scale
365
+ this . _applyScale ( this . _intendedScale , center ) ;
294
366
} ;
295
367
296
368
onStagePointerDown = ( e : KonvaEventObject < PointerEvent > ) => {
0 commit comments