@atlaskit/editor-plugin-media 12.4.1 → 12.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,10 @@
1
1
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
2
  // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
3
3
  import uuidV4 from 'uuid/v4';
4
+ import { SetAttrsStep } from '@atlaskit/adf-schema/steps';
4
5
  import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
5
6
  import { DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_WIDTH } from '@atlaskit/editor-common/media-single';
7
+ import { NodeSelection } from '@atlaskit/editor-prosemirror/state';
6
8
  import { getAttrsFromUrl, isImageRepresentationReady, isMediaBlobUrl } from '@atlaskit/media-client';
7
9
  import { getMediaClient } from '@atlaskit/media-client-react';
8
10
  import { getClientIdForFile } from '@atlaskit/media-common';
@@ -11,6 +13,7 @@ import { replaceExternalMedia, updateCurrentMediaNodeAttrs, updateMediaNodeAttrs
11
13
  import { stateKey as mediaStateKey } from '../pm-plugins/plugin-key';
12
14
  import { batchMediaNodeAttrsUpdate } from '../pm-plugins/utils/batchMediaNodeAttrs';
13
15
  import { getIdentifier } from '../pm-plugins/utils/media-common';
16
+ import { computeReplacementDisplayWidth } from './nodeviewHelpers';
14
17
  const isMediaTypeSupported = type => {
15
18
  if (type) {
16
19
  return ['image', 'file'].includes(type);
@@ -179,7 +182,80 @@ export class MediaNodeUpdater {
179
182
  return attrs.__contextId || null;
180
183
  });
181
184
  _defineProperty(this, "updateDimensions", dimensions => {
182
- batchMediaNodeAttrsUpdate(this.props.view, {
185
+ const {
186
+ view
187
+ } = this.props;
188
+ const mediaPluginState = mediaStateKey.getState(view.state);
189
+ const targetDisplayHeight = mediaPluginState === null || mediaPluginState === void 0 ? void 0 : mediaPluginState.replaceMediaTargetDisplayHeight;
190
+ if (targetDisplayHeight !== null && targetDisplayHeight !== undefined && dimensions.width > 0) {
191
+ var _this$props$lineLengt, _mediaSingleNode$attr, _mediaSingleNode, _mediaSingleNode$attr2;
192
+ // Replace mode: combine intrinsic dimension update on the media node AND
193
+ // display width update on the mediaSingle into a single transaction so they
194
+ // are never out of sync and don't cause two separate renders/layout shifts.
195
+
196
+ // Clear the stored target height — this is a one-shot adjustment
197
+ if (mediaPluginState) {
198
+ mediaPluginState.replaceMediaTargetDisplayHeight = null;
199
+ }
200
+
201
+ // Clamp to the layout's maximum width so the image never overflows its container.
202
+ const lineLength = (_this$props$lineLengt = this.props.lineLength) !== null && _this$props$lineLengt !== void 0 ? _this$props$lineLengt : 760;
203
+ const {
204
+ state
205
+ } = view;
206
+ const {
207
+ mediaSingle
208
+ } = state.schema.nodes;
209
+ // Typed explicitly because TypeScript can't track mutations inside
210
+ // the descendants callback closure and would narrow these to `never`.
211
+ let mediaSinglePos = null;
212
+ let mediaSingleNode = null;
213
+ let mediaPos = null;
214
+ state.doc.descendants((node, pos) => {
215
+ if (mediaSinglePos !== null) {
216
+ return false;
217
+ }
218
+ if (node.type === mediaSingle) {
219
+ const mediaChild = node.firstChild;
220
+ if (mediaChild && mediaChild.attrs.id === dimensions.id) {
221
+ mediaSinglePos = pos;
222
+ mediaSingleNode = node;
223
+ mediaPos = pos + 1;
224
+ }
225
+ }
226
+ return true;
227
+ });
228
+ const layout = (_mediaSingleNode$attr = (_mediaSingleNode = mediaSingleNode) === null || _mediaSingleNode === void 0 ? void 0 : (_mediaSingleNode$attr2 = _mediaSingleNode.attrs) === null || _mediaSingleNode$attr2 === void 0 ? void 0 : _mediaSingleNode$attr2.layout) !== null && _mediaSingleNode$attr !== void 0 ? _mediaSingleNode$attr : 'center';
229
+ const newDisplayWidth = computeReplacementDisplayWidth(targetDisplayHeight, dimensions.width, dimensions.height, layout, lineLength);
230
+ if (mediaSinglePos !== null && mediaSingleNode !== null && mediaPos !== null) {
231
+ const tr = state.tr;
232
+
233
+ // Update intrinsic dimensions on the media child node
234
+ tr.step(new SetAttrsStep(mediaPos, {
235
+ height: dimensions.height,
236
+ width: dimensions.width
237
+ }));
238
+
239
+ // Update display width on the mediaSingle parent
240
+ tr.setNodeMarkup(mediaSinglePos, undefined, {
241
+ ...mediaSingleNode.attrs,
242
+ width: Math.round(newDisplayWidth),
243
+ widthType: 'pixel'
244
+ });
245
+
246
+ // Re-create the NodeSelection so the floating toolbar picks up
247
+ // the updated node and renders the full set of controls.
248
+ if (state.selection instanceof NodeSelection) {
249
+ tr.setSelection(NodeSelection.create(tr.doc, mediaSinglePos));
250
+ }
251
+ tr.setMeta('scrollIntoView', false);
252
+ view.dispatch(tr);
253
+ return;
254
+ }
255
+ }
256
+
257
+ // Normal (non-replace) path: use the existing batched update mechanism
258
+ batchMediaNodeAttrsUpdate(view, {
183
259
  id: dimensions.id,
184
260
  nextAttributes: {
185
261
  height: dimensions.height,
@@ -104,7 +104,8 @@ const useMediaAsyncOperations = ({
104
104
  mediaNode,
105
105
  mediaNodeUpdater,
106
106
  addPendingTask,
107
- getPos
107
+ getPos,
108
+ mediaChildNodeId
108
109
  }) => {
109
110
  React.useEffect(() => {
110
111
  if (!mediaNodeUpdater) {
@@ -122,7 +123,11 @@ const useMediaAsyncOperations = ({
122
123
  mediaNode,
123
124
  addPendingTask
124
125
  });
125
- }, [mediaNode, addPendingTask, mediaNodeUpdater, getPos]);
126
+ // mediaChildNodeId is included so this effect re-runs when the media source
127
+ // is replaced (id changes), ensuring getRemoteDimensions fires for the new file
128
+ // with up-to-date updater props after setProps has been called.
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, [mediaNode, addPendingTask, mediaNodeUpdater, getPos, mediaChildNodeId]);
126
131
  };
127
132
  const noop = () => {};
128
133
 
@@ -248,7 +253,7 @@ const useUpdateSizeCallback = ({
248
253
  */
249
254
  const FALLBACK_MOST_COMMON_WIDTH = 760;
250
255
  export const MediaSingleNodeNext = mediaSingleNodeNextProps => {
251
- var _pluginInjectionApi$m, _pluginInjectionApi$m2, _mediaNode$firstChild;
256
+ var _mediaNode$firstChild, _pluginInjectionApi$m, _pluginInjectionApi$m2, _mediaNode$firstChild2;
252
257
  const {
253
258
  selected,
254
259
  getPos,
@@ -288,7 +293,8 @@ export const MediaSingleNodeNext = mediaSingleNodeNextProps => {
288
293
  mediaNodeUpdater,
289
294
  getPos,
290
295
  mediaNode,
291
- addPendingTask: addPendingTask || noop
296
+ addPendingTask: addPendingTask || noop,
297
+ mediaChildNodeId: (_mediaNode$firstChild = mediaNode.firstChild) === null || _mediaNode$firstChild === void 0 ? void 0 : _mediaNode$firstChild.attrs.id
292
298
  });
293
299
  React.useLayoutEffect(() => {
294
300
  mountedRef.current = true;
@@ -389,7 +395,7 @@ export const MediaSingleNodeNext = mediaSingleNodeNextProps => {
389
395
  }, [mediaSingleWidthAttribute, widthType, width, layout, contentWidthForLegacyExperience, containerWidth]);
390
396
  const currentMaxWidth = isSelected ? pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$m = pluginInjectionApi.media) === null || _pluginInjectionApi$m === void 0 ? void 0 : (_pluginInjectionApi$m2 = _pluginInjectionApi$m.sharedState.currentState()) === null || _pluginInjectionApi$m2 === void 0 ? void 0 : _pluginInjectionApi$m2.currentMaxWidth : undefined;
391
397
  const contentWidth = currentMaxWidth || lineLength;
392
- const isCurrentNodeDrafting = Boolean(isDrafting && targetNodeId === (mediaNode === null || mediaNode === void 0 ? void 0 : (_mediaNode$firstChild = mediaNode.firstChild) === null || _mediaNode$firstChild === void 0 ? void 0 : _mediaNode$firstChild.attrs.id));
398
+ const isCurrentNodeDrafting = Boolean(isDrafting && targetNodeId === (mediaNode === null || mediaNode === void 0 ? void 0 : (_mediaNode$firstChild2 = mediaNode.firstChild) === null || _mediaNode$firstChild2 === void 0 ? void 0 : _mediaNode$firstChild2.attrs.id));
393
399
  const mediaSingleWrapperRef = /*#__PURE__*/React.createRef();
394
400
  const captionPlaceHolderRef = /*#__PURE__*/React.createRef();
395
401
  const browser = getBrowserInfo();
@@ -0,0 +1,20 @@
1
+ import { akEditorFullWidthLayoutWidth, akEditorWideLayoutWidth } from '@atlaskit/editor-shared-styles';
2
+ const MIN_MEDIA_DISPLAY_WIDTH = 24;
3
+ /**
4
+ * Computes the new mediaSingle display width that preserves the original display height
5
+ * when a media node is replaced with a file of a different aspect ratio, clamped to valid bounds.
6
+ *
7
+ * @param targetDisplayHeight - The display height to preserve (from the old image)
8
+ * @param newIntrinsicWidth - The new file's intrinsic pixel width
9
+ * @param newIntrinsicHeight - The new file's intrinsic pixel height
10
+ * @param layout - The mediaSingle layout (affects max width)
11
+ * @param lineLength - The editor content column width in pixels
12
+ */
13
+ export const computeReplacementDisplayWidth = (targetDisplayHeight, newIntrinsicWidth, newIntrinsicHeight, layout, lineLength) => {
14
+ if (newIntrinsicHeight <= 0) {
15
+ return MIN_MEDIA_DISPLAY_WIDTH;
16
+ }
17
+ const unclamped = targetDisplayHeight * (newIntrinsicWidth / newIntrinsicHeight);
18
+ const maxWidth = layout === 'full-width' ? akEditorFullWidthLayoutWidth : layout === 'wide' ? akEditorWideLayoutWidth : lineLength;
19
+ return Math.max(MIN_MEDIA_DISPLAY_WIDTH, Math.min(unclamped, maxWidth));
20
+ };
@@ -4,6 +4,7 @@ import React from 'react';
4
4
  import { RawIntlProvider } from 'react-intl';
5
5
  // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
6
6
  import uuid from 'uuid';
7
+ import { SetAttrsStep } from '@atlaskit/adf-schema/steps';
7
8
  import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics';
8
9
  import { getBrowserInfo } from '@atlaskit/editor-common/browser';
9
10
  import { mediaInlineImagesEnabled } from '@atlaskit/editor-common/media-inline';
@@ -18,6 +19,7 @@ import { CellSelection } from '@atlaskit/editor-tables/cell-selection';
18
19
  import { isFileIdentifier } from '@atlaskit/media-client';
19
20
  import { getMediaFeatureFlag } from '@atlaskit/media-common';
20
21
  import { fg } from '@atlaskit/platform-feature-flags';
22
+ import { createMediaNodeUpdater } from '../nodeviews/mediaNodeUpdater';
21
23
  // Ignored via go/ees005
22
24
  // eslint-disable-next-line import/no-namespace
23
25
  import * as helpers from '../pm-plugins/commands/helpers';
@@ -72,6 +74,15 @@ export class MediaPluginStateImplementation {
72
74
  _defineProperty(this, "destroyed", false);
73
75
  _defineProperty(this, "removeOnCloseListener", () => {});
74
76
  _defineProperty(this, "onPopupToggleCallback", () => {});
77
+ // When non-null, holds the file ID of the media node being replaced. Used both as a
78
+ // flag (non-null = replace mode) and as a cross-check to ensure the correct node is
79
+ // updated if the selection moves between the picker opening and the file being picked.
80
+ _defineProperty(this, "replaceMediaFileId", null);
81
+ // The display height (in pixels) of the mediaSingle being replaced, computed at replace time
82
+ // from its display width and the old media node's intrinsic aspect ratio.
83
+ // Used by the nodeview to recompute display width from the new file's aspect ratio after
84
+ // dimensions are fetched, preserving visual height rather than visual width.
85
+ _defineProperty(this, "replaceMediaTargetDisplayHeight", null);
75
86
  _defineProperty(this, "identifierCount", new Map());
76
87
  // This is to enable mediaShallowCopySope to enable only shallow copying media referenced within the edtior
77
88
  // see: trackOutOfScopeIdentifier
@@ -107,7 +118,7 @@ export class MediaPluginStateImplementation {
107
118
  * called when we insert a new file via the picker (connected via pickerfacade)
108
119
  */
109
120
  _defineProperty(this, "insertFile", (mediaState, onMediaStateChanged, pickerType, insertMediaVia) => {
110
- var _this$pluginInjection, _this$pluginInjection2, _mediaState$collectio, _this$pluginInjection3, _this$pluginInjection4;
121
+ var _this$pluginInjection, _this$pluginInjection2, _mediaState$collectio, _this$pluginInjection6, _this$pluginInjection7;
111
122
  const {
112
123
  state
113
124
  } = this.view;
@@ -121,6 +132,21 @@ export class MediaPluginStateImplementation {
121
132
  return;
122
133
  }
123
134
 
135
+ // If replace mode was set but the selection has moved away from a mediaSingle
136
+ // (e.g. the user cancelled the replace picker and then inserted media elsewhere),
137
+ // clear replace state so this insertion proceeds as a normal insert.
138
+ if (this.replaceMediaFileId !== null) {
139
+ var _state$selection$node;
140
+ const {
141
+ mediaSingle
142
+ } = state.schema.nodes;
143
+ const isStillOnTargetMedia = state.selection instanceof NodeSelection && state.selection.node.type === mediaSingle && ((_state$selection$node = state.selection.node.firstChild) === null || _state$selection$node === void 0 ? void 0 : _state$selection$node.attrs.id) === this.replaceMediaFileId;
144
+ if (!isStillOnTargetMedia) {
145
+ this.replaceMediaFileId = null;
146
+ this.replaceMediaTargetDisplayHeight = null;
147
+ }
148
+ }
149
+
124
150
  // We need to dispatch the change to event dispatcher only for successful files
125
151
  if (mediaState.status !== 'error') {
126
152
  this.updateAndDispatch({
@@ -131,13 +157,130 @@ export class MediaPluginStateImplementation {
131
157
  this.uploadInProgressSubscriptions.forEach(fn => fn(true));
132
158
  this.uploadInProgressSubscriptionsNotified = true;
133
159
  }
160
+
161
+ // Replace mode: if a media node is being replaced, update its attrs in-place
162
+ // rather than inserting a new node. This preserves layout, width, and caption.
163
+ if (this.replaceMediaFileId !== null) {
164
+ var _mediaState$fileMimeT, _mediaState$fileName, _mediaState$fileSize;
165
+ // Clear replace mode immediately so subsequent insertions behave normally
166
+ this.replaceMediaFileId = null;
167
+ const {
168
+ state: currentState
169
+ } = this.view;
170
+ const mediaSinglePos = currentState.selection.from;
171
+ const mediaPos = mediaSinglePos + 1;
172
+ const mediaNode = currentState.doc.nodeAt(mediaPos);
173
+ if (!mediaNode || mediaNode.type.name !== 'media') {
174
+ return;
175
+ }
176
+
177
+ // Build a single transaction that:
178
+ // 1. Updates the media child node attrs (new file identity + cleared dimensions)
179
+ // 2. Re-creates the NodeSelection so the toolbar picks up the fresh node
180
+ const tr = currentState.tr;
181
+ tr.step(new SetAttrsStep(mediaPos, {
182
+ id: mediaState.id,
183
+ collection,
184
+ type: 'file',
185
+ __fileMimeType: (_mediaState$fileMimeT = mediaState.fileMimeType) !== null && _mediaState$fileMimeT !== void 0 ? _mediaState$fileMimeT : null,
186
+ __fileName: (_mediaState$fileName = mediaState.fileName) !== null && _mediaState$fileName !== void 0 ? _mediaState$fileName : null,
187
+ __fileSize: (_mediaState$fileSize = mediaState.fileSize) !== null && _mediaState$fileSize !== void 0 ? _mediaState$fileSize : null,
188
+ __mediaTraceId: null,
189
+ // Clear intrinsic dimensions — they'll be fetched once the file
190
+ // is processed and applied via updateDimensions in a single tx
191
+ // with the height-preserving mediaSingle width adjustment.
192
+ width: null,
193
+ height: null
194
+ }));
195
+ // Re-create the selection so the floating toolbar picks up
196
+ // the updated node and renders the full set of controls.
197
+ tr.setSelection(NodeSelection.create(tr.doc, mediaSinglePos));
198
+ tr.setMeta('scrollIntoView', false);
199
+ this.view.dispatch(tr);
200
+
201
+ // Still register the state-change listener so upload completion is tracked
202
+ onMediaStateChanged(this.handleMediaState);
203
+ const isEndState = state => state.status && MEDIA_RESOLVED_STATES.includes(state.status);
204
+
205
+ // After the file finishes uploading/processing, trigger a dimension fetch.
206
+ // getRemoteDimensions may fail if called too early (isImageRepresentationReady
207
+ // returns false while processing), so we wait for the ready state first.
208
+ const triggerDimensionFetch = () => {
209
+ // Find the media node in the doc by id and create a temporary
210
+ // MediaNodeUpdater to fetch and apply dimensions
211
+ const {
212
+ state: editorState
213
+ } = this.view;
214
+ const {
215
+ mediaSingle: mediaSingleType
216
+ } = editorState.schema.nodes;
217
+ let mediaChildNode = null;
218
+ editorState.doc.descendants(node => {
219
+ if (mediaChildNode) {
220
+ return false;
221
+ }
222
+ if (node.type === mediaSingleType) {
223
+ const child = node.firstChild;
224
+ if (child && child.attrs.id === mediaState.id) {
225
+ mediaChildNode = child;
226
+ }
227
+ }
228
+ return true;
229
+ });
230
+ if (mediaChildNode) {
231
+ var _this$pluginInjection3, _this$pluginInjection4, _this$pluginInjection5;
232
+ const updater = createMediaNodeUpdater({
233
+ view: this.view,
234
+ mediaProvider: this.mediaProvider ? Promise.resolve(this.mediaProvider) : undefined,
235
+ contextIdentifierProvider: this.contextIdentifierProvider ? Promise.resolve(this.contextIdentifierProvider) : undefined,
236
+ node: mediaChildNode,
237
+ isMediaSingle: true,
238
+ lineLength: (_this$pluginInjection3 = this.pluginInjectionApi) === null || _this$pluginInjection3 === void 0 ? void 0 : (_this$pluginInjection4 = _this$pluginInjection3.width) === null || _this$pluginInjection4 === void 0 ? void 0 : (_this$pluginInjection5 = _this$pluginInjection4.sharedState.currentState()) === null || _this$pluginInjection5 === void 0 ? void 0 : _this$pluginInjection5.lineLength
239
+ });
240
+ updater.getRemoteDimensions().then(dims => {
241
+ if (dims) {
242
+ updater.updateDimensions(dims);
243
+ }
244
+ }).catch(() => {
245
+ // Silently ignore — if dimensions can't be fetched (e.g. network error),
246
+ // the image will render at its current size without the height-preserving
247
+ // width adjustment. This is an acceptable degraded experience.
248
+ });
249
+ }
250
+ };
251
+ if (!isEndState(mediaStateWithContext)) {
252
+ const uploadingPromise = new Promise(resolve => {
253
+ onMediaStateChanged(newState => {
254
+ if (isEndState(newState)) {
255
+ resolve(newState);
256
+ }
257
+ });
258
+ });
259
+ this.taskManager.addPendingTask(uploadingPromise, mediaStateWithContext.id).then(() => {
260
+ this.updateAndDispatch({
261
+ allUploadsFinished: true
262
+ });
263
+ triggerDimensionFetch();
264
+ });
265
+ } else {
266
+ // File is already in a resolved state — fetch dimensions immediately
267
+ triggerDimensionFetch();
268
+ }
269
+ const {
270
+ view
271
+ } = this;
272
+ if (!view.hasFocus()) {
273
+ view.focus();
274
+ }
275
+ return;
276
+ }
134
277
  switch (getMediaNodeInsertionType(state, this.mediaOptions, mediaStateWithContext.fileMimeType)) {
135
278
  case 'inline':
136
279
  insertMediaInlineNode(editorAnalyticsAPI)(this.view, mediaStateWithContext, collection, this.allowInlineImages, this.getInputMethod(pickerType), insertMediaVia);
137
280
  break;
138
281
  case 'block':
139
282
  // read width state right before inserting to get up-to-date and define values
140
- const widthPluginState = (_this$pluginInjection3 = this.pluginInjectionApi) === null || _this$pluginInjection3 === void 0 ? void 0 : (_this$pluginInjection4 = _this$pluginInjection3.width) === null || _this$pluginInjection4 === void 0 ? void 0 : _this$pluginInjection4.sharedState.currentState();
283
+ const widthPluginState = (_this$pluginInjection6 = this.pluginInjectionApi) === null || _this$pluginInjection6 === void 0 ? void 0 : (_this$pluginInjection7 = _this$pluginInjection6.width) === null || _this$pluginInjection7 === void 0 ? void 0 : _this$pluginInjection7.sharedState.currentState();
141
284
  insertMediaSingleNode(this.view, mediaStateWithContext, this.getInputMethod(pickerType), collection, this.mediaOptions && this.mediaOptions.alignLeftOnInsert, widthPluginState, editorAnalyticsAPI, this.onNodeInserted, insertMediaVia, this.mediaOptions && this.mediaOptions.allowPixelResizing);
142
285
  break;
143
286
  case 'group':
@@ -212,6 +355,70 @@ export class MediaPluginStateImplementation {
212
355
  }
213
356
  this.onPopupToggleCallback(true);
214
357
  });
358
+ /**
359
+ * Opens the media picker in "replace" mode. The next file selected/uploaded
360
+ * will replace the currently selected mediaSingle node's media child in-place,
361
+ * preserving layout, width, and caption.
362
+ *
363
+ * The display height is computed and stored so that after the new file's intrinsic
364
+ * dimensions are fetched, the mediaSingle display width can be adjusted to maintain
365
+ * visual height stability rather than width stability.
366
+ */
367
+ _defineProperty(this, "showMediaPickerForReplace", () => {
368
+ var _this$pluginInjection8, _this$pluginInjection9, _this$pluginInjection0, _this$pluginInjection1;
369
+ const {
370
+ state
371
+ } = this.view;
372
+ const {
373
+ mediaSingle
374
+ } = state.schema.nodes;
375
+ const {
376
+ selection
377
+ } = state;
378
+
379
+ // Only activate replace mode when a mediaSingle is selected
380
+ if (!(selection instanceof NodeSelection) || selection.node.type !== mediaSingle) {
381
+ return;
382
+ }
383
+ const mediaSingleNode = selection.node;
384
+ const mediaNode = mediaSingleNode.firstChild;
385
+ if (!mediaNode) {
386
+ return;
387
+ }
388
+
389
+ // Store the current media node's id so insertFile can identify and replace it
390
+ this.replaceMediaFileId = mediaNode.attrs.id;
391
+
392
+ // Compute and store the current display height so we can preserve it after
393
+ // the new file's intrinsic dimensions are known.
394
+ // displayHeight = displayWidth * (intrinsicHeight / intrinsicWidth)
395
+ const widthAttr = mediaSingleNode.attrs.width;
396
+ const widthType = mediaSingleNode.attrs.widthType;
397
+ const intrinsicWidth = mediaNode.attrs.width;
398
+ const intrinsicHeight = mediaNode.attrs.height;
399
+
400
+ // Resolve actual pixel display width from mediaSingle attrs.
401
+ const lineLength = (_this$pluginInjection8 = (_this$pluginInjection9 = this.pluginInjectionApi) === null || _this$pluginInjection9 === void 0 ? void 0 : (_this$pluginInjection0 = _this$pluginInjection9.width) === null || _this$pluginInjection0 === void 0 ? void 0 : (_this$pluginInjection1 = _this$pluginInjection0.sharedState.currentState()) === null || _this$pluginInjection1 === void 0 ? void 0 : _this$pluginInjection1.lineLength) !== null && _this$pluginInjection8 !== void 0 ? _this$pluginInjection8 : 760;
402
+ let displayWidth = null;
403
+ if (widthAttr && widthType === 'pixel') {
404
+ displayWidth = widthAttr;
405
+ } else if (widthAttr) {
406
+ // Default widthType is 'percentage' — convert to pixels
407
+ displayWidth = widthAttr / 100 * lineLength;
408
+ } else if (intrinsicWidth) {
409
+ // No width set at all (never resized) — fall back to intrinsic width
410
+ displayWidth = intrinsicWidth;
411
+ }
412
+ if (displayWidth && intrinsicWidth && intrinsicHeight && intrinsicWidth > 0) {
413
+ this.replaceMediaTargetDisplayHeight = displayWidth * (intrinsicHeight / intrinsicWidth);
414
+ } else {
415
+ // Can't compute display height — fall back to preserving width
416
+ this.replaceMediaTargetDisplayHeight = null;
417
+ }
418
+
419
+ // Finally, show the media picker
420
+ this.showMediaPicker();
421
+ });
215
422
  _defineProperty(this, "setBrowseFn", browseFn => {
216
423
  this.openMediaPickerBrowser = browseFn;
217
424
  });
@@ -618,8 +825,8 @@ export class MediaPluginStateImplementation {
618
825
  return;
619
826
  }
620
827
  get contextIdentifierProvider() {
621
- var _this$pluginInjection5, _this$pluginInjection6, _this$pluginInjection7;
622
- return (_this$pluginInjection5 = this.pluginInjectionApi) === null || _this$pluginInjection5 === void 0 ? void 0 : (_this$pluginInjection6 = _this$pluginInjection5.contextIdentifier) === null || _this$pluginInjection6 === void 0 ? void 0 : (_this$pluginInjection7 = _this$pluginInjection6.sharedState.currentState()) === null || _this$pluginInjection7 === void 0 ? void 0 : _this$pluginInjection7.contextIdentifierProvider;
828
+ var _this$pluginInjection10, _this$pluginInjection11, _this$pluginInjection12;
829
+ return (_this$pluginInjection10 = this.pluginInjectionApi) === null || _this$pluginInjection10 === void 0 ? void 0 : (_this$pluginInjection11 = _this$pluginInjection10.contextIdentifier) === null || _this$pluginInjection11 === void 0 ? void 0 : (_this$pluginInjection12 = _this$pluginInjection11.sharedState.currentState()) === null || _this$pluginInjection12 === void 0 ? void 0 : _this$pluginInjection12.contextIdentifierProvider;
623
830
  }
624
831
  selectLastAddedMediaNode() {
625
832
  var _this$mediaOptions5;
@@ -19,6 +19,7 @@ import ImageFullscreenIcon from '@atlaskit/icon/core/image-fullscreen';
19
19
  import ImageInlineIcon from '@atlaskit/icon/core/image-inline';
20
20
  import MaximizeIcon from '@atlaskit/icon/core/maximize';
21
21
  import SmartLinkCardIcon from '@atlaskit/icon/core/smart-link-card';
22
+ import UploadIcon from '@atlaskit/icon/core/upload';
22
23
  import { mediaFilmstripItemDOMSelector } from '@atlaskit/media-filmstrip';
23
24
  import { messages } from '@atlaskit/media-ui';
24
25
  import { fg } from '@atlaskit/platform-feature-flags';
@@ -895,6 +896,7 @@ export const floatingToolbar = (state, intl, options = {}, pluginInjectionApi) =
895
896
  if (!mediaPluginState.isResizing && areToolbarFlagsEnabled(Boolean(pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : pluginInjectionApi.toolbar))) {
896
897
  var _pluginInjectionApi$a0, _pluginInjectionApi$a1, _pluginInjectionApi$a10;
897
898
  updateToFullHeightSeparator(items);
899
+ const showReplaceOption = !isViewOnly && mediaPluginState.allowsUploads && expValEquals('platform_editor_inline_media_replacement', 'isEnabled', true) && selectedNodeType === mediaSingle;
898
900
  const customOptions = [...getLinkingDropdownOptions(state, intl, mediaLinkingState, allowMediaInline && selectedNodeType && selectedNodeType === mediaInline, allowLinking, isViewOnly), ...getAltTextDropdownOption(state, intl.formatMessage, allowAltTextOnImages, selectedNodeType, pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$a0 = pluginInjectionApi.analytics) === null || _pluginInjectionApi$a0 === void 0 ? void 0 : _pluginInjectionApi$a0.actions), ...getResizeDropdownOption(options, state, intl.formatMessage, selectedNodeType)];
899
901
  if (customOptions.length) {
900
902
  customOptions.push({
@@ -911,7 +913,17 @@ export const floatingToolbar = (state, intl, options = {}, pluginInjectionApi) =
911
913
  type: 'overflow-dropdown',
912
914
  id: 'media',
913
915
  testId: overflowDropdwonBtnTriggerTestId,
914
- options: [...customOptions, {
916
+ options: [...customOptions, ...(showReplaceOption ? [{
917
+ title: intl.formatMessage(mediaAndEmbedToolbarMessages.replaceMedia),
918
+ onClick: () => {
919
+ mediaPluginState.showMediaPickerForReplace();
920
+ return true;
921
+ },
922
+ icon: /*#__PURE__*/React.createElement(UploadIcon, {
923
+ label: ""
924
+ }),
925
+ testId: 'media-replace-toolbar-button'
926
+ }] : []), {
915
927
  title: intl === null || intl === void 0 ? void 0 : intl.formatMessage(commonMessages.copyToClipboard),
916
928
  onClick: () => {
917
929
  var _pluginInjectionApi$c0, _pluginInjectionApi$f5;
@@ -1,3 +1,7 @@
1
1
  export var hasPrivateAttrsChanged = function hasPrivateAttrsChanged(currentAttrs, newAttrs) {
2
- return currentAttrs.__fileName !== newAttrs.__fileName || currentAttrs.__fileMimeType !== newAttrs.__fileMimeType || currentAttrs.__fileSize !== newAttrs.__fileSize || currentAttrs.__contextId !== newAttrs.__contextId;
2
+ return currentAttrs.__fileName !== newAttrs.__fileName || currentAttrs.__fileMimeType !== newAttrs.__fileMimeType || currentAttrs.__fileSize !== newAttrs.__fileSize || currentAttrs.__contextId !== newAttrs.__contextId ||
3
+ // A changed id means the media source was replaced — always re-initialise
4
+ // the updater so getRemoteDimensions fetches dimensions for the new file.
5
+ // Only check when id is explicitly present on the new attrs (it's Partial).
6
+ 'id' in newAttrs && currentAttrs.id !== newAttrs.id;
3
7
  };
@@ -10,8 +10,10 @@ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t =
10
10
  import _regeneratorRuntime from "@babel/runtime/regenerator";
11
11
  // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
12
12
  import uuidV4 from 'uuid/v4';
13
+ import { SetAttrsStep } from '@atlaskit/adf-schema/steps';
13
14
  import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
14
15
  import { DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_WIDTH } from '@atlaskit/editor-common/media-single';
16
+ import { NodeSelection } from '@atlaskit/editor-prosemirror/state';
15
17
  import { getAttrsFromUrl, isImageRepresentationReady, isMediaBlobUrl as _isMediaBlobUrl } from '@atlaskit/media-client';
16
18
  import { getMediaClient } from '@atlaskit/media-client-react';
17
19
  import { getClientIdForFile } from '@atlaskit/media-common';
@@ -20,6 +22,7 @@ import { replaceExternalMedia, updateCurrentMediaNodeAttrs, updateMediaNodeAttrs
20
22
  import { stateKey as mediaStateKey } from '../pm-plugins/plugin-key';
21
23
  import { batchMediaNodeAttrsUpdate } from '../pm-plugins/utils/batchMediaNodeAttrs';
22
24
  import { getIdentifier } from '../pm-plugins/utils/media-common';
25
+ import { computeReplacementDisplayWidth } from './nodeviewHelpers';
23
26
  var isMediaTypeSupported = function isMediaTypeSupported(type) {
24
27
  if (type) {
25
28
  return ['image', 'file'].includes(type);
@@ -318,7 +321,73 @@ export var MediaNodeUpdater = /*#__PURE__*/function () {
318
321
  return attrs.__contextId || null;
319
322
  });
320
323
  _defineProperty(this, "updateDimensions", function (dimensions) {
321
- batchMediaNodeAttrsUpdate(_this.props.view, {
324
+ var view = _this.props.view;
325
+ var mediaPluginState = mediaStateKey.getState(view.state);
326
+ var targetDisplayHeight = mediaPluginState === null || mediaPluginState === void 0 ? void 0 : mediaPluginState.replaceMediaTargetDisplayHeight;
327
+ if (targetDisplayHeight !== null && targetDisplayHeight !== undefined && dimensions.width > 0) {
328
+ var _this$props$lineLengt, _mediaSingleNode$attr, _mediaSingleNode;
329
+ // Replace mode: combine intrinsic dimension update on the media node AND
330
+ // display width update on the mediaSingle into a single transaction so they
331
+ // are never out of sync and don't cause two separate renders/layout shifts.
332
+
333
+ // Clear the stored target height — this is a one-shot adjustment
334
+ if (mediaPluginState) {
335
+ mediaPluginState.replaceMediaTargetDisplayHeight = null;
336
+ }
337
+
338
+ // Clamp to the layout's maximum width so the image never overflows its container.
339
+ var lineLength = (_this$props$lineLengt = _this.props.lineLength) !== null && _this$props$lineLengt !== void 0 ? _this$props$lineLengt : 760;
340
+ var state = view.state;
341
+ var mediaSingle = state.schema.nodes.mediaSingle;
342
+ // Typed explicitly because TypeScript can't track mutations inside
343
+ // the descendants callback closure and would narrow these to `never`.
344
+ var mediaSinglePos = null;
345
+ var mediaSingleNode = null;
346
+ var mediaPos = null;
347
+ state.doc.descendants(function (node, pos) {
348
+ if (mediaSinglePos !== null) {
349
+ return false;
350
+ }
351
+ if (node.type === mediaSingle) {
352
+ var mediaChild = node.firstChild;
353
+ if (mediaChild && mediaChild.attrs.id === dimensions.id) {
354
+ mediaSinglePos = pos;
355
+ mediaSingleNode = node;
356
+ mediaPos = pos + 1;
357
+ }
358
+ }
359
+ return true;
360
+ });
361
+ var layout = (_mediaSingleNode$attr = (_mediaSingleNode = mediaSingleNode) === null || _mediaSingleNode === void 0 || (_mediaSingleNode = _mediaSingleNode.attrs) === null || _mediaSingleNode === void 0 ? void 0 : _mediaSingleNode.layout) !== null && _mediaSingleNode$attr !== void 0 ? _mediaSingleNode$attr : 'center';
362
+ var newDisplayWidth = computeReplacementDisplayWidth(targetDisplayHeight, dimensions.width, dimensions.height, layout, lineLength);
363
+ if (mediaSinglePos !== null && mediaSingleNode !== null && mediaPos !== null) {
364
+ var tr = state.tr;
365
+
366
+ // Update intrinsic dimensions on the media child node
367
+ tr.step(new SetAttrsStep(mediaPos, {
368
+ height: dimensions.height,
369
+ width: dimensions.width
370
+ }));
371
+
372
+ // Update display width on the mediaSingle parent
373
+ tr.setNodeMarkup(mediaSinglePos, undefined, _objectSpread(_objectSpread({}, mediaSingleNode.attrs), {}, {
374
+ width: Math.round(newDisplayWidth),
375
+ widthType: 'pixel'
376
+ }));
377
+
378
+ // Re-create the NodeSelection so the floating toolbar picks up
379
+ // the updated node and renders the full set of controls.
380
+ if (state.selection instanceof NodeSelection) {
381
+ tr.setSelection(NodeSelection.create(tr.doc, mediaSinglePos));
382
+ }
383
+ tr.setMeta('scrollIntoView', false);
384
+ view.dispatch(tr);
385
+ return;
386
+ }
387
+ }
388
+
389
+ // Normal (non-replace) path: use the existing batched update mechanism
390
+ batchMediaNodeAttrsUpdate(view, {
322
391
  id: dimensions.id,
323
392
  nextAttributes: {
324
393
  height: dimensions.height,
@@ -145,7 +145,8 @@ var useMediaAsyncOperations = function useMediaAsyncOperations(_ref3) {
145
145
  var mediaNode = _ref3.mediaNode,
146
146
  mediaNodeUpdater = _ref3.mediaNodeUpdater,
147
147
  addPendingTask = _ref3.addPendingTask,
148
- getPos = _ref3.getPos;
148
+ getPos = _ref3.getPos,
149
+ mediaChildNodeId = _ref3.mediaChildNodeId;
149
150
  React.useEffect(function () {
150
151
  if (!mediaNodeUpdater) {
151
152
  return;
@@ -162,7 +163,11 @@ var useMediaAsyncOperations = function useMediaAsyncOperations(_ref3) {
162
163
  mediaNode: mediaNode,
163
164
  addPendingTask: addPendingTask
164
165
  });
165
- }, [mediaNode, addPendingTask, mediaNodeUpdater, getPos]);
166
+ // mediaChildNodeId is included so this effect re-runs when the media source
167
+ // is replaced (id changes), ensuring getRemoteDimensions fires for the new file
168
+ // with up-to-date updater props after setProps has been called.
169
+ // eslint-disable-next-line react-hooks/exhaustive-deps
170
+ }, [mediaNode, addPendingTask, mediaNodeUpdater, getPos, mediaChildNodeId]);
166
171
  };
167
172
  var noop = function noop() {};
168
173
 
@@ -281,7 +286,7 @@ var useUpdateSizeCallback = function useUpdateSizeCallback(_ref5) {
281
286
  */
282
287
  var FALLBACK_MOST_COMMON_WIDTH = 760;
283
288
  export var MediaSingleNodeNext = function MediaSingleNodeNext(mediaSingleNodeNextProps) {
284
- var _pluginInjectionApi$m, _mediaNode$firstChild;
289
+ var _mediaNode$firstChild, _pluginInjectionApi$m, _mediaNode$firstChild2;
285
290
  var selected = mediaSingleNodeNextProps.selected,
286
291
  getPos = mediaSingleNodeNextProps.getPos,
287
292
  nextMediaNode = mediaSingleNodeNextProps.node,
@@ -328,7 +333,8 @@ export var MediaSingleNodeNext = function MediaSingleNodeNext(mediaSingleNodeNex
328
333
  mediaNodeUpdater: mediaNodeUpdater,
329
334
  getPos: getPos,
330
335
  mediaNode: mediaNode,
331
- addPendingTask: addPendingTask || noop
336
+ addPendingTask: addPendingTask || noop,
337
+ mediaChildNodeId: (_mediaNode$firstChild = mediaNode.firstChild) === null || _mediaNode$firstChild === void 0 ? void 0 : _mediaNode$firstChild.attrs.id
332
338
  });
333
339
  React.useLayoutEffect(function () {
334
340
  mountedRef.current = true;
@@ -423,7 +429,7 @@ export var MediaSingleNodeNext = function MediaSingleNodeNext(mediaSingleNodeNex
423
429
  }, [mediaSingleWidthAttribute, widthType, width, layout, contentWidthForLegacyExperience, containerWidth]);
424
430
  var currentMaxWidth = isSelected ? pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$m = pluginInjectionApi.media) === null || _pluginInjectionApi$m === void 0 || (_pluginInjectionApi$m = _pluginInjectionApi$m.sharedState.currentState()) === null || _pluginInjectionApi$m === void 0 ? void 0 : _pluginInjectionApi$m.currentMaxWidth : undefined;
425
431
  var contentWidth = currentMaxWidth || lineLength;
426
- var isCurrentNodeDrafting = Boolean(isDrafting && targetNodeId === (mediaNode === null || mediaNode === void 0 || (_mediaNode$firstChild = mediaNode.firstChild) === null || _mediaNode$firstChild === void 0 ? void 0 : _mediaNode$firstChild.attrs.id));
432
+ var isCurrentNodeDrafting = Boolean(isDrafting && targetNodeId === (mediaNode === null || mediaNode === void 0 || (_mediaNode$firstChild2 = mediaNode.firstChild) === null || _mediaNode$firstChild2 === void 0 ? void 0 : _mediaNode$firstChild2.attrs.id));
427
433
  var mediaSingleWrapperRef = /*#__PURE__*/React.createRef();
428
434
  var captionPlaceHolderRef = /*#__PURE__*/React.createRef();
429
435
  var browser = getBrowserInfo();