From d70c2b62efc347c210e5eb17cd3c82eacea95328 Mon Sep 17 00:00:00 2001 From: clawdbot Date: Thu, 29 Jan 2026 05:03:44 +0800 Subject: [PATCH] editor: group-scale resize for multi-selection --- packages/editor/src/editor/store.ts | 77 +++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/editor/store.ts b/packages/editor/src/editor/store.ts index e808a8c..4395f2f 100644 --- a/packages/editor/src/editor/store.ts +++ b/packages/editor/src/editor/store.ts @@ -878,7 +878,10 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction): }; for (const node of next.doc.screen.nodes) { - if (next.selection.ids.includes(node.id)) drag.snapshot.set(node.id, { ...node.rect }); + if (!next.selection.ids.includes(node.id)) continue; + // Locked/hidden nodes can remain selected, but should not be transform targets. + if (node.locked || node.hidden) continue; + drag.snapshot.set(node.id, { ...node.rect }); } return { @@ -897,14 +900,80 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction): const dy = Math.round((action.current.screenY - drag.startScreenY) / scale); const handle = drag.handle; - const target = drag.snapshot.get(drag.targetId); - if (!target) return state; const isTop = /t/.test(handle); const isBottom = /b/.test(handle); const isLeft = /l/.test(handle); const isRight = /r/.test(handle); + const bounds = action.bounds; + + // M2: multi-select resize scales the whole selection (unlocked + visible nodes only). + const isGroup = drag.snapshot.size > 1; + + if (isGroup) { + const rects = Array.from(drag.snapshot.values()); + const bbox0: Rect = { + x: Math.min(...rects.map((r) => r.x)), + y: Math.min(...rects.map((r) => r.y)), + w: Math.max(...rects.map((r) => r.x + r.w)) - Math.min(...rects.map((r) => r.x)), + h: Math.max(...rects.map((r) => r.y + r.h)) - Math.min(...rects.map((r) => r.y)), + }; + + // Resize the selection bbox using the same handle semantics as single-node resize. + let bbox1: Rect = { + x: bbox0.x + (isLeft ? dx : 0), + y: bbox0.y + (isTop ? dy : 0), + w: bbox0.w + (isLeft ? -dx : isRight ? dx : 0), + h: bbox0.h + (isTop ? -dy : isBottom ? dy : 0), + }; + + // No-op axes (e.g. 't' handle should not change width/x). + if (!isLeft && !isRight) { + bbox1.x = bbox0.x; + bbox1.w = bbox0.w; + } + if (!isTop && !isBottom) { + bbox1.y = bbox0.y; + bbox1.h = bbox0.h; + } + + // Prevent degenerate scaling. + bbox1 = { + ...bbox1, + w: Math.max(1, bbox1.w), + h: Math.max(1, bbox1.h), + }; + + bbox1 = clampRectToBounds(bbox1, bounds, 50); + + const sx = bbox0.w ? bbox1.w / bbox0.w : 1; + const sy = bbox0.h ? bbox1.h / bbox0.h : 1; + + const nodes = state.doc.screen.nodes.map((n) => { + const r0 = drag.snapshot.get(n.id); + if (!r0) return n; + + const nextRect: Rect = { + x: Math.round(bbox1.x + (r0.x - bbox0.x) * sx), + y: Math.round(bbox1.y + (r0.y - bbox0.y) * sy), + w: Math.max(0, Math.round(r0.w * sx)), + h: Math.max(0, Math.round(r0.h * sy)), + }; + + return { ...n, rect: nextRect }; + }); + + return { + ...state, + doc: { screen: { ...state.doc.screen, nodes } }, + canvas: { ...state.canvas, guides: { xs: [], ys: [] } }, + }; + } + + const target = drag.snapshot.get(drag.targetId); + if (!target) return state; + const newH = target.h + (isTop ? -dy : isBottom ? dy : 0); const newW = target.w + (isLeft ? -dx : isRight ? dx : 0); @@ -915,8 +984,6 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction): h: Math.max(0, newH), }; - const bounds = action.bounds; - // goView-like: only snap when resizing a single selected node. const canSnap = state.selection.ids.length === 1; const movingId = drag.targetId;