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:
@@ -42,6 +42,7 @@ export class SelectionManager {
|
||||
private _onItemTransform: ((item: SceneItem) => void) | null = null;
|
||||
private _onItemsTransform: ((items: SceneItem[]) => void) | null = null;
|
||||
private _onObjectDragEnd: ((ids: string[]) => void) | null = null;
|
||||
private _onViewportMoved: (() => void) | null = null;
|
||||
|
||||
// Pointer state
|
||||
private _pointerDown = false;
|
||||
@@ -95,6 +96,14 @@ export class SelectionManager {
|
||||
viewport.on('globalpointermove', this._onPointerMove, this);
|
||||
viewport.on('pointerup', 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). */
|
||||
@@ -464,6 +473,7 @@ export class SelectionManager {
|
||||
this._viewport.off('globalpointermove', this._onPointerMove, this);
|
||||
this._viewport.off('pointerup', this._onPointerUp, this);
|
||||
this._viewport.off('pointerupoutside', this._onPointerUp, this);
|
||||
if (this._onViewportMoved) this._viewport.off('moved', this._onViewportMoved);
|
||||
this._overlay.destroy({ children: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +169,12 @@ export class TransformBox extends Container {
|
||||
|
||||
private _draw(): void {
|
||||
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.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
|
||||
const cx = x + w / 2;
|
||||
@@ -190,14 +191,17 @@ export class TransformBox extends Container {
|
||||
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) {
|
||||
const pos = positions[id];
|
||||
handle.clear();
|
||||
|
||||
const half = HANDLE_SIZE / 2;
|
||||
handle.rect(-half, -half, HANDLE_SIZE, HANDLE_SIZE);
|
||||
handle.rect(-half, -half, s, s);
|
||||
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);
|
||||
}
|
||||
@@ -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) {
|
||||
const orig = origTransforms.get(item.id);
|
||||
if (!orig) continue;
|
||||
@@ -317,26 +333,9 @@ export class TransformBox extends Container {
|
||||
item.data.sx = orig.sx * fx;
|
||||
item.data.sy = orig.sy * fy;
|
||||
|
||||
// Reposition to keep fixed edge in place
|
||||
switch (handleId) {
|
||||
case 'tl':
|
||||
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
|
||||
}
|
||||
// Reposition as a unit: scale each item's offset from the anchor point
|
||||
item.data.x = anchorX + (orig.x - anchorX) * fx;
|
||||
item.data.y = anchorY + (orig.y - anchorY) * fy;
|
||||
|
||||
item.displayObject.scale.set(item.data.sx, item.data.sy);
|
||||
item.displayObject.position.set(item.data.x, item.data.y);
|
||||
|
||||
Reference in New Issue
Block a user