diff --git a/.changeset/video-picker-first.md b/.changeset/video-picker-first.md new file mode 100644 index 0000000000..0ed89f56f9 --- /dev/null +++ b/.changeset/video-picker-first.md @@ -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. diff --git a/docs/src/content/docs/guides/working-with-content.mdx b/docs/src/content/docs/guides/working-with-content.mdx index 770aaf4fce..ebf5cae279 100644 --- a/docs/src/content/docs/guides/working-with-content.mdx +++ b/docs/src/content/docs/guides/working-with-content.mdx @@ -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. diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index f25ac67d07..86f53880a6 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -223,7 +223,6 @@ import { VideoExtension, isVideoBlock, mediaItemToVideoAttrs, - openPickerOnInsert, videoBlockFields, videoNodeAttrs, } from "./editor/VideoNode"; @@ -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, @@ -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`, @@ -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([]); @@ -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", @@ -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) => { @@ -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[]) => { @@ -4602,6 +4628,20 @@ export function PortableTextEditor({ confirmLabel={t`Insert image`} /> + { + 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 */} = 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()); } @@ -231,24 +223,12 @@ export const ImageUploadExtension = Extension.create { + 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; @@ -265,7 +245,7 @@ export const ImageUploadExtension = Extension.create { for (const { file, kind, placeholder } of uploads) { @@ -326,7 +306,7 @@ export const ImageUploadExtension = Extension.create): boolean { - return !attrs.mediaId && !attrs.src; -} - -interface VideoStorage { - openPickerOnMount: boolean; -} - -const videoStorage = (editor: Editor) => - (editor.storage as unknown as Record).videoBlock; - -/** Opens the picker of the empty video block the next insert adds, once it mounts. */ -export function openPickerOnInsert(editor: Editor) { - const storage = videoStorage(editor); - if (storage) storage.openPickerOnMount = true; -} - const STOP = "[data-video-stop]"; -const PLACEHOLDER = "[data-video-placeholder]"; /** Focus the node-selected video's first control, for keyboard users. */ function focusSelectedVideo(editor: Editor, type: NodeType): boolean { @@ -143,21 +122,6 @@ function focusSelectedVideo(editor: Editor, type: NodeType): boolean { return first !== null && document.activeElement === first; } -/** Open the picker of a node-selected empty video, as clicking it does. */ -function chooseSelectedVideo(editor: Editor, type: NodeType): boolean { - const { selection } = editor.state; - if (!(selection instanceof NodeSelection) || selection.node.type !== type) return false; - if (!isEmptyVideo(selection.node.attrs)) return false; - const dom = editor.view.nodeDOM(selection.from); - const placeholder = - dom instanceof HTMLElement ? dom.querySelector(PLACEHOLDER) : null; - placeholder?.click(); - return placeholder !== null; -} - -const isFileDrag = (event: { dataTransfer: DataTransfer | null }) => - Boolean(event.dataTransfer?.types.includes("Files")); - /** * Drags and drops go to ProseMirror, so dropped files reach the upload * extension and the block can be moved from the gutter handle. The player, @@ -212,9 +176,6 @@ function VideoNodeView({ const caption = isString(attrs.caption) ? attrs.caption : ""; const width = isDimension(attrs.width) ? attrs.width : undefined; const height = isDimension(attrs.height) ? attrs.height : undefined; - const empty = isEmptyVideo(attrs); - const [dropping, setDropping] = React.useState(false); - const dragDepth = React.useRef(0); const figureRef = React.useRef(null); // ProseMirror leaves keys on the player and buttons to the browser, whose own editing @@ -253,7 +214,7 @@ function VideoNodeView({ textarea.removeEventListener("dragover", keepTextDrag); textarea.removeEventListener("drop", keepTextDrag); }; - }, [editable, empty]); + }, [editable]); const selectBlock = (focus: boolean) => { const position = getPos(); @@ -306,15 +267,6 @@ function VideoNodeView({ deleteNode(); }; - // ProseMirror ignores keydown on the placeholder, so it removes the block itself rather - // than relying on the browser's editing. - const removeOnDeleteKey = (event: React.KeyboardEvent) => { - if (event.key !== "Backspace" && event.key !== "Delete") return; - if (event.nativeEvent.isComposing) return; - event.preventDefault(); - removeBlock(); - }; - // Selected, the pill stays visible while the picker is open, so the // picker can hand focus back to Replace when it closes. const openPicker = () => { @@ -322,35 +274,6 @@ function VideoNodeView({ setPickerOpen(true); }; - // Only an insert asks for the picker, never loading, undo or paste. - React.useEffect(() => { - const storage = videoStorage(editor); - const { selection } = editor.state; - if (!storage?.openPickerOnMount || !empty) return; - if (!(selection instanceof NodeSelection) || selection.from !== getPos()) return; - storage.openPickerOnMount = false; - setPickerOpen(true); - }, []); - - // Only the highlight: ProseMirror takes the drop, and the upload extension - // puts the dropped files in this block's place. - const dropHighlight = { - onDragEnter: (event: React.DragEvent) => { - if (!isFileDrag(event)) return; - dragDepth.current += 1; - setDropping(true); - }, - onDragLeave: (event: React.DragEvent) => { - if (!isFileDrag(event)) return; - dragDepth.current = Math.max(0, dragDepth.current - 1); - if (dragDepth.current === 0) setDropping(false); - }, - onDrop: () => { - dragDepth.current = 0; - setDropping(false); - }, - }; - const pillButtonClass = "h-7 w-7 pointer-coarse:h-11 pointer-coarse:w-11"; return ( @@ -361,146 +284,107 @@ function VideoNodeView({ onKeyDownCapture={handleEscape} onKeyDown={handleTab} > - {empty ? ( +
+
+ + {editable ? ( + // Inline-size containment keeps the caption's placeholder text from widening the block. +
+