Rich Text Editor: The media picker skips the "edit media" dialog when editing an image (closes #20066) (#20740)

* fix: Tiptap Media Picker: Skip media picker modal when editing existing images

Fixes the media picker workflow to match v13 behavior where clicking
an existing image directly opens the alt text/caption editor instead
of forcing users to re-select the same image from the media library.

Also fixes caption text extraction to properly read from the figcaption
node using Tiptap's NodeSelection API instead of unreliable attribute-based
approach.

Changes:
- Skip media picker when currentMediaUdi exists (lines 77-92)
- Extract caption from NodeSelection.node using descendants() (lines 55-73)
- Add NodeSelection export to tiptap externals for proper typing

* Refactor: Extract nested logic from media picker execute method

Reduces cyclomatic complexity from 15 to 1 by extracting conditional
logic into focused private helper methods. Addresses CodeScene warnings
for complex method and nested conditionals (bumpy road smell).

Created helper methods:
- #extractMediaUdi, #extractCaption, #findFigcaptionText
- #getMediaGuid, #updateImageWithMetadata

No functional changes - improves maintainability and testability.
This commit is contained in:
Jacob Overgaard
2025-11-05 15:51:16 +01:00
committed by GitHub
parent 72d7ed438f
commit 594c3f4eac
2 changed files with 60 additions and 24 deletions

View File

@@ -1,4 +1,5 @@
import type { Editor } from '../../externals.js'; import type { Editor, ProseMirrorNode } from '../../externals.js';
import { NodeSelection } from '../../externals.js';
import { UmbTiptapToolbarElementApiBase } from '../tiptap-toolbar-element-api-base.js'; import { UmbTiptapToolbarElementApiBase } from '../tiptap-toolbar-element-api-base.js';
import { getGuidFromUdi, imageSize } from '@umbraco-cms/backoffice/utils'; import { getGuidFromUdi, imageSize } from '@umbraco-cms/backoffice/utils';
import { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api'; import { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api';
@@ -41,31 +42,21 @@ export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbT
override async execute(editor: Editor) { override async execute(editor: Editor) {
const currentTarget = editor.getAttributes('image'); const currentTarget = editor.getAttributes('image');
const figure = editor.getAttributes('figure'); const currentMediaUdi = this.#extractMediaUdi(currentTarget);
const currentAltText = currentTarget?.alt;
const currentCaption = this.#extractCaption(editor.state.selection);
let currentMediaUdi: string | undefined = undefined; await this.#updateImageWithMetadata(editor, currentMediaUdi, currentAltText, currentCaption);
if (currentTarget?.['data-udi']) {
currentMediaUdi = getGuidFromUdi(currentTarget['data-udi']);
} }
let currentAltText: string | undefined = undefined; async #updateImageWithMetadata(
if (currentTarget?.alt) { editor: Editor,
currentAltText = currentTarget.alt; currentMediaUdi: string | undefined,
} currentAltText: string | undefined,
currentCaption: string | undefined,
let currentCaption: string | undefined = undefined; ) {
if (figure?.figcaption) { const mediaGuid = await this.#getMediaGuid(currentMediaUdi);
currentCaption = figure.figcaption; if (!mediaGuid) return;
}
const selection = await this.#openMediaPicker(currentMediaUdi);
if (!selection?.length) return;
const mediaGuid = selection[0];
if (!mediaGuid) {
throw new Error('No media selected');
}
const media = await this.#showMediaCaptionAltText(mediaGuid, currentAltText, currentCaption); const media = await this.#showMediaCaptionAltText(mediaGuid, currentAltText, currentCaption);
if (!media) return; if (!media) return;
@@ -73,6 +64,47 @@ export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbT
this.#insertInEditor(editor, mediaGuid, media); this.#insertInEditor(editor, mediaGuid, media);
} }
#extractMediaUdi(imageAttributes: Record<string, unknown>): string | undefined {
return imageAttributes?.['data-udi'] ? getGuidFromUdi(imageAttributes['data-udi'] as string) : undefined;
}
#extractCaption(selection: unknown): string | undefined {
if (!(selection instanceof NodeSelection)) return undefined;
if (selection.node.type.name !== 'figure') return undefined;
return this.#findFigcaptionText(selection.node);
}
#findFigcaptionText(figureNode: ProseMirrorNode): string | undefined {
let caption: string | undefined;
figureNode.descendants((child) => {
if (child.type.name === 'figcaption') {
caption = child.textContent || undefined;
return false; // Stop searching
}
return true; // Continue searching
});
return caption;
}
async #getMediaGuid(currentMediaUdi?: string): Promise<string | undefined> {
if (currentMediaUdi) {
// Image already exists, go directly to edit alt text/caption
return currentMediaUdi;
}
// No image selected, open media picker
const selection = await this.#openMediaPicker();
if (!selection?.length) return undefined;
const selectedGuid = selection[0];
if (!selectedGuid) {
throw new Error('No media selected');
}
return selectedGuid;
}
async #openMediaPicker(currentMediaUdi?: string) { async #openMediaPicker(currentMediaUdi?: string) {
const modalHandler = this.#modalManager?.open(this, UMB_MEDIA_PICKER_MODAL, { const modalHandler = this.#modalManager?.open(this, UMB_MEDIA_PICKER_MODAL, {
data: { data: {

View File

@@ -14,6 +14,10 @@ export { HardBreak } from '@tiptap/extension-hard-break';
export { Paragraph } from '@tiptap/extension-paragraph'; export { Paragraph } from '@tiptap/extension-paragraph';
export { Text } from '@tiptap/extension-text'; export { Text } from '@tiptap/extension-text';
// PROSEMIRROR TYPES
export { NodeSelection } from '@tiptap/pm/state';
export type { Node as ProseMirrorNode } from '@tiptap/pm/model';
// OPTIONAL EXTENSIONS // OPTIONAL EXTENSIONS
export { Blockquote } from '@tiptap/extension-blockquote'; export { Blockquote } from '@tiptap/extension-blockquote';
export { Bold } from '@tiptap/extension-bold'; export { Bold } from '@tiptap/extension-bold';