Skip to content

Commit 900e5dd

Browse files
authored
Merge pull request #254 from CoreMedia/drag-image-fix
Dnd of Content Images should not be converted to imageBlock elements
2 parents 50994ff + 34b0a7c commit 900e5dd

2 files changed

Lines changed: 166 additions & 1 deletion

File tree

packages/ckeditor5-coremedia-images/src/ContentImageEditingPlugin.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getOptionalPlugin, reportInitEnd, reportInitStart } from "@coremedia/ck
66
import type { Logger } from "@coremedia/ckeditor5-logging";
77
import { LoggerProvider } from "@coremedia/ckeditor5-logging";
88
import ModelBoundSubscriptionPlugin from "./ModelBoundSubscriptionPlugin";
9-
import { editingDowncastXlinkHref, preventUpcastImageSrc } from "./converters";
9+
import { editingDowncastXlinkHref, preventUpcastImageSrc, upcastContentImageAsInline } from "./converters";
1010
import {
1111
openImageInTabCommandName,
1212
registerOpenImageInTabCommand,
@@ -62,6 +62,20 @@ export default class ContentImageEditingPlugin extends Plugin {
6262
// src attribute for the editing view asynchronously.
6363
// If not prevented, the src-attribute from GRS would be written to the model.
6464
this.editor.conversion.for("upcast").add(preventUpcastImageSrc());
65+
66+
// Force content images to become imageInline when dropped inside a
67+
// paragraph (inline context). This prevents ImageBlockEditing from
68+
// splitting the host paragraph. Runs at high priority, before
69+
// ImageBlockEditing's normal-priority converter.
70+
this.editor.conversion.for("upcast").add(upcastContentImageAsInline());
71+
72+
// When a content image is dragged and dropped between block elements,
73+
// CKEditor's image upcasting may create imageBlock instead of imageInline
74+
// (because the drop target is a block-level position). The post-fixer
75+
// registered here converts any such imageBlock back to a paragraph
76+
// containing an imageInline, which is the correct model representation
77+
// for CoreMedia content images.
78+
ContentImageEditingPlugin.#setupImageBlockToInlinePostFixer(this.editor);
6579
}
6680

6781
static #setupXlinkHrefConversion(editor: Editor, modelAttributeName: string, dataAttributeName: string): void {
@@ -103,6 +117,86 @@ export default class ContentImageEditingPlugin extends Plugin {
103117
.add(editingDowncastXlinkHref(editor, modelElementName, ContentImageEditingPlugin.#logger));
104118
}
105119

120+
/**
121+
* Ensures that content images dropped between block elements remain as
122+
* `imageInline` in the model.
123+
*
124+
* When dragging a content image and dropping it between two paragraphs,
125+
* CKEditor's image upcast pipeline detects a block-level drop position and
126+
* creates an `imageBlock` model element instead of `imageInline`. Because
127+
* `xlink-href` was not allowed on `imageBlock`, the attribute was silently
128+
* dropped and GHS captured `data-xlink-href` into `htmlImgAttributes`,
129+
* making the image disappear from the editing view.
130+
*
131+
* This method:
132+
* 1. Extends the `imageBlock` schema to allow `xlink-href` so that the
133+
* existing `attributeToAttribute` upcast converter sets the attribute
134+
* correctly (and GHS no longer captures it).
135+
* 2. Registers a model post-fixer that converts every `imageBlock` carrying
136+
* a `xlink-href` attribute into a `<paragraph>` containing an
137+
* `imageInline`, which is the correct CoreMedia RichText representation.
138+
*
139+
* @param editor - Editor instance
140+
*/
141+
static #setupImageBlockToInlinePostFixer(editor: Editor): void {
142+
const { model } = editor;
143+
const { schema } = model;
144+
145+
if (!schema.isRegistered("imageBlock")) {
146+
return;
147+
}
148+
149+
// Allow xlink-href on imageBlock so the upcast can set the attribute
150+
// (preventing GHS from capturing data-xlink-href into htmlImgAttributes).
151+
schema.extend("imageBlock", {
152+
allowAttributes: [ContentImageEditingPlugin.XLINK_HREF_MODEL_ATTRIBUTE_NAME],
153+
});
154+
155+
const xlinkHrefAttr = ContentImageEditingPlugin.XLINK_HREF_MODEL_ATTRIBUTE_NAME;
156+
const imageInlineName = ContentImageEditingPlugin.IMAGE_INLINE_MODEL_ELEMENT_NAME;
157+
158+
model.document.registerPostFixer((writer) => {
159+
let changed = false;
160+
161+
for (const change of model.document.differ.getChanges()) {
162+
if (change.type === "insert" && change.name === "imageBlock") {
163+
const item = change.position.nodeAfter;
164+
if (!item || !item.is("element", "imageBlock")) {
165+
continue;
166+
}
167+
168+
const xlinkHref = item.getAttribute(xlinkHrefAttr);
169+
if (!xlinkHref) {
170+
continue;
171+
}
172+
173+
// Replace the imageBlock with a paragraph containing an imageInline.
174+
const paragraph = writer.createElement("paragraph");
175+
const imageInline = writer.createElement(imageInlineName, {
176+
[xlinkHrefAttr]: xlinkHref,
177+
});
178+
writer.append(imageInline, paragraph);
179+
writer.insert(paragraph, item, "before");
180+
writer.remove(item);
181+
changed = true;
182+
}
183+
184+
// When a content image is moved (drag-and-drop) out of a paragraph
185+
// that contained only that image, the source paragraph becomes empty.
186+
// CKEditor does not remove it automatically, so we clean it up here.
187+
if (change.type === "remove" && change.name === "imageInline") {
188+
const parent = change.position.parent;
189+
if (parent.is("element", "paragraph") && parent.childCount === 0 && parent.parent !== null) {
190+
writer.remove(parent);
191+
changed = true;
192+
}
193+
}
194+
}
195+
196+
return changed;
197+
});
198+
}
199+
106200
/**
107201
* Register `imageInline` model elements for subscription cleanup
108202
* on model changes.

packages/ckeditor5-coremedia-images/src/converters.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,77 @@ export const preventUpcastImageSrc =
7878
);
7979
};
8080

81+
/**
82+
* High-priority upcast converter that creates `imageInline` for any
83+
* `<img data-xlink-href="...">` when the current insertion position allows
84+
* inline content (e.g. inside a paragraph).
85+
*
86+
* Without this, `ImageBlockEditing`'s `normal`-priority converter runs first
87+
* and calls `safeInsert` for an `imageBlock`. When the drop cursor is *inside*
88+
* a paragraph (between letters), `safeInsert` splits that paragraph to
89+
* accommodate the block element, leaving empty paragraph fragments behind.
90+
*
91+
* By creating `imageInline` at high priority for inline positions, no
92+
* paragraph splitting occurs. When the cursor is at a block-level position
93+
* (between paragraphs) `imageInline` is not allowed there; the converter
94+
* returns without consuming so that `ImageBlockEditing` can place an
95+
* `imageBlock` at block level. The `imageBlock` post-fixer then converts
96+
* that back to `paragraph + imageInline` without any splitting.
97+
*/
98+
export const upcastContentImageAsInline =
99+
(): ((dispatcher: UpcastDispatcher) => void) =>
100+
(dispatcher: UpcastDispatcher): void => {
101+
dispatcher.on(
102+
`element:img`,
103+
(evt: EventInfo, data, conversionApi: UpcastConversionApi) => {
104+
if (!data.viewItem.hasAttribute("data-xlink-href")) {
105+
return;
106+
}
107+
108+
if (!conversionApi.consumable.test(data.viewItem, { name: true })) {
109+
return;
110+
}
111+
// Only proceed when imageInline is allowed at the current cursor
112+
// position. If not (e.g. between block elements at root level), fall
113+
// through to ImageBlockEditing so the post-fixer can handle it.
114+
115+
if (!conversionApi.schema.checkChild(data.modelCursor, "imageInline")) {
116+
return;
117+
}
118+
119+
const xlinkHref = String(data.viewItem.getAttribute("data-xlink-href"));
120+
const imageInline = conversionApi.writer.createElement("imageInline", {
121+
"xlink-href": xlinkHref,
122+
});
123+
124+
if (!conversionApi.safeInsert(imageInline, data.modelCursor)) {
125+
return;
126+
}
127+
128+
// Consume the element name so that ImageBlockEditing and
129+
// ImageInlineEditing (both guard with `consumable.test(viewItem,
130+
// { name: true })`) skip this element — they would otherwise create
131+
// a duplicate model element.
132+
//
133+
// Also consume `data-xlink-href` so the attributeToAttribute upcast
134+
// converter does not try to set xlink-href a second time (we already
135+
// set it directly in createElement above).
136+
//
137+
// IMPORTANT: do NOT call evt.stop() here. Other lower-priority
138+
// element:img listeners include CKEditor's attributeToAttribute
139+
// converters for `alt`, `width`, `height`, etc. They are guarded by
140+
// their own consumable entries ({ attributes: 'alt' }), not by
141+
// { name: true }, so they will still fire, find the imageInline via
142+
// data.modelRange, and set the remaining attributes correctly.
143+
conversionApi.consumable.consume(data.viewItem, { name: true });
144+
conversionApi.consumable.consume(data.viewItem, { attributes: "data-xlink-href" });
145+
146+
conversionApi.updateConversionResult(imageInline, data);
147+
},
148+
{ priority: "high" },
149+
);
150+
};
151+
81152
/**
82153
* Conversion for `modelElementName:xlink-href` to `img:src`.
83154
*

0 commit comments

Comments
 (0)