mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 23:42:37 +10:00
feat: add resizable embed component │ (#1401)
- Created reusable ResizableWrapper component - Added drag-to-resize functionality for embeds
This commit is contained in:
@ -0,0 +1,96 @@
|
|||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing {
|
||||||
|
user-select: none;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 10;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeHandleBottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 24px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeHandleBottom:hover .resizeBar,
|
||||||
|
.resizing .resizeBar {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-gray-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import classes from "./resizable-wrapper.module.css";
|
||||||
|
|
||||||
|
interface ResizableWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
initialHeight?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
onResize?: (height: number) => void;
|
||||||
|
isEditable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
showHandles?: "always" | "hover";
|
||||||
|
direction?: "vertical" | "horizontal" | "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||||
|
children,
|
||||||
|
initialHeight = 480,
|
||||||
|
minHeight = 200,
|
||||||
|
maxHeight = 1200,
|
||||||
|
onResize,
|
||||||
|
isEditable = true,
|
||||||
|
className,
|
||||||
|
showHandles = "hover",
|
||||||
|
direction = "vertical",
|
||||||
|
}) => {
|
||||||
|
const [resizeParams, setResizeParams] = useState<{
|
||||||
|
initialSize: number;
|
||||||
|
initialClientY: number;
|
||||||
|
initialClientX: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resizeParams) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!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 handleMouseUp = () => {
|
||||||
|
setResizeParams(null);
|
||||||
|
if (onResize && currentHeight !== initialHeight) {
|
||||||
|
onResize(currentHeight);
|
||||||
|
}
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
||||||
|
|
||||||
|
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)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={clsx(classes.wrapper, className, {
|
||||||
|
[classes.resizing]: !!resizeParams,
|
||||||
|
})}
|
||||||
|
style={{ height: currentHeight }}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
.embedWrapper {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embedIframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { useMemo } from "react";
|
import React, { useMemo, useCallback } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
AspectRatio,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
FocusTrap,
|
FocusTrap,
|
||||||
@ -14,7 +13,8 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
@ -22,6 +22,8 @@ import {
|
|||||||
getEmbedProviderById,
|
getEmbedProviderById,
|
||||||
getEmbedUrlAndProvider,
|
getEmbedUrlAndProvider,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
|
import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||||
|
import classes from "./embed-view.module.css";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
url: z
|
url: z
|
||||||
@ -33,7 +35,7 @@ const schema = z.object({
|
|||||||
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 } = node.attrs;
|
const { src, provider, height: nodeHeight } = node.attrs;
|
||||||
|
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
@ -49,6 +51,10 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
validate: zodResolver(schema),
|
validate: zodResolver(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleResize = useCallback((newHeight: number) => {
|
||||||
|
updateAttributes({ height: newHeight });
|
||||||
|
}, [updateAttributes]);
|
||||||
|
|
||||||
async function onSubmit(data: { url: string }) {
|
async function onSubmit(data: { url: string }) {
|
||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
@ -77,17 +83,25 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
{embedUrl ? (
|
{embedUrl ? (
|
||||||
<>
|
<ResizableWrapper
|
||||||
<AspectRatio ratio={16 / 9}>
|
initialHeight={nodeHeight || 480}
|
||||||
<iframe
|
minHeight={200}
|
||||||
src={embedUrl}
|
maxHeight={1200}
|
||||||
allow="encrypted-media"
|
onResize={handleResize}
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
isEditable={editor.isEditable}
|
||||||
allowFullScreen
|
className={clsx(classes.embedWrapper, {
|
||||||
frameBorder="0"
|
"ProseMirror-selectednode": selected,
|
||||||
></iframe>
|
})}
|
||||||
</AspectRatio>
|
>
|
||||||
</>
|
<iframe
|
||||||
|
className={classes.embedIframe}
|
||||||
|
src={embedUrl}
|
||||||
|
allow="encrypted-media"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
|
allowFullScreen
|
||||||
|
frameBorder="0"
|
||||||
|
/>
|
||||||
|
</ResizableWrapper>
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover
|
||||||
width={300}
|
width={300}
|
||||||
|
|||||||
Reference in New Issue
Block a user