diff --git a/apps/client/src/features/editor/components/common/node-resize.module.css b/apps/client/src/features/editor/components/common/node-resize.module.css index 24414171..7010e324 100644 --- a/apps/client/src/features/editor/components/common/node-resize.module.css +++ b/apps/client/src/features/editor/components/common/node-resize.module.css @@ -9,7 +9,8 @@ max-width: 100%; } -.wrapper img { +.wrapper img, +.wrapper video { height: auto !important; } diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx index dfece398..5de909c0 100644 --- a/apps/client/src/features/editor/components/video/video-menu.tsx +++ b/apps/client/src/features/editor/components/video/video-menu.tsx @@ -1,19 +1,23 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import React, { useCallback } from "react"; +import { useCallback } from "react"; import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; import { ActionIcon, Tooltip } from "@mantine/core"; +import clsx from "clsx"; import { IconLayoutAlignCenter, IconLayoutAlignLeft, IconLayoutAlignRight, + IconDownload, + IconTrash, } from "@tabler/icons-react"; -import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; import { useTranslation } from "react-i18next"; +import { getFileUrl } from "@/lib/config.ts"; +import classes from "../common/toolbar-menu.module.css"; export function VideoMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); @@ -32,7 +36,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { isAlignLeft: ctx.editor.isActive("video", { align: "left" }), isAlignCenter: ctx.editor.isActive("video", { align: "center" }), isAlignRight: ctx.editor.isActive("video", { align: "right" }), - width: videoAttrs?.width ? parseInt(videoAttrs.width) : null, + src: videoAttrs?.src || null, }; }, }); @@ -70,7 +74,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { }; }, [editor]); - const alignVideoLeft = useCallback(() => { + const alignLeft = useCallback(() => { editor .chain() .focus(undefined, { scrollIntoView: false }) @@ -78,7 +82,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { .run(); }, [editor]); - const alignVideoCenter = useCallback(() => { + const alignCenter = useCallback(() => { editor .chain() .focus(undefined, { scrollIntoView: false }) @@ -86,7 +90,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { .run(); }, [editor]); - const alignVideoRight = useCallback(() => { + const alignRight = useCallback(() => { editor .chain() .focus(undefined, { scrollIntoView: false }) @@ -94,16 +98,18 @@ export function VideoMenu({ editor }: EditorMenuProps) { .run(); }, [editor]); - const onWidthChange = useCallback( - (value: number) => { - editor - .chain() - .focus(undefined, { scrollIntoView: false }) - .setVideoWidth(value) - .run(); - }, - [editor], - ); + const handleDownload = useCallback(() => { + if (!editorState?.src) return; + const url = getFileUrl(editorState.src); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + }, [editorState?.src]); + + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); return ( - +
@@ -132,10 +140,12 @@ export function VideoMenu({ editor }: EditorMenuProps) { @@ -143,19 +153,43 @@ export function VideoMenu({ editor }: EditorMenuProps) { - - {editorState?.width && ( - - )} +
+ + + + + + + + + + + + +
); } diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 7b33e603..ca5572d8 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -222,6 +222,16 @@ export const mainExtensions = [ }), TiptapVideo.configure({ view: VideoView, + resize: { + enabled: true, + directions: ["left", "right"], + minWidth: 80, + minHeight: 40, + alwaysPreserveAspectRatio: true, + //@ts-ignore + createCustomHandle: createResizeHandle, + className: buildResizeClasses("node-video"), + }, }), Callout.configure({ view: CalloutView, diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts index c3c6ab3e..a296d13e 100644 --- a/packages/editor-ext/src/lib/video/video.ts +++ b/packages/editor-ext/src/lib/video/video.ts @@ -1,16 +1,35 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; -import { Range, Node } from "@tiptap/core"; +import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; +import type { ResizableNodeViewDirection } from "@tiptap/core"; + +export type VideoResizeOptions = { + enabled: boolean; + directions?: ResizableNodeViewDirection[]; + minWidth?: number; + minHeight?: number; + alwaysPreserveAspectRatio?: boolean; + createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement; + className?: { + container?: string; + wrapper?: string; + handle?: string; + resizing?: string; + }; +}; export interface VideoOptions { view: any; HTMLAttributes: Record; + resize: VideoResizeOptions | false; } + export interface VideoAttributes { src?: string; align?: string; attachmentId?: string; size?: number; - width?: number; + width?: number | string; + height?: number; aspectRatio?: number; placeholder?: { id: string; @@ -27,6 +46,7 @@ declare module "@tiptap/core" { ) => ReturnType; setVideoAlign: (align: "left" | "center" | "right") => ReturnType; setVideoWidth: (width: number) => ReturnType; + setVideoSize: (width: number, height: number) => ReturnType; }; } } @@ -44,6 +64,7 @@ export const TiptapVideo = Node.create({ return { view: null, HTMLAttributes: {}, + resize: false, }; }, @@ -64,12 +85,30 @@ export const TiptapVideo = Node.create({ }), }, width: { - default: "100%", - parseHTML: (element) => element.getAttribute("width"), + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("width"); + if (!raw) return null; + if (raw.endsWith("%")) return raw; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, renderHTML: (attributes: VideoAttributes) => ({ width: attributes.width, }), }, + height: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("height"); + if (!raw) return null; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, + renderHTML: (attributes: VideoAttributes) => ({ + height: attributes.height, + }), + }, size: { default: null, parseHTML: (element) => element.getAttribute("data-size"), @@ -136,13 +175,168 @@ export const TiptapVideo = Node.create({ commands.updateAttributes("video", { width: `${Math.max(0, Math.min(100, width))}%`, }), + + setVideoSize: + (width, height) => + ({ commands }) => + commands.updateAttributes("video", { width, height }), }; }, addNodeView() { - // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) - this.editor.isInitialized = true; + const resize = this.options.resize; - return ReactNodeViewRenderer(this.options.view); + if (!resize || !resize.enabled) { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + } + + const { + directions, + minWidth, + minHeight, + alwaysPreserveAspectRatio, + createCustomHandle, + className, + } = resize; + + return (props) => { + const { node, getPos, HTMLAttributes, editor } = props; + + if (!node.attrs.src) { + editor.isInitialized = true; + const reactView = ReactNodeViewRenderer(this.options.view); + const view = reactView(props); + + const originalUpdate = view.update?.bind(view); + view.update = (updatedNode, decorations, innerDecorations) => { + if (updatedNode.attrs.src && !node.attrs.src) { + return false; + } + if (originalUpdate) { + return originalUpdate(updatedNode, decorations, innerDecorations); + } + return true; + }; + + return view; + } + + const el = document.createElement("video"); + el.src = node.attrs.src; + el.controls = true; + el.preload = "metadata"; + el.style.display = "block"; + el.style.maxWidth = "100%"; + el.style.borderRadius = "8px"; + + let currentNode = node; + + const nodeView = new ResizableNodeView({ + element: el, + editor, + node, + getPos, + onResize: (w, h) => { + el.style.width = `${w}px`; + el.style.height = `${h}px`; + }, + onCommit: () => { + const pos = getPos(); + if (pos === undefined) return; + + this.editor + .chain() + .setNodeSelection(pos) + .updateAttributes(this.name, { + width: Math.round(el.offsetWidth), + height: Math.round(el.offsetHeight), + }) + .run(); + }, + onUpdate: (updatedNode, _decorations, _innerDecorations) => { + if (updatedNode.type !== currentNode.type) { + return false; + } + + if (updatedNode.attrs.src !== currentNode.attrs.src) { + el.src = updatedNode.attrs.src || ""; + } + + const w = updatedNode.attrs.width; + const h = updatedNode.attrs.height; + if (w != null) { + el.style.width = `${w}px`; + } + if (h != null) { + el.style.height = `${h}px`; + } + + const align = updatedNode.attrs.align || "center"; + const container = nodeView.dom as HTMLElement; + applyAlignment(container, align); + + currentNode = updatedNode; + return true; + }, + options: { + directions, + min: { + width: minWidth, + height: minHeight, + }, + preserveAspectRatio: alwaysPreserveAspectRatio === true, + createCustomHandle, + className, + }, + }); + + const dom = nodeView.dom as HTMLElement; + + applyAlignment(dom, node.attrs.align || "center"); + + // Handle percentage width backward compat + const widthAttr = node.attrs.width; + if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { + requestAnimationFrame(() => { + const parentEl = dom.parentElement; + if (parentEl) { + const containerWidth = parentEl.clientWidth; + const pctValue = parseInt(widthAttr, 10); + if (!isNaN(pctValue) && containerWidth > 0) { + const pxWidth = Math.round( + containerWidth * (pctValue / 100), + ); + el.style.width = `${pxWidth}px`; + if (node.attrs.aspectRatio) { + el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; + } + } + } + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }); + } + + // Hide until video metadata loads + dom.style.visibility = "hidden"; + dom.style.pointerEvents = "none"; + el.onloadedmetadata = () => { + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }; + + return nodeView; + }; }, }); + +function applyAlignment(container: HTMLElement, align: string) { + if (align === "left") { + container.style.justifyContent = "flex-start"; + } else if (align === "right") { + container.style.justifyContent = "flex-end"; + } else { + container.style.justifyContent = "center"; + } +}