-
-
Notifications
You must be signed in to change notification settings - Fork 466
feat(core): drag to move and corner handles to resize in the inspector #382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@open-slide/core': minor | ||
| --- | ||
|
|
||
| Add drag-to-move and corner resize handles for the selected element in the inspector, saved back to source as style edits. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = () => ( | ||
| <div | ||
| style={{ | ||
| width: '100%', | ||
| height: '100%', | ||
| background: '#0d0d10', | ||
| color: '#f4f4f5', | ||
| display: 'flex', | ||
| flexDirection: 'column', | ||
| justifyContent: 'center', | ||
| padding: '0 160px', | ||
| gap: 32, | ||
| }} | ||
| > | ||
| <p style={{ fontSize: 28, letterSpacing: 6, color: '#8b5cf6', margin: 0 }}>DRAG AND RESIZE</p> | ||
| <h1 style={{ fontSize: 120, fontWeight: 700, lineHeight: 1.05, margin: 0 }}> | ||
| Original headline of the cover | ||
| </h1> | ||
| <p style={{ fontSize: 40, color: '#a1a1aa', margin: 0 }}> | ||
| This paragraph exists so the fixture has more than one text block. | ||
| </p> | ||
| </div> | ||
| ); | ||
|
|
||
| export const meta: SlideMeta = { title: 'Verify drag and resize', createdAt: '2026-07-29' }; | ||
| export default [Cover] satisfies Page[]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,336 @@ | ||
| 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<Corner, string> = { | ||
| 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(); | ||
| }; | ||
|
|
||
| // 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 = ''; | ||
| } | ||
| }; | ||
| }, [visible]); | ||
|
|
||
| if (!visible) return null; | ||
|
|
||
| return ( | ||
| <> | ||
| {(['nw', 'ne', 'sw', 'se'] as Corner[]).map((corner) => ( | ||
| <ResizeHandle key={corner} corner={corner} rect={rect} stateRef={stateRef} /> | ||
| ))} | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| function ResizeHandle({ | ||
| corner, | ||
| rect, | ||
| stateRef, | ||
| }: { | ||
| corner: Corner; | ||
| rect: RelRect; | ||
| stateRef: React.MutableRefObject<{ | ||
| anchor: HTMLElement; | ||
| selected: ReturnType<typeof useInspector>['selected']; | ||
| bufferOps: ReturnType<typeof useInspector>['bufferOps']; | ||
| }>; | ||
| }) { | ||
| const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { | ||
| 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 snapshot = readInline(el); | ||
| const base = readTranslate(el); | ||
| // 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; | ||
|
|
||
| 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 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; | ||
| 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(); | ||
| }; | ||
|
|
||
| // 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; | ||
| const left = corner === 'nw' || corner === 'sw' ? rect.left : rect.left + rect.width; | ||
|
|
||
| return ( | ||
| <div | ||
| data-inspector-ui | ||
| data-drag-resize-handle={corner} | ||
| role="presentation" | ||
| aria-label={`Resize ${corner}`} | ||
| onPointerDown={onPointerDown} | ||
| className="pointer-events-auto absolute rounded-full border border-white bg-[#3b82f6] shadow-sm" | ||
| style={{ | ||
| width: HANDLE_SIZE_PX, | ||
| height: HANDLE_SIZE_PX, | ||
| top, | ||
| left, | ||
| transform: 'translate(-50%, -50%)', | ||
| cursor: CORNER_CURSOR[corner], | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * 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<HTMLElement>('[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 }; | ||
| } | ||
|
Comment on lines
+295
to
+300
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Translate parsing assumes exactly two px components. Both helpers split the value and
📍 Affects 1 file
🤖 Prompt for AI Agents |
||
|
|
||
| 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); | ||
| } | ||
|
Comment on lines
+326
to
+336
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🐛 Proposed fix const swallow = (e: MouseEvent) => {
e.preventDefault();
- e.stopPropagation();
+ e.stopImmediatePropagation();
+ e.stopPropagation();
window.removeEventListener('click', swallow, true);
};🤖 Prompt for AI Agents |
||
Uh oh!
There was an error while loading. Please reload this page.