Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/video-picker-first.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---

Updates `/video`, and **Video** in the add-block menu, to open the video picker first, as `/image` does. The video block is added once you choose a video, and closing the picker adds nothing. A video block saved without a video, such as one left empty by an earlier version, shows as unplayable, with **Replace video** to choose one and **Delete video** to remove it. Blocks chosen from a picker opened by the add-block menu also stay where the menu was if an upload finishes while the picker is open.
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