Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/inspector-drag-resize.md
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use present-tense changeset wording.

“Drag” is imperative. Use a user-facing present-tense description instead.

Proposed fix
-Drag the selected element to reposition it and use the corner handles to resize, saved back to source as style edits.
+Lets you reposition selected elements by dragging and resize them with corner handles.

As per coding guidelines, changeset descriptions must be “one line, present-tense, describing what changed from a user's perspective.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Drag the selected element to reposition it and use the corner handles to resize, saved back to source as style edits.
Lets you reposition selected elements by dragging and resize them with corner handles.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/inspector-drag-resize.md at line 5, Update the changeset
description to use present-tense, user-facing wording instead of the imperative
“Drag”; describe that users can reposition the selected element by dragging it
and resize it with the corner handles, with changes saved back to source as
style edits.

Source: Coding guidelines

31 changes: 31 additions & 0 deletions apps/demo/slides/verify-drag-resize/index.tsx
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[];
304 changes: 304 additions & 0 deletions packages/core/src/app/components/inspector/drag-resize-layer.tsx
Original file line number Diff line number Diff line change
@@ -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<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();
};

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) => (
<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 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`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 parseFloat the first two tokens, so percentages are reinterpreted as px and a z component is dropped.

  • packages/core/src/app/components/inspector/drag-resize-layer.tsx#L263-L268: resolve non-px units (or bail out of the gesture) when reading the computed base translate, and preserve a third component.
  • packages/core/src/app/components/inspector/drag-resize-layer.tsx#L281-L288: mirror the same unit/z handling when normalising the value written to the set-style op.
📍 Affects 1 file
  • packages/core/src/app/components/inspector/drag-resize-layer.tsx#L263-L268 (this comment)
  • packages/core/src/app/components/inspector/drag-resize-layer.tsx#L281-L288
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/app/components/inspector/drag-resize-layer.tsx` around
lines 263 - 268, Update readTranslate and the set-style normalization logic at
packages/core/src/app/components/inspector/drag-resize-layer.tsx:263-268 and
:281-288 to handle computed translate units safely: resolve non-pixel components
or abort the gesture, and preserve the optional third z component instead of
dropping it. Apply consistent unit and z-component handling in both sites.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

stopPropagation() does not block the overlay's own click listener.

InspectOverlay registers onClick on window in the capture phase (inspect-overlay.tsx Line 73), and this swallow listener is added on the same target later, so it runs after it. stopPropagation only stops propagation to other nodes, not to co-registered listeners on window — the post-gesture click still reaches onClick and re-selects whatever is under the pointer. Use stopImmediatePropagation().

🐛 Proposed fix
   const swallow = (e: MouseEvent) => {
     e.preventDefault();
-    e.stopPropagation();
+    e.stopImmediatePropagation();
+    e.stopPropagation();
     window.removeEventListener('click', swallow, true);
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/app/components/inspector/drag-resize-layer.tsx` around
lines 294 - 304, Update suppressNextClick so the swallow handler calls
stopImmediatePropagation() instead of stopPropagation(), ensuring the
later-registered window click listener is not invoked while preserving
preventDefault and listener cleanup.

13 changes: 12 additions & 1 deletion packages/core/src/app/components/inspector/inspect-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -87,7 +88,13 @@ export function InspectOverlay() {
if (!active) return null;
return (
<div ref={overlayRef} data-inspector-ui className="pointer-events-none absolute inset-0 z-30">
<Frame anchor={selectedAnchor} overlayRef={overlayRef} variant="selected" showImageActions />
<Frame
anchor={selectedAnchor}
overlayRef={overlayRef}
variant="selected"
showImageActions
interactive
/>
<Frame anchor={dedupedHover} overlayRef={overlayRef} variant="hover" />
</div>
);
Expand All @@ -105,11 +112,14 @@ function Frame({
overlayRef,
variant,
showImageActions = false,
interactive = false,
}: {
anchor: HTMLElement | null;
overlayRef: React.RefObject<HTMLDivElement>;
variant: FrameVariant;
showImageActions?: boolean;
/** Selected frame only: drag to move, corner handles to resize. */
interactive?: boolean;
}) {
const [rect, setRect] = useState<RelRect | null>(null);
const [hasTarget, setHasTarget] = useState(false);
Expand Down Expand Up @@ -213,6 +223,7 @@ function Frame({
...FRAME_STYLES[variant],
}}
/>
{interactive && anchor && <DragResizeLayer anchor={anchor} rect={rect} visible={visible} />}
{showImageActions && imageAnchor && (
<ImageActionPanel
anchor={imageAnchor}
Expand Down
Loading