Skip to content

Commit 686c874

Browse files
feat(ui): canvas scroll scale snap
1 parent 945a360 commit 686c874

File tree

1 file changed

+82
-10
lines changed

1 file changed

+82
-10
lines changed

invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ type CanvasStageModuleConfig = {
3939
const DEFAULT_CONFIG: CanvasStageModuleConfig = {
4040
MIN_SCALE: 0.1,
4141
MAX_SCALE: 20,
42-
SCALE_FACTOR: 0.9995,
42+
SCALE_FACTOR: 0.999,
4343
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],
4545
SCALE_SNAP_TOLERANCE: 0.05,
4646
};
4747

@@ -53,6 +53,11 @@ export class CanvasStageModule extends CanvasModuleBase {
5353
readonly manager: CanvasManager;
5454
readonly log: Logger;
5555

56+
// State for scale snapping logic
57+
private _intendedScale: number = 1;
58+
private _activeSnapPoint: number | null = null;
59+
private _snapTimeout: number | null = null;
60+
5661
container: HTMLDivElement;
5762
konva: { stage: Konva.Stage };
5863

@@ -87,6 +92,9 @@ export class CanvasStageModule extends CanvasModuleBase {
8792
container,
8893
}),
8994
};
95+
96+
// Initialize intended scale to the default stage scale
97+
this._intendedScale = this.konva.stage.scaleX();
9098
}
9199

92100
setContainer = (container: HTMLDivElement) => {
@@ -206,6 +214,10 @@ export class CanvasStageModule extends CanvasModuleBase {
206214
-rect.y * scale + this.config.FIT_LAYERS_TO_STAGE_PADDING_PX + (availableHeight - rect.height * scale) / 2
207215
);
208216

217+
// When fitting the stage, we update the intended scale and reset any active snap.
218+
this._intendedScale = scale;
219+
this._activeSnapPoint = null;
220+
209221
this.konva.stage.setAttrs({
210222
x,
211223
y,
@@ -245,18 +257,32 @@ export class CanvasStageModule extends CanvasModuleBase {
245257
};
246258

247259
/**
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.
251264
*/
252265
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');
255267
const newScale = this.constrainScale(scale);
256268

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 => {
258281
const oldScale = this.getScale();
259282

283+
const _center = center ?? this.getCenter(true);
284+
const { x, y } = this.getPosition();
285+
260286
const deltaX = (_center.x - x) / oldScale;
261287
const deltaY = (_center.y - y) / oldScale;
262288

@@ -275,6 +301,7 @@ export class CanvasStageModule extends CanvasModuleBase {
275301

276302
onStageMouseWheel = (e: KonvaEventObject<WheelEvent>) => {
277303
e.evt.preventDefault();
304+
this._snapTimeout && window.clearTimeout(this._snapTimeout);
278305

279306
if (e.evt.ctrlKey || e.evt.metaKey) {
280307
return;
@@ -289,8 +316,53 @@ export class CanvasStageModule extends CanvasModuleBase {
289316

290317
// When wheeling on trackpad, e.evt.ctrlKey is true - in that case, let's reverse the direction
291318
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);
294366
};
295367

296368
onStagePointerDown = (e: KonvaEventObject<PointerEvent>) => {

0 commit comments

Comments
 (0)