Remove resize grid-snap (#12528)

* fix: Remove unused grid snapping from element resizing

Horizontal resizing snapped widths to a 5% grid, which is no longer
desired. Replace the only remaining use of the gridSnap prop (the
minimum-width clamp) with a named constant and drop the prop entirely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: Remove resize lag by disabling size transition while dragging

The width/height CSS transition on resizable elements existed to smooth
the discrete jumps from grid snapping. With pixel-by-pixel resizing the
element perpetually animates toward a target ~150ms in the future, so it
visibly trails the cursor. Disable the transition while actively dragging
and restore it afterwards so snap-back and collaborative size changes
still animate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: Constrain image resizing to editor edge instead of snapping to natural size

When dragging an element past the editor bounds, the full-width sentinel
forced the width to the natural size. For images narrower than the editor
this snapped them back to their (smaller) natural width at the boundary.
Only use the natural-width sentinel when the image is genuinely wider than
the editor; otherwise constrain to the editor edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* PR feedback

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-05-29 22:46:59 -04:00
committed by GitHub
parent a37bb13956
commit 70b6476afa
5 changed files with 87 additions and 87 deletions
+63 -64
View File
@@ -16,7 +16,7 @@ type Props = ComponentProps & {
};
const Embed = (props: Props) => {
const ref = React.useRef<HTMLIFrameElement>(null);
const ref = React.useRef<HTMLDivElement>(null);
const { node, isEditable, embedsDisabled, onChangeSize } = props;
const naturalWidth = 0;
const naturalHeight = 400;
@@ -28,7 +28,6 @@ const Embed = (props: Props) => {
height: node.attrs.height ?? naturalHeight,
naturalWidth,
naturalHeight,
gridSnap: 5,
onChangeSize,
ref,
}
@@ -51,8 +50,8 @@ const Embed = (props: Props) => {
};
return (
<FrameWrapper ref={ref}>
<InnerEmbed ref={ref} style={style} {...props} />
<FrameWrapper ref={ref} $dragging={!!dragging}>
<InnerEmbed style={style} {...props} />
{isEditable && isResizable && (
<>
<ResizeBottom
@@ -65,69 +64,69 @@ const Embed = (props: Props) => {
);
};
const InnerEmbed = React.forwardRef<HTMLIFrameElement, Props>(
function InnerEmbed_(
{ isEditable, isSelected, node, embeds, embedsDisabled, style },
ref
) {
const cache = React.useMemo(
() => getMatchingEmbed(embeds, node.attrs.href),
[embeds, node.attrs.href]
);
if (!cache) {
return null;
}
const { embed, matches } = cache;
if (embedsDisabled) {
return (
<DisabledEmbed
href={node.attrs.href}
embed={embed}
isEditable={isEditable}
isSelected={isSelected}
/>
);
}
if (embed.transformMatch) {
const src = embed.transformMatch(matches);
return (
<Frame
ref={ref}
src={src}
style={style}
isSelected={isSelected}
canonicalUrl={embed.hideToolbar ? undefined : node.attrs.href}
title={embed.title}
referrerPolicy="strict-origin-when-cross-origin"
border
/>
);
}
if ("component" in embed) {
return (
// @ts-expect-error Component type
<embed.component
ref={ref}
attrs={node.attrs}
style={style}
matches={matches}
isEditable={isEditable}
isSelected={isSelected}
embed={embed}
/>
);
}
function InnerEmbed({
isEditable,
isSelected,
node,
embeds,
embedsDisabled,
style,
}: Props) {
const cache = React.useMemo(
() => getMatchingEmbed(embeds, node.attrs.href),
[embeds, node.attrs.href]
);
if (!cache) {
return null;
}
);
const FrameWrapper = styled.div`
const { embed, matches } = cache;
if (embedsDisabled) {
return (
<DisabledEmbed
href={node.attrs.href}
embed={embed}
isEditable={isEditable}
isSelected={isSelected}
/>
);
}
if (embed.transformMatch) {
const src = embed.transformMatch(matches);
return (
<Frame
src={src}
style={style}
isSelected={isSelected}
canonicalUrl={embed.hideToolbar ? undefined : node.attrs.href}
title={embed.title}
referrerPolicy="strict-origin-when-cross-origin"
border
/>
);
}
if ("component" in embed) {
return (
// @ts-expect-error Component type
<embed.component
attrs={node.attrs}
style={style}
matches={matches}
isEditable={isEditable}
isSelected={isSelected}
embed={embed}
/>
);
}
return null;
}
const FrameWrapper = styled.div<{ $dragging: boolean }>`
line-height: 0;
position: relative;
margin-left: auto;
@@ -139,7 +138,7 @@ const FrameWrapper = styled.div`
max-width: 100%;
transition-property: width, max-height;
transition-duration: 150ms;
transition-duration: ${(props) => (props.$dragging ? "0ms" : "150ms")};
transition-timing-function: ease-in-out;
&:hover {
+6 -4
View File
@@ -50,7 +50,6 @@ const Image = (props: Props) => {
height: node.attrs.height ?? naturalHeight,
naturalWidth,
naturalHeight,
gridSnap: 5,
onChangeSize,
ref,
});
@@ -119,6 +118,7 @@ const Image = (props: Props) => {
<div contentEditable={false} className={className} ref={ref}>
<ImageWrapper
isFullWidth={isFullWidth}
$dragging={!!dragging}
className={
isSelected || dragging
? "image-wrapper ProseMirror-selectednode"
@@ -319,20 +319,22 @@ const Button = styled.button`
}
`;
const ImageWrapper = styled.div<{ isFullWidth: boolean }>`
const ImageWrapper = styled.div<{ isFullWidth: boolean; $dragging: boolean }>`
line-height: 0;
position: relative;
margin-left: auto;
margin-right: auto;
max-width: ${(props) => (props.isFullWidth ? "initial" : "100%")};
transition-property: width, height;
transition-duration: ${(props) => (props.isFullWidth ? "0ms" : "150ms")};
transition-duration: ${(props) =>
props.isFullWidth || props.$dragging ? "0ms" : "150ms"};
transition-timing-function: ease-in-out;
overflow: hidden;
img {
transition-property: width, height;
transition-duration: ${(props) => (props.isFullWidth ? "0ms" : "150ms")};
transition-duration: ${(props) =>
props.isFullWidth || props.$dragging ? "0ms" : "150ms"};
transition-timing-function: ease-in-out;
}
+2 -3
View File
@@ -33,7 +33,6 @@ export default function PdfViewer(props: Props) {
height: node.attrs.height,
naturalWidth: 300,
naturalHeight: 424,
gridSnap: 5,
onChangeSize,
ref,
}
@@ -145,7 +144,7 @@ const PDFWrapper = styled.div<{ $dragging: boolean }>`
margin-right: auto;
max-width: 100%;
transition-property: width, height;
transition-duration: 120ms;
transition-duration: ${(props) => (props.$dragging ? "0ms" : "120ms")};
transition-timing-function: ease-in-out;
overflow: hidden;
will-change: ${(props) => (props.$dragging ? "width, height" : "auto")};
@@ -155,7 +154,7 @@ const PDFWrapper = styled.div<{ $dragging: boolean }>`
embed {
transition-property: width, height;
transition-duration: 120ms;
transition-duration: ${(props) => (props.$dragging ? "0ms" : "120ms")};
transition-timing-function: ease-in-out;
will-change: ${(props) => (props.$dragging ? "width, height" : "auto")};
}
+4 -4
View File
@@ -30,7 +30,6 @@ export default function Video(props: Props) {
height: node.attrs.height ?? naturalHeight,
naturalWidth,
naturalHeight,
gridSnap: 5,
onChangeSize,
ref,
});
@@ -54,6 +53,7 @@ export default function Video(props: Props) {
<div contentEditable={false} ref={ref}>
<VideoWrapper
className={isSelected ? "ProseMirror-selectednode" : ""}
$dragging={!!dragging}
style={style}
>
<StyledVideo
@@ -97,7 +97,7 @@ const StyledVideo = styled.video`
${videoStyle}
`;
const VideoWrapper = styled.div`
const VideoWrapper = styled.div<{ $dragging: boolean }>`
line-height: 0;
position: relative;
margin-left: auto;
@@ -110,12 +110,12 @@ const VideoWrapper = styled.div`
overflow: hidden;
transition-property: width, max-height;
transition-duration: 150ms;
transition-duration: ${(props) => (props.$dragging ? "0ms" : "150ms")};
transition-timing-function: ease-in-out;
video {
transition-property: width, max-height;
transition-duration: 150ms;
transition-duration: ${(props) => (props.$dragging ? "0ms" : "150ms")};
transition-timing-function: ease-in-out;
}
+12 -12
View File
@@ -5,6 +5,9 @@ type DragDirection = "left" | "right" | "bottom";
type SizeState = { width: number; height?: number };
/** The minimum width an element can be resized to, as a fraction of the maximum width. */
const minWidthRatio = 0.05;
/**
* Hook for resizing an element by dragging its sides.
*/
@@ -36,8 +39,6 @@ type Params = {
naturalWidth: number;
/** The natural height of the element. */
naturalHeight: number;
/** The percentage of the grid to snap the element to. */
gridSnap: 5;
/** The pixel increment to snap vertical resizing to. */
gridHeightSnap?: number;
/** The minimum height in pixels when resizing vertically. */
@@ -51,7 +52,6 @@ export default function useDragResize(props: Params): ReturnValue {
onChangeSize,
naturalWidth,
naturalHeight,
gridSnap,
gridHeightSnap,
minHeight,
ref,
@@ -74,10 +74,10 @@ export default function useDragResize(props: Params): ReturnValue {
const constrainWidth = React.useCallback(
(width: number, max: number) => {
const minWidth = Math.min(naturalWidth, (gridSnap / 100) * max);
const minWidth = Math.min(naturalWidth, minWidthRatio * max);
return Math.round(Math.min(max, Math.max(width, minWidth)));
},
[naturalWidth, gridSnap]
[naturalWidth]
);
const handlePointerMove = React.useCallback(
@@ -94,17 +94,18 @@ export default function useDragResize(props: Params): ReturnValue {
}
if (diffX && sizeAtDragStart.width) {
const gridWidth = (gridSnap / 100) * maxWidth;
const newWidth = sizeAtDragStart.width + diffX * 2;
const widthOnGrid = Math.round(newWidth / gridWidth) * gridWidth;
const constrainedWidth = constrainWidth(widthOnGrid, maxWidth);
const constrainedWidth = constrainWidth(newWidth, maxWidth);
const aspectRatio = naturalHeight / naturalWidth;
setSize({
// When dragged to or beyond the editor edge, store the natural width as a
// sentinel for "full width" so the element stays responsive. Only do this
// when the natural width actually exceeds the editor — otherwise constrain
// to the editor edge rather than snapping a smaller image back down to its
// natural size.
width:
// If the natural width is the same as the constrained width, use the natural width -
// special case for images resized to the full width of the editor.
constrainedWidth === Math.min(newWidth, maxWidth)
newWidth >= maxWidth && naturalWidth >= maxWidth
? naturalWidth
: constrainedWidth,
height: naturalWidth
@@ -129,7 +130,6 @@ export default function useDragResize(props: Params): ReturnValue {
offset,
sizeAtDragStart,
maxWidth,
gridSnap,
gridHeightSnap,
naturalWidth,
naturalHeight,