diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
index 049e1503..11a88925 100644
--- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts
+++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
@@ -205,8 +205,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
+
uploadVideoAction(file, editor.view, pos, pageId);
}
+
+ // Reset the input value to allow uploading the same file again if needed
+ input.value = "";
};
input.click();
},
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 57a012a8..dfece398 100644
--- a/apps/client/src/features/editor/components/video/video-menu.tsx
+++ b/apps/client/src/features/editor/components/video/video-menu.tsx
@@ -20,7 +20,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
const editorState = useEditorState({
editor,
- selector: ctx => {
+ selector: (ctx) => {
if (!ctx.editor) {
return null;
}
@@ -43,7 +43,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return false;
}
- return editor.isActive("video");
+ return editor.isActive("video") && editor.getAttributes("video").src;
},
[editor],
);
diff --git a/apps/client/src/features/editor/components/video/video-view.module.css b/apps/client/src/features/editor/components/video/video-view.module.css
new file mode 100644
index 00000000..8e6b2f63
--- /dev/null
+++ b/apps/client/src/features/editor/components/video/video-view.module.css
@@ -0,0 +1,15 @@
+.videoWrapper {
+ border-radius: 8px;
+ @mixin light {
+ background-color: var(--mantine-color-gray-0);
+ }
+
+ @mixin dark {
+ background-color: var(--mantine-color-dark-7);
+ }
+}
+.video {
+ display: block;
+ width: 100%;
+ border-radius: 8px;
+}
diff --git a/apps/client/src/features/editor/components/video/video-view.tsx b/apps/client/src/features/editor/components/video/video-view.tsx
index d47d9a4a..13a9d02d 100644
--- a/apps/client/src/features/editor/components/video/video-view.tsx
+++ b/apps/client/src/features/editor/components/video/video-view.tsx
@@ -2,11 +2,11 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx";
+import classes from "./video-view.module.css";
export default function VideoView(props: NodeViewProps) {
const { node, selected } = props;
- const { src, width, align } = node.attrs;
-
+ const { src, width, align, aspectRatio } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
@@ -16,14 +16,26 @@ export default function VideoView(props: NodeViewProps) {
return (
-
+
+ {src && (
+
+ )}
+
);
}
diff --git a/packages/editor-ext/src/lib/video/video-upload.ts b/packages/editor-ext/src/lib/video/video-upload.ts
index 1e976ecc..c3d9a9d4 100644
--- a/packages/editor-ext/src/lib/video/video-upload.ts
+++ b/packages/editor-ext/src/lib/video/video-upload.ts
@@ -1,132 +1,140 @@
-import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
-import { Decoration, DecorationSet } from "@tiptap/pm/view";
-import {
- insertTrailingNode,
- MediaUploadOptions,
- UploadFn,
-} from "../media-utils";
+import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
+import { generateNodeId } from "../utils";
+import { Node } from "@tiptap/pm/model";
-const uploadKey = new PluginKey("video-upload");
+const findVideoNodeByPlaceholderId = (
+ doc: Node,
+ placeholderId: string,
+): { node: Node; pos: number } | null => {
+ let result: { node: Node; pos: number } | null = null;
-export const VideoUploadPlugin = ({
- placeholderClass,
-}: {
- placeholderClass: string;
-}) =>
- new Plugin({
- key: uploadKey,
- state: {
- init() {
- return DecorationSet.empty;
- },
- apply(tr, set) {
- set = set.map(tr.mapping, tr.doc);
- // See if the transaction adds or removes any placeholders
- //@-ts-expect-error - not yet sure what the type I need here
- const action = tr.getMeta(this);
- if (action?.add) {
- const { id, pos, src } = action.add;
+ doc.descendants((node, pos) => {
+ if (result) return false;
- const placeholder = document.createElement("div");
- placeholder.setAttribute("class", "video-placeholder");
- const video = document.createElement("video");
- video.setAttribute("class", placeholderClass);
- video.src = src;
- placeholder.appendChild(video);
- const deco = Decoration.widget(pos + 1, placeholder, {
- id,
- });
- set = set.add(tr.doc, [deco]);
- } else if (action?.remove) {
- set = set.remove(
- set.find(
- undefined,
- undefined,
- (spec) => spec.id == action.remove.id,
- ),
- );
- }
- return set;
- },
- },
- props: {
- decorations(state) {
- return this.getState(state);
- },
- },
+ if (
+ node.type.name === "video" &&
+ node.attrs.placeholderId === placeholderId
+ ) {
+ result = { node, pos };
+ return false;
+ }
+
+ return true;
});
-function findPlaceholder(state: EditorState, id: {}) {
- const decos = uploadKey.getState(state) as DecorationSet;
- const found = decos.find(undefined, undefined, (spec) => spec.id == id);
- return found.length ? found[0]?.from : null;
-}
+ return result;
+};
+const getVideoDimensions = (
+ url: string,
+): Promise<
+ { width: number; height: number; aspectRatio: number } | undefined
+> => {
+ return new Promise<
+ { width: number; height: number; aspectRatio: number } | undefined
+ >((resolve) => {
+ const video = document.createElement("video");
-export const handleVideoUpload =
+ video.preload = "metadata";
+ video.onloadedmetadata = () => {
+ const width = video.videoWidth;
+ const height = video.videoHeight;
+ const aspectRatio = height > 0 ? width / height : 1;
+
+ resolve({ width, height, aspectRatio });
+ };
+ video.onerror = () => {
+ resolve(undefined);
+ };
+ video.src = url;
+ });
+};
+const handleVideoUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId) => {
- // check if the file is an image
+ // check if the file is valid
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
- // A fresh object to act as the ID for this upload
- const id = {};
- // Replace the selection with a placeholder
+ const objectUrl = URL.createObjectURL(file);
+ const videoDimensions = await getVideoDimensions(objectUrl);
+ const placeholderId = generateNodeId();
+ const aspectRatio = videoDimensions.aspectRatio;
+ const initialPlaceholderNode = view.state.schema.nodes.video?.create({
+ placeholderId,
+ aspectRatio,
+ });
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = () => {
- const tr = view.state.tr;
- if (!tr.selection.empty) tr.deleteSelection();
+ let placeholderShown = false;
+ let tr = view.state.tr;
- tr.setMeta(uploadKey, {
- add: {
- id,
- pos,
- src: reader.result,
- },
- });
+ if (!initialPlaceholderNode) {
+ URL.revokeObjectURL(objectUrl);
+ return;
+ }
- insertTrailingNode(tr, pos, view);
+ const { parent } = tr.doc.resolve(pos);
+ const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
+
+ if (isEmptyTextBlock) {
+ // Replace e.g. empty paragraph with the video
+ tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
+ } else {
+ tr.insert(pos, initialPlaceholderNode);
+ }
+
+ // Only show the placeholder if the upload takes more than 250ms
+ const displayPlaceholderTimeout = setTimeout(() => {
view.dispatch(tr);
- };
+ placeholderShown = true;
+ tr = view.state.tr;
+ }, 250);
- await onUpload(file, pageId).then(
- (attachment: IAttachment) => {
- const { schema } = view.state;
+ try {
+ const attachment: IAttachment = await onUpload(file, pageId);
+ const { pos: currentPos = null } =
+ findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
- const pos = findPlaceholder(view.state, id);
+ // If the placeholder is not found or attachment is missing, abort the process
+ if (currentPos === null || !attachment) return;
- // If the content around the placeholder has been deleted, drop
- // the image
- if (pos == null) return;
+ // Update the placeholder node with the actual video data
+ tr.setNodeMarkup(currentPos, undefined, {
+ src: `/api/files/${attachment.id}/${attachment.fileName}`,
+ attachmentId: attachment.id,
+ title: attachment.fileName,
+ size: attachment.fileSize,
+ aspectRatio,
+ });
+ } catch (error) {
+ const { pos: currentPos = null } =
+ findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
- // Otherwise, insert it at the placeholder's position, and remove
- // the placeholder
+ if (currentPos === null) return;
- if (!attachment) return;
+ // Delete the video placeholder on error
+ tr.delete(
+ currentPos,
+ currentPos + (initialPlaceholderNode.nodeSize ?? 2),
+ );
+ } finally {
+ clearTimeout(displayPlaceholderTimeout);
- const node = schema.nodes.video?.create({
- src: `/api/files/${attachment.id}/${attachment.fileName}`,
- attachmentId: attachment.id,
- title: attachment.fileName,
- size: attachment.fileSize,
- });
- if (!node) return;
+ const dispatchFinal = () => {
+ view.dispatch(tr);
+ URL.revokeObjectURL(objectUrl);
+ };
- const transaction = view.state.tr
- .replaceWith(pos, pos, node)
- .setMeta(uploadKey, { remove: { id } });
- view.dispatch(transaction);
- },
- () => {
- // Deletes the image placeholder on error
- const transaction = view.state.tr
- .delete(pos, pos)
- .setMeta(uploadKey, { remove: { id } });
- view.dispatch(transaction);
- },
- );
+ // If the placeholder was shown, delay showing the video to avoid flicker
+ if (placeholderShown) {
+ setTimeout(() => {
+ dispatchFinal();
+ }, 100);
+ } else {
+ dispatchFinal();
+ }
+ }
};
+
+export { handleVideoUpload };
diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts
index 40f6db32..0f56e75d 100644
--- a/packages/editor-ext/src/lib/video/video.ts
+++ b/packages/editor-ext/src/lib/video/video.ts
@@ -1,6 +1,5 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
-import { VideoUploadPlugin } from "./video-upload";
-import { mergeAttributes, Range, Node, nodeInputRule } from "@tiptap/core";
+import { Range, Node } from "@tiptap/core";
export interface VideoOptions {
view: any;
@@ -13,6 +12,8 @@ export interface VideoAttributes {
attachmentId?: string;
size?: number;
width?: number;
+ aspectRatio?: number;
+ placeholderId?: string;
}
declare module "@tiptap/core" {
@@ -20,7 +21,7 @@ declare module "@tiptap/core" {
videoBlock: {
setVideo: (attributes: VideoAttributes) => ReturnType;
setVideoAt: (
- attributes: VideoAttributes & { pos: number | Range }
+ attributes: VideoAttributes & { pos: number | Range },
) => ReturnType;
setVideoAlign: (align: "left" | "center" | "right") => ReturnType;
setVideoWidth: (width: number) => ReturnType;
@@ -81,6 +82,17 @@ export const TiptapVideo = Node.create({
"data-align": attributes.align,
}),
},
+ aspectRatio: {
+ default: null,
+ parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
+ renderHTML: (attributes: VideoAttributes) => ({
+ "data-aspect-ratio": attributes.aspectRatio,
+ }),
+ },
+ placeholderId: {
+ default: null,
+ rendered: false,
+ },
};
},
@@ -131,12 +143,4 @@ export const TiptapVideo = Node.create({
return ReactNodeViewRenderer(this.options.view);
},
-
- addProseMirrorPlugins() {
- return [
- VideoUploadPlugin({
- placeholderClass: "video-upload",
- }),
- ];
- },
});