Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .changeset/editor-video-block.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"emdash": minor
---

Adds a video block to the rich text editor. Type `/video` to choose a Media Library video or upload one. Closing the picker leaves an empty video block in place, which you can fill later by clicking it or dropping a video file on it. Video files dropped or pasted anywhere in the text also upload to the Media Library and appear where you dropped them. The video plays in the editor at the width of the text, with a caption field under it and **Replace video** and **Delete video** in its corner. A video's **Used in** tab in the Media Library lists the entries that use it in a video block.
Adds a video block to the rich text editor. Type `/video` to choose a Media Library video or upload one. Video files dropped or pasted anywhere in the text also upload to the Media Library and appear where you dropped them. The video plays in the editor at the width of the text, with a caption field under it and **Replace video** and **Delete video** in its corner. A video's **Used in** tab in the Media Library lists the entries that use it in a video block.

On the site, `Video` from `emdash/ui` renders the `video` block as the browser's own player with its caption. Media Library videos play from your storage's public URL when one is configured, and need the block's `asset.url`: a block without one renders nothing on the site, and the editor shows it as unplayable. A block whose `asset.provider` names a media provider renders from that provider's embed. An empty video block is saved without `asset` and renders nothing.
On the site, `Video` from `emdash/ui` renders the `video` block as the browser's own player with its caption. Media Library videos play from your storage's public URL when one is configured, and need the block's `asset.url`: a block without one renders nothing on the site, and the editor shows it as unplayable. A block whose `asset.provider` names a media provider renders from that provider's embed.

Uploads follow `maxUploadSize`, 50 MiB by default. The admin's content security policy now allows media from `blob:` and `https:` URLs (`media-src 'self' blob: https:`), as it already did for images. This lets the admin read a video's size before uploading it, and preview a video block whose `asset.url` is on another site. Before, videos uploaded from the admin in production were saved without a width and height.

Expand Down
7 changes: 3 additions & 4 deletions docs/src/content/docs/guides/working-with-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,9 @@ where changing or replacing the asset can affect other entries that use it. See

### Add a video

Type `/video` to add a video block, and choose a video from the Media Library or upload one in the
picker that opens. If you close the picker, the empty video block stays where you put it, so you can
fill it later: click it, press Enter while it's selected, or drop a video file on it. An empty video
block shows nothing on your site. You can also drop a video file anywhere in the text.
Type `/video`, then choose a video from the Media Library or upload one in the picker that opens.
The video block is added once you choose a video. You can also drop a video file anywhere in the
text.

The video plays in the editor as it will on your site, at the width of the text. Type a caption in
the field under it, and press Enter to keep writing below it.
Expand Down
80 changes: 60 additions & 20 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,6 @@ import {
VideoExtension,
isVideoBlock,
mediaItemToVideoAttrs,
openPickerOnInsert,
videoBlockFields,
videoNodeAttrs,
} from "./editor/VideoNode";
Expand Down Expand Up @@ -1955,14 +1954,6 @@ function insertIframeBlock(editor: Editor, range?: Range, position?: number) {
insertTopLevelBlock(editor, editor.schema.nodes.iframeBlock!.create(), range, position);
}

// The new block opens its picker over the editor, which keeps focus underneath: closing the
// picker returns focus there, with the empty block selected so Enter reopens it.
function insertVideoBlock(editor: Editor, range?: Range, position?: number) {
openPickerOnInsert(editor);
insertTopLevelBlock(editor, editor.schema.nodes.videoBlock!.create(), range, position);
editor.view.focus();
}

function insertHtmlBlock(editor: Editor, range?: Range, position?: number) {
insertTopLevelBlock(
editor,
Expand Down Expand Up @@ -2096,16 +2087,6 @@ const htmlSlashCommand: SlashCommandItem = {
command: ({ editor, range }) => insertHtmlBlock(editor, range),
};

const videoSlashCommand: SlashCommandItem = {
id: "video",
title: msg`Video`,
description: msg`Upload or choose a video`,
icon: VideoCamera,
aliases: ["movie", "clip", "mp4", "film"],
category: MEDIA_CATEGORY,
command: ({ editor, range }) => insertVideoBlock(editor, range),
};

const iframeSlashCommand: SlashCommandItem = {
id: "iframe",
title: msg`Iframe`,
Expand Down Expand Up @@ -3510,6 +3491,8 @@ export function PortableTextEditor({
// Media picker state (for image insertion)
const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false);

const [videoPickerOpen, setVideoPickerOpen] = React.useState(false);

// Multi-select media picker state (for gallery insertion)
const [galleryPickerOpen, setGalleryPickerOpen] = React.useState(false);
const [conversionErrorMarks, setConversionErrorMarks] = React.useState<string[]>([]);
Expand Down Expand Up @@ -3666,7 +3649,19 @@ export function PortableTextEditor({
);
// A plugin's own video block replaces the built-in one.
if (!pluginBlockTypes.has("video")) {
cmds.push(topLevelInsert(videoSlashCommand, insertVideoBlock));
cmds.push({
id: "video",
title: msg`Video`,
description: msg`Upload or choose a video`,
icon: VideoCamera,
aliases: ["movie", "clip", "mp4", "film"],
category: MEDIA_CATEGORY,
deferInsertion: true,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
setVideoPickerOpen(true);
},
});
}
cmds.push(topLevelInsert(htmlSlashCommand, insertHtmlBlock), {
id: "section",
Expand Down Expand Up @@ -4202,6 +4197,23 @@ export function PortableTextEditor({
};
}, [editor]);

// A picker can stay open while an upload lands, so the insert position follows the document.
React.useEffect(() => {
if (!editor) return;
const follow = ({ transaction, appendedTransactions }: EditorEvents["transaction"]) => {
const position = pendingBlockInsertPosRef.current;
if (position === null) return;
pendingBlockInsertPosRef.current = [transaction, ...appendedTransactions].reduce(
(pos, tr) => tr.mapping.map(pos, -1),
position,
);
};
editor.on("transaction", follow);
return () => {
editor.off("transaction", follow);
};
}, [editor]);

// Handle image selection from media picker
const handleImageSelect = React.useCallback(
(item: MediaItem) => {
Expand All @@ -4225,6 +4237,20 @@ export function PortableTextEditor({
[editor],
);

const handleVideoSelect = React.useCallback(
(item: MediaItem) => {
if (editor?.isEditable) {
const video = editor.schema.nodes.videoBlock!.create(mediaItemToVideoAttrs(item));
const position = pendingBlockInsertPosRef.current ?? undefined;
insertTopLevelBlock(editor, video, undefined, position);
editor.view.focus();
}
pendingBlockInsertPosRef.current = null;
setVideoPickerOpen(false);
},
[editor],
);

// Handle gallery insertion from the multi-select media picker
const handleGallerySelect = React.useCallback(
(items: MediaItem[]) => {
Expand Down Expand Up @@ -4602,6 +4628,20 @@ export function PortableTextEditor({
confirmLabel={t`Insert image`}
/>

<MediaPickerModal
open={videoPickerOpen}
onOpenChange={(open) => {
setVideoPickerOpen(open);
if (!open) pendingBlockInsertPosRef.current = null;
}}
onSelect={handleVideoSelect}
mimeTypeFilter="video/"
mediaKind="video"
localOnly
title={t`Select video`}
confirmLabel={t`Insert video`}
/>

{/* Multi-select media picker for gallery insertion */}
<MediaPickerModal
open={galleryPickerOpen}
Expand Down
37 changes: 6 additions & 31 deletions packages/admin/src/components/editor/ImageUploadExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ import { i18n } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { Extension } from "@tiptap/core";
import type { Node } from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";
import { createElement } from "react";
import { createRoot, type Root } from "react-dom/client";

import { createUploadPreviewUrl } from "../../lib/media-utils.js";
import { matchesMimeAllowlist } from "../../lib/mime-utils.js";
import { getMutationError } from "../DialogError.js";
import { isEmptyVideo } from "./VideoNode.js";

export interface ImageUploadOptions {
/** Uploads a file and resolves to the attributes of the image or video node to insert. */
Expand Down Expand Up @@ -137,13 +136,6 @@ function blockBoundary(doc: Node, pos: number) {
return pos <= ($pos.start(1) + $pos.end(1)) / 2 ? $pos.before(1) : $pos.after(1);
}

/** The range of the empty video block at `pos`, which files dropped or pasted on it replace. */
function emptyVideoAt(doc: Node, pos: number) {
const node = pos >= 0 ? doc.nodeAt(pos) : null;
if (node?.type.name !== "videoBlock" || !isEmptyVideo(node.attrs)) return undefined;
return { from: pos, to: pos + node.nodeSize };
}

function hasText(html: string) {
return Boolean(new DOMParser().parseFromString(html, "text/html").body.textContent?.trim());
}
Expand Down Expand Up @@ -231,24 +223,12 @@ export const ImageUploadExtension = Extension.create<ImageUploadOptions, ImageUp
releasePreview(id);
};

const start = (
view: EditorView,
files: File[],
dropPos: number,
emptyVideo?: { from: number; to: number },
) => {
const start = (view: EditorView, files: File[], dropPos: number) => {
const pos = blockBoundary(view.state.doc, dropPos);
const media = files.flatMap((file) => {
const kind = kindOf(view, file);
return kind ? [{ file, kind }] : [];
});
const tr = view.state.tr;
let pos: number;
if (emptyVideo && media.length > 0) {
tr.delete(emptyVideo.from, emptyVideo.to);
pos = emptyVideo.from;
} else {
pos = blockBoundary(view.state.doc, dropPos);
}
const uploads = media.map(({ file, kind }) => {
const id = ++nextId;
const previewUrl = kind === "image" ? createUploadPreviewUrl(file) : undefined;
Expand All @@ -265,7 +245,7 @@ export const ImageUploadExtension = Extension.create<ImageUploadOptions, ImageUp
: i18n._(msg`Only image files can be uploaded here.`),
});
}
view.dispatch(tr.setMeta(imageUploadKey, { add: placeholders } satisfies PlaceholderMeta));
dispatchMeta(view, { add: placeholders });

void (async () => {
for (const { file, kind, placeholder } of uploads) {
Expand Down Expand Up @@ -326,7 +306,7 @@ export const ImageUploadExtension = Extension.create<ImageUploadOptions, ImageUp
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
if (!coords) return false;
event.preventDefault();
start(view, files, coords.pos, emptyVideoAt(view.state.doc, coords.inside));
start(view, files, coords.pos);
return true;
},
handlePaste(view, event) {
Expand All @@ -337,12 +317,7 @@ export const ImageUploadExtension = Extension.create<ImageUploadOptions, ImageUp
const html = data.getData("text/html");
if (html && hasText(html)) return false;
event.preventDefault();
const { selection } = view.state;
const emptyVideo =
selection instanceof NodeSelection
? emptyVideoAt(view.state.doc, selection.from)
: undefined;
start(view, [...data.files], selection.from, emptyVideo);
start(view, [...data.files], view.state.selection.from);
return true;
},
},
Expand Down
Loading
Loading