dockview-core 5.0.0 → 5.2.0

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,5 +1,5 @@
1
1
  import { getPanelData } from '../../../dnd/dataTransfer';
2
- import { addClasses, isChildEntirelyVisibleWithinParent, OverflowObserver, removeClasses, } from '../../../dom';
2
+ import { addClasses, isChildEntirelyVisibleWithinParent, OverflowObserver, removeClasses, toggleClass, } from '../../../dom';
3
3
  import { addDisposableListener, Emitter } from '../../../events';
4
4
  import { CompositeDisposable, Disposable, MutableDisposable, } from '../../../lifecycle';
5
5
  import { Scrollbar } from '../../../scrollbar';
@@ -66,6 +66,7 @@ export class Tabs extends CompositeDisposable {
66
66
  this.selectedIndex = -1;
67
67
  this._showTabsOverflowControl = false;
68
68
  this._direction = 'horizontal';
69
+ this._animState = null;
69
70
  this._onTabDragStart = new Emitter();
70
71
  this.onTabDragStart = this._onTabDragStart.event;
71
72
  this._onDrop = new Emitter();
@@ -94,7 +95,88 @@ export class Tabs extends CompositeDisposable {
94
95
  if (isLeftClick) {
95
96
  this.accessor.doSetGroupActive(this.group);
96
97
  }
97
- }), Disposable.from(() => {
98
+ }), addDisposableListener(this._tabsList, 'dragover', (event) => {
99
+ if (!this._animState) {
100
+ // Check for external drag from another group
101
+ if (this.accessor.options.tabAnimation !== 'smooth' ||
102
+ this.accessor.options.disableDnd) {
103
+ return;
104
+ }
105
+ const data = getPanelData();
106
+ if (data &&
107
+ data.panelId &&
108
+ data.groupId !== this.group.id) {
109
+ this._animState = {
110
+ sourceTabId: data.panelId,
111
+ sourceIndex: -1,
112
+ tabPositions: this.snapshotTabPositions(),
113
+ currentInsertionIndex: null,
114
+ };
115
+ }
116
+ else {
117
+ return;
118
+ }
119
+ }
120
+ this.handleDragOver(event);
121
+ }, true), addDisposableListener(this._tabsList, 'dragleave', (event) => {
122
+ if (!this._animState) {
123
+ return;
124
+ }
125
+ // Only handle if leaving the container itself, not moving between children
126
+ if (event.relatedTarget &&
127
+ this._tabsList.contains(event.relatedTarget)) {
128
+ return;
129
+ }
130
+ this.resetTabTransforms();
131
+ if (this._animState) {
132
+ if (this._animState.sourceIndex === -1) {
133
+ // External drag left — clear state entirely
134
+ // (no dragend will fire on this tab list)
135
+ this._animState = null;
136
+ }
137
+ else {
138
+ this._animState.currentInsertionIndex = null;
139
+ }
140
+ }
141
+ }, true), addDisposableListener(this._tabsList, 'dragend', () => {
142
+ // Only fires for cancel (not after successful drop, since
143
+ // source tab is removed from DOM and doesn't bubble)
144
+ this.resetDragAnimation();
145
+ }), addDisposableListener(this._tabsList, 'drop', (event) => {
146
+ if (this.accessor.options.tabAnimation !== 'smooth' ||
147
+ !this._animState ||
148
+ this._animState.currentInsertionIndex === null) {
149
+ return;
150
+ }
151
+ event.stopPropagation();
152
+ event.preventDefault();
153
+ const animState = this._animState;
154
+ this._animState = null;
155
+ const insertionIndex = animState.currentInsertionIndex;
156
+ const sourceIndex = animState.sourceIndex;
157
+ // After the source tab is removed, indices after it shift
158
+ // down by one, so adjust the target index accordingly.
159
+ const adjustedIndex = insertionIndex -
160
+ (sourceIndex !== -1 && sourceIndex < insertionIndex
161
+ ? 1
162
+ : 0);
163
+ // No-op: drop at the same position, nothing to animate
164
+ if (adjustedIndex === sourceIndex) {
165
+ this.resetTabTransforms();
166
+ return;
167
+ }
168
+ // Snapshot current visual positions (with margins still applied)
169
+ // before resetting transforms, so FLIP starts from what the
170
+ // user currently sees — not from a teleported state.
171
+ const firstPositions = this.snapshotTabPositions();
172
+ this.resetTabTransforms();
173
+ this._onDrop.fire({ event, index: adjustedIndex });
174
+ this.runFlipAnimation(firstPositions, animState.sourceTabId, animState.sourceIndex === -1, {
175
+ from: Math.min(sourceIndex, adjustedIndex),
176
+ to: Math.max(sourceIndex, adjustedIndex),
177
+ });
178
+ }, true), Disposable.from(() => {
179
+ this.resetDragAnimation();
98
180
  for (const { value, disposable } of this._tabs) {
99
181
  disposable.dispose();
100
182
  value.dispose();
@@ -134,6 +216,14 @@ export class Tabs extends CompositeDisposable {
134
216
  tab.setContent(panel.view.tab);
135
217
  const disposable = new CompositeDisposable(tab.onDragStart((event) => {
136
218
  this._onTabDragStart.fire({ nativeEvent: event, panel });
219
+ if (this.accessor.options.tabAnimation === 'smooth') {
220
+ this._animState = {
221
+ sourceTabId: panel.id,
222
+ sourceIndex: this._tabs.findIndex((x) => x.value === tab),
223
+ tabPositions: this.snapshotTabPositions(),
224
+ currentInsertionIndex: null,
225
+ };
226
+ }
137
227
  }), tab.onPointerDown((event) => {
138
228
  if (event.defaultPrevented) {
139
229
  return;
@@ -163,10 +253,29 @@ export class Tabs extends CompositeDisposable {
163
253
  break;
164
254
  }
165
255
  }), tab.onDrop((event) => {
166
- this._onDrop.fire({
167
- event: event.nativeEvent,
168
- index: this._tabs.findIndex((x) => x.value === tab),
169
- });
256
+ const animState = this._animState;
257
+ this._animState = null;
258
+ const dropIndex = this._tabs.findIndex((x) => x.value === tab);
259
+ if (animState) {
260
+ const firstPositions = this.snapshotTabPositions();
261
+ this.resetTabTransforms();
262
+ this._onDrop.fire({
263
+ event: event.nativeEvent,
264
+ index: dropIndex,
265
+ });
266
+ this.runFlipAnimation(firstPositions, animState.sourceTabId, animState.sourceIndex === -1, animState.sourceIndex !== -1
267
+ ? {
268
+ from: Math.min(animState.sourceIndex, dropIndex),
269
+ to: Math.max(animState.sourceIndex, dropIndex),
270
+ }
271
+ : undefined);
272
+ }
273
+ else {
274
+ this._onDrop.fire({
275
+ event: event.nativeEvent,
276
+ index: dropIndex,
277
+ });
278
+ }
170
279
  }), tab.onWillShowOverlay((event) => {
171
280
  this._onWillShowOverlay.fire(new DockviewWillShowOverlayLocationEvent(event, {
172
281
  kind: 'tab',
@@ -178,14 +287,29 @@ export class Tabs extends CompositeDisposable {
178
287
  }));
179
288
  const value = { value: tab, disposable };
180
289
  this.addTab(value, index);
290
+ // If a tab was added during active drag, refresh positions
291
+ if (this._animState) {
292
+ this._animState.tabPositions = this.snapshotTabPositions();
293
+ this.applyDragOverTransforms();
294
+ }
181
295
  }
182
296
  delete(id) {
297
+ var _a;
298
+ if (((_a = this._animState) === null || _a === void 0 ? void 0 : _a.sourceTabId) === id) {
299
+ this.resetTabTransforms();
300
+ this._animState = null;
301
+ }
183
302
  const index = this.indexOf(id);
184
303
  const tabToRemove = this._tabs.splice(index, 1)[0];
185
304
  const { value, disposable } = tabToRemove;
186
305
  disposable.dispose();
187
306
  value.dispose();
188
307
  value.element.remove();
308
+ // If a non-source tab was removed during active drag, refresh positions
309
+ if (this._animState) {
310
+ this._animState.tabPositions = this.snapshotTabPositions();
311
+ this.applyDragOverTransforms();
312
+ }
189
313
  }
190
314
  addTab(tab, index = this._tabs.length) {
191
315
  if (index < 0 || index > this._tabs.length) {
@@ -214,4 +338,155 @@ export class Tabs extends CompositeDisposable {
214
338
  tab.value.updateDragAndDropState();
215
339
  }
216
340
  }
341
+ snapshotTabPositions() {
342
+ const positions = new Map();
343
+ for (const tab of this._tabs) {
344
+ positions.set(tab.value.panel.id, tab.value.element.getBoundingClientRect());
345
+ }
346
+ return positions;
347
+ }
348
+ getAverageTabWidth() {
349
+ if (this._tabs.length === 0) {
350
+ return 0;
351
+ }
352
+ let totalWidth = 0;
353
+ for (const tab of this._tabs) {
354
+ totalWidth += tab.value.element.getBoundingClientRect().width;
355
+ }
356
+ return totalWidth / this._tabs.length;
357
+ }
358
+ handleDragOver(event) {
359
+ if (!this._animState) {
360
+ return;
361
+ }
362
+ const mouseX = event.clientX;
363
+ let insertionIndex = null;
364
+ for (let i = 0; i < this._tabs.length; i++) {
365
+ const tab = this._tabs[i].value;
366
+ if (tab.panel.id === this._animState.sourceTabId) {
367
+ continue;
368
+ }
369
+ const rect = tab.element.getBoundingClientRect();
370
+ const midpoint = rect.left + rect.width / 2;
371
+ if (mouseX < midpoint) {
372
+ insertionIndex = i;
373
+ break;
374
+ }
375
+ insertionIndex = i + 1;
376
+ }
377
+ if (insertionIndex === this._animState.currentInsertionIndex) {
378
+ return;
379
+ }
380
+ this._animState.currentInsertionIndex = insertionIndex;
381
+ this.applyDragOverTransforms();
382
+ }
383
+ applyDragOverTransforms() {
384
+ if (!this._animState ||
385
+ this._animState.currentInsertionIndex === null) {
386
+ this.resetTabTransforms();
387
+ return;
388
+ }
389
+ const insertionIndex = this._animState.currentInsertionIndex;
390
+ const sourceRect = this._animState.tabPositions.get(this._animState.sourceTabId);
391
+ const gapWidth = sourceRect
392
+ ? sourceRect.width
393
+ : this.getAverageTabWidth();
394
+ // Find the first non-source tab at insertionIndex to receive the gap margin
395
+ let gapApplied = false;
396
+ for (let i = 0; i < this._tabs.length; i++) {
397
+ const tab = this._tabs[i].value;
398
+ if (tab.panel.id === this._animState.sourceTabId) {
399
+ continue;
400
+ }
401
+ if (!gapApplied && i >= insertionIndex) {
402
+ tab.element.style.marginLeft = `${gapWidth}px`;
403
+ toggleClass(tab.element, 'dv-tab--shifting', true);
404
+ gapApplied = true;
405
+ }
406
+ else {
407
+ // Keep shifting class while margin animates back to 0,
408
+ // then remove both once the transition ends
409
+ if (tab.element.style.marginLeft) {
410
+ tab.element.style.marginLeft = '0px';
411
+ toggleClass(tab.element, 'dv-tab--shifting', true);
412
+ const onEnd = () => {
413
+ tab.element.style.removeProperty('margin-left');
414
+ toggleClass(tab.element, 'dv-tab--shifting', false);
415
+ tab.element.removeEventListener('transitionend', onEnd);
416
+ };
417
+ tab.element.addEventListener('transitionend', onEnd);
418
+ }
419
+ else {
420
+ toggleClass(tab.element, 'dv-tab--shifting', false);
421
+ }
422
+ }
423
+ }
424
+ }
425
+ resetTabTransforms() {
426
+ for (const tab of this._tabs) {
427
+ tab.value.element.style.removeProperty('margin-left');
428
+ tab.value.element.style.removeProperty('transform');
429
+ toggleClass(tab.value.element, 'dv-tab--shifting', false);
430
+ }
431
+ }
432
+ resetDragAnimation() {
433
+ this.resetTabTransforms();
434
+ this._animState = null;
435
+ for (const tab of this._tabs) {
436
+ toggleClass(tab.value.element, 'dv-tab--dragging', false);
437
+ }
438
+ }
439
+ runFlipAnimation(firstPositions, sourceTabId, isCrossGroup = false, animRange) {
440
+ let hasAnimation = false;
441
+ for (let i = 0; i < this._tabs.length; i++) {
442
+ const tab = this._tabs[i];
443
+ const panelId = tab.value.panel.id;
444
+ if (panelId === sourceTabId) {
445
+ if (isCrossGroup) {
446
+ // Newly inserted tab: slide in from the right
447
+ const rect = tab.value.element.getBoundingClientRect();
448
+ tab.value.element.style.transform = `translateX(${rect.width}px)`;
449
+ toggleClass(tab.value.element, 'dv-tab--shifting', true);
450
+ hasAnimation = true;
451
+ }
452
+ continue;
453
+ }
454
+ // Skip tabs outside the affected range (they don't logically move)
455
+ if (animRange !== undefined &&
456
+ (i < animRange.from || i > animRange.to)) {
457
+ continue;
458
+ }
459
+ const firstRect = firstPositions.get(panelId);
460
+ if (!firstRect) {
461
+ continue;
462
+ }
463
+ const lastRect = tab.value.element.getBoundingClientRect();
464
+ const deltaX = firstRect.left - lastRect.left;
465
+ if (Math.abs(deltaX) < 1) {
466
+ continue;
467
+ }
468
+ tab.value.element.style.transform = `translateX(${deltaX}px)`;
469
+ toggleClass(tab.value.element, 'dv-tab--shifting', true);
470
+ hasAnimation = true;
471
+ }
472
+ if (!hasAnimation) {
473
+ return;
474
+ }
475
+ requestAnimationFrame(() => {
476
+ for (const tab of this._tabs) {
477
+ if (tab.value.element.style.transform) {
478
+ tab.value.element.style.transform = '';
479
+ }
480
+ }
481
+ const onTransitionEnd = (event) => {
482
+ if (event.propertyName === 'transform') {
483
+ this._tabsList.removeEventListener('transitionend', onTransitionEnd);
484
+ for (const tab of this._tabs) {
485
+ toggleClass(tab.value.element, 'dv-tab--shifting', false);
486
+ }
487
+ }
488
+ };
489
+ this._tabsList.addEventListener('transitionend', onTransitionEnd);
490
+ });
491
+ }
217
492
  }
@@ -69,7 +69,20 @@ export interface DockviewOptions {
69
69
  * This is only applied to the tab header section. Defaults to `custom`.
70
70
  */
71
71
  scrollbars?: 'native' | 'custom';
72
+ /**
73
+ * Controls tab drag-and-drop reorder animation style.
74
+ *
75
+ * - `"smooth"`: tabs animate smoothly during drag-and-drop reorder —
76
+ * tabs slide apart to reveal the insertion gap, then animate to their
77
+ * final positions on drop (Chrome-like behavior).
78
+ * - `"default"`: standard tab reorder behavior without animation.
79
+ *
80
+ * Defaults to `"default"`.
81
+ */
82
+ tabAnimation?: TabAnimation;
72
83
  }
84
+ export type TabAnimation = 'smooth' | 'default';
85
+ export declare const DEFAULT_TAB_ANIMATION: TabAnimation;
73
86
  export interface DockviewDndOverlayEvent extends IAcceptableEvent {
74
87
  nativeEvent: DragEvent;
75
88
  target: DockviewGroupDropLocation;
@@ -1,4 +1,5 @@
1
1
  import { AcceptableEvent } from '../events';
2
+ export const DEFAULT_TAB_ANIMATION = 'default';
2
3
  export class DockviewUnhandledDragOverEvent extends AcceptableEvent {
3
4
  constructor(nativeEvent, target, position, getData, group) {
4
5
  super();
@@ -33,6 +34,7 @@ export const PROPERTY_KEYS_DOCKVIEW = (() => {
33
34
  theme: undefined,
34
35
  disableTabsOverflowList: undefined,
35
36
  scrollbars: undefined,
37
+ tabAnimation: undefined,
36
38
  };
37
39
  return Object.keys(properties);
38
40
  })();
@@ -79,6 +79,9 @@ export class OverlayRenderContainer extends CompositeDisposable {
79
79
  if (!this.map[panel.api.id]) {
80
80
  const element = createFocusableElement();
81
81
  element.className = 'dv-render-overlay';
82
+ // Hide until the first RAF-based position is applied to prevent a
83
+ // one-frame flash at position 0,0 when the element is first attached.
84
+ element.style.visibility = 'hidden';
82
85
  this.map[panel.api.id] = {
83
86
  panel,
84
87
  disposable: Disposable.NONE,
@@ -115,6 +118,11 @@ export class OverlayRenderContainer extends CompositeDisposable {
115
118
  focusContainer.style.top = `${top}px`;
116
119
  focusContainer.style.width = `${width}px`;
117
120
  focusContainer.style.height = `${height}px`;
121
+ // Reveal after the first position is applied (was hidden to
122
+ // prevent a flash at 0,0 before the initial layout fires).
123
+ if (focusContainer.style.visibility === 'hidden') {
124
+ focusContainer.style.visibility = '';
125
+ }
118
126
  toggleClass(focusContainer, 'dv-render-overlay-float', panel.group.api.location.type === 'floating');
119
127
  });
120
128
  };