Skip to content

Commit 46c4710

Browse files
authored
feat(canvas): multi-select resize (#284)
The selection scales as a group via an invisible transformer proxy at the model union bbox: shapes scale geometry (thickness stays), everything else reprojects its position only, in one undo entry.
1 parent db14058 commit 46c4710

8 files changed

Lines changed: 462 additions & 22 deletions

File tree

‎src/components/Canvas/LabelCanvas.tsx‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { useColorScheme } from "../../hooks/useColorScheme";
5050
import { useT } from "../../hooks/useT";
5151
import { useCanvasPanZoom } from "./hooks/useCanvasPanZoom";
5252
import { useCanvasLasso } from "./hooks/useCanvasLasso";
53-
import { useKonvaTransformer } from "./hooks/useKonvaTransformer";
53+
import { useKonvaTransformer, MULTI_RESIZE_PROXY_ID } from "./hooks/useKonvaTransformer";
5454
import { useKonvaDragController } from "./hooks/useKonvaDragController";
5555
import { useSnapBypassRef } from "./hooks/useSnapBypassRef";
5656
import { PaginationControl } from "./PaginationControl";
@@ -355,6 +355,8 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
355355
if (isEditableTarget(e.target as HTMLElement)) return;
356356
// Preview is a frozen snapshot; editing would drift the comparison.
357357
if (selectPreviewLocksEditor(useLabelStore.getState())) return;
358+
// Deleting mid-resize would destroy the nodes under the active gesture.
359+
if (transformActiveRef.current) return;
358360
const { selectedIds: ids } = useLabelStore.getState();
359361
if (ids.length === 0) return;
360362
e.preventDefault();
@@ -558,6 +560,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
558560
// part (follows the drag) and a static part (locked, stays put) so the chrome
559561
// reflects the real union, not a rigid translate.
560562
const selectionFrameRef = useRef<Konva.Rect>(null);
563+
const multiResizeProxyRef = useRef<Konva.Rect>(null);
561564
// Movable group sub-outlines share one drag offset.
562565
const subOutlineGroupRef = useRef<Konva.Group>(null);
563566
const isMultiFrame = attachableIds.length > 1;
@@ -586,7 +589,14 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
586589
// (single drags too), so compute them for any selection.
587590
const hasSelection = visibleSelIds.length > 0;
588591
const selectionFrameBase = isMultiFrame ? toFramePx(selectionUnionDots(objects, visibleSelIds, frameCtx)) : null;
589-
const movableFrameBase = hasSelection ? toFramePx(selectionUnionDots(objects, movableSelIds, frameCtx)) : null;
592+
const movableUnionDots = hasSelection ? selectionUnionDots(objects, movableSelIds, frameCtx) : null;
593+
// Needs >1 visible movable leaf (attachableIds counts hidden ones) and no
594+
// locked member; either would break the projected union.
595+
const multiResizeBboxDots =
596+
movableSelIds.length > 1 && staticSelIds.length === 0 && !previewLocks
597+
? movableUnionDots
598+
: null;
599+
const movableFrameBase = toFramePx(movableUnionDots);
590600
const staticFrameBase = hasSelection ? toFramePx(selectionUnionDots(objects, staticSelIds, frameCtx)) : null;
591601
// Preflight runs over the EXPORTABLE leaves (includeInExport), not the visible
592602
// ones, so a hidden-but-exported object is still warned and a visible-but-not-
@@ -720,6 +730,11 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
720730
if (!live) return;
721731
selectionFrameRef.current?.position({ x: live.x, y: live.y });
722732
selectionFrameRef.current?.size({ width: live.width, height: live.height });
733+
// Proxy follows the drag so the transformer chrome tracks live (the
734+
// controller already forceUpdates the transformer each tick).
735+
if (multiResizeBboxDots && moved) {
736+
multiResizeProxyRef.current?.position({ x: moved.x, y: moved.y });
737+
}
723738
// Movable sub-outlines follow the drag (reset to 0 on end).
724739
subOutlineGroupRef.current?.position({ x: dx, y: dy });
725740
// Shared clamp/flip policy; measure the bar lazily since a drag that selects
@@ -895,6 +910,8 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
895910
labelOffsetY,
896911
snap,
897912
updateObject,
913+
updateObjects,
914+
multiResizeBboxDots,
898915
labelRect: transformerSnapLabelRect,
899916
objectSnapEnabled: smartSnapActive,
900917
snapBypassRef,
@@ -1583,7 +1600,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
15831600

15841601
{/* Multi-select/group frame from model bounds (matches snap borders);
15851602
positioned imperatively in useLayoutEffect + the drag onDelta. */}
1586-
{!previewLocks && selectionFrameBase && (
1603+
{!previewLocks && selectionFrameBase && !multiResizeBboxDots && (
15871604
<Rect
15881605
ref={selectionFrameRef}
15891606
name={CAPTURE_CHROME}
@@ -1594,6 +1611,18 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
15941611
/>
15951612
)}
15961613

1614+
{/* Multi-resize proxy; geometry synced by the transformer hook. */}
1615+
{!previewLocks && multiResizeBboxDots && (
1616+
<Rect
1617+
ref={multiResizeProxyRef}
1618+
id={MULTI_RESIZE_PROXY_ID}
1619+
name={CAPTURE_CHROME}
1620+
listening={false}
1621+
fillEnabled={false}
1622+
strokeEnabled={false}
1623+
/>
1624+
)}
1625+
15971626
{/* Box per group member: movable parts (blue) drag; locked parts
15981627
(amber, the locked convention) stay put. */}
15991628
{!previewLocks && movableGroupRects.length > 0 && (
@@ -1654,6 +1683,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
16541683
{!previewLocks && (
16551684
<Transformer
16561685
ref={transformerRef}
1686+
borderDash={isMultiSelection && multiResizeBboxDots ? [6, 4] : undefined}
16571687
rotateEnabled={rotateEnabled}
16581688
resizeEnabled={resizeEnabled}
16591689
enabledAnchors={enabledAnchors}

‎src/components/Canvas/LineObject.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
type SnapRect,
1212
} from "../../lib/snapGuides";
1313
import { diagonalPolygonPoints } from "../../lib/shapeGeometry";
14-
import { selectionHandlers, type KonvaObjectProps, MIN_HIT_STROKE_PX } from "./konvaObjectProps";
14+
import { selectionHandlers, type KonvaObjectProps, MIN_HIT_STROKE_PX, lineHandlesNodeId, lineRootNodeId } from "./konvaObjectProps";
15+
1516

1617
const HANDLE_VISIBLE_SIZE = 7;
1718
const HANDLE_HIT_SIZE = 14;
@@ -329,7 +330,7 @@ export function LineObject({
329330
lineCenterY + (isHorizontal ? lineStrokeWidth : 0);
330331

331332
return (
332-
<Group>
333+
<Group id={lineRootNodeId(obj.id)}>
333334
{isAxisAligned ? (
334335
<>
335336
<KLine
@@ -341,6 +342,7 @@ export function LineObject({
341342
]}
342343
stroke={strokeColor}
343344
strokeWidth={lineStrokeWidth}
345+
strokeScaleEnabled={false}
344346
lineCap="butt"
345347
listening={false}
346348
globalCompositeOperation={isReverse ? "difference" : "source-over"}
@@ -390,12 +392,13 @@ export function LineObject({
390392
]}
391393
stroke="transparent"
392394
strokeWidth={Math.max(lineStrokeWidth, MIN_HIT_STROKE_PX)}
395+
strokeScaleEnabled={false}
393396
draggable={!obj.locked}
394397
{...selectionHandlers(onSelect)}
395398
{...dragHandlers}
396399
/>
397400
{isSelected && (
398-
<>
401+
<Group id={lineHandlesNodeId(obj.id)}>
399402
{/* Start point: dragging moves origin; end stays fixed. */}
400403
<Rect
401404
x={(livePt1?.x ?? x1 + dx) - HANDLE_HIT_SIZE / 2}
@@ -586,7 +589,7 @@ export function LineObject({
586589
strokeWidth={1}
587590
listening={false}
588591
/>
589-
</>
592+
</Group>
590593
)}
591594
</Group>
592595
);

0 commit comments

Comments
 (0)