瀏覽代碼

Neo: editor media alignment and size, live embeds, upload errors, toolbar icons (#5813)

- Toolbar: alignment menu (all platforms), size menu S/M/L/XL for a selected
  image / video / embed (web), indent/outdent in one menu, Lucide icons for
  lists, checklist and code, styled letters for B/I/U/S and the quote;
  checklist and code moved after embed
- Post view: editor video and embeds keep the width and paragraph alignment
  set in the editor; video keeps its own aspect ratio
- Web editor: embeds show as embed cards instead of the preview image
- Inline image / video upload: a failed upload shows an error under the
  editor instead of vanishing
- DropdownMenu: optional minPopupWidth

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL 1 周之前
父節點
當前提交
f2f41e7acd

+ 2 - 2
neo/packages/app/components/elements/entity-text.js

@@ -248,8 +248,8 @@ function HtmlWithMedia({ html }) {
         return <Html data={clearLinks(html)} />
 
     return segments.map((s, i) => {
-        if (s.type === 'video') return <InlineVideo key={i} src={s.src} poster={s.poster} />
-        if (s.type === 'embed') return <InlineEmbed key={i} url={s.url} />
+        if (s.type === 'video') return <InlineVideo key={i} src={s.src} poster={s.poster} width={s.width} height={s.height} align={s.align} />
+        if (s.type === 'embed') return <InlineEmbed key={i} url={s.url} width={s.width} align={s.align} />
         return <Html key={i} data={clearLinks(s.html)} />
     })
 }

+ 25 - 7
neo/packages/app/components/form-fields/editor-inner-enriched.js

@@ -27,6 +27,7 @@ import { useIsDesktop } from 'app/context/measure'
 import { fitInlineImageSize, getEditorPasteImagesMode, revokePastedBlobUri } from 'app/lib/editor/editor-paste-images'
 import { pickEditorImages, pickEditorVideo } from 'app/lib/editor/pick-editor-images'
 import { uploadInlineVideo, toVideoPosterSrc } from 'app/lib/editor/inline-video'
+import { useEditorEmbedViews } from 'app/lib/editor/editor-embed-views'
 import { uploadInlineImage, swapEditorImageSrc } from 'app/lib/editor/upload-inline-image'
 import { trackFormUploadStart, trackFormUploadEnd } from 'app/lib/form/form-helpers'
 import { useFormInstanceId } from 'app/context/form-instance'
@@ -105,6 +106,8 @@ export default function RftTextEnriched(props) {
 
     // Web only; null on native and until the library has mounted TipTap.
     const tipTap = useTiptapEditor(containerRef)
+    // Web: embed images in the body show as embed cards (portals into their node views).
+    const embedViews = useEditorEmbedViews(tipTap)
 
     // Toolbar inputs — only tracked when there is a toolbar to feed.
     const [styleState, setStyleState] = useState(null)
@@ -254,9 +257,14 @@ export default function RftTextEnriched(props) {
         : 'off'
     const isInline = !isLimitedHtml && pasteImagesMode === 'inline'
 
+    // Why the last inline image/video didn't make it into the body (UNA's text when it gave one).
+    const [mediaError, setMediaError] = useState(null)
+    const showUploadError = useCallback((error) => setMediaError(error || t('Upload failed')), [t])
+
     const sendImagesToFiles = useCallback((images) => {
         if (!images?.length || pasteImagesMode === 'off') return
         if (isInline) {
+            setMediaError(null)
             images.forEach(async (img, index) => {
                 // Placeholder: the local blob:/file:// image right away, swapped for the
                 // uploaded URL (or dropped on failure). Submit waits on the pending upload.
@@ -265,8 +273,9 @@ export default function RftTextEnriched(props) {
                 trackFormUploadStart(form_name, formInstanceId, uploadId)
                 callEditor(editorRef, 'setImage', img.uri, size.width, size.height)
                 try {
-                    const src = await uploadInlineImage(img)
-                    await swapEditorImageSrc(editorRef, tipTap, img.uri, src)
+                    const { link, error } = await uploadInlineImage(img)
+                    await swapEditorImageSrc(editorRef, tipTap, img.uri, link)
+                    if (!link) showUploadError(error)
                 } finally {
                     trackFormUploadEnd(form_name, formInstanceId, uploadId)
                     revokePastedBlobUri(img.uri)
@@ -279,7 +288,7 @@ export default function RftTextEnriched(props) {
             images,
             inlinePaste: false,
         })
-    }, [form_name, formInstanceId, isInline, pasteImagesMode, tipTap])
+    }, [form_name, formInstanceId, isInline, pasteImagesMode, tipTap, showUploadError])
 
     const onInsertImage = useCallback(async () => {
         const images = await pickEditorImages()
@@ -295,20 +304,23 @@ export default function RftTextEnriched(props) {
         if (!video) return
         const uploadId = `inline-video-${Date.now()}`
         trackFormUploadStart(form_name, formInstanceId, uploadId)
+        setMediaError(null)
         setVideoProgress(0)
         try {
-            const res = await uploadInlineVideo(video, setVideoProgress)
-            if (res?.link) {
+            const { link, error } = await uploadInlineVideo(video, setVideoProgress)
+            if (link) {
                 const size = video.width && video.height
                     ? fitInlineImageSize(video.width, video.height)
                     : { width: 720, height: 405 }
-                callEditor(editorRef, 'setImage', toVideoPosterSrc(res.link), size.width, size.height)
+                callEditor(editorRef, 'setImage', toVideoPosterSrc(link), size.width, size.height)
+            } else {
+                showUploadError(error)
             }
         } finally {
             setVideoProgress(null)
             trackFormUploadEnd(form_name, formInstanceId, uploadId)
         }
-    }, [form_name, formInstanceId])
+    }, [form_name, formInstanceId, showUploadError])
 
     // Library onPasteImages: blob: on web, file:// on native.
     const onPasteImages = useCallback((e) => {
@@ -460,6 +472,7 @@ export default function RftTextEnriched(props) {
                 </EnrichedEditorBoundary>
 
 
+                {embedViews}
                 {isToolBar ? (
                     <EditorToolbar items={toolbarItems} linkBar={linkBar} />
                 ) : null}
@@ -468,6 +481,11 @@ export default function RftTextEnriched(props) {
                         {t('Uploading video')} {Math.round(videoProgress * 100)}%
                     </Text>
                 ) : null}
+                {mediaError && videoProgress == null ? (
+                    <Text className="text-xs text-destructive mt-1" accessibilityRole="alert">
+                        {mediaError}
+                    </Text>
+                ) : null}
             </View>
         </View>
     )

+ 8 - 1
neo/packages/app/default/translations/de.json

@@ -515,5 +515,12 @@
     "date_weekday_short_3": "Mi",
     "date_weekday_short_4": "Do",
     "date_weekday_short_5": "Fr",
-    "date_weekday_short_6": "Sa"
+    "date_weekday_short_6": "Sa",
+    "Alignment": "Ausrichtung",
+    "Align left": "Linksbündig",
+    "Align center": "Zentriert",
+    "Align right": "Rechtsbündig",
+    "Indentation": "Einzug",
+    "Indent": "Einzug vergrößern",
+    "Outdent": "Einzug verkleinern"
 }

+ 8 - 1
neo/packages/app/default/translations/es.json

@@ -515,5 +515,12 @@
     "date_weekday_short_3": "mi",
     "date_weekday_short_4": "ju",
     "date_weekday_short_5": "vi",
-    "date_weekday_short_6": "sá"
+    "date_weekday_short_6": "sá",
+    "Alignment": "Alineación",
+    "Align left": "Alinear a la izquierda",
+    "Align center": "Centrar",
+    "Align right": "Alinear a la derecha",
+    "Indentation": "Sangría",
+    "Indent": "Aumentar sangría",
+    "Outdent": "Reducir sangría"
 }

+ 8 - 1
neo/packages/app/default/translations/fr.json

@@ -515,5 +515,12 @@
     "date_weekday_short_3": "me",
     "date_weekday_short_4": "je",
     "date_weekday_short_5": "ve",
-    "date_weekday_short_6": "sa"
+    "date_weekday_short_6": "sa",
+    "Alignment": "Alignement",
+    "Align left": "Aligner à gauche",
+    "Align center": "Centrer",
+    "Align right": "Aligner à droite",
+    "Indentation": "Retrait",
+    "Indent": "Augmenter le retrait",
+    "Outdent": "Diminuer le retrait"
 }

+ 8 - 1
neo/packages/app/default/translations/ru.json

@@ -517,5 +517,12 @@
     "date_weekday_short_3": "Ср",
     "date_weekday_short_4": "Чт",
     "date_weekday_short_5": "Пт",
-    "date_weekday_short_6": "Сб"
+    "date_weekday_short_6": "Сб",
+    "Alignment": "Выравнивание",
+    "Align left": "По левому краю",
+    "Align center": "По центру",
+    "Align right": "По правому краю",
+    "Indentation": "Отступ",
+    "Indent": "Увеличить отступ",
+    "Outdent": "Уменьшить отступ"
 }

+ 6 - 0
neo/packages/app/lib/editor/editor-embed-views.tsx

@@ -0,0 +1,6 @@
+import type { ReactNode } from 'react'
+
+/** Native: the editor draws embeds as their preview image (no node views to swap). */
+export function useEditorEmbedViews(_tipTap: any): ReactNode {
+    return null
+}

+ 99 - 0
neo/packages/app/lib/editor/editor-embed-views.web.tsx

@@ -0,0 +1,99 @@
+'use client'
+import { useEffect, useState, type ComponentType, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import { useFetch } from 'app/lib/hooks/use-fetch'
+import { appSetting } from 'app/lib/util'
+import EmbedJs from 'app/ui/molecules/content/embed'
+import { embedUrlFromImageSrc } from './inline-video'
+
+const Embed = EmbedJs as ComponentType<{ data: Record<string, any> }>
+
+type EmbedView = { id: number; dom: HTMLElement; url: string; image: string }
+
+let nextViewId = 0
+
+/** Embed card as the post shows it; the preview image until UNA answers (or can't embed). */
+function EditorEmbedCard({ url, image }: { url: string; image: string }) {
+    const { data: response } = useFetch(`/api.php?r=${appSetting('urls', 'embeds_new')}${encodeURIComponent(url)}`)
+    if (response?.data) return <Embed data={{ url, ...response.data }} />
+    return <img src={image} alt="" style={{ display: 'block', width: '100%', height: 'auto' }} />
+}
+
+/**
+ * Web: draw body embeds (images with a `#neo-embed=` src, see toEmbedImageSrc) as embed
+ * cards in the editor. The image node view is swapped through TipTap's node view getter,
+ * so the HTML stays the same image; each card is a portal rendered by the editor (theme,
+ * fetch cache). Cards ignore the pointer: a click selects the node like an image.
+ */
+export function useEditorEmbedViews(tipTap: any): ReactNode {
+    const [views, setViews] = useState<EmbedView[]>([])
+
+    useEffect(() => {
+        const manager = tipTap?.extensionManager
+        if (!manager || tipTap.isDestroyed) return undefined
+        const baseGet = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(manager), 'nodeViews')?.get
+        if (!baseGet) return undefined
+
+        // Node views are built inside ProseMirror updates, which may run in a React render.
+        const upsert = (view: EmbedView) => queueMicrotask(() => setViews((list) => [...list.filter((v) => v.id !== view.id), view]))
+        const remove = (id: number) => queueMicrotask(() => setViews((list) => list.filter((v) => v.id !== id)))
+
+        const createEmbedView = (initial: any) => {
+            const id = ++nextViewId
+            const dom = document.createElement('span')
+            dom.contentEditable = 'false'
+            dom.style.display = 'inline-block'
+            dom.style.verticalAlign = 'text-bottom'
+            dom.style.maxWidth = '100%'
+            dom.style.borderRadius = '8px'
+            const render = (node: any) => {
+                dom.style.width = `${Number(node.attrs?.width) || 720}px`
+                upsert({ id, dom, url: embedUrlFromImageSrc(node.attrs?.src)!, image: String(node.attrs?.src).split('#')[0]! })
+            }
+            render(initial)
+            return {
+                dom,
+                update: (node: any) => {
+                    if (node.type !== initial.type || !embedUrlFromImageSrc(node.attrs?.src)) return false
+                    render(node)
+                    return true
+                },
+                selectNode: () => { dom.style.outline = '2px solid Highlight' },
+                deselectNode: () => { dom.style.outline = '' },
+                ignoreMutation: () => true,
+                destroy: () => remove(id),
+            }
+        }
+
+        Object.defineProperty(manager, 'nodeViews', {
+            configurable: true,
+            get() {
+                const nodeViews = baseGet.call(this)
+                const image = nodeViews?.image
+                if (!image) return nodeViews
+                return {
+                    ...nodeViews,
+                    image: (node: any, ...rest: any[]) =>
+                        embedUrlFromImageSrc(node.attrs?.src) ? createEmbedView(node) : image(node, ...rest),
+                }
+            },
+        })
+        tipTap.createNodeViews?.()
+
+        return () => {
+            delete manager.nodeViews
+            if (!tipTap.isDestroyed) tipTap.createNodeViews?.()
+            setViews([])
+        }
+    }, [tipTap])
+
+    return views.map((view) =>
+        createPortal(
+            <span style={{ display: 'block', pointerEvents: 'none' }}>
+                <EditorEmbedCard url={view.url} image={view.image} />
+            </span>,
+            view.dom,
+            String(view.id),
+        ),
+    )
+}

+ 204 - 40
neo/packages/app/lib/editor/editor-toolbar.tsx

@@ -4,7 +4,7 @@
  * Shared UI + enriched adapter. Tentap adapter lives in `editor-toolbar-tentap.ts`
  * so enriched does not pull `@10play/tentap-editor`.
  */
-import { useState, useCallback, useMemo } from 'react'
+import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
 import { Platform, TextInput } from 'react-native'
 import { View } from 'app/design/view'
 import { NeoButton } from 'app/design/controls'
@@ -14,6 +14,7 @@ import { getTiptapEditorFromContainer } from 'app/lib/editor/comment-editor-keyb
 import { useTranslation } from 'react-i18next'
 import { toEmbedImageSrc } from 'app/lib/editor/inline-video'
 import { UNA_URL } from 'app/config'
+import DropdownMenu from 'app/ui/atoms/dropdown-menu'
 
 export const EDITOR_TOOLBAR_COMMAND_IDS = [
     'bold',
@@ -30,32 +31,50 @@ export const EDITOR_TOOLBAR_COMMAND_IDS = [
     'outdent',
     'undo',
     'redo',
+    'align',
     'image',
     'video',
 ]
 
+/** Accessible names of the toolbar buttons (every button shows an icon). */
 export const COMMAND_LABELS: Record<string, string> = {
-    bold: 'B',
-    italic: 'I',
+    bold: 'Bold',
+    italic: 'Italic',
     link: 'Link',
-    checklist: '☐',
-    code: '</>',
-    underline: 'U',
-    strikethrough: 'S',
-    quote: '❝',
-    bullet: '• List',
-    ordered: '1. List',
-    indent: '→',
-    outdent: '←',
-    undo: '↶',
-    redo: '↷',
+    checklist: 'Checklist',
+    code: 'Code',
+    underline: 'Underline',
+    strikethrough: 'Strikethrough',
+    quote: 'Quote',
+    bullet: 'Bulleted list',
+    ordered: 'Numbered list',
+    indent: 'Indent',
+    outdent: 'Outdent',
+    undo: 'Undo',
+    redo: 'Redo',
     image: 'Image',
     video: 'Video',
     embed: 'Embed',
+    align: 'Align',
+}
+
+/** Formatting buttons show their letter in that style (Lucide's letter icons look cramped at mini size). */
+const COMMAND_GLYPHS: Record<string, { text: string; className: string }> = {
+    bold: { text: 'B', className: 'font-bold' },
+    italic: { text: 'I', className: 'font-normal italic font-serif' },
+    underline: { text: 'U', className: 'font-normal underline' },
+    strikethrough: { text: 'S', className: 'font-normal line-through' },
+    quote: { text: '“', className: 'font-bold font-serif text-xl leading-none' },
 }
 
 /** Lucide icons shown instead of the text label (the label stays as the a11y name). */
 export const COMMAND_ICONS: Record<string, string> = {
+    bullet: 'List',
+    ordered: 'ListOrdered',
+    indent: 'ListIndentIncrease',
+    outdent: 'ListIndentDecrease',
+    undo: 'Undo2',
+    redo: 'Redo2',
     checklist: 'ListTodo',
     code: 'Braces',
     image: 'Image',
@@ -63,10 +82,72 @@ export const COMMAND_ICONS: Record<string, string> = {
     embed: 'CodeXml',
 }
 
+/** Paragraph alignments of the align menu; `left` clears it (`auto`). */
+const ALIGNMENTS = [
+    { id: 'left', title: 'Align left', icon: 'TextAlignStart' },
+    { id: 'center', title: 'Align center', icon: 'TextAlignCenter' },
+    { id: 'right', title: 'Align right', icon: 'TextAlignEnd' },
+]
+
+/** Width presets for a selected body image / video / embed (web); inserted media is at most 720px. */
+const MEDIA_SIZES = [
+    { id: 'size-s', label: 'S', width: 240 },
+    { id: 'size-m', label: 'M', width: 360 },
+    { id: 'size-l', label: 'L', width: 540 },
+    { id: 'size-xl', label: 'XL', width: 720 },
+]
+
+type SelectedMedia = { pos: number; width: number; height: number }
+
+/** Menu row: text only (the trigger shows the icon); `selected` marks the current choice. */
+type ToolbarMenuItem = { id: string; title: string; selected?: boolean }
+
+/** Toolbar item: a button, or (with `menu`) a dropdown whose choice goes to `onSelect`. */
+type ToolbarItem = {
+    id: string
+    disabled?: boolean
+    active?: boolean
+    onPress?: () => void
+    label?: string
+    icon?: string
+    menu?: ToolbarMenuItem[]
+    onSelect?: (id: string) => void
+}
+
+/** Web: the image node TipTap has selected (a click on it), `null` otherwise. */
+function useSelectedMedia(tipTap: any): SelectedMedia | null {
+    const [media, setMedia] = useState<SelectedMedia | null>(null)
+    const lastKey = useRef('')
+
+    useEffect(() => {
+        if (!tipTap?.on) return undefined
+        const update = () => {
+            const sel = tipTap.state?.selection
+            const node = sel?.node
+            const next = node?.type?.name?.toLowerCase().includes('image')
+                ? { pos: sel.from, width: Number(node.attrs?.width) || 0, height: Number(node.attrs?.height) || 0 }
+                : null
+            const key = next ? `${next.pos}:${next.width}:${next.height}` : ''
+            if (key === lastKey.current) return
+            lastKey.current = key
+            setMedia(next)
+        }
+        update()
+        tipTap.on('selectionUpdate', update)
+        tipTap.on('update', update)
+        return () => {
+            tipTap.off('selectionUpdate', update)
+            tipTap.off('update', update)
+        }
+    }, [tipTap])
+
+    return media
+}
+
 /**
  * Flat Enriched-style toolbar (+ optional inline link bar).
  */
-export function EditorToolbar({ items = [], linkBar, className }: { items?: any[]; linkBar?: any; className?: string }) {
+export function EditorToolbar({ items = [], linkBar, className }: { items?: ToolbarItem[]; linkBar?: any; className?: string }) {
     // Keep editor selection when clicking toolbar chrome (not the link URL input).
     const onMouseDown =
         Platform.OS === 'web'
@@ -98,11 +179,14 @@ export function EditorToolbar({ items = [], linkBar, className }: { items?: any[
             onMouseDown={onMouseDown}
             className={cn('flex-row flex-wrap items-center gap-2 mt-2', className)}
         >
-            {items.map((item) => (
+            {items.map((item) => item.menu ? (
+                <ToolbarMenu key={item.id} item={item} />
+            ) : (
                 <ToolbarButton
                     key={item.id}
                     label={item.label ?? COMMAND_LABELS[item.id] ?? item.id}
-                    icon={COMMAND_ICONS[item.id]}
+                    icon={item.icon ?? COMMAND_ICONS[item.id]}
+                    glyph={COMMAND_GLYPHS[item.id]}
                     active={!!item.active}
                     disabled={!!item.disabled}
                     onPress={item.onPress}
@@ -112,14 +196,44 @@ export function EditorToolbar({ items = [], linkBar, className }: { items?: any[
     )
 }
 
-function ToolbarButton({ label, icon, active, disabled, onPress }: { label: string; icon?: string; active?: boolean; disabled?: boolean; onPress?: () => void }) {
+/** Compact toolbar menus: short labels, no need for the default 256px popup. */
+const MENU_MIN_WIDTH = 160
+
+function ToolbarMenu({ item }: { item: ToolbarItem }) {
+    const label = item.label ?? COMMAND_LABELS[item.id] ?? item.id
+    const icon = item.icon ?? COMMAND_ICONS[item.id]
+    return (
+        <DropdownMenu
+            items={item.menu!}
+            onSelect={(choice) => item.onSelect?.(String(choice.id))}
+            mode="popup"
+            variant="tabs-overflow"
+            tabsOverflowSize="sm"
+            minPopupWidth={MENU_MIN_WIDTH}
+            buttonProps={{
+                style: 'borderless',
+                controlSize: 'mini',
+                selected: !!item.active,
+                disabled: !!item.disabled,
+                haptics: false,
+                ...(icon ? { image: icon, accessibilityLabel: label } : { label }),
+            }}
+            triggerAccessibilityLabel={label}
+        />
+    )
+}
+
+function ToolbarButton({ label, icon, glyph, active, disabled, onPress }: { label: string; icon?: string; glyph?: { text: string; className: string }; active?: boolean; disabled?: boolean; onPress?: () => void }) {
+    const content = glyph
+        ? { label: glyph.text, accessibilityLabel: label, classNames: { text: cn('text-base', glyph.className) }, expoUI: false }
+        : icon ? { image: icon, accessibilityLabel: label } : { label }
     return (
         <NeoButton
             style="borderless"
             controlSize="mini"
             selected={!!active}
             disabled={!!disabled}
-            {...(icon ? { image: icon, accessibilityLabel: label } : { label })}
+            {...content}
             onPress={onPress}
             haptics={false}
         />
@@ -202,6 +316,8 @@ export function useEnrichedToolbar({
         // eslint-disable-next-line react-hooks/exhaustive-deps
     }, [isWeb, enabled, tipTapProp, styleState, containerRef])
 
+    const selectedMedia = useSelectedMedia(tipTap)
+
     const closeLink = useCallback(() => setLinkOpen(false), [])
 
     const submitLink = useCallback((url: string) => {
@@ -257,7 +373,7 @@ export function useEnrichedToolbar({
         const canSink = !!(tip?.can?.()?.sinkListItem?.('listItem'))
         const canLift = !!(tip?.can?.()?.liftListItem?.('listItem'))
 
-        const defs: Array<{ id: string; disabled?: boolean; active?: boolean; onPress: () => void; label?: string }> = [
+        const defs: ToolbarItem[] = [
             {
                 id: 'bold',
                 active: s.bold?.isActive,
@@ -286,18 +402,6 @@ export function useEnrichedToolbar({
             //         }
             //     },
             // },
-            {
-                id: 'checklist',
-                active: s.checkboxList?.isActive,
-                disabled: s.checkboxList?.isBlocking,
-                onPress: run(() => ed()?.toggleCheckboxList?.(false)),
-            },
-            {
-                id: 'code',
-                active: s.inlineCode?.isActive,
-                disabled: s.inlineCode?.isBlocking,
-                onPress: run(() => ed()?.toggleInlineCode?.()),
-            },
             {
                 id: 'underline',
                 active: s.underline?.isActive,
@@ -330,6 +434,20 @@ export function useEnrichedToolbar({
             },
         ]
 
+        // Paragraph alignment; body media sit in their own paragraph, so it places them too.
+        const align = ['center', 'right'].includes(s.alignment) ? s.alignment : 'left'
+        defs.push({
+            id: 'align',
+            label: t('Alignment'),
+            icon: ALIGNMENTS.find((a) => a.id === align)?.icon,
+            active: align !== 'left',
+            menu: ALIGNMENTS.map((a) => ({ id: a.id, title: t(a.title), selected: a.id === align })),
+            onSelect: (id) => {
+                ed()?.setTextAlignment?.(id === 'left' ? 'auto' : id)
+                ed()?.focus?.()
+            },
+        })
+
         // Picker → upload → inserted into the body (handlers live in the editor).
         if (onInsertImage) {
             defs.push({ id: 'image', onPress: onInsertImage })
@@ -341,18 +459,64 @@ export function useEnrichedToolbar({
             defs.push({ id: 'embed', onPress: () => setEmbedOpen(true) })
         }
 
+        defs.push(
+            {
+                id: 'checklist',
+                active: s.checkboxList?.isActive,
+                disabled: s.checkboxList?.isBlocking,
+                onPress: run(() => ed()?.toggleCheckboxList?.(false)),
+            },
+            {
+                id: 'code',
+                active: s.inlineCode?.isActive,
+                disabled: s.inlineCode?.isBlocking,
+                onPress: run(() => ed()?.toggleInlineCode?.()),
+            },
+        )
+
+        // Size of the selected body media: width preset, height keeps the ratio (web only).
+        if (tip && selectedMedia) {
+            const { pos, width, height } = selectedMedia
+            const current = MEDIA_SIZES.find((size) => size.width === width)
+            defs.push({
+                id: 'size',
+                label: current?.label,
+                icon: current ? undefined : 'Scaling',
+                menu: MEDIA_SIZES.map((size) => ({ id: size.id, title: `${size.label} · ${size.width}px`, selected: size.width === width })),
+                onSelect: (id) => {
+                    const size = MEDIA_SIZES.find((item) => item.id === id)
+                    if (!size) return
+                    tip.chain().focus().command(({ tr }: any) => {
+                        const node = tr.doc.nodeAt(pos)
+                        if (!node) return false
+                        tr.setNodeMarkup(pos, undefined, {
+                            ...node.attrs,
+                            width: size.width,
+                            height: width && height ? Math.round(size.width * height / width) : node.attrs.height,
+                        })
+                        return true
+                    }).setNodeSelection(pos).run()
+                },
+            })
+        }
+
         // Indent / outdent / undo / redo via TipTap on web only.
         if (isWeb) {
             defs.push(
                 {
+                    // List item nesting: only the moves possible at the caret.
                     id: 'indent',
-                    disabled: !canSink,
-                    onPress: run(() => tip?.chain?.().focus().sinkListItem('listItem').run()),
-                },
-                {
-                    id: 'outdent',
-                    disabled: !canLift,
-                    onPress: run(() => tip?.chain?.().focus().liftListItem('listItem').run()),
+                    label: t('Indentation'),
+                    disabled: !canSink && !canLift,
+                    menu: [
+                        ...(canSink ? [{ id: 'indent', title: t('Indent') }] : []),
+                        ...(canLift ? [{ id: 'outdent', title: t('Outdent') }] : []),
+                    ],
+                    onSelect: (id) => {
+                        const chain = tip?.chain?.().focus()
+                        if (id === 'indent') chain?.sinkListItem('listItem').run()
+                        else chain?.liftListItem('listItem').run()
+                    },
                 },
                 {
                     id: 'undo',
@@ -368,7 +532,7 @@ export function useEnrichedToolbar({
         }
 
         return defs
-    }, [enabled, styleState, editorRef, tipTap, isWeb, selection, onInsertImage, onInsertVideo, embeds])
+    }, [enabled, styleState, editorRef, tipTap, isWeb, selection, selectedMedia, onInsertImage, onInsertVideo, embeds, t])
 
     const linkBar = linkOpen || embedOpen
         ? {

+ 44 - 9
neo/packages/app/lib/editor/inline-video.ts

@@ -1,6 +1,7 @@
 import { uploadImage } from 'app/lib/util/upload'
 import { genRnd } from 'app/lib/util/misc'
 import { revokePastedBlobUri } from './editor-paste-images'
+import { inlineUploadError, type InlineUploadResult } from './upload-inline-image'
 
 /** UNA editor video storage + transcoders (`sys_videos_editor*`, see UNA install SQL). */
 const VIDEO_STORAGE = 'sys_videos_editor'
@@ -14,7 +15,7 @@ const VIDEO_POSTER = 'sys_videos_editor_poster'
 export function uploadInlineVideo(
     video: { uri: string; fileName?: string; mimeType?: string },
     onProgress?: (fraction: number) => void,
-): Promise<{ link: string; poster: string } | null> {
+): Promise<InlineUploadResult> {
     const url = '/api.php?r=system/get_data_api/TemplUploaderServices/&params[]='
         + `&a=upload_inline&uo=sys_html5&so=${VIDEO_STORAGE}&o=${VIDEO_STORAGE}`
         + `&vt=${VIDEO_MP4}&pt=${VIDEO_POSTER}&uid=${genRnd(8)}`
@@ -25,14 +26,15 @@ export function uploadInlineVideo(
             url,
             ({ result }) => {
                 const data = result?.data || result
-                resolve(data?.link ? { link: data.link, poster: data.poster || '' } : null)
+                if (!data?.link) console.warn('[editor] inline video upload rejected', result)
+                resolve({ link: data?.link || null, error: data?.link ? null : inlineUploadError(data) })
             },
             { fileName: video.fileName, mimeType: video.mimeType },
             { onProgress },
         )
             .catch((err) => {
                 console.warn('[editor] inline video upload failed', err)
-                resolve(null)
+                resolve({ link: null, error: null })
             })
             .finally(() => revokePastedBlobUri(video.uri))
     })
@@ -40,8 +42,13 @@ export function uploadInlineVideo(
 
 export type InlineSegment =
     | { type: 'html'; html: string }
-    | { type: 'video'; src: string; poster: string }
-    | { type: 'embed'; url: string }
+    | ({ type: 'video'; src: string; poster: string } & InlineMediaLayout)
+    | ({ type: 'embed'; url: string } & InlineMediaLayout)
+
+export type InlineMediaAlign = 'left' | 'center' | 'right'
+
+/** Size and paragraph alignment the editor gave the media image (`width`/`height`, `text-align`). */
+export type InlineMediaLayout = { width?: number; height?: number; align?: InlineMediaAlign }
 
 /*
  * Video and embeds go into the body as images — the only atomic node of the editors on
@@ -62,6 +69,18 @@ export function toVideoPosterSrc(mp4Link: string) {
     return mp4Link.replace(`o=${VIDEO_MP4}&`, `o=${VIDEO_POSTER}&`)
 }
 
+/** The embedded URL an editor image src carries (`toEmbedImageSrc`), `null` for a plain image. */
+export function embedUrlFromImageSrc(src: string | null | undefined): string | null {
+    const hash = src ? src.indexOf(`#${EMBED_MARK}`) : -1
+    if (!src || hash < 0) return null
+    try {
+        const url = decodeURIComponent(src.slice(hash + 1 + EMBED_MARK.length))
+        return isHttpUrl(url) ? url : null
+    } catch {
+        return null
+    }
+}
+
 const decodeAmp = (s: string) => s.replace(/&amp;/g, '&')
 
 /** Only http(s) URLs may be embedded: anything else (`javascript:`, `data:`…) is dropped. */
@@ -77,10 +96,25 @@ export function isHttpUrl(url: string | null | undefined): boolean {
 
 const embedSegment = (url: string): InlineSegment | null => (isHttpUrl(url) ? { type: 'embed', url } : null)
 
-const IMG = '<img\\b[^>]*\\bsrc="([^"]+)"[^>]*>'
+const IMG = '(<img\\b[^>]*\\bsrc="([^"]+)"[^>]*>)'
 // Legacy (link) format of the first builds: a paragraph with only a marked link.
 const LINK = '<a\\b[^>]*\\bhref="([^"]+)"[^>]*>[^<]*</a>'
-const MEDIA = new RegExp(`<p[^>]*>\\s*${IMG}\\s*</p>|${IMG}|<p[^>]*>\\s*${LINK}\\s*</p>`, 'gi')
+// Groups: 1 paragraph attributes, 2-3 its image tag and src, 4-5 a bare image tag and src, 6 a legacy link.
+const MEDIA = new RegExp(`<p([^>]*)>\\s*${IMG}\\s*</p>|${IMG}|<p[^>]*>\\s*${LINK}\\s*</p>`, 'gi')
+
+const dimension = (tag: string, name: string) => {
+    const value = Number(tag.match(new RegExp(`\\b${name}="(\\d+)"`, 'i'))?.[1])
+    return value > 0 ? value : undefined
+}
+
+function toLayout(imgTag: string | undefined, pAttrs: string | undefined): InlineMediaLayout {
+    const align = pAttrs?.match(/text-align\s*:\s*(left|center|right)/i)?.[1]?.toLowerCase()
+    return {
+        width: imgTag ? dimension(imgTag, 'width') : undefined,
+        height: imgTag ? dimension(imgTag, 'height') : undefined,
+        align: align as InlineMediaAlign | undefined,
+    }
+}
 
 function toSegment(rawSrc: string | undefined, isLink: boolean): InlineSegment | null {
     if (!rawSrc) return null
@@ -113,8 +147,9 @@ export function splitInlineMedia(html: string | null | undefined): InlineSegment
     const segments: InlineSegment[] = []
     let last = 0
     for (const match of html.matchAll(MEDIA)) {
-        const segment = toSegment(match[1] || match[2] || match[3], !!match[3])
-        if (!segment) continue
+        const found = toSegment(match[3] || match[5] || match[6], !!match[6])
+        if (!found) continue
+        const segment = found.type === 'html' ? found : { ...found, ...toLayout(match[2] || match[4], match[1]) }
         const index = match.index ?? 0
         if (index > last) segments.push({ type: 'html', html: html.slice(last, index) })
         segments.push(segment)

+ 17 - 4
neo/packages/app/lib/editor/upload-inline-image.ts

@@ -3,16 +3,25 @@ import { genRnd } from 'app/lib/util/misc'
 
 const INLINE_STORAGE = 'sys_images_editor'
 
+/** `upload_inline` outcome: the file link, or UNA's error text (`null` when it gave none). */
+export type InlineUploadResult = { link: string | null; error: string | null }
+
+/** UNA's text from a failed `upload_inline` answer; `null` for a bare `'1'` or a non-JSON reply. */
+export function inlineUploadError(data: any): string | null {
+    const error = data?.error
+    return typeof error === 'string' && error !== '' && error !== '1' ? error : null
+}
+
 /**
  * Upload one body image the way UNA's Quill does (`storage.php?a=upload`): straight into
  * the public editor storage with the ghost record dropped, so the URL is permanent and the
- * file never becomes a post attachment. Resolves the image URL, or `null` on failure.
+ * file never becomes a post attachment.
  */
 export function uploadInlineImage(image: {
     uri: string
     fileName?: string
     mimeType?: string
-}): Promise<string | null> {
+}): Promise<InlineUploadResult> {
     const url = '/api.php?r=system/get_data_api/TemplUploaderServices/&params[]='
         + '&a=upload_inline&uo=sys_html5'
         + `&so=${INLINE_STORAGE}&o=${INLINE_STORAGE}&t=${INLINE_STORAGE}&uid=${genRnd(8)}`
@@ -21,12 +30,16 @@ export function uploadInlineImage(image: {
         uploadImage(
             image.uri,
             url,
-            ({ result }) => resolve(result?.data?.link || result?.link || null),
+            ({ result }) => {
+                const data = result?.data || result
+                if (!data?.link) console.warn('[editor] inline image upload rejected', result)
+                resolve({ link: data?.link || null, error: data?.link ? null : inlineUploadError(data) })
+            },
             { fileName: image.fileName, mimeType: image.mimeType },
         )
             .catch((err) => {
                 console.warn('[editor] inline image upload failed', err)
-                resolve(null)
+                resolve({ link: null, error: null })
             })
     })
 }

+ 6 - 1
neo/packages/app/ui/atoms/dropdown-menu.tsx

@@ -66,6 +66,8 @@ export type DropdownMenuProps = {
     triggerStyle?: StyleProp<ViewStyle>;
     /** Sectioned menus: one column instead of side-by-side sections. */
     stacked?: boolean;
+    /** Popup min width (px) of a plain list menu; 256 by default. */
+    minPopupWidth?: number;
 };
 
 type DropdownMenuPopupProps = Omit<DropdownMenuProps, 'mode' | 'title' | 'cancelable'>;
@@ -143,6 +145,7 @@ function DropdownMenuPopup({
     triggerClassName,
     triggerStyle,
     stacked = false,
+    minPopupWidth = 256,
 }: DropdownMenuPopupProps) {
     const DropdownMenuItem = getDropdownMenuItem();
     const redirectdRef = useRef<RedirectHandle | null>(null);
@@ -274,7 +277,7 @@ function DropdownMenuPopup({
                         ? gridLayout!.popupWidth
                         : useSectionsLayout
                             ? sectionsMinPopupWidth
-                            : 256
+                            : minPopupWidth
                 }
                 maxPopupWidth={
                     isGridLayout
@@ -566,6 +569,7 @@ export default function DropdownMenu({
     triggerClassName,
     triggerStyle,
     stacked = false,
+    minPopupWidth,
 }: DropdownMenuProps) {
     const isWeb = Platform.OS === 'web';
 
@@ -591,6 +595,7 @@ export default function DropdownMenu({
                 triggerClassName={triggerClassName}
                 triggerStyle={triggerStyle}
                 stacked={stacked}
+                minPopupWidth={minPopupWidth}
             />
         );
     }

+ 6 - 6
neo/packages/app/ui/molecules/content/inline-embed.js

@@ -1,12 +1,12 @@
 import { useFetch } from 'app/lib/hooks/use-fetch'
 import { appSetting } from 'app/lib/util'
 import { isHttpUrl } from 'app/lib/editor/inline-video'
-import { View } from 'app/design/view'
 import Link from 'app/ui/atoms/link'
 import Embed from 'app/ui/molecules/content/embed'
+import InlineMediaFrame from 'app/ui/molecules/content/inline-media-frame'
 
 /** Embed-button link in a post body: UNA embed card / player (same API as the `embed` form field). */
-export default function InlineEmbed({ url }) {
+export default function InlineEmbed({ url, width, align }) {
     const safe = isHttpUrl(url)
     // Encoded: the URL is a query value (`params[]=`), its own `&` would split it.
     const { data: response } = useFetch(safe ? `/api.php?r=${appSetting('urls', 'embeds_new')}${encodeURIComponent(url)}` : null)
@@ -16,14 +16,14 @@ export default function InlineEmbed({ url }) {
     // Until UNA answers (or when it can't embed the URL) keep the plain link.
     if (!response?.data)
         return (
-            <View className='my-3'>
+            <InlineMediaFrame width={width} align={align}>
                 <Link href={url} asExternal>{url}</Link>
-            </View>
+            </InlineMediaFrame>
         )
 
     return (
-        <View className='w-full my-3'>
+        <InlineMediaFrame width={width} align={align}>
             <Embed data={{ url, ...response.data }} />
-        </View>
+        </InlineMediaFrame>
     )
 }

+ 32 - 0
neo/packages/app/ui/molecules/content/inline-media-frame.tsx

@@ -0,0 +1,32 @@
+import type { ReactNode } from 'react'
+import { View } from 'app/design/view'
+import { cn } from 'app/lib/util'
+import type { InlineMediaLayout } from 'app/lib/editor/inline-video'
+
+const ALIGN_CLASS = {
+    left: 'items-start',
+    center: 'items-center',
+    right: 'items-end',
+} as const
+
+type InlineMediaFrameProps = InlineMediaLayout & {
+    /** Keep the editor's width/height ratio (video); off for cards that size themselves (embed). */
+    keepRatio?: boolean
+    className?: string
+    children: ReactNode
+}
+
+/**
+ * Body video / embed box: the width the editor gave the image (never wider than the
+ * column) at its paragraph's alignment. No width — the whole column, as before.
+ */
+export default function InlineMediaFrame({ width, height, align, keepRatio, className, children }: InlineMediaFrameProps) {
+    const aspectRatio = keepRatio ? (width && height ? width / height : 16 / 9) : undefined
+    return (
+        <View className={cn('w-full my-3', ALIGN_CLASS[align || 'left'])}>
+            <View className={cn('max-w-full', !width && 'w-full', className)} style={{ width, aspectRatio }}>
+                {children}
+            </View>
+        </View>
+    )
+}

+ 4 - 3
neo/packages/app/ui/molecules/content/inline-video.js

@@ -5,11 +5,12 @@ import { Text } from 'app/design/typography'
 import Video from 'app/ui/atoms/video'
 import Loading from 'app/ui/atoms/loading'
 import { isInlineVideoReady } from 'app/lib/editor/inline-video-ready'
+import InlineMediaFrame from 'app/ui/molecules/content/inline-media-frame'
 
 const RECHECK_MS = 15000
 
 /** Editor video in a post body: a "processing" placeholder until UNA has transcoded the mp4. */
-export default function InlineVideo({ src, poster }) {
+export default function InlineVideo({ src, poster, width, height, align }) {
     const { t } = useTranslation()
     const [ready, setReady] = useState(false)
 
@@ -30,7 +31,7 @@ export default function InlineVideo({ src, poster }) {
     }, [src])
 
     return (
-        <View className='w-full aspect-video rounded-xl overflow-hidden my-3 bg-muted'>
+        <InlineMediaFrame width={width} height={height} align={align} keepRatio className='rounded-xl overflow-hidden bg-muted'>
             {ready ? (
                 <Video poster={poster} src={src} cover={true} controls={true} />
             ) : (
@@ -39,6 +40,6 @@ export default function InlineVideo({ src, poster }) {
                     <Text className='text-sm text-muted-foreground'>{t('Video is processing')}</Text>
                 </View>
             )}
-        </View>
+        </InlineMediaFrame>
     )
 }