From 9e8e575d04fbdea741a6c3e6bffb3b237d3bd827 Mon Sep 17 00:00:00 2001 From: Bruno Henrique Leal da Cunha <257131764+bruno-begh@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:34:57 -0300 Subject: [PATCH 1/2] feat(inspector): drag to move and corner handles to resize The inspector edits properties of the selected element but cannot move it, so nudging a headline a few pixels means going back to the agent. Dragging the body of a selected element now repositions it and the four corner handles resize it, with the opposite edge anchored. Both end up as ordinary set-style operations (translate, width, height), the same ones the panel already emits, so Save, undo and redo need no new machinery and the result is written back into the .tsx. Movement uses the standalone translate property rather than transform, so it composes with whatever transform the slide already declares. The canvas scale is resolved from the element's own canvas because the same slide also renders in the thumbnail rail and the overview at smaller scales. Verified with packages/core/tools/verify-drag-resize.mjs against the fixture added in apps/demo. --- .changeset/inspector-drag-resize.md | 5 + apps/demo/slides/verify-drag-resize/index.tsx | 31 ++ .../inspector/drag-resize-layer.tsx | 304 ++++++++++++++++++ .../components/inspector/inspect-overlay.tsx | 13 +- packages/core/tools/verify-drag-resize.mjs | 157 +++++++++ 5 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 .changeset/inspector-drag-resize.md create mode 100644 apps/demo/slides/verify-drag-resize/index.tsx create mode 100644 packages/core/src/app/components/inspector/drag-resize-layer.tsx create mode 100644 packages/core/tools/verify-drag-resize.mjs diff --git a/.changeset/inspector-drag-resize.md b/.changeset/inspector-drag-resize.md new file mode 100644 index 000000000..3df6ce105 --- /dev/null +++ b/.changeset/inspector-drag-resize.md @@ -0,0 +1,5 @@ +--- +'@open-slide/core': minor +--- + +Drag the selected element to reposition it and use the corner handles to resize, saved back to source as style edits. diff --git a/apps/demo/slides/verify-drag-resize/index.tsx b/apps/demo/slides/verify-drag-resize/index.tsx new file mode 100644 index 000000000..08f1666a9 --- /dev/null +++ b/apps/demo/slides/verify-drag-resize/index.tsx @@ -0,0 +1,31 @@ +import type { Page, SlideMeta } from '@open-slide/core'; + +// Fixture for tools/verify-drag-resize.mjs. The heading text is what the script +// clicks on, so keep it in sync with the script's default selector. + +const Cover: Page = () => ( +
+

DRAG AND RESIZE

+

+ Original headline of the cover +

+

+ This paragraph exists so the fixture has more than one text block. +

+
+); + +export const meta: SlideMeta = { title: 'Verify drag and resize', createdAt: '2026-07-29' }; +export default [Cover] satisfies Page[]; diff --git a/packages/core/src/app/components/inspector/drag-resize-layer.tsx b/packages/core/src/app/components/inspector/drag-resize-layer.tsx new file mode 100644 index 000000000..60129438e --- /dev/null +++ b/packages/core/src/app/components/inspector/drag-resize-layer.tsx @@ -0,0 +1,304 @@ +import { useEffect, useRef } from 'react'; +import type { EditOp } from '@/lib/inspector/use-editor'; +import { CANVAS_WIDTH } from '@/lib/sdk'; +import { useInspector } from './inspector-provider'; + +type RelRect = { left: number; top: number; width: number; height: number }; + +const DRAG_THRESHOLD_PX = 3; +const MIN_SIZE_PX = 8; +const HANDLE_SIZE_PX = 10; + +type Corner = 'nw' | 'ne' | 'sw' | 'se'; + +const CORNER_CURSOR: Record = { + nw: 'nwse-resize', + ne: 'nesw-resize', + sw: 'nesw-resize', + se: 'nwse-resize', +}; + +type InlineSnapshot = { translate: string; width: string; height: string }; + +/** + * Movement uses the standalone `translate` property rather than `transform` so + * it composes with whatever `transform` the slide already declares instead of + * overwriting it. + */ +export function DragResizeLayer({ + anchor, + rect, + visible, +}: { + anchor: HTMLElement; + rect: RelRect; + visible: boolean; +}) { + const { selected, bufferOps } = useInspector(); + // A ref keeps the window handlers reading current props without re-binding on + // every measure tick. + const stateRef = useRef({ anchor, selected, bufferOps }); + stateRef.current = { anchor, selected, bufferOps }; + + useEffect(() => { + if (!visible) return; + + let drag: { + startX: number; + startY: number; + baseX: number; + baseY: number; + scale: number; + snapshot: InlineSnapshot; + moved: boolean; + } | null = null; + + const onPointerDown = (e: PointerEvent) => { + if (e.button !== 0) return; + const { anchor: el } = stateRef.current; + if (!el?.isConnected) return; + if (!(e.target instanceof Element)) return; + // Handles and every other overlay control carry this attribute; they run + // their own gesture and must not also start a body drag. + if (e.target.closest('[data-inspector-ui]')) return; + if (!e.target.closest('[data-inspector-root]')) return; + + const box = el.getBoundingClientRect(); + const inside = + e.clientX >= box.left && + e.clientX <= box.right && + e.clientY >= box.top && + e.clientY <= box.bottom; + if (!inside) return; + + const base = readTranslate(el); + drag = { + startX: e.clientX, + startY: e.clientY, + baseX: base.x, + baseY: base.y, + scale: canvasScale(el), + snapshot: readInline(el), + moved: false, + }; + }; + + const onPointerMove = (e: PointerEvent) => { + if (!drag) return; + const { anchor: el } = stateRef.current; + if (!el?.isConnected) { + drag = null; + return; + } + + const dx = e.clientX - drag.startX; + const dy = e.clientY - drag.startY; + if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; + + if (!drag.moved) { + drag.moved = true; + document.body.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + } + e.preventDefault(); + const x = Math.round(drag.baseX + dx / drag.scale); + const y = Math.round(drag.baseY + dy / drag.scale); + el.style.translate = `${x}px ${y}px`; + }; + + const onPointerUp = () => { + const current = drag; + drag = null; + if (!current?.moved) return; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + const { anchor: el, selected: sel, bufferOps: buffer } = stateRef.current; + if (!el?.isConnected || !sel) return; + const finalTranslate = el.style.translate; + // Hand the DOM back exactly as we found it: `bufferOps` snapshots the + // current inline value for undo, so it has to see the pre-drag state. + restoreInline(el, current.snapshot); + buffer(sel.line, sel.column, el, [ + { kind: 'set-style', key: 'translate', value: normaliseTranslate(finalTranslate) }, + ]); + suppressNextClick(); + }; + + window.addEventListener('pointerdown', onPointerDown, true); + window.addEventListener('pointermove', onPointerMove, true); + window.addEventListener('pointerup', onPointerUp, true); + return () => { + window.removeEventListener('pointerdown', onPointerDown, true); + window.removeEventListener('pointermove', onPointerMove, true); + window.removeEventListener('pointerup', onPointerUp, true); + if (drag?.moved) { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + }; + }, [visible]); + + if (!visible) return null; + + return ( + <> + {(['nw', 'ne', 'sw', 'se'] as Corner[]).map((corner) => ( + + ))} + + ); +} + +function ResizeHandle({ + corner, + rect, + stateRef, +}: { + corner: Corner; + rect: RelRect; + stateRef: React.MutableRefObject<{ + anchor: HTMLElement; + selected: ReturnType['selected']; + bufferOps: ReturnType['bufferOps']; + }>; +}) { + const onPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + const { anchor: el } = stateRef.current; + if (!el?.isConnected) return; + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + + const scale = canvasScale(el); + const box = el.getBoundingClientRect(); + const snapshot = readInline(el); + const base = readTranslate(el); + const startW = box.width / scale; + const startH = box.height / scale; + const startX = e.clientX; + const startY = e.clientY; + let moved = false; + + const onMove = (ev: PointerEvent) => { + const dx = (ev.clientX - startX) / scale; + const dy = (ev.clientY - startY) / scale; + if (!moved && Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD_PX) { + return; + } + moved = true; + + // Dragging a top or left corner keeps the opposite edge pinned: the size + // change is mirrored by an equal translate so the anchor edge stays put. + const west = corner === 'nw' || corner === 'sw'; + const north = corner === 'nw' || corner === 'ne'; + const w = Math.max(MIN_SIZE_PX, Math.round(west ? startW - dx : startW + dx)); + const h = Math.max(MIN_SIZE_PX, Math.round(north ? startH - dy : startH + dy)); + const x = Math.round(west ? base.x + (startW - w) : base.x); + const y = Math.round(north ? base.y + (startH - h) : base.y); + + el.style.width = `${w}px`; + el.style.height = `${h}px`; + el.style.translate = `${x}px ${y}px`; + }; + + const onUp = () => { + window.removeEventListener('pointermove', onMove, true); + window.removeEventListener('pointerup', onUp, true); + if (!moved) return; + + const { anchor: live, selected: sel, bufferOps: buffer } = stateRef.current; + if (!live?.isConnected || !sel) return; + const ops: EditOp[] = [ + { kind: 'set-style', key: 'width', value: live.style.width }, + { kind: 'set-style', key: 'height', value: live.style.height }, + { kind: 'set-style', key: 'translate', value: normaliseTranslate(live.style.translate) }, + ]; + restoreInline(live, snapshot); + buffer(sel.line, sel.column, live, ops); + suppressNextClick(); + }; + + window.addEventListener('pointermove', onMove, true); + window.addEventListener('pointerup', onUp, true); + }; + + const top = corner === 'nw' || corner === 'ne' ? rect.top : rect.top + rect.height; + const left = corner === 'nw' || corner === 'sw' ? rect.left : rect.left + rect.width; + + return ( +
+ ); +} + +/** + * Live scale of the 1920px canvas holding `el`, so screen deltas become canvas + * pixels. Must be resolved from the element itself: the page also renders the + * same slide into the thumbnail rail and the overview grid, each at its own + * (much smaller) scale, and picking the wrong one multiplies every drag. + */ +function canvasScale(el: HTMLElement): number { + const canvas = el.closest('[data-osd-canvas]'); + if (!canvas) return 1; + const w = canvas.getBoundingClientRect().width; + return w > 0 ? w / CANVAS_WIDTH : 1; +} + +function readTranslate(el: HTMLElement): { x: number; y: number } { + const raw = getComputedStyle(el).translate; + if (!raw || raw === 'none') return { x: 0, y: 0 }; + const [x, y] = raw.split(' '); + return { x: Number.parseFloat(x ?? '0') || 0, y: Number.parseFloat(y ?? '0') || 0 }; +} + +function readInline(el: HTMLElement): InlineSnapshot { + return { translate: el.style.translate, width: el.style.width, height: el.style.height }; +} + +function restoreInline(el: HTMLElement, snap: InlineSnapshot): void { + el.style.translate = snap.translate; + el.style.width = snap.width; + el.style.height = snap.height; +} + +/** `0px 0px` is the default; drop it so we don't litter the source. */ +function normaliseTranslate(value: string): string | null { + const { x, y } = { + x: Number.parseFloat(value) || 0, + y: Number.parseFloat(value.split(' ')[1] ?? '0') || 0, + }; + if (x === 0 && y === 0) return null; + return `${x}px ${y}px`; +} + +/** + * A drag ends with a click event the overlay would read as "select whatever is + * under the pointer". Swallow exactly one. + */ +function suppressNextClick(): void { + const swallow = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + window.removeEventListener('click', swallow, true); + }; + window.addEventListener('click', swallow, true); + // If no click follows (pointer left the window, gesture cancelled), don't + // leave the listener armed for the user's next real click. + setTimeout(() => window.removeEventListener('click', swallow, true), 300); +} diff --git a/packages/core/src/app/components/inspector/inspect-overlay.tsx b/packages/core/src/app/components/inspector/inspect-overlay.tsx index 52bdfede5..442f63b42 100644 --- a/packages/core/src/app/components/inspector/inspect-overlay.tsx +++ b/packages/core/src/app/components/inspector/inspect-overlay.tsx @@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { findSlideSource, type SlideSourceHit } from '@/lib/inspector/fiber'; import { useLocale } from '@/lib/use-locale'; import { cn } from '@/lib/utils'; +import { DragResizeLayer } from './drag-resize-layer'; import { useInspector } from './inspector-provider'; type Highlight = { hit: SlideSourceHit }; @@ -87,7 +88,13 @@ export function InspectOverlay() { if (!active) return null; return (
- +
); @@ -105,11 +112,14 @@ function Frame({ overlayRef, variant, showImageActions = false, + interactive = false, }: { anchor: HTMLElement | null; overlayRef: React.RefObject; variant: FrameVariant; showImageActions?: boolean; + /** Selected frame only: drag to move, corner handles to resize. */ + interactive?: boolean; }) { const [rect, setRect] = useState(null); const [hasTarget, setHasTarget] = useState(false); @@ -213,6 +223,7 @@ function Frame({ ...FRAME_STYLES[variant], }} /> + {interactive && anchor && } {showImageActions && imageAnchor && ( { + console.log(`${ok ? ' [ok]' : ' [FAIL]'} ${msg}`); + if (!ok) failures.push(msg); +}; + +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); +page.on('pageerror', (e) => console.log(` [pageerror] ${e.message}`)); + +/** Fresh page, Inspect on, heading selected. Returns the locator and the scale. */ +async function selectHeading() { + writeFileSync(SLIDE_FILE, ORIGINAL); + await page.goto(URL, { waitUntil: 'networkidle' }); + await page + .getByRole('button', { name: /Inspect/i }) + .first() + .click(); + await page.waitForTimeout(400); + // Scoped on purpose: the same text renders in the thumbnail rail, and + // clicking that navigates instead of selecting. + const el = page.locator('[data-inspector-root]').getByText(TARGET_TEXT).first(); + await el.click(); + await page.waitForTimeout(500); + const scale = await el.evaluate((node) => { + const canvas = node.closest('[data-osd-canvas]'); + return canvas ? canvas.getBoundingClientRect().width / 1920 : 0; + }); + return { el, scale }; +} + +async function save() { + const btn = page.getByRole('button', { name: /^Save/i }).first(); + if ((await btn.count()) === 0) return false; + await btn.click(); + await page.waitForTimeout(1800); + return true; +} + +console.log('\n1. dragging an element moves it and persists to source'); +{ + const { el, scale } = await selectHeading(); + check(scale > 0.1, `main canvas detected (scale ${scale.toFixed(3)})`); + + const box = await el.boundingBox(); + const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + const DX = 120; + const DY = 60; + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + // Intermediate steps: a single move can be coalesced by the browser and never + // cross the drag threshold. + for (let i = 1; i <= 10; i++) { + await page.mouse.move(from.x + (DX / 10) * i, from.y + (DY / 10) * i); + await page.waitForTimeout(16); + } + await page.mouse.up(); + await page.waitForTimeout(300); + + const handles = await page.locator('[data-drag-resize-handle]').count(); + check(handles === 4, `4 corner handles rendered (found ${handles})`); + + const saved = await save(); + check(saved, 'Save button appeared after the drag'); + + const after = readFileSync(SLIDE_FILE, 'utf8'); + const m = after.match(/translate:\s*'([^']+)'/); + check(!!m, 'translate written into the .tsx'); + if (m) { + // A drag adds to whatever translate the element already had, so the check is + // on the delta: running against a fixture left dirty by an earlier run must + // not fail correct behaviour. + const before = ORIGINAL.match(/translate:\s*'([^']+)'/); + const [bx, by] = before ? before[1].split(' ').map(Number.parseFloat) : [0, 0]; + const [ax, ay] = m[1].split(' ').map(Number.parseFloat); + const x = ax - bx; + const y = ay - by; + // Screen displacement becomes canvas pixels: divide by the scale. + const expX = DX / scale; + const expY = DY / scale; + const okX = Math.abs(x - expX) / expX < 0.15; + const okY = Math.abs(y - expY) / expY < 0.15; + check( + okX && okY, + `displacement is proportional: wrote ${m[1]}, expected ~${Math.round(expX)}px ${Math.round(expY)}px`, + ); + } +} + +console.log('\n2. corner handle resizes and persists to source'); +{ + const { scale } = await selectHeading(); + const handle = page.locator('[data-drag-resize-handle="se"]'); + const hb = await handle.boundingBox(); + check(!!hb, 'bottom right handle is visible'); + if (hb) { + const from = { x: hb.x + hb.width / 2, y: hb.y + hb.height / 2 }; + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) { + await page.mouse.move(from.x - 10 * i, from.y + 5 * i); + await page.waitForTimeout(16); + } + await page.mouse.up(); + await page.waitForTimeout(300); + + const saved = await save(); + check(saved, 'Save button appeared after the resize'); + + const after = readFileSync(SLIDE_FILE, 'utf8'); + check(/width:\s*'\d+px'/.test(after), 'width written into the .tsx'); + check(/height:\s*'\d+px'/.test(after), 'height written into the .tsx'); + const h = after.match(/height:\s*'(\d+)px'/); + if (h) { + const expected = 50 / scale; + const grew = Number.parseInt(h[1], 10); + check(grew > expected * 0.5, `height grew with the drag (${grew}px)`); + } + } +} + +await browser.close(); +writeFileSync(SLIDE_FILE, ORIGINAL); +console.log('\n[ok] fixture restored'); + +if (failures.length > 0) { + console.error(`\n${failures.length} check(s) failed.`); + process.exit(1); +} +console.log('all checks passed.'); From d6391db286dcfc370733f1f56c55914d7fe77f1a Mon Sep 17 00:00:00 2001 From: Bruno Henrique Leal da Cunha <257131764+bruno-begh@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:02:02 -0300 Subject: [PATCH 2/2] fix(inspector): unwind drag and resize on pointercancel A cancelled pointer, a touch the browser turns into a scroll or a device that goes away mid-gesture, never delivers pointerup. The resize handle left its window listeners attached, so the element kept resizing on every later pointer move, and both gestures left it wherever the gesture happened to die. Also seed the resize from the computed width and height instead of the rendered box. Under content-box the two differ by padding and border, and it is the computed value that means the same thing as the width being written back. --- .changeset/inspector-drag-resize.md | 2 +- .../inspector/drag-resize-layer.tsx | 40 +++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.changeset/inspector-drag-resize.md b/.changeset/inspector-drag-resize.md index 3df6ce105..441e3124d 100644 --- a/.changeset/inspector-drag-resize.md +++ b/.changeset/inspector-drag-resize.md @@ -2,4 +2,4 @@ '@open-slide/core': minor --- -Drag the selected element to reposition it and use the corner handles to resize, saved back to source as style edits. +Add drag-to-move and corner resize handles for the selected element in the inspector, saved back to source as style edits. diff --git a/packages/core/src/app/components/inspector/drag-resize-layer.tsx b/packages/core/src/app/components/inspector/drag-resize-layer.tsx index 60129438e..a0f272e8f 100644 --- a/packages/core/src/app/components/inspector/drag-resize-layer.tsx +++ b/packages/core/src/app/components/inspector/drag-resize-layer.tsx @@ -125,13 +125,29 @@ export function DragResizeLayer({ suppressNextClick(); }; + // The browser can take the pointer away mid-gesture (a touch turning into a + // scroll, the window losing the device). No pointerup follows, so the drag + // has to be unwound here or the element keeps the position it happened to + // have when the gesture died. + const onPointerCancel = () => { + const current = drag; + drag = null; + if (!current?.moved) return; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + const { anchor: el } = stateRef.current; + if (el?.isConnected) restoreInline(el, current.snapshot); + }; + window.addEventListener('pointerdown', onPointerDown, true); window.addEventListener('pointermove', onPointerMove, true); window.addEventListener('pointerup', onPointerUp, true); + window.addEventListener('pointercancel', onPointerCancel, true); return () => { window.removeEventListener('pointerdown', onPointerDown, true); window.removeEventListener('pointermove', onPointerMove, true); window.removeEventListener('pointerup', onPointerUp, true); + window.removeEventListener('pointercancel', onPointerCancel, true); if (drag?.moved) { document.body.style.cursor = ''; document.body.style.userSelect = ''; @@ -172,11 +188,14 @@ function ResizeHandle({ e.currentTarget.setPointerCapture(e.pointerId); const scale = canvasScale(el); - const box = el.getBoundingClientRect(); const snapshot = readInline(el); const base = readTranslate(el); - const startW = box.width / scale; - const startH = box.height / scale; + // Seed from the computed style, not the rendered box: under content-box the + // two differ by padding and border, and it is the computed value that means + // the same thing as the width we are about to write. + const { width, height } = getComputedStyle(el); + const startW = Number.parseFloat(width) || 0; + const startH = Number.parseFloat(height) || 0; const startX = e.clientX; const startY = e.clientY; let moved = false; @@ -203,9 +222,14 @@ function ResizeHandle({ el.style.translate = `${x}px ${y}px`; }; - const onUp = () => { + const detach = () => { window.removeEventListener('pointermove', onMove, true); window.removeEventListener('pointerup', onUp, true); + window.removeEventListener('pointercancel', onCancel, true); + }; + + const onUp = () => { + detach(); if (!moved) return; const { anchor: live, selected: sel, bufferOps: buffer } = stateRef.current; @@ -220,8 +244,16 @@ function ResizeHandle({ suppressNextClick(); }; + // Without this the listeners outlive a cancelled gesture and the element + // keeps resizing on every later pointer move. + const onCancel = () => { + detach(); + if (moved && el.isConnected) restoreInline(el, snapshot); + }; + window.addEventListener('pointermove', onMove, true); window.addEventListener('pointerup', onUp, true); + window.addEventListener('pointercancel', onCancel, true); }; const top = corner === 'nw' || corner === 'ne' ? rect.top : rect.top + rect.height;