@atlaskit/editor-plugin-layout 8.0.22 → 8.1.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.
@@ -0,0 +1,361 @@
1
+ import { bind } from 'bind-event-listener';
2
+ import { Fragment } from '@atlaskit/editor-prosemirror/model';
3
+ import { Decoration } from '@atlaskit/editor-prosemirror/view';
4
+ // Class names for the column resize divider widget — must stay in sync with layout.ts in editor-core
5
+ const layoutColumnDividerClassName = 'layout-column-divider';
6
+ const layoutColumnDividerRailClassName = 'layout-column-divider-rail';
7
+ const layoutColumnDividerThumbClassName = 'layout-column-divider-thumb';
8
+
9
+ // Minimum column width percentage to prevent columns from collapsing
10
+ const MIN_COLUMN_WIDTH_PERCENT = 5;
11
+
12
+ // Module-level drag state so it survives widget DOM recreation during transactions.
13
+ let dragState = null;
14
+
15
+ /**
16
+ * Dispatches a single undoable ProseMirror transaction to commit the final
17
+ * column widths after a drag completes.
18
+ */
19
+ const dispatchColumnWidths = (view, sectionPos, leftColIndex, leftWidth, rightWidth) => {
20
+ const {
21
+ state
22
+ } = view;
23
+ const sectionNode = state.doc.nodeAt(sectionPos);
24
+ if (!sectionNode) {
25
+ return;
26
+ }
27
+ const {
28
+ layoutColumn
29
+ } = state.schema.nodes;
30
+ const tr = state.tr;
31
+ const newColumns = [];
32
+ sectionNode.forEach((child, _offset, index) => {
33
+ if (child.type === layoutColumn) {
34
+ let newWidth = child.attrs.width;
35
+ if (index === leftColIndex) {
36
+ newWidth = Number(leftWidth.toFixed(2));
37
+ } else if (index === leftColIndex + 1) {
38
+ newWidth = Number(rightWidth.toFixed(2));
39
+ }
40
+ newColumns.push(layoutColumn.create({
41
+ ...child.attrs,
42
+ width: newWidth
43
+ }, child.content, child.marks));
44
+ } else {
45
+ newColumns.push(child);
46
+ }
47
+ });
48
+ tr.replaceWith(sectionPos + 1, sectionPos + sectionNode.nodeSize - 1, Fragment.from(newColumns));
49
+ tr.setMeta('layoutColumnResize', true);
50
+ tr.setMeta('scrollIntoView', false);
51
+ view.dispatch(tr);
52
+ };
53
+
54
+ /**
55
+ * Calculates new column widths from the current mouse X position during drag.
56
+ * Uses the columns-only width cached at mousedown — no layout reflow.
57
+ *
58
+ * The denominator is `columnsWidth` (the total flex container width minus
59
+ * divider widgets and flex gaps) so that a 1 px mouse movement corresponds
60
+ * to the exact same visual shift in the column boundary, eliminating the
61
+ * few-pixel drift that occurred when using the full section width.
62
+ */
63
+ const calcDragWidths = clientX => {
64
+ if (!dragState) {
65
+ return null;
66
+ }
67
+ const {
68
+ columnsWidth
69
+ } = dragState;
70
+ if (columnsWidth === 0) {
71
+ return null;
72
+ }
73
+ const deltaX = clientX - dragState.startX;
74
+ const combinedWidth = dragState.startLeftWidth + dragState.startRightWidth;
75
+ const deltaPercent = deltaX / columnsWidth * 100;
76
+ let leftWidth = dragState.startLeftWidth + deltaPercent;
77
+ let rightWidth = dragState.startRightWidth - deltaPercent;
78
+ if (leftWidth < MIN_COLUMN_WIDTH_PERCENT) {
79
+ leftWidth = MIN_COLUMN_WIDTH_PERCENT;
80
+ rightWidth = combinedWidth - MIN_COLUMN_WIDTH_PERCENT;
81
+ } else if (rightWidth < MIN_COLUMN_WIDTH_PERCENT) {
82
+ rightWidth = MIN_COLUMN_WIDTH_PERCENT;
83
+ leftWidth = combinedWidth - MIN_COLUMN_WIDTH_PERCENT;
84
+ }
85
+ return {
86
+ leftWidth,
87
+ rightWidth
88
+ };
89
+ };
90
+ const onDragMouseMove = e => {
91
+ if (!dragState) {
92
+ return;
93
+ }
94
+
95
+ // If the mouse button was released outside the window (e.g. over browser chrome
96
+ // or an iframe), we won't receive a mouseup on ownerDoc. Detect this by checking
97
+ // whether any button is still held — if not, treat it as a drag end.
98
+ if (e.buttons === 0) {
99
+ onDragEnd(e.clientX);
100
+ return;
101
+ }
102
+
103
+ // Always capture the latest clientX so the rAF callback uses the most recent
104
+ // mouse position. Previously, intermediate positions were dropped when an rAF
105
+ // was already scheduled, causing the column boundary to lag behind the cursor.
106
+ dragState.lastClientX = e.clientX;
107
+
108
+ // If a paint frame is already scheduled it will pick up lastClientX — no need
109
+ // to schedule another one.
110
+ if (dragState.rafId !== null) {
111
+ return;
112
+ }
113
+ dragState.rafId = requestAnimationFrame(() => {
114
+ if (!dragState) {
115
+ return;
116
+ }
117
+ dragState.rafId = null;
118
+ const widths = calcDragWidths(dragState.lastClientX);
119
+ if (!widths) {
120
+ return;
121
+ }
122
+
123
+ // Write flex-basis directly onto the column elements' inline styles for immediate
124
+ // visual feedback. This beats PM's own inline flex-basis value without dispatching
125
+ // any PM transaction — keeping drag completely off the ProseMirror render path.
126
+ // The LayoutColumnView.ignoreMutation implementation ensures PM's MutationObserver
127
+ // does not revert these style changes mid-drag.
128
+ dragState.hasDragged = true;
129
+ dragState.leftColEl.style.flexBasis = `${widths.leftWidth}%`;
130
+ dragState.rightColEl.style.flexBasis = `${widths.rightWidth}%`;
131
+ });
132
+ };
133
+
134
+ /**
135
+ * Shared teardown for all drag-end paths (mouseup, missed mouseup detected via
136
+ * e.buttons===0 on mousemove, window blur, and visibilitychange). Commits the
137
+ * final column widths if a real drag occurred.
138
+ */
139
+ const onDragEnd = clientX => {
140
+ if (!dragState) {
141
+ return;
142
+ }
143
+ const {
144
+ view,
145
+ sectionPos,
146
+ leftColIndex,
147
+ leftColEl,
148
+ rightColEl,
149
+ hasDragged,
150
+ rafId,
151
+ startLeftWidth,
152
+ startRightWidth,
153
+ unbindListeners
154
+ } = dragState;
155
+ unbindListeners();
156
+
157
+ // Cancel any pending rAF so a stale frame doesn't write styles after teardown.
158
+ if (rafId !== null) {
159
+ cancelAnimationFrame(rafId);
160
+ }
161
+ const ownerDoc = view.dom.ownerDocument;
162
+ ownerDoc.body.style.userSelect = '';
163
+ ownerDoc.body.style.cursor = '';
164
+ const widths = calcDragWidths(clientX);
165
+ dragState = null;
166
+ if (!hasDragged) {
167
+ // The user clicked without dragging — no flex-basis overrides were written,
168
+ // so there is nothing to clean up and no transaction to dispatch.
169
+ return;
170
+ }
171
+
172
+ // Clear the drag-time flex-basis overrides. The PM transaction below will
173
+ // write the committed widths back into the node attrs and re-render the DOM.
174
+ leftColEl.style.flexBasis = '';
175
+ rightColEl.style.flexBasis = '';
176
+ if (widths && (widths.leftWidth !== startLeftWidth || widths.rightWidth !== startRightWidth)) {
177
+ dispatchColumnWidths(view, sectionPos, leftColIndex, widths.leftWidth, widths.rightWidth);
178
+ }
179
+ };
180
+ const onDragMouseUp = e => {
181
+ onDragEnd(e.clientX);
182
+ };
183
+
184
+ /**
185
+ * Called when the window loses focus (blur) or the tab becomes hidden
186
+ * (visibilitychange). In either case the user can't be dragging anymore,
187
+ * so we commit the drag at the last known mouse position.
188
+ */
189
+ const onDragCancel = () => {
190
+ if (!dragState) {
191
+ return;
192
+ }
193
+ // Commit at the last captured mouse position rather than startX, so the
194
+ // columns stay where the user last saw them.
195
+ onDragEnd(dragState.lastClientX);
196
+ };
197
+
198
+ /**
199
+ * Creates a column divider widget DOM element with drag-to-resize interaction for
200
+ * the adjacent layout columns. During drag, flex-basis is mutated directly on the
201
+ * column DOM elements for zero-overhead visual feedback (no PM transactions).
202
+ * A single undoable PM transaction is dispatched on mouseup to commit the final widths.
203
+ */
204
+ const createColumnDividerWidget = (view, sectionPos, columnIndex // index of the column to the RIGHT of this divider
205
+ ) => {
206
+ const ownerDoc = view.dom.ownerDocument;
207
+
208
+ // Outer container: wide transparent hit area for easy grabbing, zero flex footprint
209
+ const divider = ownerDoc.createElement('div');
210
+ divider.classList.add(layoutColumnDividerClassName);
211
+ divider.contentEditable = 'false';
212
+
213
+ // Rail: styled via layoutColumnDividerStyles in layout.ts
214
+ const rail = ownerDoc.createElement('div');
215
+ rail.classList.add(layoutColumnDividerRailClassName);
216
+ divider.appendChild(rail);
217
+
218
+ // Thumb: styled via layoutColumnDividerStyles in layout.ts
219
+ const thumb = ownerDoc.createElement('div');
220
+ thumb.classList.add(layoutColumnDividerThumbClassName);
221
+ rail.appendChild(thumb);
222
+ const leftColIndex = columnIndex - 1;
223
+ bind(divider, {
224
+ type: 'mousedown',
225
+ listener: e => {
226
+ var _ownerDoc$defaultView;
227
+ e.preventDefault();
228
+ e.stopPropagation();
229
+ const sectionNode = view.state.doc.nodeAt(sectionPos);
230
+ if (!sectionNode) {
231
+ return;
232
+ }
233
+
234
+ // Get the initial widths of the two adjacent columns
235
+ let leftCol = null;
236
+ let rightCol = null;
237
+ sectionNode.forEach((child, _offset, index) => {
238
+ if (index === leftColIndex) {
239
+ leftCol = child;
240
+ }
241
+ if (index === leftColIndex + 1) {
242
+ rightCol = child;
243
+ }
244
+ });
245
+ if (!leftCol || !rightCol) {
246
+ return;
247
+ }
248
+ const sectionElement = divider.closest('[data-layout-section]');
249
+ if (!(sectionElement instanceof HTMLElement)) {
250
+ return;
251
+ }
252
+
253
+ // Capture the two adjacent column DOM elements upfront so mousemove can
254
+ // mutate their flex-basis directly without any PM transaction overhead.
255
+ // Query by data-layout-column-index (stamped by LayoutColumnView) rather than
256
+ // relying on positional order of [data-layout-column] elements, which would
257
+ // break if the DOM structure or ordering ever changes.
258
+ const leftColEl = sectionElement.querySelector(`[data-layout-column-index="${leftColIndex}"]`);
259
+ const rightColEl = sectionElement.querySelector(`[data-layout-column-index="${leftColIndex + 1}"]`);
260
+ if (!leftColEl || !rightColEl) {
261
+ return;
262
+ }
263
+ const unbindMove = bind(ownerDoc, {
264
+ type: 'mousemove',
265
+ listener: onDragMouseMove
266
+ });
267
+ const unbindUp = bind(ownerDoc, {
268
+ type: 'mouseup',
269
+ listener: onDragMouseUp
270
+ });
271
+ // If the user releases the mouse outside the browser window (e.g. over the
272
+ // OS desktop) and then brings the cursor back, we won't get a mouseup on
273
+ // ownerDoc. Listening on window for blur and on the document for
274
+ // visibilitychange catches tab switches and window focus loss respectively.
275
+ const unbindBlur = bind((_ownerDoc$defaultView = ownerDoc.defaultView) !== null && _ownerDoc$defaultView !== void 0 ? _ownerDoc$defaultView : window, {
276
+ type: 'blur',
277
+ listener: onDragCancel
278
+ });
279
+ const unbindVisibility = bind(ownerDoc, {
280
+ type: 'visibilitychange',
281
+ listener: onDragCancel
282
+ });
283
+
284
+ // Compute the width available to columns only (excluding divider widgets and
285
+ // flex gaps). Using this as the denominator ensures that a 1 px mouse delta
286
+ // translates to the exact pixel shift on the column boundary.
287
+ const sectionRect = sectionElement.getBoundingClientRect();
288
+ const dividers = sectionElement.querySelectorAll(`.${layoutColumnDividerClassName}`);
289
+ let dividersWidth = 0;
290
+ dividers.forEach(d => {
291
+ dividersWidth += d.getBoundingClientRect().width;
292
+ });
293
+ // Account for CSS gap between flex children. The gap is applied between
294
+ // every pair of direct children (columns + divider widgets).
295
+ const computedGap = parseFloat(getComputedStyle(sectionElement).gap || '0');
296
+ const childCount = sectionElement.children.length;
297
+ const totalGap = childCount > 1 ? computedGap * (childCount - 1) : 0;
298
+ const columnsWidth = sectionRect.width - dividersWidth - totalGap;
299
+ dragState = {
300
+ hasDragged: false,
301
+ lastClientX: e.clientX,
302
+ rafId: null,
303
+ view,
304
+ sectionPos,
305
+ leftColIndex,
306
+ leftColEl,
307
+ rightColEl,
308
+ startX: e.clientX,
309
+ startLeftWidth: leftCol.attrs.width,
310
+ startRightWidth: rightCol.attrs.width,
311
+ columnsWidth,
312
+ sectionElement,
313
+ unbindListeners: () => {
314
+ unbindMove();
315
+ unbindUp();
316
+ unbindBlur();
317
+ unbindVisibility();
318
+ }
319
+ };
320
+ ownerDoc.body.style.userSelect = 'none';
321
+ ownerDoc.body.style.cursor = 'col-resize';
322
+ }
323
+ });
324
+ return divider;
325
+ };
326
+
327
+ /**
328
+ * Returns ProseMirror Decoration widgets for column dividers between layout columns.
329
+ * Each divider supports drag-to-resize interaction for the adjacent columns.
330
+ */
331
+ export const getColumnDividerDecorations = (state, view) => {
332
+ const decorations = [];
333
+ if (!view) {
334
+ return decorations;
335
+ }
336
+ const {
337
+ layoutSection
338
+ } = state.schema.nodes;
339
+ state.doc.descendants((node, pos) => {
340
+ if (node.type === layoutSection) {
341
+ // Walk through layout column children and add dividers between them
342
+ node.forEach((child, offset, index) => {
343
+ // Add a divider widget BEFORE every column except the first
344
+ if (index > 0) {
345
+ const sectionPos = pos;
346
+ const colIndex = index;
347
+ const widgetPos = pos + offset + 1; // position at the start of this column
348
+ decorations.push(Decoration.widget(widgetPos, () => createColumnDividerWidget(view, sectionPos, colIndex), {
349
+ side: -1,
350
+ // place before the position
351
+ key: `layout-col-divider-${pos}-${index}`,
352
+ ignoreSelection: true
353
+ }));
354
+ }
355
+ });
356
+ return false; // don't descend into children
357
+ }
358
+ return true; // continue descending
359
+ });
360
+ return decorations;
361
+ };
@@ -8,6 +8,7 @@ import { findParentNodeClosestToPos, findParentNodeOfType } from '@atlaskit/edit
8
8
  import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view';
9
9
  import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
10
10
  import { fixColumnSizes, fixColumnStructure, getSelectedLayout } from './actions';
11
+ import { getColumnDividerDecorations } from './column-resize-divider';
11
12
  import { EVEN_DISTRIBUTED_COL_WIDTHS } from './consts';
12
13
  import { pluginKey } from './plugin-key';
13
14
  import { getMaybeLayoutSection } from './utils';
@@ -92,103 +93,132 @@ const handleDeleteLayoutColumn = (state, dispatch) => {
92
93
  }
93
94
  return false;
94
95
  };
95
- export default (options => new SafePlugin({
96
- key: pluginKey,
97
- state: {
98
- init: (_, state) => getInitialPluginState(options, state),
99
- apply: (tr, pluginState, oldState, newState) => {
100
- var _tr$getMeta, _pluginKey$getState;
101
- const isResizing = editorExperiment('single_column_layouts', true) ? (_tr$getMeta = tr.getMeta('is-resizer-resizing')) !== null && _tr$getMeta !== void 0 ? _tr$getMeta : (_pluginKey$getState = pluginKey.getState(oldState)) === null || _pluginKey$getState === void 0 ? void 0 : _pluginKey$getState.isResizing : false;
102
- if (tr.docChanged || tr.selectionSet) {
103
- const maybeLayoutSection = getMaybeLayoutSection(newState);
104
- const newPluginState = {
105
- ...pluginState,
106
- pos: maybeLayoutSection ? maybeLayoutSection.pos : null,
107
- isResizing,
108
- selectedLayout: getSelectedLayout(maybeLayoutSection && maybeLayoutSection.node,
109
- // Ignored via go/ees005
110
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
111
- pluginState.selectedLayout)
112
- };
113
- return newPluginState;
114
- }
96
+ export default (options => {
97
+ // Store a reference to the EditorView so widget decorations can dispatch transactions
98
+ let editorViewRef;
99
+ return new SafePlugin({
100
+ key: pluginKey,
101
+ view(view) {
102
+ editorViewRef = view;
115
103
  return {
116
- ...pluginState,
117
- isResizing
104
+ update(updatedView) {
105
+ editorViewRef = updatedView;
106
+ },
107
+ destroy() {
108
+ editorViewRef = undefined;
109
+ }
118
110
  };
119
- }
120
- },
121
- props: {
122
- decorations(state) {
123
- const layoutState = pluginKey.getState(state);
124
- if (layoutState.pos !== null) {
125
- return DecorationSet.create(state.doc, getNodeDecoration(layoutState.pos, state.doc.nodeAt(layoutState.pos)));
126
- }
127
- return undefined;
128
111
  },
129
- handleKeyDown: keydownHandler({
130
- Tab: filter(isWholeSelectionInsideLayoutColumn, moveCursorToNextColumn),
131
- 'Mod-Backspace': handleDeleteLayoutColumn,
132
- 'Mod-Delete': handleDeleteLayoutColumn,
133
- Backspace: handleDeleteLayoutColumn,
134
- Delete: handleDeleteLayoutColumn
135
- }),
136
- handleClickOn: createSelectionClickHandler(['layoutColumn'], target => target.hasAttribute('data-layout-section') || target.hasAttribute('data-layout-column'), {
137
- useLongPressSelection: options.useLongPressSelection || false,
138
- getNodeSelectionPos: (state, nodePos) => state.doc.resolve(nodePos).before()
139
- })
140
- },
141
- appendTransaction: (transactions, _oldState, newState) => {
142
- const changes = [];
143
- transactions.forEach(prevTr => {
144
- // remap change segments across the transaction set
145
- changes.forEach(change => {
112
+ state: {
113
+ init: (_, state) => getInitialPluginState(options, state),
114
+ apply: (tr, pluginState, oldState, newState) => {
115
+ var _tr$getMeta, _pluginKey$getState;
116
+ const isResizing = editorExperiment('single_column_layouts', true) ? (_tr$getMeta = tr.getMeta('is-resizer-resizing')) !== null && _tr$getMeta !== void 0 ? _tr$getMeta : (_pluginKey$getState = pluginKey.getState(oldState)) === null || _pluginKey$getState === void 0 ? void 0 : _pluginKey$getState.isResizing : false;
117
+ if (tr.docChanged || tr.selectionSet) {
118
+ const maybeLayoutSection = getMaybeLayoutSection(newState);
119
+ const newPluginState = {
120
+ ...pluginState,
121
+ pos: maybeLayoutSection ? maybeLayoutSection.pos : null,
122
+ isResizing,
123
+ selectedLayout: getSelectedLayout(maybeLayoutSection && maybeLayoutSection.node,
124
+ // Ignored via go/ees005
125
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
126
+ pluginState.selectedLayout)
127
+ };
128
+ return newPluginState;
129
+ }
146
130
  return {
147
- from: prevTr.mapping.map(change.from),
148
- to: prevTr.mapping.map(change.to),
149
- slice: change.slice
131
+ ...pluginState,
132
+ isResizing
150
133
  };
151
- });
152
-
153
- // don't consider transactions that don't mutate
154
- if (!prevTr.docChanged) {
155
- return;
156
- }
157
- const change = fixColumnSizes(prevTr, newState);
158
- if (change) {
159
- changes.push(change);
160
134
  }
161
- });
162
- if (editorExperiment('advanced_layouts', true) && changes.length === 1) {
163
- var _change$slice$content, _change$slice$content2;
164
- const change = changes[0];
165
- // When editorExperiment('single_column_layouts', true) is on
166
- // delete can create a single column layout
167
- // otherwise we replace the single column layout with its content
168
- if (!editorExperiment('single_column_layouts', true) && change.slice.content.childCount === 1 && ((_change$slice$content = change.slice.content.firstChild) === null || _change$slice$content === void 0 ? void 0 : _change$slice$content.type.name) === 'layoutColumn' && ((_change$slice$content2 = change.slice.content.firstChild) === null || _change$slice$content2 === void 0 ? void 0 : _change$slice$content2.attrs.width) === EVEN_DISTRIBUTED_COL_WIDTHS[1]) {
169
- const tr = newState.tr;
170
- const {
171
- content
172
- } = change.slice.content.firstChild;
173
- tr.replaceWith(change.from - 1, change.to, content);
174
- return tr;
175
- }
176
- }
177
- if (changes.length) {
178
- let tr = newState.tr;
179
- const selection = newState.selection.toJSON();
180
- changes.forEach(change => {
181
- tr.replaceRange(change.from, change.to, change.slice);
135
+ },
136
+ props: {
137
+ decorations(state) {
138
+ const layoutState = pluginKey.getState(state);
139
+ if (editorExperiment('advanced_layouts', true) && editorExperiment('platform_editor_layout_column_resize_handle', true)) {
140
+ const dividerDecorations = getColumnDividerDecorations(state, editorViewRef);
141
+ const selectedDecorations = layoutState.pos !== null ? getNodeDecoration(layoutState.pos, state.doc.nodeAt(layoutState.pos)) : [];
142
+ const allDecorations = [...selectedDecorations, ...dividerDecorations];
143
+ if (allDecorations.length > 0) {
144
+ return DecorationSet.create(state.doc, allDecorations);
145
+ }
146
+ return undefined;
147
+ }
148
+ if (layoutState.pos !== null) {
149
+ return DecorationSet.create(state.doc, getNodeDecoration(layoutState.pos, state.doc.nodeAt(layoutState.pos)));
150
+ }
151
+ return undefined;
152
+ },
153
+ handleKeyDown: keydownHandler({
154
+ Tab: filter(isWholeSelectionInsideLayoutColumn, moveCursorToNextColumn),
155
+ 'Mod-Backspace': handleDeleteLayoutColumn,
156
+ 'Mod-Delete': handleDeleteLayoutColumn,
157
+ Backspace: handleDeleteLayoutColumn,
158
+ Delete: handleDeleteLayoutColumn
159
+ }),
160
+ handleClickOn: createSelectionClickHandler(['layoutColumn'], target => target.hasAttribute('data-layout-section') || target.hasAttribute('data-layout-column'), {
161
+ useLongPressSelection: options.useLongPressSelection || false,
162
+ getNodeSelectionPos: (state, nodePos) => state.doc.resolve(nodePos).before()
163
+ })
164
+ },
165
+ appendTransaction: (transactions, _oldState, newState) => {
166
+ const changes = [];
167
+ transactions.forEach(prevTr => {
168
+ // remap change segments across the transaction set
169
+ changes.forEach(change => {
170
+ return {
171
+ from: prevTr.mapping.map(change.from),
172
+ to: prevTr.mapping.map(change.to),
173
+ slice: change.slice
174
+ };
175
+ });
176
+
177
+ // don't consider transactions that don't mutate
178
+ if (!prevTr.docChanged) {
179
+ return;
180
+ }
181
+
182
+ // Skip fixing column sizes for column resize drag transactions
183
+ if (editorExperiment('platform_editor_layout_column_resize_handle', true) && prevTr.getMeta('layoutColumnResize')) {
184
+ return;
185
+ }
186
+ const change = fixColumnSizes(prevTr, newState);
187
+ if (change) {
188
+ changes.push(change);
189
+ }
182
190
  });
191
+ if (editorExperiment('advanced_layouts', true) && changes.length === 1) {
192
+ var _change$slice$content, _change$slice$content2;
193
+ const change = changes[0];
194
+ // When editorExperiment('single_column_layouts', true) is on
195
+ // delete can create a single column layout
196
+ // otherwise we replace the single column layout with its content
197
+ if (!editorExperiment('single_column_layouts', true) && change.slice.content.childCount === 1 && ((_change$slice$content = change.slice.content.firstChild) === null || _change$slice$content === void 0 ? void 0 : _change$slice$content.type.name) === 'layoutColumn' && ((_change$slice$content2 = change.slice.content.firstChild) === null || _change$slice$content2 === void 0 ? void 0 : _change$slice$content2.attrs.width) === EVEN_DISTRIBUTED_COL_WIDTHS[1]) {
198
+ const tr = newState.tr;
199
+ const {
200
+ content
201
+ } = change.slice.content.firstChild;
202
+ tr.replaceWith(change.from - 1, change.to, content);
203
+ return tr;
204
+ }
205
+ }
206
+ if (changes.length) {
207
+ let tr = newState.tr;
208
+ const selection = newState.selection.toJSON();
209
+ changes.forEach(change => {
210
+ tr.replaceRange(change.from, change.to, change.slice);
211
+ });
183
212
 
184
- // selecting and deleting across columns in 3 col layouts can remove
185
- // a layoutColumn so we fix the structure here
186
- tr = fixColumnStructure(newState) || tr;
187
- if (tr.docChanged) {
188
- tr.setSelection(Selection.fromJSON(tr.doc, selection));
189
- return tr;
213
+ // selecting and deleting across columns in 3 col layouts can remove
214
+ // a layoutColumn so we fix the structure here
215
+ tr = fixColumnStructure(newState) || tr;
216
+ if (tr.docChanged) {
217
+ tr.setSelection(Selection.fromJSON(tr.doc, selection));
218
+ return tr;
219
+ }
190
220
  }
221
+ return;
191
222
  }
192
- return;
193
- }
194
- }));
223
+ });
224
+ });
@@ -1,7 +1,61 @@
1
1
  import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
2
+ import { DOMSerializer } from '@atlaskit/editor-prosemirror/model';
2
3
  import { PluginKey } from '@atlaskit/editor-prosemirror/state';
4
+ import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
3
5
  import { LayoutSectionView } from '../nodeviews';
4
6
  export const pluginKey = new PluginKey('layoutResizingPlugin');
7
+
8
+ /**
9
+ * Minimal node view for layoutColumn that delegates all DOM serialization to the
10
+ * NodeSpec's own toDOM, but overrides ignoreMutation to suppress style attribute
11
+ * mutations from ProseMirror's MutationObserver.
12
+ *
13
+ * This is necessary so that direct inline style mutations during column drag
14
+ * (e.g. setting flex-basis to give real-time visual feedback without dispatching
15
+ * PM transactions) are not "corrected" back by ProseMirror's DOM reconciliation.
16
+ */
17
+ class LayoutColumnView {
18
+ constructor(node, view, getPos) {
19
+ // Use the NodeSpec's own toDOM to produce the correct DOM structure and attributes.
20
+ const nodeType = view.state.schema.nodes[node.type.name];
21
+
22
+ // Fallback: create a plain div so PM always has a valid DOM node to work with.
23
+ // This path should never be reached in practice — layoutColumn always has a toDOM.
24
+ if (!nodeType.spec.toDOM) {
25
+ const fallbackDiv = document.createElement('div');
26
+ this.dom = fallbackDiv;
27
+ this.contentDOM = fallbackDiv;
28
+ return;
29
+ }
30
+ const {
31
+ dom,
32
+ contentDOM
33
+ } = DOMSerializer.renderSpec(document, nodeType.spec.toDOM(node));
34
+ if (!(dom instanceof HTMLElement) || !(contentDOM instanceof HTMLElement)) {
35
+ const fallbackDiv = document.createElement('div');
36
+ this.dom = fallbackDiv;
37
+ this.contentDOM = fallbackDiv;
38
+ return;
39
+ }
40
+ this.dom = dom;
41
+ this.contentDOM = contentDOM;
42
+
43
+ // Stamp the column's index within its parent section onto the DOM element so that
44
+ // column-resize-divider can query columns by index rather than relying on positional
45
+ // order of [data-layout-column] elements (which could break if the DOM structure changes).
46
+ const pos = getPos();
47
+ if (pos !== undefined) {
48
+ const $pos = view.state.doc.resolve(pos);
49
+ this.dom.setAttribute('data-layout-column-index', String($pos.index()));
50
+ }
51
+ }
52
+ ignoreMutation(mutation) {
53
+ // Ignore style attribute mutations — these are direct DOM writes during column drag
54
+ // (setting flex-basis for real-time resize feedback). Without this, PM's
55
+ // MutationObserver would immediately revert our style changes.
56
+ return mutation.type === 'attributes' && mutation.attributeName === 'style';
57
+ }
58
+ }
5
59
  export default ((options, pluginInjectionApi, portalProviderAPI, eventDispatcher) => new SafePlugin({
6
60
  key: pluginKey,
7
61
  props: {
@@ -16,7 +70,13 @@ export default ((options, pluginInjectionApi, portalProviderAPI, eventDispatcher
16
70
  pluginInjectionApi,
17
71
  options
18
72
  }).init();
19
- }
73
+ },
74
+ // Only register the column node view when the resize handle experiment is on.
75
+ // It exists solely to suppress style-attribute MutationObserver callbacks
76
+ // during drag, allowing direct flex-basis writes without PM interference.
77
+ ...(editorExperiment('platform_editor_layout_column_resize_handle', true) ? {
78
+ layoutColumn: (node, view, getPos) => new LayoutColumnView(node, view, getPos)
79
+ } : {})
20
80
  }
21
81
  }
22
82
  }));