feat: enhance embed resizer

This commit is contained in:
Philipinho
2026-03-02 02:45:13 +00:00
parent 614baf153b
commit cf43e2b4fe
6 changed files with 312 additions and 146 deletions
@@ -48,7 +48,7 @@ export function useResolveCommentMutation() {
resolvedAt: variables.resolved ? new Date() : null, resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? "optimistic" : null, resolvedById: variables.resolved ? "optimistic" : null,
resolvedBy: variables.resolved resolvedBy: variables.resolved
? { id: "optimistic", name: "", avatarUrl: null } ? ({ id: "optimistic", name: "", avatarUrl: null } as IComment["resolvedBy"])
: null, : null,
})), })),
); );
@@ -67,3 +67,9 @@
.resizing .handleBar { .resizing .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
} }
@media print {
.handle {
display: none !important;
}
}
@@ -1,13 +1,11 @@
.wrapper { .wrapper {
position: relative; position: relative;
width: 100%; overflow: visible;
overflow: hidden;
border-radius: 8px; border-radius: 8px;
} }
.resizing { .resizing {
user-select: none; user-select: none;
cursor: ns-resize;
} }
.overlay { .overlay {
@@ -20,12 +18,118 @@
background: transparent; background: transparent;
} }
.resizeHandleBottom { .cornerHandle {
position: absolute; position: absolute;
width: 36px;
height: 36px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s ease;
touch-action: none;
-webkit-user-select: none;
user-select: none;
&::before,
&::after {
content: "";
position: absolute;
border-radius: 1px;
background-color: light-dark(
var(--mantine-color-blue-4),
var(--mantine-color-blue-5)
);
transition: background-color 0.15s ease;
}
&::before {
width: 28px;
height: 3px;
}
&::after {
width: 3px;
height: 28px;
}
&:hover::before,
&:hover::after {
background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
}
}
.cornerHandleTL {
top: -2px;
left: -2px;
cursor: nwse-resize;
&::before {
top: 0;
left: 0;
}
&::after {
top: 0;
left: 0;
}
}
.cornerHandleTR {
top: -2px;
right: -2px;
cursor: nesw-resize;
&::before {
top: 0;
right: 0;
}
&::after {
top: 0;
right: 0;
}
}
.cornerHandleBL {
bottom: -2px;
left: -2px;
cursor: nesw-resize;
&::before {
bottom: 0; bottom: 0;
left: 0; left: 0;
}
&::after {
bottom: 0;
left: 0;
}
}
.cornerHandleBR {
bottom: -2px;
right: -2px;
cursor: nwse-resize;
&::before {
bottom: 0;
right: 0; right: 0;
height: 24px; }
&::after {
bottom: 0;
right: 0;
}
}
.resizeHandleBottom {
position: absolute;
bottom: -4px;
left: 20px;
right: 20px;
height: 12px;
cursor: ns-resize; cursor: ns-resize;
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
@@ -36,61 +140,53 @@
touch-action: none; touch-action: none;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
@mixin light {
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
}
@mixin dark {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.05)
);
}
&:hover {
@mixin light {
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
}
@mixin dark {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.1)
);
}
}
}
.wrapper:hover .resizeHandleBottom,
.resizing .resizeHandleBottom {
opacity: 1;
} }
.resizeBar { .resizeBar {
width: 50px; width: 50px;
height: 4px; height: 3px;
border-radius: 2px; border-radius: 2px;
transition: background-color 0.2s ease; transition: background-color 0.15s ease;
background-color: light-dark(
@mixin light { var(--mantine-color-blue-4),
background-color: var(--mantine-color-gray-5); var(--mantine-color-blue-5)
} );
}
@mixin dark {
background-color: var(--mantine-color-gray-6); .resizeHandleBottom:hover .resizeBar {
} background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
}
.wrapper:hover .cornerHandle,
.wrapper:hover .resizeHandleBottom,
.wrapper:global(.ProseMirror-selectednode) .cornerHandle,
.wrapper:global(.ProseMirror-selectednode) .resizeHandleBottom,
.resizing .cornerHandle,
.resizing .resizeHandleBottom {
opacity: 1;
}
.resizing .cornerHandle::before,
.resizing .cornerHandle::after {
background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
} }
.resizeHandleBottom:hover .resizeBar,
.resizing .resizeBar { .resizing .resizeBar {
@mixin light { background-color: light-dark(
background-color: var(--mantine-color-gray-7); var(--mantine-color-blue-6),
} var(--mantine-color-blue-4)
);
}
@mixin dark { @media print {
background-color: var(--mantine-color-gray-4); .cornerHandle,
.resizeHandleBottom {
display: none !important;
} }
} }
@@ -2,110 +2,162 @@ import React, { ReactNode, useCallback, useEffect, useRef, useState } from "reac
import clsx from "clsx"; import clsx from "clsx";
import classes from "./resizable-wrapper.module.css"; import classes from "./resizable-wrapper.module.css";
type Handle = "tl" | "tr" | "bl" | "br" | "bottom";
const HANDLE_SIGN: Record<Handle, { x: number; y: number }> = {
br: { x: 1, y: 1 },
bl: { x: -1, y: 1 },
tr: { x: 1, y: -1 },
tl: { x: -1, y: -1 },
bottom: { x: 0, y: 1 },
};
const HANDLE_CURSOR: Record<Handle, string> = {
br: "nwse-resize",
tl: "nwse-resize",
bl: "nesw-resize",
tr: "nesw-resize",
bottom: "ns-resize",
};
const CORNER_CLASSES: Record<string, string> = {
tl: classes.cornerHandleTL,
tr: classes.cornerHandleTR,
bl: classes.cornerHandleBL,
br: classes.cornerHandleBR,
};
interface ResizableWrapperProps { interface ResizableWrapperProps {
children: ReactNode; children: ReactNode;
initialWidth?: number;
initialHeight?: number; initialHeight?: number;
minWidth?: number;
maxWidth?: number;
minHeight?: number; minHeight?: number;
maxHeight?: number; maxHeight?: number;
onResize?: (height: number) => void; onResize?: (width: number, height: number) => void;
isEditable?: boolean; isEditable?: boolean;
className?: string; className?: string;
showHandles?: "always" | "hover"; selected?: boolean;
direction?: "vertical" | "horizontal" | "both";
} }
type DragState = {
handle: Handle;
startX: number;
startY: number;
startWidth: number;
startHeight: number;
};
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({ export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
children, children,
initialWidth = 640,
initialHeight = 480, initialHeight = 480,
minWidth = 200,
maxWidth = 1200,
minHeight = 200, minHeight = 200,
maxHeight = 1200, maxHeight = 1200,
onResize, onResize,
isEditable = true, isEditable = true,
className, className,
showHandles = "hover", selected = false,
direction = "vertical",
}) => { }) => {
const [resizeParams, setResizeParams] = useState<{ const [isResizing, setIsResizing] = useState(false);
initialSize: number;
initialClientY: number;
initialClientX: number;
} | null>(null);
const [currentHeight, setCurrentHeight] = useState(initialHeight);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => { const dragRef = useRef<DragState | null>(null);
if (!resizeParams) return; const widthRef = useRef(initialWidth);
const heightRef = useRef(initialHeight);
const onResizeRef = useRef(onResize);
onResizeRef.current = onResize;
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = useRef((e: MouseEvent) => {
if (!wrapperRef.current) return; const drag = dragRef.current;
if (!drag || !wrapperRef.current) return;
if (direction === "vertical" || direction === "both") { const sign = HANDLE_SIGN[drag.handle];
const deltaY = e.clientY - resizeParams.initialClientY; const { minWidth, maxWidth, minHeight, maxHeight } = constraintsRef.current;
const newHeight = Math.min(
Math.max(resizeParams.initialSize + deltaY, minHeight), const deltaY = e.clientY - drag.startY;
maxHeight const newHeight = Math.min(Math.max(drag.startHeight + deltaY * sign.y, minHeight), maxHeight);
); heightRef.current = newHeight;
setCurrentHeight(newHeight);
wrapperRef.current.style.height = `${newHeight}px`; wrapperRef.current.style.height = `${newHeight}px`;
}
};
const handleMouseUp = () => { if (sign.x !== 0) {
setResizeParams(null); const deltaX = e.clientX - drag.startX;
if (onResize && currentHeight !== initialHeight) { const newWidth = Math.min(Math.max(drag.startWidth + deltaX * sign.x, minWidth), maxWidth);
onResize(currentHeight); widthRef.current = newWidth;
wrapperRef.current.style.width = `${newWidth}px`;
} }
}).current;
const handleMouseUp = useRef(() => {
dragRef.current = null;
setIsResizing(false);
document.body.style.cursor = ""; document.body.style.cursor = "";
document.body.style.userSelect = ""; document.body.style.userSelect = "";
}; document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
onResizeRef.current?.(widthRef.current, heightRef.current);
}).current;
const handleResizeStart = useCallback((e: React.MouseEvent, handle: Handle) => {
e.preventDefault();
e.stopPropagation();
dragRef.current = {
handle,
startX: e.clientX,
startY: e.clientY,
startWidth: widthRef.current,
startHeight: heightRef.current,
};
setIsResizing(true);
document.body.style.cursor = HANDLE_CURSOR[handle];
document.body.style.userSelect = "none";
document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
}, [handleMouseMove, handleMouseUp]);
useEffect(() => {
return () => { return () => {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
}; };
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]); }, [handleMouseMove, handleMouseUp]);
const handleResizeStart = useCallback((e: React.MouseEvent) => { const shouldShowHandles = isEditable && (isHovered || isResizing || selected);
e.preventDefault();
e.stopPropagation();
setResizeParams({
initialSize: currentHeight,
initialClientY: e.clientY,
initialClientX: e.clientX,
});
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
}, [currentHeight]);
const shouldShowHandles =
isEditable &&
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
return ( return (
<div <div
ref={wrapperRef} ref={wrapperRef}
className={clsx(classes.wrapper, className, { className={clsx(classes.wrapper, className, {
[classes.resizing]: !!resizeParams, [classes.resizing]: isResizing,
})} })}
style={{ height: currentHeight }} style={{ width: widthRef.current, height: heightRef.current }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
{children} {children}
{!!resizeParams && <div className={classes.overlay} />} {isResizing && <div className={classes.overlay} />}
{shouldShowHandles && direction === "vertical" && ( {shouldShowHandles && (
<>
{(["tl", "tr", "bl", "br"] as const).map((corner) => (
<div
key={corner}
className={clsx(classes.cornerHandle, CORNER_CLASSES[corner])}
onMouseDown={(e) => handleResizeStart(e, corner)}
/>
))}
<div <div
className={classes.resizeHandleBottom} className={classes.resizeHandleBottom}
onMouseDown={handleResizeStart} onMouseDown={(e) => handleResizeStart(e, "bottom")}
> >
<div className={classes.resizeBar} /> <div className={classes.resizeBar} />
</div> </div>
</>
)} )}
</div> </div>
); );
@@ -1,3 +1,12 @@
:global(.ProseMirror .node-embed.ProseMirror-selectednode) {
outline: none;
}
.embedContainer {
display: flex;
justify-content: center;
}
.embedWrapper { .embedWrapper {
@mixin light { @mixin light {
background-color: var(--mantine-color-gray-0); background-color: var(--mantine-color-gray-0);
@@ -27,16 +27,13 @@ import { ResizableWrapper } from "../common/resizable-wrapper";
import classes from "./embed-view.module.css"; import classes from "./embed-view.module.css";
const schema = z.object({ const schema = z.object({
url: z url: z.url({ message: i18n.t("Please enter a valid url") }).trim(),
.string()
.trim()
.url({ message: i18n.t("Please enter a valid url") }),
}); });
export default function EmbedView(props: NodeViewProps) { export default function EmbedView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { node, selected, updateAttributes, editor } = props; const { node, selected, updateAttributes, editor } = props;
const { src, provider, height: nodeHeight } = node.attrs; const { src, provider, width: nodeWidth, height: nodeHeight } = node.attrs;
const embedUrl = useMemo(() => { const embedUrl = useMemo(() => {
if (src) { if (src) {
@@ -53,8 +50,8 @@ export default function EmbedView(props: NodeViewProps) {
}); });
const handleResize = useCallback( const handleResize = useCallback(
(newHeight: number) => { (newWidth: number, newHeight: number) => {
updateAttributes({ height: newHeight }); updateAttributes({ width: newWidth, height: newHeight });
}, },
[updateAttributes], [updateAttributes],
); );
@@ -85,14 +82,19 @@ export default function EmbedView(props: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle className={classes.embedNodeView}>
{embedUrl ? ( {embedUrl ? (
<div className={classes.embedContainer}>
<ResizableWrapper <ResizableWrapper
initialWidth={nodeWidth || 640}
initialHeight={nodeHeight || 480} initialHeight={nodeHeight || 480}
minWidth={200}
maxWidth={1200}
minHeight={200} minHeight={200}
maxHeight={1200} maxHeight={1200}
onResize={handleResize} onResize={handleResize}
isEditable={editor.isEditable} isEditable={editor.isEditable}
selected={selected}
className={clsx(classes.embedWrapper, { className={clsx(classes.embedWrapper, {
"ProseMirror-selectednode": selected, "ProseMirror-selectednode": selected,
})} })}
@@ -106,6 +108,7 @@ export default function EmbedView(props: NodeViewProps) {
frameBorder="0" frameBorder="0"
/> />
</ResizableWrapper> </ResizableWrapper>
</div>
) : ( ) : (
<Popover <Popover
width={300} width={300}