Files
outline/shared/editor/components/Embed.tsx
T
Tom Moor 70b6476afa 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>
2026-05-29 22:46:59 -04:00

152 lines
3.5 KiB
TypeScript

import * as React from "react";
import styled from "styled-components";
import type { EmbedDescriptor } from "../embeds";
import { getMatchingEmbed } from "../lib/embeds";
import type { ComponentProps } from "../types";
import DisabledEmbed from "./DisabledEmbed";
import Frame from "./Frame";
import { ResizeBottom, ResizeLeft, ResizeRight } from "./ResizeHandle";
import useDragResize from "./hooks/useDragResize";
type Props = ComponentProps & {
embeds: EmbedDescriptor[];
embedsDisabled?: boolean;
style?: React.CSSProperties;
onChangeSize?: (props: { width: number; height?: number }) => void;
};
const Embed = (props: Props) => {
const ref = React.useRef<HTMLDivElement>(null);
const { node, isEditable, embedsDisabled, onChangeSize } = props;
const naturalWidth = 0;
const naturalHeight = 400;
const isResizable = !!onChangeSize && !embedsDisabled;
const { width, height, setSize, handlePointerDown, dragging } = useDragResize(
{
width: node.attrs.width ?? naturalWidth,
height: node.attrs.height ?? naturalHeight,
naturalWidth,
naturalHeight,
onChangeSize,
ref,
}
);
React.useEffect(() => {
if (node.attrs.height && node.attrs.height !== height) {
setSize({
width: node.attrs.width,
height: node.attrs.height,
});
}
}, [node.attrs.height]);
const style: React.CSSProperties = {
width: width || "100%",
height: height || 400,
maxWidth: "100%",
pointerEvents: dragging ? "none" : "all",
};
return (
<FrameWrapper ref={ref} $dragging={!!dragging}>
<InnerEmbed style={style} {...props} />
{isEditable && isResizable && (
<>
<ResizeBottom
onPointerDown={handlePointerDown("bottom")}
$dragging={!!dragging}
/>
</>
)}
</FrameWrapper>
);
};
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 { 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;
margin-right: auto;
white-space: nowrap;
cursor: default;
border-radius: 8px;
user-select: none;
max-width: 100%;
transition-property: width, max-height;
transition-duration: ${(props) => (props.$dragging ? "0ms" : "150ms")};
transition-timing-function: ease-in-out;
&:hover {
${ResizeLeft}, ${ResizeRight} {
opacity: 1;
}
}
`;
export default Embed;