@mintplayer/ng-bootstrap 20.6.0 → 20.6.2

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.
@@ -39,6 +39,7 @@ const templateHtml = `
39
39
  box-sizing: border-box;
40
40
  font-family: inherit;
41
41
  color: inherit;
42
+ --dock-split-gap: 0.25rem;
42
43
  }
43
44
 
44
45
  .dock-root,
@@ -73,6 +74,13 @@ const templateHtml = `
73
74
  z-index: 5;
74
75
  }
75
76
 
77
+ .dock-intersections-layer {
78
+ position: absolute;
79
+ inset: 0;
80
+ pointer-events: none;
81
+ z-index: 120;
82
+ }
83
+
76
84
  .dock-floating {
77
85
  position: absolute;
78
86
  display: flex;
@@ -127,7 +135,8 @@ const templateHtml = `
127
135
  transition: background 120ms ease;
128
136
  }
129
137
 
130
- .dock-floating__resizer:hover {
138
+ .dock-floating__resizer:hover,
139
+ .dock-floating__resizer[data-resizing='true'] {
131
140
  background: rgba(148, 163, 184, 0.4);
132
141
  }
133
142
 
@@ -200,7 +209,7 @@ const templateHtml = `
200
209
  .dock-split {
201
210
  display: flex;
202
211
  flex: 1 1 0;
203
- gap: 0.25rem;
212
+ gap: var(--dock-split-gap);
204
213
  position: relative;
205
214
  }
206
215
 
@@ -228,11 +237,17 @@ const templateHtml = `
228
237
  .dock-split[data-direction="horizontal"] > .dock-split__divider {
229
238
  width: 0.5rem;
230
239
  cursor: col-resize;
240
+ /* Extend through perpendicular gaps for visual continuity */
241
+ margin-top: calc(var(--dock-split-gap) * -1);
242
+ margin-bottom: calc(var(--dock-split-gap) * -1);
231
243
  }
232
244
 
233
245
  .dock-split[data-direction="vertical"] > .dock-split__divider {
234
246
  height: 0.5rem;
235
247
  cursor: row-resize;
248
+ /* Extend through perpendicular gaps for visual continuity */
249
+ margin-left: calc(var(--dock-split-gap) * -1);
250
+ margin-right: calc(var(--dock-split-gap) * -1);
236
251
  }
237
252
 
238
253
  .dock-split__divider::after {
@@ -261,6 +276,45 @@ const templateHtml = `
261
276
  background: rgba(59, 130, 246, 0.35);
262
277
  }
263
278
 
279
+ .dock-intersection-handle {
280
+ position: absolute;
281
+ width: 1rem;
282
+ height: 1rem;
283
+ margin-left: -0.5rem;
284
+ margin-top: -0.5rem;
285
+ border-radius: 0.375rem;
286
+ background: rgba(59, 130, 246, 0.2);
287
+ border: 1px solid rgba(59, 130, 246, 0.6);
288
+ box-shadow: 0 2px 6px rgba(15, 23, 42, 0.2);
289
+ cursor: all-scroll;
290
+ pointer-events: auto;
291
+ opacity: 0;
292
+ transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease;
293
+ }
294
+
295
+ .dock-intersection-handle:hover,
296
+ .dock-intersection-handle:focus-visible,
297
+ .dock-intersection-handle[data-visible='true'],
298
+ .dock-intersection-handle[data-resizing='true'] {
299
+ background: rgba(59, 130, 246, 0.35);
300
+ border-color: rgba(59, 130, 246, 0.9);
301
+ opacity: 1;
302
+ outline: none;
303
+ }
304
+
305
+ .dock-snap-marker {
306
+ position: absolute;
307
+ width: 6px;
308
+ height: 6px;
309
+ margin-left: -3px;
310
+ margin-top: -3px;
311
+ border-radius: 50%;
312
+ background: rgba(59, 130, 246, 0.7);
313
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
314
+ pointer-events: none;
315
+ z-index: 130;
316
+ }
317
+
264
318
  .dock-stack {
265
319
  display: flex;
266
320
  flex-direction: column;
@@ -409,6 +463,7 @@ const templateHtml = `
409
463
  <div class="dock-root">
410
464
  <div class="dock-docked"></div>
411
465
  <div class="dock-floating-layer"></div>
466
+ <div class="dock-intersections-layer dock-intersection-layer"></div>
412
467
  </div>
413
468
  <div class="dock-drop-indicator"></div>
414
469
  <div class="dock-drop-joystick" data-visible="false">
@@ -477,8 +532,93 @@ class MintDockManagerElement extends HTMLElement {
477
532
  }
478
533
  static get observedAttributes() {
479
534
  return ['layout'];
535
+ // return ['layout', 'debug-snap-markers'];
480
536
  }
481
537
  static { this.instanceCounter = 0; }
538
+ renderSnapMarkersForDivider() {
539
+ if (!this.showSnapMarkers)
540
+ return;
541
+ const layer = this.shadowRoot?.querySelector('.dock-intersections-layer, .dock-intersection-layer');
542
+ if (!layer)
543
+ return;
544
+ // Clear previous
545
+ Array.from(layer.querySelectorAll('.dock-snap-marker')).forEach((el) => el.remove());
546
+ if (!this.resizeState || !this.activeSnapAxis || this.activeSnapTargets.length === 0)
547
+ return;
548
+ const rootRect = this.rootEl.getBoundingClientRect();
549
+ const dRect = this.resizeState.divider.getBoundingClientRect();
550
+ if (this.activeSnapAxis === 'x') {
551
+ const y = dRect.top + dRect.height / 2 - rootRect.top;
552
+ this.activeSnapTargets.forEach((sx) => {
553
+ const dot = this.documentRef.createElement('div');
554
+ dot.className = 'dock-snap-marker';
555
+ dot.style.left = `${rootRect.left + sx - rootRect.left}px`;
556
+ dot.style.top = `${y}px`;
557
+ layer.appendChild(dot);
558
+ });
559
+ }
560
+ else if (this.activeSnapAxis === 'y') {
561
+ const x = dRect.left + dRect.width / 2 - rootRect.left;
562
+ this.activeSnapTargets.forEach((sy) => {
563
+ const dot = this.documentRef.createElement('div');
564
+ dot.className = 'dock-snap-marker';
565
+ dot.style.left = `${x}px`;
566
+ dot.style.top = `${rootRect.top + sy - rootRect.top}px`;
567
+ layer.appendChild(dot);
568
+ });
569
+ }
570
+ }
571
+ renderSnapMarkersForCorner() {
572
+ if (!this.showSnapMarkers)
573
+ return;
574
+ const layer = this.shadowRoot?.querySelector('.dock-intersections-layer, .dock-intersection-layer');
575
+ if (!layer)
576
+ return;
577
+ Array.from(layer.querySelectorAll('.dock-snap-marker')).forEach((el) => el.remove());
578
+ if (!this.cornerResizeState)
579
+ return;
580
+ const rootRect = this.rootEl.getBoundingClientRect();
581
+ // Compute representative center lines from first entries
582
+ let centerX = null;
583
+ let centerY = null;
584
+ const st = this.cornerResizeState;
585
+ if (st.vs.length > 0) {
586
+ const vRect = st.vs[0].container.querySelector(':scope > .dock-split__divider')?.getBoundingClientRect();
587
+ if (vRect)
588
+ centerX = vRect.left + vRect.width / 2 - rootRect.left;
589
+ }
590
+ if (st.hs.length > 0) {
591
+ const hRect = st.hs[0].container.querySelector(':scope > .dock-split__divider')?.getBoundingClientRect();
592
+ if (hRect)
593
+ centerY = hRect.top + hRect.height / 2 - rootRect.top;
594
+ }
595
+ if (centerY != null) {
596
+ this.cornerSnapXTargets.forEach((sx) => {
597
+ const dot = this.documentRef.createElement('div');
598
+ dot.className = 'dock-snap-marker';
599
+ dot.style.left = `${sx}px`;
600
+ dot.style.top = `${centerY}px`;
601
+ layer.appendChild(dot);
602
+ });
603
+ }
604
+ if (centerX != null) {
605
+ this.cornerSnapYTargets.forEach((sy) => {
606
+ const dot = this.documentRef.createElement('div');
607
+ dot.className = 'dock-snap-marker';
608
+ dot.style.left = `${centerX}px`;
609
+ dot.style.top = `${sy}px`;
610
+ layer.appendChild(dot);
611
+ });
612
+ }
613
+ }
614
+ clearSnapMarkers() {
615
+ if (!this.showSnapMarkers)
616
+ return;
617
+ const layer = this.shadowRoot?.querySelector('.dock-intersections-layer, .dock-intersection-layer');
618
+ if (!layer)
619
+ return;
620
+ Array.from(layer.querySelectorAll('.dock-snap-marker')).forEach((el) => el.remove());
621
+ }
482
622
  constructor() {
483
623
  super();
484
624
  this.dropJoystickTarget = null;
@@ -490,10 +630,22 @@ class MintDockManagerElement extends HTMLElement {
490
630
  this.dragState = null;
491
631
  this.floatingDragState = null;
492
632
  this.floatingResizeState = null;
633
+ this.intersectionRaf = null;
634
+ this.intersectionHandles = new Map();
635
+ this.cornerResizeState = null;
493
636
  this.pointerTrackingActive = false;
494
637
  this.dragPointerTrackingActive = false;
495
638
  this.lastDragPointerPosition = null;
639
+ // Localized snapping while dragging a divider
640
+ this.activeSnapAxis = null;
641
+ this.activeSnapTargets = [];
642
+ // Localized snapping while dragging an intersection handle
643
+ this.cornerSnapXTargets = [];
644
+ this.cornerSnapYTargets = [];
645
+ // Debug: render snap markers while dragging
646
+ this.showSnapMarkers = false;
496
647
  this.pendingDragEndTimeout = null;
648
+ this.previousSplitSizes = new Map();
497
649
  const documentRef = this.resolveDocument();
498
650
  this.documentRef = documentRef;
499
651
  this.windowRef = this.resolveWindow(documentRef);
@@ -543,6 +695,7 @@ class MintDockManagerElement extends HTMLElement {
543
695
  this.onDragTouchMove = this.onDragTouchMove.bind(this);
544
696
  this.onDragMouseUp = this.onDragMouseUp.bind(this);
545
697
  this.onDragTouchEnd = this.onDragTouchEnd.bind(this);
698
+ this.onWindowResize = this.onWindowResize.bind(this);
546
699
  }
547
700
  connectedCallback() {
548
701
  if (!this.hasAttribute('role')) {
@@ -577,6 +730,7 @@ class MintDockManagerElement extends HTMLElement {
577
730
  win?.addEventListener('dragover', this.onGlobalDragOver);
578
731
  win?.addEventListener('drag', this.onDrag);
579
732
  win?.addEventListener('dragend', this.onGlobalDragEnd, true);
733
+ win?.addEventListener('resize', this.onWindowResize);
580
734
  }
581
735
  disconnectedCallback() {
582
736
  this.rootEl.removeEventListener('dragover', this.onDragOver);
@@ -593,11 +747,19 @@ class MintDockManagerElement extends HTMLElement {
593
747
  win?.removeEventListener('pointermove', this.onPointerMove);
594
748
  win?.removeEventListener('pointerup', this.onPointerUp);
595
749
  this.pointerTrackingActive = false;
750
+ const win2 = this.windowRef;
751
+ win2?.removeEventListener('resize', this.onWindowResize);
596
752
  }
597
753
  attributeChangedCallback(name, _oldValue, newValue) {
598
754
  if (name === 'layout') {
599
755
  this.layout = newValue ? this.parseLayout(newValue) : null;
600
756
  }
757
+ else if (name === 'debug-snap-markers') {
758
+ this.showSnapMarkers = !(newValue === null || newValue === 'false' || newValue === '0');
759
+ if (!this.showSnapMarkers) {
760
+ this.clearSnapMarkers();
761
+ }
762
+ }
601
763
  }
602
764
  get layout() {
603
765
  return {
@@ -673,6 +835,7 @@ class MintDockManagerElement extends HTMLElement {
673
835
  this.dockedEl.appendChild(fragment);
674
836
  }
675
837
  this.renderFloatingPanes();
838
+ this.scheduleRenderIntersectionHandles();
676
839
  }
677
840
  renderNode(node, path, floatingIndex) {
678
841
  if (node.kind === 'split') {
@@ -767,6 +930,479 @@ class MintDockManagerElement extends HTMLElement {
767
930
  this.floatingLayerEl.appendChild(wrapper);
768
931
  });
769
932
  }
933
+ onWindowResize() {
934
+ // Recompute intersection handles on window resize
935
+ this.scheduleRenderIntersectionHandles();
936
+ }
937
+ scheduleRenderIntersectionHandles() {
938
+ this.intersectionRaf = null;
939
+ this.renderIntersectionHandles();
940
+ }
941
+ renderIntersectionHandles() {
942
+ const layer = this.shadowRoot?.querySelector('.dock-intersections-layer, .dock-intersection-layer');
943
+ if (!layer)
944
+ return;
945
+ // Keep existing handles; we will diff and update positions
946
+ // 1) Clean up legacy handles (created before keying) that lack a data-key
947
+ Array.from(layer.querySelectorAll('.dock-intersection-handle'))
948
+ .filter((el) => !el.dataset['key'])
949
+ .forEach((el) => el.remove());
950
+ // 2) Rebuild the internal map from DOM to avoid drifting state and dedupe duplicates
951
+ const domByKey = new Map();
952
+ Array.from(layer.querySelectorAll('.dock-intersection-handle[data-key]')).forEach((el) => {
953
+ const key = el.dataset['key'] ?? '';
954
+ if (!key)
955
+ return;
956
+ if (domByKey.has(key)) {
957
+ // Remove duplicates with the same key, keep the first one
958
+ el.remove();
959
+ return;
960
+ }
961
+ domByKey.set(key, el);
962
+ // Ensure listener is attached only once
963
+ if (!el.dataset['listener']) {
964
+ el.dataset['listener'] = '1';
965
+ // Listener will be (re)assigned later when we know the current h/v pair
966
+ }
967
+ });
968
+ // Sync internal map with DOM
969
+ this.intersectionHandles = domByKey;
970
+ const rootRect = this.rootEl.getBoundingClientRect();
971
+ // If a corner resize is active, only update that handle's position and avoid creating new ones
972
+ if (this.cornerResizeState) {
973
+ const st = this.cornerResizeState;
974
+ const h0 = st.hs[0];
975
+ const v0 = st.vs[0];
976
+ const hPathStr = this.formatPath(h0.path);
977
+ const vPathStr = this.formatPath(v0.path);
978
+ const key = `${hPathStr}:${h0.index}|${vPathStr}:${v0.index}`;
979
+ // Find divider elements corresponding to active paths
980
+ const hDiv = this.shadowRoot?.querySelector(`.dock-split__divider[data-path="${hPathStr}"][data-index="${h0.index}"]`);
981
+ const vDiv = this.shadowRoot?.querySelector(`.dock-split__divider[data-path="${vPathStr}"][data-index="${v0.index}"]`);
982
+ if (hDiv && vDiv) {
983
+ const hr = hDiv.getBoundingClientRect();
984
+ const vr = vDiv.getBoundingClientRect();
985
+ const x = vr.left + vr.width / 2 - rootRect.left;
986
+ const y = hr.top + hr.height / 2 - rootRect.top;
987
+ const handle = st.handle;
988
+ if (!handle.dataset['key']) {
989
+ handle.dataset['key'] = key;
990
+ }
991
+ this.intersectionHandles.set(key, handle);
992
+ handle.style.left = `${x}px`;
993
+ handle.style.top = `${y}px`;
994
+ // Remove any other handles that don't match the active key
995
+ Array.from(layer.querySelectorAll('.dock-intersection-handle')).forEach((el) => {
996
+ if ((el.dataset['key'] ?? '') !== key) {
997
+ el.remove();
998
+ }
999
+ });
1000
+ // Normalize internal map as well
1001
+ this.intersectionHandles = new Map([[key, handle]]);
1002
+ }
1003
+ return;
1004
+ }
1005
+ const allDividers = Array.from(this.shadowRoot?.querySelectorAll('.dock-split__divider') ?? []);
1006
+ const hDividers = [];
1007
+ const vDividers = [];
1008
+ allDividers.forEach((el) => {
1009
+ const orientation = el.dataset['orientation'] ?? undefined;
1010
+ const rect = el.getBoundingClientRect();
1011
+ const container = el.closest('.dock-split');
1012
+ const path = this.parsePath(el.dataset['path']);
1013
+ const pathStr = el.dataset['path'] ?? '';
1014
+ const index = Number.parseInt(el.dataset['index'] ?? '', 10);
1015
+ if (!container || !Number.isFinite(index))
1016
+ return;
1017
+ const info = { el, rect, path, pathStr, index, container };
1018
+ // Note: node.direction === 'horizontal' means the split lays out children left-to-right,
1019
+ // which yields a VERTICAL divider bar. So mapping is inverted here.
1020
+ if (orientation === 'horizontal') {
1021
+ vDividers.push(info);
1022
+ }
1023
+ else if (orientation === 'vertical') {
1024
+ hDividers.push(info);
1025
+ }
1026
+ });
1027
+ const desiredKeys = new Set();
1028
+ const tol = 24; // px tolerance to account for gaps and subpixel layout
1029
+ const groupMap = new Map();
1030
+ const groupPairs = new Map();
1031
+ hDividers.forEach((h) => {
1032
+ const hCenterY = h.rect.top + h.rect.height / 2;
1033
+ vDividers.forEach((v) => {
1034
+ const vCenterX = v.rect.left + v.rect.width / 2;
1035
+ const dx = vCenterX < h.rect.left ? h.rect.left - vCenterX : vCenterX > h.rect.right ? vCenterX - h.rect.right : 0;
1036
+ const dy = hCenterY < v.rect.top ? v.rect.top - hCenterY : hCenterY > v.rect.bottom ? hCenterY - v.rect.bottom : 0;
1037
+ if (dx > tol || dy > tol)
1038
+ return;
1039
+ const x = vCenterX - rootRect.left;
1040
+ const y = hCenterY - rootRect.top;
1041
+ const key = `${h.pathStr}:${h.index}|${v.pathStr}:${v.index}`;
1042
+ const gk = `${Math.round(x)}:${Math.round(y)}`;
1043
+ let handle = groupMap.get(gk);
1044
+ if (!handle) {
1045
+ // Try reuse via existing pair mapping
1046
+ handle = this.intersectionHandles.get(key) ?? null;
1047
+ if (!handle) {
1048
+ handle = this.documentRef.createElement('div');
1049
+ handle.classList.add('dock-intersection-handle', 'glyph');
1050
+ handle.setAttribute('role', 'separator');
1051
+ handle.setAttribute('aria-label', 'Resize split intersection');
1052
+ handle.dataset['key'] = key;
1053
+ handle.dataset['listener'] = '1';
1054
+ handle.addEventListener('pointerdown', (ev) => this.beginCornerResize(ev, h, v, handle));
1055
+ handle.addEventListener('dblclick', (ev) => this.onIntersectionDoubleClick(ev, handle));
1056
+ layer.appendChild(handle);
1057
+ }
1058
+ groupMap.set(gk, handle);
1059
+ }
1060
+ // Track pairs for this group and map all pair keys to the same handle
1061
+ const arr = groupPairs.get(gk) ?? [];
1062
+ arr.push({ h: { pathStr: h.pathStr ?? '', index: h.index }, v: { pathStr: v.pathStr ?? '', index: v.index } });
1063
+ groupPairs.set(gk, arr);
1064
+ this.intersectionHandles.set(key, handle);
1065
+ // Update position for the grouped handle
1066
+ handle.style.left = `${x}px`;
1067
+ handle.style.top = `${y}px`;
1068
+ });
1069
+ });
1070
+ // Attach grouped pairs data to each handle and prune stale ones
1071
+ const keep = new Set(groupMap.values());
1072
+ groupMap.forEach((handle, gk) => {
1073
+ const pairs = groupPairs.get(gk) ?? [];
1074
+ handle.dataset['pairs'] = JSON.stringify(pairs);
1075
+ });
1076
+ Array.from(layer.querySelectorAll('.dock-intersection-handle')).forEach((el) => {
1077
+ if (!keep.has(el)) {
1078
+ el.remove();
1079
+ }
1080
+ });
1081
+ // Reset intersectionHandles to only currently mapped keys
1082
+ const newMap = new Map();
1083
+ groupPairs.forEach((pairs, gk) => {
1084
+ const handle = groupMap.get(gk);
1085
+ pairs.forEach((p) => newMap.set(`${p.h.pathStr}:${p.h.index}|${p.v.pathStr}:${p.v.index}`, handle));
1086
+ });
1087
+ this.intersectionHandles = newMap;
1088
+ }
1089
+ beginCornerResize(event, h, v, handle) {
1090
+ event.preventDefault();
1091
+ // Build pairs from dataset if available (grouped intersections), otherwise from the provided pair
1092
+ const pairsRaw = handle.dataset['pairs'];
1093
+ const parsed = pairsRaw ? JSON.parse(pairsRaw) : [];
1094
+ const hs = [];
1095
+ const vs = [];
1096
+ const ensureHV = (pathStr, index, axis) => {
1097
+ const path = this.parsePath(pathStr);
1098
+ if (!path)
1099
+ return;
1100
+ const div = this.shadowRoot?.querySelector(`.dock-split__divider[data-path="${pathStr}"][data-index="${index}"]`) ?? null;
1101
+ const container = div?.closest('.dock-split');
1102
+ if (!container)
1103
+ return;
1104
+ if (axis === 'h') {
1105
+ const children = Array.from(container.querySelectorAll(':scope > .dock-split__child'));
1106
+ const initial = children.map((c) => c.getBoundingClientRect().height);
1107
+ hs.push({ path, index, container, initialSizes: initial, before: initial[index], after: initial[index + 1] });
1108
+ }
1109
+ else {
1110
+ const children = Array.from(container.querySelectorAll(':scope > .dock-split__child'));
1111
+ const initial = children.map((c) => c.getBoundingClientRect().width);
1112
+ vs.push({ path, index, container, initialSizes: initial, before: initial[index], after: initial[index + 1] });
1113
+ }
1114
+ };
1115
+ if (parsed.length > 0) {
1116
+ parsed.forEach((p) => { ensureHV(p.h.pathStr, p.h.index, 'h'); ensureHV(p.v.pathStr, p.v.index, 'v'); });
1117
+ }
1118
+ else if (h.path && v.path) {
1119
+ ensureHV(this.formatPath(h.path), h.index, 'h');
1120
+ ensureHV(this.formatPath(v.path), v.index, 'v');
1121
+ }
1122
+ if (hs.length === 0 && vs.length === 0)
1123
+ return;
1124
+ try {
1125
+ handle.setPointerCapture(event.pointerId);
1126
+ handle.dataset['resizing'] = 'true';
1127
+ handle.classList.add('hovering');
1128
+ }
1129
+ catch { }
1130
+ this.cornerResizeState = {
1131
+ pointerId: event.pointerId,
1132
+ handle,
1133
+ hs: hs.map((e) => ({ path: this.clonePath(e.path), index: e.index, container: e.container, beforeSize: e.before, afterSize: e.after, initialSizes: e.initialSizes, startY: event.clientY })),
1134
+ vs: vs.map((e) => ({ path: this.clonePath(e.path), index: e.index, container: e.container, beforeSize: e.before, afterSize: e.after, initialSizes: e.initialSizes, startX: event.clientX })),
1135
+ };
1136
+ this.startPointerTracking();
1137
+ // Ensure handle has a stable key (use group if present)
1138
+ if (!handle.dataset['key']) {
1139
+ handle.dataset['key'] = handle.dataset['group'] ?? '';
1140
+ }
1141
+ this.renderSnapMarkersForCorner();
1142
+ // Compute localized snap targets for this intersection
1143
+ try {
1144
+ const rootRect = this.rootEl.getBoundingClientRect();
1145
+ // Use first pair to define the crossing lines
1146
+ let centerX = null;
1147
+ let centerY = null;
1148
+ // Resolve one vertical bar (from vs) and one horizontal bar (from hs)
1149
+ if (vs.length > 0) {
1150
+ const vPair = vs[0];
1151
+ const vPathStr = this.formatPath(vPair.path);
1152
+ const vDiv = this.shadowRoot?.querySelector(`.dock-split__divider[data-path="${vPathStr}"][data-index="${vPair.index}"]`) ?? null;
1153
+ const vr = vDiv?.getBoundingClientRect();
1154
+ if (vr)
1155
+ centerX = vr.left + vr.width / 2;
1156
+ }
1157
+ if (hs.length > 0) {
1158
+ const hPair = hs[0];
1159
+ const hPathStr = this.formatPath(hPair.path);
1160
+ const hDiv = this.shadowRoot?.querySelector(`.dock-split__divider[data-path="${hPathStr}"][data-index="${hPair.index}"]`) ?? null;
1161
+ const hr = hDiv?.getBoundingClientRect();
1162
+ if (hr)
1163
+ centerY = hr.top + hr.height / 2;
1164
+ }
1165
+ const xTargets = [];
1166
+ const yTargets = [];
1167
+ const allDividers = Array.from(this.shadowRoot?.querySelectorAll('.dock-split__divider') ?? []);
1168
+ allDividers.forEach((el) => {
1169
+ const o = el.dataset['orientation'] ?? undefined;
1170
+ const r = el.getBoundingClientRect();
1171
+ if (o === 'horizontal' && centerY != null) {
1172
+ // vertical bar → contributes X if it crosses centerY
1173
+ if (centerY >= r.top && centerY <= r.bottom) {
1174
+ xTargets.push(r.left + r.width / 2 - rootRect.left);
1175
+ }
1176
+ }
1177
+ else if (o === 'vertical' && centerX != null) {
1178
+ // horizontal bar → contributes Y if it crosses centerX
1179
+ if (centerX >= r.left && centerX <= r.right) {
1180
+ yTargets.push(r.top + r.height / 2 - rootRect.top);
1181
+ }
1182
+ }
1183
+ });
1184
+ this.cornerSnapXTargets = xTargets;
1185
+ this.cornerSnapYTargets = yTargets;
1186
+ }
1187
+ catch {
1188
+ this.cornerSnapXTargets = [];
1189
+ this.cornerSnapYTargets = [];
1190
+ }
1191
+ }
1192
+ handleCornerResizeMove(event) {
1193
+ const state = this.cornerResizeState;
1194
+ if (!state || state.pointerId !== event.pointerId)
1195
+ return;
1196
+ const snapValue = (val, total, active) => {
1197
+ if (!active || total <= 0)
1198
+ return val;
1199
+ const ratios = [1 / 3, 1 / 2, 2 / 3];
1200
+ const r = val / total;
1201
+ let best = ratios[0];
1202
+ let d = Math.abs(r - best);
1203
+ for (let i = 1; i < ratios.length; i++) {
1204
+ const dd = Math.abs(r - ratios[i]);
1205
+ if (dd < d) {
1206
+ d = dd;
1207
+ best = ratios[i];
1208
+ }
1209
+ }
1210
+ return best * total;
1211
+ };
1212
+ // Axis snapping to nearby intersections
1213
+ const tol = 10;
1214
+ const rootRect = this.rootEl.getBoundingClientRect();
1215
+ let clientX = event.clientX;
1216
+ let clientY = event.clientY;
1217
+ if (this.cornerSnapXTargets.length) {
1218
+ let best = clientX, bestDist = tol + 1;
1219
+ this.cornerSnapXTargets.forEach((sx) => {
1220
+ const px = rootRect.left + sx;
1221
+ const d = Math.abs(px - clientX);
1222
+ if (d < bestDist) {
1223
+ bestDist = d;
1224
+ best = px;
1225
+ }
1226
+ });
1227
+ if (bestDist <= tol)
1228
+ clientX = best;
1229
+ }
1230
+ if (this.cornerSnapYTargets.length) {
1231
+ let best = clientY, bestDist = tol + 1;
1232
+ this.cornerSnapYTargets.forEach((sy) => {
1233
+ const py = rootRect.top + sy;
1234
+ const d = Math.abs(py - clientY);
1235
+ if (d < bestDist) {
1236
+ bestDist = d;
1237
+ best = py;
1238
+ }
1239
+ });
1240
+ if (bestDist <= tol)
1241
+ clientY = best;
1242
+ }
1243
+ // Update all horizontal bars (vertical splits) with Y delta
1244
+ state.hs.forEach((h) => {
1245
+ const node = this.resolveSplitNode(h.path);
1246
+ if (!node)
1247
+ return;
1248
+ const deltaY = clientY - h.startY;
1249
+ const minSize = 48;
1250
+ const pairTotal = h.beforeSize + h.afterSize;
1251
+ let newBefore = Math.min(Math.max(h.beforeSize + deltaY, minSize), pairTotal - minSize);
1252
+ newBefore = snapValue(newBefore, pairTotal, event.shiftKey);
1253
+ const newAfter = pairTotal - newBefore;
1254
+ const sizesPx = [...h.initialSizes];
1255
+ sizesPx[h.index] = newBefore;
1256
+ sizesPx[h.index + 1] = newAfter;
1257
+ const total = sizesPx.reduce((a, s) => a + s, 0);
1258
+ const normalized = total > 0 ? sizesPx.map((s) => s / total) : [];
1259
+ node.sizes = normalized;
1260
+ const children = Array.from(h.container.querySelectorAll(':scope > .dock-split__child'));
1261
+ normalized.forEach((size, idx) => { if (children[idx])
1262
+ children[idx].style.flex = `${Math.max(size, 0)} 1 0`; });
1263
+ });
1264
+ // Update all vertical bars (horizontal splits) with X delta
1265
+ state.vs.forEach((v) => {
1266
+ const node = this.resolveSplitNode(v.path);
1267
+ if (!node)
1268
+ return;
1269
+ const deltaX = clientX - v.startX;
1270
+ const minSize = 48;
1271
+ const pairTotal = v.beforeSize + v.afterSize;
1272
+ let newBefore = Math.min(Math.max(v.beforeSize + deltaX, minSize), pairTotal - minSize);
1273
+ newBefore = snapValue(newBefore, pairTotal, event.shiftKey);
1274
+ const newAfter = pairTotal - newBefore;
1275
+ const sizesPx = [...v.initialSizes];
1276
+ sizesPx[v.index] = newBefore;
1277
+ sizesPx[v.index + 1] = newAfter;
1278
+ const total = sizesPx.reduce((a, s) => a + s, 0);
1279
+ const normalized = total > 0 ? sizesPx.map((s) => s / total) : [];
1280
+ node.sizes = normalized;
1281
+ const children = Array.from(v.container.querySelectorAll(':scope > .dock-split__child'));
1282
+ normalized.forEach((size, idx) => { if (children[idx])
1283
+ children[idx].style.flex = `${Math.max(size, 0)} 1 0`; });
1284
+ });
1285
+ this.dispatchLayoutChanged();
1286
+ }
1287
+ endCornerResize(pointerId) {
1288
+ const state = this.cornerResizeState;
1289
+ if (!state || pointerId !== state.pointerId)
1290
+ return;
1291
+ try {
1292
+ state.handle.releasePointerCapture(state.pointerId);
1293
+ }
1294
+ catch { }
1295
+ state.handle.dataset['resizing'] = 'false';
1296
+ this.cornerResizeState = null;
1297
+ // Re-render handles to account for new positions
1298
+ this.scheduleRenderIntersectionHandles();
1299
+ this.cornerSnapXTargets = [];
1300
+ this.cornerSnapYTargets = [];
1301
+ }
1302
+ onIntersectionDoubleClick(event, handle) {
1303
+ event.preventDefault();
1304
+ const pairsRaw = handle.dataset['pairs'];
1305
+ const parsed = pairsRaw ? JSON.parse(pairsRaw) : [];
1306
+ if (!Array.isArray(parsed) || parsed.length === 0) {
1307
+ const k = handle.dataset['key'] ?? '';
1308
+ const parts = k.split('|');
1309
+ if (parts.length === 2) {
1310
+ const [hPart, vPart] = parts;
1311
+ const hi = hPart.lastIndexOf(':');
1312
+ const vi = vPart.lastIndexOf(':');
1313
+ if (hi > 0 && vi > 0) {
1314
+ const hPathStr = hPart.slice(0, hi);
1315
+ const vPathStr = vPart.slice(0, vi);
1316
+ const hIdx = Number.parseInt(hPart.slice(hi + 1), 10);
1317
+ const vIdx = Number.parseInt(vPart.slice(vi + 1), 10);
1318
+ parsed.push({ h: { pathStr: hPathStr, index: hIdx }, v: { pathStr: vPathStr, index: vIdx } });
1319
+ }
1320
+ }
1321
+ }
1322
+ if (parsed.length === 0)
1323
+ return;
1324
+ const splitKeys = new Set();
1325
+ parsed.forEach((p) => { splitKeys.add(p.h.pathStr); splitKeys.add(p.v.pathStr); });
1326
+ let hasStored = false;
1327
+ splitKeys.forEach((k) => { if (this.previousSplitSizes.has(k))
1328
+ hasStored = true; });
1329
+ const applySizes = (pathStr, mutate) => {
1330
+ const path = this.parsePath(pathStr);
1331
+ if (!path)
1332
+ return;
1333
+ const node = this.resolveSplitNode(path);
1334
+ if (!node)
1335
+ return;
1336
+ const sizes = this.normalizeSizesArray(node.sizes ?? [], node.children.length);
1337
+ // Find divider index from any divider belonging to this path
1338
+ const divEl = this.shadowRoot?.querySelector(`.dock-split__divider[data-path="${pathStr}"]`);
1339
+ const index = divEl ? Number.parseInt(divEl.dataset['index'] ?? '0', 10) : 0;
1340
+ const newSizes = mutate([...sizes], index);
1341
+ node.sizes = newSizes;
1342
+ const segments = path.segments.join('/');
1343
+ const container = this.shadowRoot?.querySelector(`.dock-split[data-path="${segments}"]`);
1344
+ if (container) {
1345
+ const children = Array.from(container.querySelectorAll(':scope > .dock-split__child'));
1346
+ newSizes.forEach((s, i) => { if (children[i])
1347
+ children[i].style.flex = `${Math.max(s, 0)} 1 0`; });
1348
+ }
1349
+ };
1350
+ if (hasStored) {
1351
+ // Restore stored sizes
1352
+ this.previousSplitSizes.forEach((sizes, pathStr) => {
1353
+ const path = this.parsePath(pathStr);
1354
+ const node = path ? this.resolveSplitNode(path) : null;
1355
+ if (!node)
1356
+ return;
1357
+ const norm = this.normalizeSizesArray(sizes, node.children.length);
1358
+ node.sizes = norm;
1359
+ const segments = path.segments.join('/');
1360
+ const container = this.shadowRoot?.querySelector(`.dock-split[data-path="${segments}"]`);
1361
+ if (container) {
1362
+ const children = Array.from(container.querySelectorAll(':scope > .dock-split__child'));
1363
+ norm.forEach((s, i) => { if (children[i])
1364
+ children[i].style.flex = `${Math.max(s, 0)} 1 0`; });
1365
+ }
1366
+ });
1367
+ this.previousSplitSizes.clear();
1368
+ }
1369
+ else {
1370
+ // Equalize the two panes adjacent to each divider and store previous sizes
1371
+ const touched = new Set();
1372
+ parsed.forEach((p) => {
1373
+ [p.h.pathStr, p.v.pathStr].forEach((key) => {
1374
+ if (touched.has(key))
1375
+ return;
1376
+ const path = this.parsePath(key);
1377
+ const node = path ? this.resolveSplitNode(path) : null;
1378
+ if (node && Array.isArray(node.sizes)) {
1379
+ this.previousSplitSizes.set(key, [...node.sizes]);
1380
+ }
1381
+ touched.add(key);
1382
+ });
1383
+ applySizes(p.h.pathStr, (sizes, idx) => {
1384
+ const total = (sizes[idx] ?? 0) + (sizes[idx + 1] ?? 0);
1385
+ if (total <= 0)
1386
+ return sizes;
1387
+ sizes[idx] = total / 2;
1388
+ sizes[idx + 1] = total / 2;
1389
+ const sum = sizes.reduce((a, s) => a + s, 0);
1390
+ return sum > 0 ? sizes.map((s) => s / sum) : sizes;
1391
+ });
1392
+ applySizes(p.v.pathStr, (sizes, idx) => {
1393
+ const total = (sizes[idx] ?? 0) + (sizes[idx + 1] ?? 0);
1394
+ if (total <= 0)
1395
+ return sizes;
1396
+ sizes[idx] = total / 2;
1397
+ sizes[idx + 1] = total / 2;
1398
+ const sum = sizes.reduce((a, s) => a + s, 0);
1399
+ return sum > 0 ? sizes.map((s) => s / sum) : sizes;
1400
+ });
1401
+ });
1402
+ }
1403
+ this.dispatchLayoutChanged();
1404
+ this.scheduleRenderIntersectionHandles();
1405
+ }
770
1406
  beginFloatingDrag(event, index, wrapper, handle) {
771
1407
  const floating = this.floatingLayouts[index];
772
1408
  if (!floating) {
@@ -803,6 +1439,7 @@ class MintDockManagerElement extends HTMLElement {
803
1439
  event.stopPropagation();
804
1440
  try {
805
1441
  handle.setPointerCapture(event.pointerId);
1442
+ handle.dataset['resizing'] = 'true';
806
1443
  }
807
1444
  catch (err) {
808
1445
  /* pointer capture may not be supported */
@@ -848,6 +1485,7 @@ class MintDockManagerElement extends HTMLElement {
848
1485
  }
849
1486
  try {
850
1487
  state.handle.releasePointerCapture(state.pointerId);
1488
+ delete state.handle.dataset['resizing'];
851
1489
  }
852
1490
  catch (err) {
853
1491
  /* no-op */
@@ -997,7 +1635,8 @@ class MintDockManagerElement extends HTMLElement {
997
1635
  if (this.pointerTrackingActive &&
998
1636
  !this.resizeState &&
999
1637
  !this.floatingDragState &&
1000
- !this.floatingResizeState) {
1638
+ !this.floatingResizeState &&
1639
+ !this.cornerResizeState) {
1001
1640
  const win = this.windowRef;
1002
1641
  win?.removeEventListener('pointermove', this.onPointerMove);
1003
1642
  win?.removeEventListener('pointerup', this.onPointerUp);
@@ -1059,6 +1698,13 @@ class MintDockManagerElement extends HTMLElement {
1059
1698
  divider.classList.add('dock-split__divider');
1060
1699
  divider.setAttribute('role', 'separator');
1061
1700
  divider.tabIndex = 0;
1701
+ // Tag divider with metadata for intersection detection
1702
+ const dividerPath = typeof floatingIndex === 'number'
1703
+ ? { type: 'floating', index: floatingIndex, segments: [...path] }
1704
+ : { type: 'docked', segments: [...path] };
1705
+ divider.dataset['path'] = this.formatPath(dividerPath);
1706
+ divider.dataset['index'] = String(index);
1707
+ divider.dataset['orientation'] = node.direction;
1062
1708
  divider.addEventListener('pointerdown', (event) => this.beginResize(event, container, floatingIndex !== undefined
1063
1709
  ? { type: 'floating', index: floatingIndex, segments: [...path] }
1064
1710
  : { type: 'docked', segments: [...path] }, index));
@@ -1183,19 +1829,121 @@ class MintDockManagerElement extends HTMLElement {
1183
1829
  afterSize,
1184
1830
  };
1185
1831
  this.startPointerTracking();
1832
+ // Compute localized snap targets: intersections with perpendicular dividers near this divider
1833
+ try {
1834
+ const rootRect = this.rootEl.getBoundingClientRect();
1835
+ const dividerRect = divider.getBoundingClientRect();
1836
+ const allDividers = Array.from(this.shadowRoot?.querySelectorAll('.dock-split__divider') ?? []);
1837
+ const targets = [];
1838
+ if (orientation === 'horizontal') {
1839
+ // Current bar is vertical → snap X to centers of other vertical bars (no crossing check needed)
1840
+ allDividers.forEach((el) => {
1841
+ if (el === divider)
1842
+ return;
1843
+ const o = el.dataset['orientation'] ?? undefined;
1844
+ if (o !== 'horizontal')
1845
+ return; // vertical divider bars (split direction horizontal)
1846
+ const r = el.getBoundingClientRect();
1847
+ const xCenter = r.left + r.width / 2 - rootRect.left;
1848
+ targets.push(xCenter);
1849
+ });
1850
+ this.activeSnapAxis = 'x';
1851
+ this.activeSnapTargets = targets;
1852
+ this.renderSnapMarkersForDivider();
1853
+ }
1854
+ else {
1855
+ // Current bar is horizontal → snap Y to centers of other horizontal bars (no crossing check needed)
1856
+ allDividers.forEach((el) => {
1857
+ if (el === divider)
1858
+ return;
1859
+ const o = el.dataset['orientation'] ?? undefined;
1860
+ if (o !== 'vertical')
1861
+ return; // horizontal divider bars (split direction vertical)
1862
+ const r = el.getBoundingClientRect();
1863
+ const yCenter = r.top + r.height / 2 - rootRect.top;
1864
+ targets.push(yCenter);
1865
+ });
1866
+ this.activeSnapAxis = 'y';
1867
+ this.activeSnapTargets = targets;
1868
+ this.renderSnapMarkersForDivider();
1869
+ }
1870
+ }
1871
+ catch {
1872
+ this.activeSnapAxis = null;
1873
+ this.activeSnapTargets = [];
1874
+ this.clearSnapMarkers();
1875
+ }
1186
1876
  }
1187
1877
  onPointerMove(event) {
1878
+ if (this.cornerResizeState && event.pointerId === this.cornerResizeState.pointerId) {
1879
+ this.handleCornerResizeMove(event);
1880
+ }
1188
1881
  if (this.resizeState && event.pointerId === this.resizeState.pointerId) {
1189
1882
  const state = this.resizeState;
1190
1883
  const splitNode = this.resolveSplitNode(state.path);
1191
1884
  if (!splitNode) {
1192
1885
  return;
1193
1886
  }
1194
- const currentPos = state.orientation === 'horizontal' ? event.clientX : event.clientY;
1887
+ let currentPos = state.orientation === 'horizontal' ? event.clientX : event.clientY;
1888
+ // Localized axis snap near neighboring intersections
1889
+ const tol = 10;
1890
+ const rootRect = this.rootEl.getBoundingClientRect();
1891
+ if (this.activeSnapTargets.length) {
1892
+ if (state.orientation === 'horizontal' && this.activeSnapAxis === 'x') {
1893
+ // Vertical divider snapping along X
1894
+ let closest = Number.POSITIVE_INFINITY;
1895
+ let best = currentPos;
1896
+ const pointerX = event.clientX;
1897
+ this.activeSnapTargets.forEach((sx) => {
1898
+ const px = rootRect.left + sx;
1899
+ const d = Math.abs(pointerX - px);
1900
+ if (d < closest) {
1901
+ closest = d;
1902
+ best = px;
1903
+ }
1904
+ });
1905
+ if (closest <= tol)
1906
+ currentPos = best;
1907
+ this.renderSnapMarkersForDivider();
1908
+ }
1909
+ else if (state.orientation === 'vertical' && this.activeSnapAxis === 'y') {
1910
+ // Horizontal divider snapping along Y
1911
+ let closest = Number.POSITIVE_INFINITY;
1912
+ let best = currentPos;
1913
+ const pointerY = event.clientY;
1914
+ this.activeSnapTargets.forEach((sy) => {
1915
+ const py = rootRect.top + sy;
1916
+ const d = Math.abs(pointerY - py);
1917
+ if (d < closest) {
1918
+ closest = d;
1919
+ best = py;
1920
+ }
1921
+ });
1922
+ if (closest <= tol)
1923
+ currentPos = best;
1924
+ this.renderSnapMarkersForDivider();
1925
+ }
1926
+ }
1195
1927
  const delta = currentPos - state.startPos;
1196
1928
  const minSize = 48;
1197
1929
  const pairTotal = state.beforeSize + state.afterSize;
1198
- let newBefore = Math.min(Math.max(state.beforeSize + delta, minSize), pairTotal - minSize);
1930
+ let newBefore = state.beforeSize + delta;
1931
+ // Optional snap with Shift
1932
+ if (event.shiftKey && pairTotal > 0) {
1933
+ const ratios = [1 / 3, 1 / 2, 2 / 3];
1934
+ const target = newBefore / pairTotal;
1935
+ let best = ratios[0];
1936
+ let bestDist = Math.abs(target - best);
1937
+ for (let i = 1; i < ratios.length; i++) {
1938
+ const d = Math.abs(target - ratios[i]);
1939
+ if (d < bestDist) {
1940
+ best = ratios[i];
1941
+ bestDist = d;
1942
+ }
1943
+ }
1944
+ newBefore = best * pairTotal;
1945
+ }
1946
+ newBefore = Math.min(Math.max(newBefore, minSize), pairTotal - minSize);
1199
1947
  let newAfter = pairTotal - newBefore;
1200
1948
  if (!Number.isFinite(newBefore) || !Number.isFinite(newAfter)) {
1201
1949
  return;
@@ -1226,11 +1974,17 @@ class MintDockManagerElement extends HTMLElement {
1226
1974
  }
1227
1975
  }
1228
1976
  onPointerUp(event) {
1977
+ if (this.cornerResizeState && event.pointerId === this.cornerResizeState.pointerId) {
1978
+ this.endCornerResize(event.pointerId);
1979
+ }
1229
1980
  if (this.resizeState && event.pointerId === this.resizeState.pointerId) {
1230
1981
  const divider = this.resizeState.divider;
1231
1982
  divider.dataset['resizing'] = 'false';
1232
1983
  divider.releasePointerCapture(this.resizeState.pointerId);
1233
1984
  this.resizeState = null;
1985
+ this.scheduleRenderIntersectionHandles();
1986
+ this.activeSnapAxis = null;
1987
+ this.activeSnapTargets = [];
1234
1988
  }
1235
1989
  if (this.floatingDragState && event.pointerId === this.floatingDragState.pointerId) {
1236
1990
  this.endFloatingDrag(event.pointerId);
@@ -1341,6 +2095,28 @@ class MintDockManagerElement extends HTMLElement {
1341
2095
  this.startDragPointerTracking();
1342
2096
  event.dataTransfer.effectAllowed = 'move';
1343
2097
  event.dataTransfer.setData('text/plain', pane);
2098
+ // Preferred UX: if the dragged tab is the only one in its stack,
2099
+ // immediately convert to a floating window unless it is already the
2100
+ // only pane in a floating window (this case is handled by reuse logic).
2101
+ if (this.dragState && this.dragState.floatingIndex !== null && this.dragState.floatingIndex < 0) {
2102
+ const loc = this.resolveStackLocation(this.dragState.sourcePath);
2103
+ if (loc && Array.isArray(loc.node.panes) && loc.node.panes.length === 1) {
2104
+ let shouldConvert = false;
2105
+ if (loc.context === "docked") {
2106
+ shouldConvert = true;
2107
+ }
2108
+ else if (loc.context === "floating") {
2109
+ const floating = this.floatingLayouts[loc.index];
2110
+ const totalPanes = floating && floating.root ? this.countPanesInTree(floating.root) : 0;
2111
+ shouldConvert = totalPanes > 1; // not the only pane in this floating window
2112
+ }
2113
+ if (shouldConvert) {
2114
+ const startX = Number.isFinite(event.clientX) ? event.clientX : (this.dragState.startClientX ?? 0);
2115
+ const startY = Number.isFinite(event.clientY) ? event.clientY : (this.dragState.startClientY ?? 0);
2116
+ this.convertPendingTabDragToFloating(startX, startY);
2117
+ }
2118
+ }
2119
+ }
1344
2120
  }
1345
2121
  preparePaneDragSource(path, pane, stackEl, event) {
1346
2122
  const location = this.resolveStackLocation(path);
@@ -1607,6 +2383,8 @@ class MintDockManagerElement extends HTMLElement {
1607
2383
  if (this.dropJoystick.dataset['visible'] !== 'true') {
1608
2384
  this.hideDropIndicator();
1609
2385
  }
2386
+ // Also ensure any in-header placeholder is cleared when not over a stack
2387
+ this.clearHeaderDragPlaceholder();
1610
2388
  return;
1611
2389
  }
1612
2390
  // If we moved to a different target stack, reset any sticky zone so
@@ -1617,7 +2395,7 @@ class MintDockManagerElement extends HTMLElement {
1617
2395
  }
1618
2396
  // Previous behavior hid the indicator and returned early here; instead,
1619
2397
  // allow the live-reorder branch below to handle in-header drags.
1620
- // While reordering within the same header, update order live and suppress joystick/indicator
2398
+ // While dragging within the same header, show a placeholder and suppress joystick/indicator
1621
2399
  if (this.dragState &&
1622
2400
  this.dragState.floatingIndex !== null &&
1623
2401
  this.dragState.floatingIndex < 0 &&
@@ -1628,16 +2406,12 @@ class MintDockManagerElement extends HTMLElement {
1628
2406
  if (inHeaderByBounds || inHeaderByHitTest) {
1629
2407
  const header = stack.querySelector('.dock-stack__header');
1630
2408
  if (header) {
2409
+ // Ensure placeholder exists and move it as the pointer moves
2410
+ this.ensureHeaderDragPlaceholder(header, this.dragState.pane);
1631
2411
  const idx = this.computeHeaderInsertIndex(header, clientX);
1632
2412
  if (this.dragState.liveReorderIndex !== idx) {
1633
- this.ensureHeaderDragPlaceholder(header, this.dragState.pane);
1634
2413
  this.updateHeaderDragPlaceholderPosition(header, idx);
1635
- const location = this.resolveStackLocation(path);
1636
- if (location) {
1637
- this.reorderPaneInLocationAtIndex(location, this.dragState.pane, idx);
1638
- this.render();
1639
- this.dispatchLayoutChanged();
1640
- }
2414
+ // Keep model reordering until drop; only move the placeholder now
1641
2415
  this.dragState.liveReorderIndex = idx;
1642
2416
  }
1643
2417
  }
@@ -1645,6 +2419,8 @@ class MintDockManagerElement extends HTMLElement {
1645
2419
  return;
1646
2420
  }
1647
2421
  }
2422
+ // Leaving the header: ensure any placeholder is removed immediately
2423
+ this.clearHeaderDragPlaceholder();
1648
2424
  const zoneHint = this.findDropZoneByPoint(clientX, clientY);
1649
2425
  const zone = this.computeDropZone(stack, { clientX, clientY }, zoneHint);
1650
2426
  this.showDropIndicator(stack, zone);
@@ -1687,11 +2463,14 @@ class MintDockManagerElement extends HTMLElement {
1687
2463
  placeholder.type = 'button';
1688
2464
  placeholder.classList.add('dock-tab');
1689
2465
  placeholder.dataset['placeholder'] = 'true';
1690
- placeholder.textContent = dragged.textContent ?? '';
1691
- // Hide the original dragged tab so it doesn't duplicate visually
1692
- dragged.style.visibility = 'hidden';
1693
- // Insert placeholder next to dragged initially
1694
- header.insertBefore(placeholder, dragged.nextSibling);
2466
+ // Keep the placeholder visually empty but reserving the same width
2467
+ placeholder.textContent = '';
2468
+ placeholder.setAttribute('aria-hidden', 'true');
2469
+ placeholder.style.width = `${dragged.offsetWidth}px`;
2470
+ // Hide the original dragged tab so it doesn't duplicate visually and free up its slot
2471
+ dragged.style.display = 'none';
2472
+ // Insert placeholder in the original position of the dragged tab
2473
+ header.insertBefore(placeholder, dragged);
1695
2474
  if (this.dragState) {
1696
2475
  this.dragState.placeholderHeader = header;
1697
2476
  this.dragState.placeholderEl = placeholder;
@@ -1703,8 +2482,9 @@ class MintDockManagerElement extends HTMLElement {
1703
2482
  if (!placeholder) {
1704
2483
  return;
1705
2484
  }
2485
+ const draggedPane = this.dragState?.pane ?? null;
1706
2486
  const tabs = Array.from(header.querySelectorAll('.dock-tab'))
1707
- .filter((t) => t !== placeholder);
2487
+ .filter((t) => t !== placeholder && (!draggedPane || t.dataset['pane'] !== draggedPane));
1708
2488
  const clampedTarget = Math.max(0, Math.min(targetIndex, tabs.length));
1709
2489
  const ref = tabs[clampedTarget] ?? null;
1710
2490
  header.insertBefore(placeholder, ref);
@@ -1718,7 +2498,7 @@ class MintDockManagerElement extends HTMLElement {
1718
2498
  ? (Array.from(header.querySelectorAll('.dock-tab')).find((t) => t.dataset['pane'] === this.dragState?.pane) ?? null)
1719
2499
  : null;
1720
2500
  if (dragged) {
1721
- dragged.style.visibility = '';
2501
+ dragged.style.display = '';
1722
2502
  }
1723
2503
  }
1724
2504
  if (ph && ph.parentElement) {
@@ -1872,20 +2652,41 @@ class MintDockManagerElement extends HTMLElement {
1872
2652
  this.dispatchLayoutChanged();
1873
2653
  }
1874
2654
  // Compute the intended tab insert index within a header based on pointer X
2655
+ // Adds a slight rightward bias and uses the placeholder rect (if present)
2656
+ // to ensure offsets are correct even when the dragged tab is display:none.
1875
2657
  computeHeaderInsertIndex(header, clientX) {
1876
- const tabs = Array.from(header.querySelectorAll('.dock-tab'))
1877
- .filter((t) => t.dataset['placeholder'] !== 'true');
1878
- if (tabs.length === 0) {
2658
+ const allTabs = Array.from(header.querySelectorAll('.dock-tab'));
2659
+ if (allTabs.length === 0) {
1879
2660
  return 0;
1880
2661
  }
1881
- for (let i = 0; i < tabs.length; i += 1) {
1882
- const rect = tabs[i].getBoundingClientRect();
1883
- const mid = rect.left + rect.width / 2;
2662
+ const draggedPane = this.dragState?.pane ?? null;
2663
+ const draggedEl = draggedPane
2664
+ ? (allTabs.find((t) => t.dataset['pane'] === draggedPane) ?? null)
2665
+ : null;
2666
+ const placeholderEl = header.querySelector('.dock-tab[data-placeholder="true"]');
2667
+ const targets = allTabs.filter((t) => t !== draggedEl && t !== placeholderEl);
2668
+ if (targets.length === 0) {
2669
+ return 0;
2670
+ }
2671
+ const rightBias = 12;
2672
+ const leftBias = 0;
2673
+ const baseRect = placeholderEl
2674
+ ? placeholderEl.getBoundingClientRect()
2675
+ : draggedEl
2676
+ ? draggedEl.getBoundingClientRect()
2677
+ : null;
2678
+ const rectValid = !!baseRect && Number.isFinite(baseRect.width) && baseRect.width > 0;
2679
+ const draggedCenter = rectValid && baseRect ? baseRect.left + baseRect.width / 2 : null;
2680
+ for (let i = 0; i < targets.length; i += 1) {
2681
+ const rect = targets[i].getBoundingClientRect();
2682
+ const baseMid = rect.left + rect.width / 2;
2683
+ const isRightOfDragged = draggedCenter !== null ? baseMid >= draggedCenter : false;
2684
+ const mid = isRightOfDragged ? baseMid + rightBias : baseMid - leftBias;
1884
2685
  if (clientX < mid) {
1885
2686
  return i;
1886
2687
  }
1887
2688
  }
1888
- return tabs.length; // insert at end
2689
+ return targets.length;
1889
2690
  }
1890
2691
  reorderPaneInLocationAtIndex(location, pane, targetIndex) {
1891
2692
  const panes = location.node.panes;