Skip to content

Commit c209729

Browse files
ryan-williamsclaude
andcommitted
feat(v-drag): multi-select corner-resize snap-to-guides
`GroupDragControl.onPointermoveResize` previously had `// Reserved for future snap alignment support` with `void findSnap; void getSnapTargets` — no snap. Wired up: - Snap the dragged corner against alignment targets (slide edges/center + other elements' edges/centers, excluding the entire selection). Re-derives the group's new width/height from the snapped corner so the scaling math matches - Shift (AR-preserve): project the snap along the diagonal from anchor through corner, so the AR constraint isn't broken by axis-aligned snaps. Mirrors `applyDiagonalSnap` in the single-element corner-resize code path - ⌘ (metaKey): disable snap (per the deck-wide modifier convention) - `groupResizeSnapLines` local ref drives the rendered red guides; merged with per-element `activeSnapLines` (from multi-select body drag) in the existing `activeSnapLines` computed - Pointerup clears the local resize lines `getSnapTargets` now accepts `string | Set<string>` for the exclusion arg so multiple selected elements can be filtered out in one call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ddc857b commit c209729

2 files changed

Lines changed: 86 additions & 13 deletions

File tree

packages/client/composables/useDragElements.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ function unregisterDragElement(page: number, dragId: string) {
9292
// Snap alignment logic
9393
const SNAP_THRESHOLD = 8
9494

95-
export function getSnapTargets(pageNum: number, selfDragId: string) {
95+
export function getSnapTargets(pageNum: number, selfDragId: string | Set<string>) {
96+
const exclude = typeof selfDragId === 'string' ? new Set([selfDragId]) : selfDragId
9697
const targets = { x: new Set<number>(), y: new Set<number>() }
9798

9899
// Slide edges and center
@@ -106,7 +107,7 @@ export function getSnapTargets(pageNum: number, selfDragId: string) {
106107
// Other elements on this page (use visible/cropped bounds)
107108
const elements = getDragElementsForPage(pageNum)
108109
for (const el of elements) {
109-
if (el.dragId === selfDragId)
110+
if (exclude.has(el.dragId))
110111
continue
111112
const { cx, cy, w, h } = getVisibleBounds(el)
112113
targets.x.add(cx - w / 2)

packages/client/internals/GroupDragControl.vue

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,19 @@ const { left: slideLeft, top: slideTop } = useSlideBounds()
6161
const ctrlSize = 10
6262
const minRemain = 10
6363
64-
// Aggregate snap-line guides from all selected elements. The v-drag directive's body-drag
65-
// handler calls `state.applySnap(...)` on the clicked element during multi-select drags,
66-
// which writes into that element's `activeSnapLines` — but its `DragControl` isn't mounted
67-
// while multi-selected, so without this aggregation the red snap guides wouldn't render.
64+
// Snap-line guides during corner resize. The body-drag handler in `v-drag.ts` writes
65+
// snap lines onto the clicked element's `activeSnapLines`; corner resize lives here in
66+
// GroupDragControl and writes onto this local ref. The merged `activeSnapLines` computed
67+
// (below) renders both.
68+
const groupResizeSnapLines = ref<{ x: number[], y: number[] }>({ x: [], y: [] })
69+
70+
// Aggregate snap-line guides from the local resize ref plus each selected element's own
71+
// lines (set by the v-drag directive during a multi-select body drag — that element's
72+
// `DragControl` isn't mounted while multi-selected, so without this aggregation the
73+
// red snap guides wouldn't render).
6874
const activeSnapLines = computed(() => {
69-
const x = new Set<number>()
70-
const y = new Set<number>()
75+
const x = new Set<number>(groupResizeSnapLines.value.x)
76+
const y = new Set<number>(groupResizeSnapLines.value.y)
7177
for (const el of selectedElements.value) {
7278
for (const lx of el.activeSnapLines.value.x) x.add(lx)
7379
for (const ly of el.activeSnapLines.value.y) y.add(ly)
@@ -154,9 +160,55 @@ function onPointerdown(ev: PointerEvent) {
154160
;(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId)
155161
}
156162
157-
// Reserved for future snap alignment support
158-
void findSnap
159-
void getSnapTargets
163+
// Snap the dragged corner `(cornerX, cornerY)` of the group's bounding box to alignment
164+
// targets (slide edges/center + other elements' edges/centers). For free resize, snap each
165+
// axis independently. With `lockAR`, snap along the diagonal from the anchor through the
166+
// corner so the AR constraint isn't broken. Returns the snapped corner plus the guide
167+
// lines to render.
168+
function applyGroupCornerSnap(
169+
cornerX: number,
170+
cornerY: number,
171+
anchorX: number,
172+
anchorY: number,
173+
lockAR: boolean,
174+
excludeIds: Set<string>,
175+
pageNum: number,
176+
): { x: number, y: number, linesX: number[], linesY: number[] } {
177+
const targets = getSnapTargets(pageNum, excludeIds)
178+
if (!lockAR) {
179+
const sX = findSnap(cornerX, 0, targets.x)
180+
const sY = findSnap(cornerY, 0, targets.y)
181+
return { x: sX.value, y: sY.value, linesX: sX.lines, linesY: sY.lines }
182+
}
183+
// AR-locked: snap projects guide-aligned positions onto the AR-preserving diagonal.
184+
const dx = cornerX - anchorX
185+
const dy = cornerY - anchorY
186+
if (Math.abs(dx) < 0.001 && Math.abs(dy) < 0.001)
187+
return { x: cornerX, y: cornerY, linesX: [], linesY: [] }
188+
const sX = findSnap(cornerX, 0, targets.x)
189+
let xDist = Infinity
190+
let xSnX = cornerX
191+
let xSnY = cornerY
192+
if (sX.lines.length > 0 && Math.abs(dx) > 0.001) {
193+
xSnX = sX.value
194+
xSnY = anchorY + (xSnX - anchorX) / dx * dy
195+
xDist = Math.abs(sX.value - cornerX)
196+
}
197+
const sY = findSnap(cornerY, 0, targets.y)
198+
let yDist = Infinity
199+
let ySnX = cornerX
200+
let ySnY = cornerY
201+
if (sY.lines.length > 0 && Math.abs(dy) > 0.001) {
202+
ySnY = sY.value
203+
ySnX = anchorX + (ySnY - anchorY) / dy * dx
204+
yDist = Math.abs(sY.value - cornerY)
205+
}
206+
if (xDist < yDist && xDist < Infinity)
207+
return { x: xSnX, y: xSnY, linesX: [xSnX], linesY: [] }
208+
if (yDist < Infinity)
209+
return { x: ySnX, y: ySnY, linesX: [], linesY: [ySnY] }
210+
return { x: cornerX, y: cornerY, linesX: [], linesY: [] }
211+
}
160212
161213
function onPointermoveResize(ev: PointerEvent, isLeft: boolean, isTop: boolean) {
162214
if (!currentDrag || ev.buttons !== 1 || !bounds.value)
@@ -189,7 +241,7 @@ function onPointermoveResize(ev: PointerEvent, isLeft: boolean, isTop: boolean)
189241
newWidth = Math.max(newWidth, minSize)
190242
newHeight = Math.max(newHeight, minSize)
191243
192-
// With Shift: preserve aspect ratio
244+
// With Shift: preserve aspect ratio (per the deck-wide modifier convention).
193245
if (ev.shiftKey) {
194246
const origRatio = origWidth / origHeight
195247
const newRatio = newWidth / newHeight
@@ -201,6 +253,24 @@ function onPointermoveResize(ev: PointerEvent, isLeft: boolean, isTop: boolean)
201253
}
202254
}
203255
256+
// Snap the dragged corner unless ⌘ is held (per the modifier convention). For AR-locked
257+
// (Shift) resize, snap along the AR-preserving diagonal so we don't break the constraint.
258+
if (!ev.metaKey) {
259+
const slideNo = selectedElements.value[0]?.page.value
260+
if (slideNo != null) {
261+
const cornerX = isLeft ? (anchorX - newWidth) : (anchorX + newWidth)
262+
const cornerY = isTop ? (anchorY - newHeight) : (anchorY + newHeight)
263+
const excludeIds = new Set(selectedElements.value.map(s => s.dragId))
264+
const snapped = applyGroupCornerSnap(cornerX, cornerY, anchorX, anchorY, ev.shiftKey, excludeIds, slideNo)
265+
groupResizeSnapLines.value = { x: snapped.linesX, y: snapped.linesY }
266+
newWidth = Math.max(Math.abs(snapped.x - anchorX), minSize)
267+
newHeight = Math.max(Math.abs(snapped.y - anchorY), minSize)
268+
}
269+
}
270+
else {
271+
groupResizeSnapLines.value = { x: [], y: [] }
272+
}
273+
204274
// Calculate scale factors
205275
const scaleX = newWidth / origWidth
206276
const scaleY = newHeight / origHeight
@@ -244,9 +314,11 @@ function onPointerup(ev: PointerEvent) {
244314
selectedElements.value,
245315
)
246316
// Clear any snap-line guides that the body-drag handler left on individual elements
247-
// (the v-drag directive's pointerup only clears the primary element's lines).
317+
// (the v-drag directive's pointerup only clears the primary element's lines), and the
318+
// corner-resize ones we wrote locally.
248319
for (const el of selectedElements.value)
249320
el.clearSnapLines()
321+
groupResizeSnapLines.value = { x: [], y: [] }
250322
currentDrag = null
251323
}
252324

0 commit comments

Comments
 (0)