Skip to content

Commit 58cd429

Browse files
rjvelazcoclaude
andauthored
fix(block-editor): apply links to a selected image instead of replacing it (#36368)
## Proposed Changes Fixes #36361 — in the new block editor, selecting an image and clicking the toolbar **Link** button deleted the image and replaced it with a text link. The `dotImage` node already supports `href`/`target` end-to-end (it parses a wrapping `<a>` and re-wraps the `<img>` on render); the break was purely in the UI flow, which always routed through the text-link *insert* path. ### What changed - **Toolbar Link button is image-aware** — when a `dotImage` node is selected, the link popover opens in *image-link mode* (URL + "Open in new tab" only; Text/Advanced fields hidden) and applies the link via `updateAttributes('dotImage', { href, target })`, so the image is preserved instead of replaced. - **Remove link** — a trash-can action appears when editing an image that already has a link, clearing `href`/`target`. - **Click behavior** — clicking a linked image no longer opens the text-link editor; it selects the image node (the link is edited from the toolbar) and prevents the anchor from navigating. - **Layout fixes** — eliminated the vertical jump/extra space when a link wraps the image: zero the inline-block baseline descender gap, and zero the Tailwind Typography `img` margin that reappears once the image is no longer a direct `<figure>` child. - **Active state** — the toolbar Link button now highlights for linked images (`isImageLinked`), mirroring `isLink` for text links. The text-link flow is unchanged. No `dotImage` node name or stored-content shape changed — links round-trip through the existing `href`/`target` attributes, so no data migration is required. ### Files - `link-popover.component.{ts,html}` — image-link mode, Remove action - `toolbar.component.{ts,html}` + `editor-toolbar.store.ts` — image detection, active state - `editor-chrome-click.ts` — don't open the dialog on linked-image clicks - `editor.component.css` — layout/margin fixes - `editor-popover.service.ts` — `isImageLink` payload flag - `Language.properties` — `dot.block.editor.dialog.link.remove` ## Testing - `pnpm nx lint new-block-editor` ✓ - `tsc --noEmit` on the lib ✓ - Manual: insert image → select → Link button opens URL-only popover → Save keeps the image wrapped in `<a>` (not replaced); re-select shows active Link button + trash to remove; no layout jump toggling the link; text-link flow unchanged. ## Video https://github.com/user-attachments/assets/43a03e69-d132-496a-a360-2f238c3129e8 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f9be978 commit 58cd429

13 files changed

Lines changed: 278 additions & 54 deletions

File tree

core-web/libs/new-block-editor/src/lib/editor/components/link-popover/link-popover.component.html

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -75,22 +75,24 @@
7575
</p-autoComplete>
7676
</div>
7777

78-
<div class="flex flex-col gap-1">
79-
<label for="link-text" class="text-xs font-medium text-gray-700">
80-
{{ 'dot.block.editor.dialog.link.field.text.label' | dm }}
81-
<span class="font-normal text-gray-400">
82-
{{ 'dot.block.editor.dialog.link.field.text.optional' | dm }}
83-
</span>
84-
</label>
85-
<input
86-
pInputText
87-
id="link-text"
88-
type="text"
89-
[formControl]="form.controls.displayText"
90-
[placeholder]="'dot.block.editor.dialog.link.field.text.placeholder' | dm"
91-
pSize="small"
92-
class="w-full rounded-sm border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
93-
</div>
78+
@if (!isImageLink()) {
79+
<div class="flex flex-col gap-1">
80+
<label for="link-text" class="text-xs font-medium text-gray-700">
81+
{{ 'dot.block.editor.dialog.link.field.text.label' | dm }}
82+
<span class="font-normal text-gray-400">
83+
{{ 'dot.block.editor.dialog.link.field.text.optional' | dm }}
84+
</span>
85+
</label>
86+
<input
87+
pInputText
88+
id="link-text"
89+
type="text"
90+
[formControl]="form.controls.displayText"
91+
[placeholder]="'dot.block.editor.dialog.link.field.text.placeholder' | dm"
92+
pSize="small"
93+
class="w-full rounded-sm border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
94+
</div>
95+
}
9496

9597
<label class="flex cursor-pointer items-center gap-2" for="open-in-new-tab">
9698
<input
@@ -103,20 +105,22 @@
103105
</span>
104106
</label>
105107

106-
<button
107-
type="button"
108-
[attr.aria-expanded]="advancedOpen()"
109-
aria-controls="link-advanced-section"
110-
data-testid="link-advanced-toggle"
111-
(mousedown)="$event.preventDefault(); toggleAdvanced()"
112-
class="flex items-center gap-1 self-start rounded-sm text-xs font-medium text-gray-600 hover:text-indigo-700 focus:ring-2 focus:ring-indigo-300 focus:outline-none">
113-
<span>{{ 'dot.block.editor.dialog.link.advanced' | dm }}</span>
114-
<span aria-hidden="true" class="material-symbols-outlined text-base">
115-
{{ advancedOpen() ? 'expand_less' : 'expand_more' }}
116-
</span>
117-
</button>
108+
@if (!isImageLink()) {
109+
<button
110+
type="button"
111+
[attr.aria-expanded]="advancedOpen()"
112+
aria-controls="link-advanced-section"
113+
data-testid="link-advanced-toggle"
114+
(mousedown)="$event.preventDefault(); toggleAdvanced()"
115+
class="flex items-center gap-1 self-start rounded-sm text-xs font-medium text-gray-600 hover:text-indigo-700 focus:ring-2 focus:ring-indigo-300 focus:outline-none">
116+
<span>{{ 'dot.block.editor.dialog.link.advanced' | dm }}</span>
117+
<span aria-hidden="true" class="material-symbols-outlined text-base">
118+
{{ advancedOpen() ? 'expand_less' : 'expand_more' }}
119+
</span>
120+
</button>
121+
}
118122

119-
@if (advancedOpen()) {
123+
@if (!isImageLink() && advancedOpen()) {
120124
<div id="link-advanced-section" class="flex flex-col gap-3">
121125
<div class="flex flex-col gap-1">
122126
<label for="link-title" class="text-xs font-medium text-gray-700">
@@ -174,6 +178,20 @@
174178
}
175179

176180
<div class="flex justify-end gap-2 pt-1">
181+
@if (canRemoveLink()) {
182+
<button
183+
type="button"
184+
data-testid="link-remove"
185+
[attr.aria-label]="'dot.block.editor.dialog.link.remove' | dm"
186+
[attr.title]="'dot.block.editor.dialog.link.remove' | dm"
187+
(mousedown)="$event.preventDefault()"
188+
(click)="onRemoveLink()"
189+
class="mr-auto flex cursor-pointer items-center rounded-sm p-1.5 text-red-600 hover:bg-red-50 focus:ring-2 focus:ring-red-300 focus:outline-none">
190+
<span aria-hidden="true" class="material-symbols-outlined text-xl">
191+
delete
192+
</span>
193+
</button>
194+
}
177195
<button
178196
type="button"
179197
(mousedown)="$event.preventDefault(); manager.close()"

core-web/libs/new-block-editor/src/lib/editor/components/link-popover/link-popover.component.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { DotCMSContentlet } from '@dotcms/dotcms-models';
3333
import { DotMessagePipe } from '@dotcms/ui';
3434

3535
import { FULLSCREEN_AWARE_OVERLAY_OPTIONS } from '../../config.utils';
36+
import { DOT_IMAGE_NODE_NAME } from '../../extensions/nodes/image.extension';
3637
import { LINK_SELECTION_KEY } from '../../extensions/selection-preserve.extension';
3738
import { EditorPopoverService } from '../../services/editor-popover.service';
3839
import { EditorStore } from '../../store/editor.store';
@@ -139,6 +140,26 @@ export class LinkPopoverComponent {
139140
() => this.manager.linkPayload()?.initialValues != null
140141
);
141142

143+
/**
144+
* True when the popover targets a selected `dotImage` node. Hides the text-only fields (Text,
145+
* Advanced) and routes {@link onInsert} to update the image node's `href`/`target` attributes
146+
* instead of inserting a text node with a link mark.
147+
*/
148+
protected readonly isImageLink = computed(
149+
() => this.manager.linkPayload()?.isImageLink === true
150+
);
151+
152+
/**
153+
* True when there's an existing link to remove — drives the trash (Remove link) button.
154+
* For images: the node already has an `href`. For text: the popover was opened on a real
155+
* anchor (`linkEl` set), not while creating a new link over plain selected text.
156+
*/
157+
protected readonly canRemoveLink = computed(() => {
158+
const payload = this.manager.linkPayload();
159+
if (!payload) return false;
160+
return payload.isImageLink ? !!payload.initialValues?.href : !!payload.linkEl;
161+
});
162+
142163
/**
143164
* Tracks whether the Advanced (Title / Aria Label / Rel) section is visible.
144165
* Auto-expands when an existing link's payload carries any of those values, so the
@@ -274,6 +295,8 @@ export class LinkPopoverComponent {
274295
effect((onCleanup) => {
275296
if (!this.manager.isOpen('link')) return;
276297
if (this.manager.linkPayload()?.linkEl) return;
298+
// Image-link mode is a NodeSelection — there is no text range to paint.
299+
if (this.manager.linkPayload()?.isImageLink) return;
277300
const view = this.editor().view;
278301
view.dispatch(view.state.tr.setMeta(LINK_SELECTION_KEY, { active: true }));
279302
onCleanup(() =>
@@ -364,6 +387,21 @@ export class LinkPopoverComponent {
364387
rel: (rel ?? '').trim() || null
365388
};
366389

390+
if (payload?.isImageLink) {
391+
// Image-link mode — set the link on the selected image node's attributes. The node's
392+
// renderHTML/nodeView wrap the <img> in <a href target>, so the image is preserved.
393+
editor
394+
.chain()
395+
.focus()
396+
.updateAttributes(DOT_IMAGE_NODE_NAME, {
397+
href,
398+
target: openInNewTab ? '_blank' : null
399+
})
400+
.run();
401+
this.manager.close();
402+
return;
403+
}
404+
367405
if (payload?.linkEl) {
368406
// Edit mode — update the link in place using the pre-computed anchor position.
369407
const linkEl = payload.linkEl;
@@ -401,4 +439,42 @@ export class LinkPopoverComponent {
401439

402440
this.manager.close();
403441
}
442+
443+
/**
444+
* Removes the existing link. For an image, clears the node's `href`/`target`; for text,
445+
* unsets the `link` mark over its full range (anchored at the captured edit position).
446+
*/
447+
protected onRemoveLink(): void {
448+
const payload = this.manager.linkPayload();
449+
const editor = this.editor();
450+
451+
if (payload?.isImageLink) {
452+
editor
453+
.chain()
454+
.focus()
455+
.updateAttributes(DOT_IMAGE_NODE_NAME, { href: null, target: null })
456+
.run();
457+
this.manager.close();
458+
return;
459+
}
460+
461+
const linkEl = payload?.linkEl;
462+
const anchorPos =
463+
payload?.anchorPos ??
464+
(() => {
465+
try {
466+
return linkEl ? editor.view.posAtDOM(linkEl, 0) : editor.state.selection.from;
467+
} catch {
468+
return editor.state.selection.from;
469+
}
470+
})();
471+
editor
472+
.chain()
473+
.focus()
474+
.setTextSelection(anchorPos)
475+
.extendMarkRange('link')
476+
.unsetMark('link')
477+
.run();
478+
this.manager.close();
479+
}
404480
}

core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar.store.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export class EditorToolbarStore {
2424
readonly canIndent = signal(false);
2525
readonly canOutdent = signal(false);
2626
readonly isImageSelected = signal(false);
27+
/** True when the selected `dotImage` node carries a link (`href`) — drives the toolbar
28+
* Link button's active state for linked images, mirroring `isLink` for text links. */
29+
readonly isImageLinked = signal(false);
2730
readonly imageTextWrap = signal<string | null>(null);
2831
readonly imageTextAlign = signal<string | null>(null);
2932
readonly textAlign = signal<'left' | 'center' | 'right' | 'justify'>('left');
@@ -47,6 +50,9 @@ export class EditorToolbarStore {
4750
this.isCodeBlock.set(editor.isActive('codeBlock'));
4851
this.isLink.set(editor.isActive('link'));
4952
this.isImageSelected.set(editor.isActive('dotImage'));
53+
this.isImageLinked.set(
54+
editor.isActive('dotImage') && !!editor.getAttributes('dotImage').href
55+
);
5056
this.imageTextWrap.set(
5157
editor.isActive('dotImage')
5258
? (editor.getAttributes('dotImage').textWrap ?? null)

core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@
369369
[tooltipPosition]="TOOLTIP_POSITION"
370370
[tooltipOptions]="overlayTooltipOptions()"
371371
[showDelay]="TOOLTIP_SHOW_DELAY"
372-
[class]="btnClass(state.isLink() || popovers.isOpen('link'))"
372+
[class]="btnClass(state.isLink() || state.isImageLinked() || popovers.isOpen('link'))"
373373
(mousedown)="openLinkDialog($event)">
374374
<span aria-hidden="true" class="material-symbols-outlined">link</span>
375375
</button>

core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
FULLSCREEN_AWARE_OVERLAY_OPTIONS,
2727
OVERLAY_ABOVE_FULLSCREEN_Z_INDEX
2828
} from '../../config.utils';
29+
import { DOT_IMAGE_NODE_NAME } from '../../extensions/nodes/image.extension';
2930
import { BLOCK_TARGET_KEY } from '../../extensions/selection-preserve.extension';
3031
import { ContentletEditUrlService } from '../../services/contentlet-edit-url.service';
3132
import { EditorModalService } from '../../services/editor-modal.service';
@@ -412,6 +413,19 @@ export class ToolbarComponent implements OnDestroy {
412413
const { from, to, empty } = editor.state.selection;
413414
const btn = event.currentTarget as HTMLElement;
414415

416+
// Image selected → apply the link to the image node's href/target instead of inserting
417+
// text. Without this branch the insert-text path below would delete the image (#36361).
418+
if (editor.isActive(DOT_IMAGE_NODE_NAME)) {
419+
const attrs = editor.getAttributes(DOT_IMAGE_NODE_NAME);
420+
this.popovers.openLink(() => btn.getBoundingClientRect(), {
421+
isImageLink: true,
422+
initialValues: attrs['href']
423+
? { href: attrs['href'], target: attrs['target'] ?? null }
424+
: undefined
425+
});
426+
return;
427+
}
428+
415429
// Check if cursor/selection is inside an existing link
416430
const linkMark = editor.state.doc
417431
.resolve(from)

core-web/libs/new-block-editor/src/lib/editor/editor-chrome-click.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ export function handleEditorProseMirrorClick(
1414
const anchor = (event.target as HTMLElement).closest('a[href]');
1515
if (!anchor) return;
1616

17+
// A linked image renders as <a href><img></a>. Clicking the image must NOT open the link
18+
// editor — the click selects the `dotImage` node (surfacing the image toolbar group), and the
19+
// link is edited from the toolbar Link button. Just stop the anchor from navigating. Opening
20+
// the text-link editor here would show the wrong fields and, on save, replace the image
21+
// (#36361). Gate on the *clicked* element being the image (not merely any descendant img) so
22+
// a text click in a mixed anchor still routes to the text-link editor.
23+
const clickedImage = (event.target as HTMLElement).closest('img');
24+
if (clickedImage && anchor.contains(clickedImage)) {
25+
event.preventDefault();
26+
return;
27+
}
28+
1729
const href = anchor.getAttribute('href') ?? '';
1830
const displayText = anchor.textContent?.trim() ?? '';
1931
const target = anchor.getAttribute('target');

core-web/libs/new-block-editor/src/lib/editor/editor.component.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,26 @@
225225
display: inline-block;
226226
max-width: 100%;
227227
height: auto;
228+
/* Align to the top of the line box so the inline-block image carries no baseline
229+
descender gap. Without this, wrapping the image in an inline <a> (when a link is
230+
added) introduces extra space below it and the layout visibly jumps (#36361). */
231+
vertical-align: top;
232+
/* Tailwind Typography's `.prose :where(img)` adds `margin: 2em 0`, normally cancelled by
233+
its `figure > *` reset. Once the image is wrapped in <a> (linked), it's no longer a direct
234+
figure child so that reset stops applying and the 2em margin returns. Zero it explicitly —
235+
this selector out-specifies prose's zero-specificity `:where()` rules (#36361). */
236+
margin: 0;
237+
}
238+
239+
/* A linked image is rendered as <figure><a href><img></a></figure>. Keep the anchor a
240+
zero-line-height inline-block so it hugs the image and linking doesn't shift layout.
241+
`vertical-align: top` is essential: once the anchor wraps the image, the *anchor* (not the
242+
img) is the inline-level box in the figure's formatting context, so it must align to the top
243+
or the browser reserves font-descender space below it and the layout jumps (#36361). */
244+
:host ::ng-deep .ProseMirror figure a {
245+
display: inline-block;
246+
line-height: 0;
247+
vertical-align: top;
228248
}
229249

230250
/* ─── Selected node ring ────────────────────────────────── */

core-web/libs/new-block-editor/src/lib/editor/editor.utils.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import {
1010
replacePlaceholder,
1111
removePlaceholder
1212
} from './extensions/nodes/upload-placeholder.extension';
13-
import { type DotVideoData, DOT_VIDEO_NODE_NAME } from './extensions/nodes/video.extension';
13+
import {
14+
type DotVideoData,
15+
DOT_VIDEO_NODE_NAME,
16+
videoMetaAttrsFromContentlet
17+
} from './extensions/nodes/video.extension';
1418
import { type UploadedImage, type UploadedVideo } from './services/dot-upload.service';
1519

1620
export function handleMediaDrop(
@@ -81,11 +85,11 @@ export function handleMediaDrop(
8185

8286
if (uploadVideo) {
8387
uploadVideo(file)
84-
.then(({ src, data }) => {
88+
.then(({ src, data, mimeType, width, height, orientation }) => {
8589
const title = file.name.replace(/\.[^.]+$/, '');
8690
replacePlaceholder(editor, id, {
8791
type: DOT_VIDEO_NODE_NAME,
88-
attrs: { src, title, data }
92+
attrs: { src, title, data, mimeType, width, height, orientation }
8993
});
9094
})
9195
.catch((err) => {
@@ -154,7 +158,8 @@ export function insertDotVideoFromContentlet(editor: Editor, contentlet: DotCMSC
154158
attrs: {
155159
src: data.asset,
156160
title: data.title || null,
157-
data
161+
data,
162+
...videoMetaAttrsFromContentlet(contentlet)
158163
}
159164
})
160165
.run();

core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/image.extension.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,15 +140,17 @@ export const DotImage = Image.extend({
140140
else if (textAlign) figAttrs['class'] = `image-align-${textAlign}`;
141141

142142
const img = ['img', mergeAttributes(this.options.HTMLAttributes, imgAttrs)];
143-
const anchorAttrs: Record<string, string> = { href };
143+
// Build conditionally so a null/absent href never serializes as href="null".
144+
const anchorAttrs: Record<string, string> = {};
145+
if (href) anchorAttrs['href'] = href;
144146
if (target) anchorAttrs['target'] = target;
145147
const inner = href ? ['a', anchorAttrs, img] : img;
146148

147149
return ['figure', figAttrs, inner];
148150
},
149151

150152
addNodeView() {
151-
return ({ node }) => {
153+
return ({ node, getPos, editor }) => {
152154
const figure = document.createElement('figure');
153155
const img = document.createElement('img');
154156

@@ -175,6 +177,17 @@ export const DotImage = Image.extend({
175177
if (target) a.setAttribute('target', String(target));
176178
a.appendChild(img);
177179
figure.appendChild(a);
180+
181+
// The wrapping <a> makes the browser treat clicks as link interactions, so
182+
// ProseMirror won't cleanly node-select the image (it took several clicks to
183+
// activate the image + its toolbar options). Select the node ourselves on the
184+
// first mousedown and suppress the anchor's default behaviour (#36361).
185+
figure.addEventListener('mousedown', (event) => {
186+
event.preventDefault();
187+
if (typeof getPos === 'function') {
188+
editor.commands.setNodeSelection(getPos());
189+
}
190+
});
178191
} else {
179192
figure.appendChild(img);
180193
}

0 commit comments

Comments
 (0)