diff --git a/apps/web/package.json b/apps/web/package.json index 56e3bd9..efbfcf0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/geist": "^5.2.8", + "@tiptap/core": "^3.22.2", "@tiptap/extension-image": "^3.22.2", "@tiptap/extension-link": "^3.22.2", "@tiptap/extension-youtube": "^3.22.2", diff --git a/apps/web/src/components/editor/resizable-media-node-view.tsx b/apps/web/src/components/editor/resizable-media-node-view.tsx new file mode 100644 index 0000000..e0c0874 --- /dev/null +++ b/apps/web/src/components/editor/resizable-media-node-view.tsx @@ -0,0 +1,283 @@ +import { useEffect, useRef, useState } from "react"; +import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; +import { cn } from "@/lib/utils"; + +type MediaAlign = "left" | "center" | "right"; +type MediaKind = "image" | "video" | "youtube"; +type ResizeSide = "left" | "right"; + +type ResizableMediaNodeViewProps = NodeViewProps & { + mediaKind: MediaKind; +}; + +type HandleDescriptor = { + key: string; + side: ResizeSide; + className: string; +}; + +const HANDLE_DESCRIPTORS: HandleDescriptor[] = [ + { + key: "top-left", + side: "left", + className: "-left-1.5 -top-1.5 cursor-ew-resize" + }, + { + key: "bottom-left", + side: "left", + className: "-bottom-1.5 -left-1.5 cursor-ew-resize" + }, + { + key: "top-right", + side: "right", + className: "-right-1.5 -top-1.5 cursor-ew-resize" + }, + { + key: "bottom-right", + side: "right", + className: "-bottom-1.5 -right-1.5 cursor-ew-resize" + } +]; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function readWidthPercent(value: unknown): number { + const numericValue = typeof value === "number" ? value : Number(value); + + if (Number.isNaN(numericValue)) { + return 100; + } + + return clamp(numericValue, 25, 100); +} + +function readAlign(value: unknown): MediaAlign { + if (value === "left" || value === "right" || value === "center") { + return value; + } + + return "center"; +} + +function resolveAlignClass(align: MediaAlign): string { + if (align === "left") { + return "mr-auto"; + } + + if (align === "right") { + return "ml-auto"; + } + + return "mx-auto"; +} + +function isStringValue(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export function ResizableMediaNodeView({ + editor, + getPos, + mediaKind, + node, + selected, + updateAttributes +}: ResizableMediaNodeViewProps) { + const [isResizing, setIsResizing] = useState(false); + const mediaFrameRef = useRef(null); + const cleanupResizeRef = useRef<(() => void) | null>(null); + + const widthPercent = readWidthPercent(node.attrs.widthPercent); + const align = readAlign(node.attrs.align); + const src = isStringValue(node.attrs.src) ? node.attrs.src : ""; + const alt = isStringValue(node.attrs.alt) ? node.attrs.alt : ""; + const title = isStringValue(node.attrs.title) ? node.attrs.title : ""; + const showControls = selected || isResizing; + + useEffect(() => { + return () => { + cleanupResizeRef.current?.(); + }; + }, []); + + function selectCurrentNode(): void { + const position = getPos(); + + if (typeof position !== "number") { + return; + } + + editor.chain().focus().setNodeSelection(position).run(); + } + + function applyAlign(nextAlign: MediaAlign): void { + selectCurrentNode(); + updateAttributes({ align: nextAlign }); + } + + function startResize(side: ResizeSide) { + return (event: React.PointerEvent): void => { + event.preventDefault(); + event.stopPropagation(); + + selectCurrentNode(); + + const mediaFrame = mediaFrameRef.current; + const editorRoot = mediaFrame?.closest(".ProseMirror") as HTMLElement | null; + + if (!mediaFrame || !editorRoot) { + return; + } + + const startX = event.clientX; + const startWidth = mediaFrame.getBoundingClientRect().width; + const maxWidth = Math.max(editorRoot.clientWidth - 24, 240); + + const handlePointerMove = (moveEvent: PointerEvent): void => { + const delta = moveEvent.clientX - startX; + const resizedWidth = side === "right" ? startWidth + delta : startWidth - delta; + const nextWidth = clamp(resizedWidth, 180, maxWidth); + const nextWidthPercent = clamp((nextWidth / maxWidth) * 100, 25, 100); + + updateAttributes({ + widthPercent: Math.round(nextWidthPercent) + }); + }; + + const handlePointerUp = (): void => { + cleanupResizeRef.current?.(); + cleanupResizeRef.current = null; + setIsResizing(false); + }; + + cleanupResizeRef.current = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp, { once: true }); + setIsResizing(true); + }; + } + + function renderMediaContent() { + if (mediaKind === "image") { + return ( + {alt} + ); + } + + if (mediaKind === "youtube") { + return ( +
+