fix(canvas): zoom-stable handles and group-scale multi-selection

- Transform handles counter-scale by 1/zoom so they stay constant screen size
- Selection border stroke also counter-scales
- Multi-select resize scales items as a unit from bounding box anchor
- Redraw transform box on viewport zoom/pan via 'moved' listener
This commit is contained in:
Hiren Kangad
2026-03-11 16:59:10 +05:30
parent 155dc6ac01
commit 4ed475c62e
2 changed files with 34 additions and 25 deletions
+10
View File
@@ -42,6 +42,7 @@ export class SelectionManager {
private _onItemTransform: ((item: SceneItem) => void) | null = null; private _onItemTransform: ((item: SceneItem) => void) | null = null;
private _onItemsTransform: ((items: SceneItem[]) => void) | null = null; private _onItemsTransform: ((items: SceneItem[]) => void) | null = null;
private _onObjectDragEnd: ((ids: string[]) => void) | null = null; private _onObjectDragEnd: ((ids: string[]) => void) | null = null;
private _onViewportMoved: (() => void) | null = null;
// Pointer state // Pointer state
private _pointerDown = false; private _pointerDown = false;
@@ -95,6 +96,14 @@ export class SelectionManager {
viewport.on('globalpointermove', this._onPointerMove, this); viewport.on('globalpointermove', this._onPointerMove, this);
viewport.on('pointerup', this._onPointerUp, this); viewport.on('pointerup', this._onPointerUp, this);
viewport.on('pointerupoutside', this._onPointerUp, this); viewport.on('pointerupoutside', this._onPointerUp, this);
// Redraw transform box on zoom so handles stay constant screen size
this._onViewportMoved = () => {
if (this.selectedIds.size > 0) {
this.transformBox.update(this.getSelectedItems());
}
};
viewport.on('moved', this._onViewportMoved);
} }
/** Enable/disable selection interaction (disable during draw/text/eraser tools). */ /** Enable/disable selection interaction (disable during draw/text/eraser tools). */
@@ -464,6 +473,7 @@ export class SelectionManager {
this._viewport.off('globalpointermove', this._onPointerMove, this); this._viewport.off('globalpointermove', this._onPointerMove, this);
this._viewport.off('pointerup', this._onPointerUp, this); this._viewport.off('pointerup', this._onPointerUp, this);
this._viewport.off('pointerupoutside', this._onPointerUp, this); this._viewport.off('pointerupoutside', this._onPointerUp, this);
if (this._onViewportMoved) this._viewport.off('moved', this._onViewportMoved);
this._overlay.destroy({ children: true }); this._overlay.destroy({ children: true });
} }
} }
+24 -25
View File
@@ -169,11 +169,12 @@ export class TransformBox extends Container {
private _draw(): void { private _draw(): void {
const { x, y, w, h } = this._bounds; const { x, y, w, h } = this._bounds;
const zoom = this._viewport?.scale.x ?? 1;
// Border // Border — constant screen-width stroke
this._border.clear(); this._border.clear();
this._border.rect(x, y, w, h); this._border.rect(x, y, w, h);
this._border.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH }); this._border.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH / zoom });
// Position handles // Position handles
const cx = x + w / 2; const cx = x + w / 2;
@@ -190,14 +191,17 @@ export class TransformBox extends Container {
br: { px: x + w, py: y + h }, br: { px: x + w, py: y + h },
}; };
// Counter-scale handles so they stay the same screen size at any zoom
const s = HANDLE_SIZE / zoom;
const half = s / 2;
for (const [id, handle] of this._handles) { for (const [id, handle] of this._handles) {
const pos = positions[id]; const pos = positions[id];
handle.clear(); handle.clear();
const half = HANDLE_SIZE / 2; handle.rect(-half, -half, s, s);
handle.rect(-half, -half, HANDLE_SIZE, HANDLE_SIZE);
handle.fill(HANDLE_FILL); handle.fill(HANDLE_FILL);
handle.stroke({ color: HANDLE_STROKE, width: 1 }); handle.stroke({ color: HANDLE_STROKE, width: 1 / zoom });
handle.position.set(pos.px, pos.py); handle.position.set(pos.px, pos.py);
} }
@@ -309,6 +313,18 @@ export class TransformBox extends Container {
} }
} }
// Compute the anchor point (fixed edge of bounding box)
let anchorX = ob.x; // default: top-left is fixed (br, mr, bc handles)
let anchorY = ob.y;
switch (handleId) {
case 'tl': anchorX = ob.x + ob.w; anchorY = ob.y + ob.h; break;
case 'tc': anchorY = ob.y + ob.h; break;
case 'tr': anchorX = ob.x; anchorY = ob.y + ob.h; break;
case 'ml': anchorX = ob.x + ob.w; break;
case 'bl': anchorX = ob.x + ob.w; anchorY = ob.y; break;
// br, mr, bc: anchor is top-left (default)
}
for (const item of this._items) { for (const item of this._items) {
const orig = origTransforms.get(item.id); const orig = origTransforms.get(item.id);
if (!orig) continue; if (!orig) continue;
@@ -317,26 +333,9 @@ export class TransformBox extends Container {
item.data.sx = orig.sx * fx; item.data.sx = orig.sx * fx;
item.data.sy = orig.sy * fy; item.data.sy = orig.sy * fy;
// Reposition to keep fixed edge in place // Reposition as a unit: scale each item's offset from the anchor point
switch (handleId) { item.data.x = anchorX + (orig.x - anchorX) * fx;
case 'tl': item.data.y = anchorY + (orig.y - anchorY) * fy;
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
break;
case 'tc':
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
break;
case 'tr':
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
break;
case 'ml':
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
break;
case 'bl':
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
break;
// br, mr, bc: top-left is fixed, no reposition needed
}
item.displayObject.scale.set(item.data.sx, item.data.sy); item.displayObject.scale.set(item.data.sx, item.data.sy);
item.displayObject.position.set(item.data.x, item.data.y); item.displayObject.position.set(item.data.x, item.data.y);