From ad5cf1e18bcb0534eab031db01c30ed94c60d850 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 25 Jul 2025 00:23:14 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20add=20resizable=20embed=20component=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=E2=94=82=20(#1401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created reusable ResizableWrapper component - Added drag-to-resize functionality for embeds --- .../common/resizable-wrapper.module.css | 96 +++++++++++++++ .../components/common/resizable-wrapper.tsx | 112 ++++++++++++++++++ .../components/embed/embed-view.module.css | 16 +++ .../editor/components/embed/embed-view.tsx | 44 ++++--- 4 files changed, 253 insertions(+), 15 deletions(-) create mode 100644 apps/client/src/features/editor/components/common/resizable-wrapper.module.css create mode 100644 apps/client/src/features/editor/components/common/resizable-wrapper.tsx create mode 100644 apps/client/src/features/editor/components/embed/embed-view.module.css diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.module.css b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css new file mode 100644 index 00000000..02791e86 --- /dev/null +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css @@ -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); + } +} diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.tsx b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx new file mode 100644 index 00000000..c3cd1b62 --- /dev/null +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx @@ -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 = ({ + 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(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 ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {children} + {!!resizeParams &&
} + {shouldShowHandles && direction === "vertical" && ( +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/apps/client/src/features/editor/components/embed/embed-view.module.css b/apps/client/src/features/editor/components/embed/embed-view.module.css new file mode 100644 index 00000000..c58f3965 --- /dev/null +++ b/apps/client/src/features/editor/components/embed/embed-view.module.css @@ -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; +} \ No newline at end of file diff --git a/apps/client/src/features/editor/components/embed/embed-view.tsx b/apps/client/src/features/editor/components/embed/embed-view.tsx index dfc6a5da..414ccdaf 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.tsx +++ b/apps/client/src/features/editor/components/embed/embed-view.tsx @@ -1,9 +1,8 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { useMemo } from "react"; +import React, { useMemo, useCallback } from "react"; import clsx from "clsx"; import { ActionIcon, - AspectRatio, Button, Card, FocusTrap, @@ -14,7 +13,8 @@ import { } from "@mantine/core"; import { IconEdit } from "@tabler/icons-react"; 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 { useTranslation } from "react-i18next"; import i18n from "i18next"; @@ -22,6 +22,8 @@ import { getEmbedProviderById, getEmbedUrlAndProvider, } from "@docmost/editor-ext"; +import { ResizableWrapper } from "../common/resizable-wrapper"; +import classes from "./embed-view.module.css"; const schema = z.object({ url: z @@ -33,7 +35,7 @@ const schema = z.object({ export default function EmbedView(props: NodeViewProps) { const { t } = useTranslation(); const { node, selected, updateAttributes, editor } = props; - const { src, provider } = node.attrs; + const { src, provider, height: nodeHeight } = node.attrs; const embedUrl = useMemo(() => { if (src) { @@ -49,6 +51,10 @@ export default function EmbedView(props: NodeViewProps) { validate: zodResolver(schema), }); + const handleResize = useCallback((newHeight: number) => { + updateAttributes({ height: newHeight }); + }, [updateAttributes]); + async function onSubmit(data: { url: string }) { if (!editor.isEditable) { return; @@ -77,17 +83,25 @@ export default function EmbedView(props: NodeViewProps) { return ( {embedUrl ? ( - <> - - - - + +