feat(ui): canvas scroll scale snap

This commit is contained in:
psychedelicious
2025-05-27 11:42:02 +10:00
parent bce88a8873
commit a2d8261d40

View File

@@ -39,9 +39,9 @@ type CanvasStageModuleConfig = {
const DEFAULT_CONFIG: CanvasStageModuleConfig = {
MIN_SCALE: 0.1,
MAX_SCALE: 20,
SCALE_FACTOR: 0.9995,
SCALE_FACTOR: 0.999,
FIT_LAYERS_TO_STAGE_PADDING_PX: 48,
SCALE_SNAP_POINTS: [0.25, 0.5, 0.75, 1.5, 2, 3, 4, 5],
SCALE_SNAP_POINTS: [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 5],
SCALE_SNAP_TOLERANCE: 0.05,
};
@@ -53,6 +53,11 @@ export class CanvasStageModule extends CanvasModuleBase {
readonly manager: CanvasManager;
readonly log: Logger;
// State for scale snapping logic
private _intendedScale: number = 1;
private _activeSnapPoint: number | null = null;
private _snapTimeout: number | null = null;
container: HTMLDivElement;
konva: { stage: Konva.Stage };
@@ -87,6 +92,9 @@ export class CanvasStageModule extends CanvasModuleBase {
container,
}),
};
// Initialize intended scale to the default stage scale
this._intendedScale = this.konva.stage.scaleX();
}
setContainer = (container: HTMLDivElement) => {
@@ -206,6 +214,10 @@ export class CanvasStageModule extends CanvasModuleBase {
-rect.y * scale + this.config.FIT_LAYERS_TO_STAGE_PADDING_PX + (availableHeight - rect.height * scale) / 2
);
// When fitting the stage, we update the intended scale and reset any active snap.
this._intendedScale = scale;
this._activeSnapPoint = null;
this.konva.stage.setAttrs({
x,
y,
@@ -245,18 +257,32 @@ export class CanvasStageModule extends CanvasModuleBase {
};
/**
* Sets the scale of the stage. If center is provided, the stage will zoom in/out on that point.
* @param scale The new scale to set
* @param center The center of the stage to zoom in/out on
* Programmatically sets the scale of the stage, overriding any active snapping.
* If a center point is provided, the stage will zoom on that point.
* @param scale The new scale to set.
* @param center The center point for the zoom.
*/
setScale = (scale: number, center?: Coordinate): void => {
this.log.trace('Setting scale');
const _center = center ?? this.getCenter(true);
this.log.trace({ scale }, 'Programmatically setting scale');
const newScale = this.constrainScale(scale);
const { x, y } = this.getPosition();
// When scale is set programmatically, update the intended scale and reset any active snap.
this._intendedScale = newScale;
this._activeSnapPoint = null;
this._applyScale(newScale, center);
};
/**
* Applies a scale to the stage, adjusting the position to keep the given center point stationary.
* This internal method does NOT modify snapping state.
*/
private _applyScale = (newScale: number, center?: Coordinate): void => {
const oldScale = this.getScale();
const _center = center ?? this.getCenter(true);
const { x, y } = this.getPosition();
const deltaX = (_center.x - x) / oldScale;
const deltaY = (_center.y - y) / oldScale;
@@ -275,6 +301,7 @@ export class CanvasStageModule extends CanvasModuleBase {
onStageMouseWheel = (e: KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();
this._snapTimeout && window.clearTimeout(this._snapTimeout);
if (e.evt.ctrlKey || e.evt.metaKey) {
return;
@@ -289,8 +316,53 @@ export class CanvasStageModule extends CanvasModuleBase {
// When wheeling on trackpad, e.evt.ctrlKey is true - in that case, let's reverse the direction
const delta = e.evt.ctrlKey ? -e.evt.deltaY : e.evt.deltaY;
const scale = this.manager.stage.getScale() * this.config.SCALE_FACTOR ** delta;
this.manager.stage.setScale(scale, cursorPos);
// Update the intended scale based on the last intended scale, creating a continuous zoom feel
const newIntendedScale = this._intendedScale * this.config.SCALE_FACTOR ** delta;
this._intendedScale = this.constrainScale(newIntendedScale);
// Pass control to the snapping logic
this._updateScaleWithSnapping(cursorPos);
this._snapTimeout = window.setTimeout(() => {
// After a short delay, we can reset the intended scale to the current scale
// This allows for continuous zooming without snapping back to the last snapped scale
this._intendedScale = this.getScale();
}, 100);
};
/**
* Implements "sticky" snap logic.
* - If not snapped, checks if the intended scale is close enough to a snap point to engage the snap.
* - If snapped, checks if the intended scale has moved far enough away to break the snap.
* - Applies the resulting scale to the stage.
*/
private _updateScaleWithSnapping = (center: Coordinate) => {
// If we are currently snapped, check if we should break out
if (this._activeSnapPoint !== null) {
const threshold = this._activeSnapPoint * this.config.SCALE_SNAP_TOLERANCE;
if (Math.abs(this._intendedScale - this._activeSnapPoint) > threshold) {
// User has scrolled far enough to break the snap
this._activeSnapPoint = null;
this._applyScale(this._intendedScale, center);
}
// Else, do nothing - we remain snapped at the current scale, creating a "dead zone"
return;
}
// If we are not snapped, check if we should snap to a point
for (const snapPoint of this.config.SCALE_SNAP_POINTS) {
const threshold = snapPoint * this.config.SCALE_SNAP_TOLERANCE;
if (Math.abs(this._intendedScale - snapPoint) < threshold) {
// Engage the snap
this._activeSnapPoint = snapPoint;
this._applyScale(snapPoint, center);
return;
}
}
// If we are not snapping and not breaking a snap, just update to the intended scale
this._applyScale(this._intendedScale, center);
};
onStagePointerDown = (e: KonvaEventObject<PointerEvent>) => {