mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: enhance embed resizer
This commit is contained in:
@@ -48,7 +48,7 @@ export function useResolveCommentMutation() {
|
||||
resolvedAt: variables.resolved ? new Date() : null,
|
||||
resolvedById: variables.resolved ? "optimistic" : null,
|
||||
resolvedBy: variables.resolved
|
||||
? { id: "optimistic", name: "", avatarUrl: null }
|
||||
? ({ id: "optimistic", name: "", avatarUrl: null } as IComment["resolvedBy"])
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -67,3 +67,9 @@
|
||||
.resizing .handleBar {
|
||||
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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.resizing {
|
||||
user-select: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
@@ -20,12 +18,118 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cornerHandle {
|
||||
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;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cornerHandleBR {
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
cursor: nwse-resize;
|
||||
|
||||
&::before {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.resizeHandleBottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
bottom: -4px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 12px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -36,61 +140,53 @@
|
||||
touch-action: none;
|
||||
-webkit-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 {
|
||||
width: 50px;
|
||||
height: 4px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
transition: background-color 0.15s ease;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-4),
|
||||
var(--mantine-color-blue-5)
|
||||
);
|
||||
}
|
||||
|
||||
.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 {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-7);
|
||||
}
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-6),
|
||||
var(--mantine-color-blue-4)
|
||||
);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
@media print {
|
||||
.cornerHandle,
|
||||
.resizeHandleBottom {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,111 +2,163 @@ import React, { ReactNode, useCallback, useEffect, useRef, useState } from "reac
|
||||
import clsx from "clsx";
|
||||
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 {
|
||||
children: ReactNode;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
onResize?: (height: number) => void;
|
||||
onResize?: (width: number, height: number) => void;
|
||||
isEditable?: boolean;
|
||||
className?: string;
|
||||
showHandles?: "always" | "hover";
|
||||
direction?: "vertical" | "horizontal" | "both";
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
handle: Handle;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
};
|
||||
|
||||
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||
children,
|
||||
initialWidth = 640,
|
||||
initialHeight = 480,
|
||||
minWidth = 200,
|
||||
maxWidth = 1200,
|
||||
minHeight = 200,
|
||||
maxHeight = 1200,
|
||||
onResize,
|
||||
isEditable = true,
|
||||
className,
|
||||
showHandles = "hover",
|
||||
direction = "vertical",
|
||||
selected = false,
|
||||
}) => {
|
||||
const [resizeParams, setResizeParams] = useState<{
|
||||
initialSize: number;
|
||||
initialClientY: number;
|
||||
initialClientX: number;
|
||||
} | null>(null);
|
||||
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeParams) return;
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
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) => {
|
||||
if (!wrapperRef.current) return;
|
||||
const handleMouseMove = useRef((e: MouseEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !wrapperRef.current) return;
|
||||
|
||||
if (direction === "vertical" || direction === "both") {
|
||||
const deltaY = e.clientY - resizeParams.initialClientY;
|
||||
const newHeight = Math.min(
|
||||
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
||||
maxHeight
|
||||
);
|
||||
setCurrentHeight(newHeight);
|
||||
wrapperRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
const sign = HANDLE_SIGN[drag.handle];
|
||||
const { minWidth, maxWidth, minHeight, maxHeight } = constraintsRef.current;
|
||||
|
||||
const deltaY = e.clientY - drag.startY;
|
||||
const newHeight = Math.min(Math.max(drag.startHeight + deltaY * sign.y, minHeight), maxHeight);
|
||||
heightRef.current = newHeight;
|
||||
wrapperRef.current.style.height = `${newHeight}px`;
|
||||
|
||||
if (sign.x !== 0) {
|
||||
const deltaX = e.clientX - drag.startX;
|
||||
const newWidth = Math.min(Math.max(drag.startWidth + deltaX * sign.x, minWidth), maxWidth);
|
||||
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.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,
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizeParams(null);
|
||||
if (onResize && currentHeight !== initialHeight) {
|
||||
onResize(currentHeight);
|
||||
}
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
setIsResizing(true);
|
||||
document.body.style.cursor = HANDLE_CURSOR[handle];
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
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)));
|
||||
const shouldShowHandles = isEditable && (isHovered || isResizing || selected);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={clsx(classes.wrapper, className, {
|
||||
[classes.resizing]: !!resizeParams,
|
||||
[classes.resizing]: isResizing,
|
||||
})}
|
||||
style={{ height: currentHeight }}
|
||||
style={{ width: widthRef.current, height: heightRef.current }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{children}
|
||||
{!!resizeParams && <div className={classes.overlay} />}
|
||||
{shouldShowHandles && direction === "vertical" && (
|
||||
<div
|
||||
className={classes.resizeHandleBottom}
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div className={classes.resizeBar} />
|
||||
</div>
|
||||
{isResizing && <div className={classes.overlay} />}
|
||||
{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
|
||||
className={classes.resizeHandleBottom}
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom")}
|
||||
>
|
||||
<div className={classes.resizeBar} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
:global(.ProseMirror .node-embed.ProseMirror-selectednode) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.embedContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.embedWrapper {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
@@ -13,4 +22,4 @@
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,13 @@ import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||
import classes from "./embed-view.module.css";
|
||||
|
||||
const schema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.url({ message: i18n.t("Please enter a valid url") }),
|
||||
url: z.url({ message: i18n.t("Please enter a valid url") }).trim(),
|
||||
});
|
||||
|
||||
export default function EmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
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(() => {
|
||||
if (src) {
|
||||
@@ -53,8 +50,8 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
});
|
||||
|
||||
const handleResize = useCallback(
|
||||
(newHeight: number) => {
|
||||
updateAttributes({ height: newHeight });
|
||||
(newWidth: number, newHeight: number) => {
|
||||
updateAttributes({ width: newWidth, height: newHeight });
|
||||
},
|
||||
[updateAttributes],
|
||||
);
|
||||
@@ -85,27 +82,33 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<NodeViewWrapper data-drag-handle className={classes.embedNodeView}>
|
||||
{embedUrl ? (
|
||||
<ResizableWrapper
|
||||
initialHeight={nodeHeight || 480}
|
||||
minHeight={200}
|
||||
maxHeight={1200}
|
||||
onResize={handleResize}
|
||||
isEditable={editor.isEditable}
|
||||
className={clsx(classes.embedWrapper, {
|
||||
"ProseMirror-selectednode": selected,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
className={classes.embedIframe}
|
||||
src={sanitizeUrl(embedUrl)}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
/>
|
||||
</ResizableWrapper>
|
||||
<div className={classes.embedContainer}>
|
||||
<ResizableWrapper
|
||||
initialWidth={nodeWidth || 640}
|
||||
initialHeight={nodeHeight || 480}
|
||||
minWidth={200}
|
||||
maxWidth={1200}
|
||||
minHeight={200}
|
||||
maxHeight={1200}
|
||||
onResize={handleResize}
|
||||
isEditable={editor.isEditable}
|
||||
selected={selected}
|
||||
className={clsx(classes.embedWrapper, {
|
||||
"ProseMirror-selectednode": selected,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
className={classes.embedIframe}
|
||||
src={sanitizeUrl(embedUrl)}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
/>
|
||||
</ResizableWrapper>
|
||||
</div>
|
||||
) : (
|
||||
<Popover
|
||||
width={300}
|
||||
|
||||
Reference in New Issue
Block a user