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";
+ }
+}