@mintplayer/ng-bootstrap 20.4.0 → 20.5.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.
@@ -484,6 +484,7 @@ class MintDockManagerElement extends HTMLElement {
484
484
  this.dropJoystickTarget = null;
485
485
  this.rootLayout = null;
486
486
  this.floatingLayouts = [];
487
+ this.titles = {};
487
488
  this.pendingTabDragMetrics = null;
488
489
  this.resizeState = null;
489
490
  this.dragState = null;
@@ -547,6 +548,9 @@ class MintDockManagerElement extends HTMLElement {
547
548
  if (!this.hasAttribute('role')) {
548
549
  this.setAttribute('role', 'application');
549
550
  }
551
+ // Tag the docked surface with a root path so it can act as
552
+ // a drop target when the main layout is empty.
553
+ this.dockedEl.dataset['path'] = this.formatPath({ type: 'docked', segments: [] });
550
554
  this.render();
551
555
  this.rootEl.addEventListener('dragover', this.onDragOver);
552
556
  this.rootEl.addEventListener('drop', this.onDrop);
@@ -554,6 +558,21 @@ class MintDockManagerElement extends HTMLElement {
554
558
  this.dropJoystick.addEventListener('dragover', this.onDragOver);
555
559
  this.dropJoystick.addEventListener('drop', this.onDrop);
556
560
  this.dropJoystick.addEventListener('dragleave', this.onDragLeave);
561
+ // Strengthen zone tracking by reacting to dragenter/dragover directly on the buttons.
562
+ // This avoids relying solely on hit-testing each frame which can be jittery during HTML5 drag.
563
+ this.dropJoystickButtons.forEach((btn) => {
564
+ const handler = (e) => {
565
+ if (!this.dragState)
566
+ return;
567
+ const z = btn.dataset['zone'];
568
+ if (this.isDropZone(z)) {
569
+ this.updateDropJoystickActiveZone(z);
570
+ e.preventDefault();
571
+ }
572
+ };
573
+ btn.addEventListener('dragenter', handler);
574
+ btn.addEventListener('dragover', handler);
575
+ });
557
576
  const win = this.windowRef;
558
577
  win?.addEventListener('dragover', this.onGlobalDragOver);
559
578
  win?.addEventListener('drag', this.onDrag);
@@ -584,12 +603,14 @@ class MintDockManagerElement extends HTMLElement {
584
603
  return {
585
604
  root: this.cloneLayoutNode(this.rootLayout),
586
605
  floating: this.cloneFloatingArray(this.floatingLayouts),
606
+ titles: { ...this.titles },
587
607
  };
588
608
  }
589
609
  set layout(value) {
590
610
  const snapshot = this.ensureSnapshot(value);
591
611
  this.rootLayout = this.cloneLayoutNode(snapshot.root);
592
612
  this.floatingLayouts = this.cloneFloatingArray(snapshot.floating);
613
+ this.titles = snapshot.titles ? { ...snapshot.titles } : {};
593
614
  this.render();
594
615
  }
595
616
  get snapshot() {
@@ -629,10 +650,10 @@ class MintDockManagerElement extends HTMLElement {
629
650
  }
630
651
  ensureSnapshot(value) {
631
652
  if (!value) {
632
- return { root: null, floating: [] };
653
+ return { root: null, floating: [], titles: {} };
633
654
  }
634
655
  if (value.kind) {
635
- return { root: value, floating: [] };
656
+ return { root: value, floating: [], titles: {} };
636
657
  }
637
658
  const layout = value;
638
659
  return {
@@ -640,6 +661,7 @@ class MintDockManagerElement extends HTMLElement {
640
661
  floating: Array.isArray(layout.floating)
641
662
  ? layout.floating.map((floating) => this.normalizeFloatingLayout(floating))
642
663
  : [],
664
+ titles: layout.titles ? { ...layout.titles } : {},
643
665
  };
644
666
  }
645
667
  render() {
@@ -860,6 +882,12 @@ class MintDockManagerElement extends HTMLElement {
860
882
  }
861
883
  return;
862
884
  }
885
+ // Reset sticky zone when moving to another stack while dragging the
886
+ // floating window so the side doesn't carry over.
887
+ if (this.dropJoystickTarget && this.dropJoystickTarget !== stack) {
888
+ delete this.dropJoystick.dataset['zone'];
889
+ this.updateDropJoystickActiveZone(null);
890
+ }
863
891
  const zone = this.computeDropZone(stack, event, this.extractDropZoneFromEvent(event));
864
892
  if (zone) {
865
893
  state.dropTarget = { path, zone };
@@ -985,11 +1013,7 @@ class MintDockManagerElement extends HTMLElement {
985
1013
  if (!preferred) {
986
1014
  return fallback;
987
1015
  }
988
- const owningStack = this.findStackContainingPane(floating.root, preferred);
989
- if (!owningStack) {
990
- return preferred ?? fallback;
991
- }
992
- return owningStack.titles?.[preferred] ?? preferred ?? fallback;
1016
+ return this.titles[preferred] ?? preferred ?? fallback;
993
1017
  }
994
1018
  updateFloatingWindowTitle(index) {
995
1019
  const floating = this.floatingLayouts[index];
@@ -1079,7 +1103,7 @@ class MintDockManagerElement extends HTMLElement {
1079
1103
  button.classList.add('dock-tab');
1080
1104
  button.dataset['pane'] = paneName;
1081
1105
  button.id = tabId;
1082
- button.textContent = node.titles?.[paneName] ?? paneName;
1106
+ button.textContent = this.titles[paneName] ?? paneName;
1083
1107
  button.setAttribute('role', 'tab');
1084
1108
  button.setAttribute('aria-controls', panelId);
1085
1109
  if (paneName === activePane) {
@@ -1266,6 +1290,12 @@ class MintDockManagerElement extends HTMLElement {
1266
1290
  // We defer its removal to ensure the browser has captured it.
1267
1291
  setTimeout(() => ghost.remove(), 0);
1268
1292
  const { path: sourcePath, floatingIndex, pointerOffsetX, pointerOffsetY, } = this.preparePaneDragSource(path, pane, stackEl, event);
1293
+ // Capture header bounds for detecting when to convert to floating
1294
+ const headerEl = stackEl?.querySelector('.dock-stack__header') ?? null;
1295
+ const headerRect = headerEl ? headerEl.getBoundingClientRect() : null;
1296
+ const headerBounds = headerRect
1297
+ ? { left: headerRect.left, top: headerRect.top, right: headerRect.right, bottom: headerRect.bottom }
1298
+ : null;
1269
1299
  this.dragState = {
1270
1300
  pane,
1271
1301
  sourcePath: this.clonePath(sourcePath),
@@ -1273,7 +1303,18 @@ class MintDockManagerElement extends HTMLElement {
1273
1303
  pointerOffsetX,
1274
1304
  pointerOffsetY,
1275
1305
  dropHandled: false,
1306
+ sourceStackEl: stackEl,
1307
+ sourceHeaderBounds: headerBounds,
1308
+ startClientX: Number.isFinite(event.clientX) ? event.clientX : undefined,
1309
+ startClientY: Number.isFinite(event.clientY) ? event.clientY : undefined,
1276
1310
  };
1311
+ // Prefer the pointer offset relative to the dragged tab to avoid jumps on conversion
1312
+ if (Number.isFinite(event.offsetX)) {
1313
+ this.dragState.pointerOffsetX = event.offsetX;
1314
+ }
1315
+ if (Number.isFinite(event.offsetY)) {
1316
+ this.dragState.pointerOffsetY = event.offsetY;
1317
+ }
1277
1318
  this.updateDraggedFloatingPosition(event);
1278
1319
  this.startDragPointerTracking();
1279
1320
  event.dataTransfer.effectAllowed = 'move';
@@ -1282,7 +1323,6 @@ class MintDockManagerElement extends HTMLElement {
1282
1323
  preparePaneDragSource(path, pane, stackEl, event) {
1283
1324
  const location = this.resolveStackLocation(path);
1284
1325
  if (!location || !location.node.panes.includes(pane)) {
1285
- this.clearPendingTabDragMetrics();
1286
1326
  return {
1287
1327
  path,
1288
1328
  floatingIndex: null,
@@ -1291,7 +1331,6 @@ class MintDockManagerElement extends HTMLElement {
1291
1331
  };
1292
1332
  }
1293
1333
  const metrics = this.pendingTabDragMetrics;
1294
- this.pendingTabDragMetrics = null;
1295
1334
  const domHasSibling = !!stackEl &&
1296
1335
  Array.from(stackEl.querySelectorAll('.dock-tab')).some((button) => button.dataset['pane'] && button.dataset['pane'] !== pane);
1297
1336
  const hasSiblingInStack = location.node.panes.some((existing) => existing !== pane);
@@ -1341,95 +1380,19 @@ class MintDockManagerElement extends HTMLElement {
1341
1380
  pointerOffsetY: 0,
1342
1381
  };
1343
1382
  }
1344
- const hostRect = this.getBoundingClientRect();
1345
- const stackRect = stackEl?.getBoundingClientRect() ?? null;
1346
- const fallbackWidth = 320;
1347
- const fallbackHeight = 240;
1348
- const width = metrics && Number.isFinite(metrics.width) && metrics.width > 0
1349
- ? metrics.width
1350
- : stackRect && Number.isFinite(stackRect.width)
1351
- ? stackRect.width
1352
- : fallbackWidth;
1353
- const height = metrics && Number.isFinite(metrics.height) && metrics.height > 0
1354
- ? metrics.height
1355
- : stackRect && Number.isFinite(stackRect.height)
1356
- ? stackRect.height
1357
- : fallbackHeight;
1358
- const pointerOffsetX = metrics && Number.isFinite(metrics.pointerOffsetX)
1383
+ // Do not convert to floating yet; allow in-header reordering first.
1384
+ // We return placeholder values and will convert once the pointer leaves the tab header.
1385
+ const pointerOffsetXVal = metrics && Number.isFinite(metrics.pointerOffsetX)
1359
1386
  ? metrics.pointerOffsetX
1360
- : stackRect && Number.isFinite(event.clientX)
1361
- ? event.clientX - stackRect.left
1362
- : width / 2;
1363
- const pointerOffsetY = metrics && Number.isFinite(metrics.pointerOffsetY)
1387
+ : 0;
1388
+ const pointerOffsetYVal = metrics && Number.isFinite(metrics.pointerOffsetY)
1364
1389
  ? metrics.pointerOffsetY
1365
- : stackRect && Number.isFinite(event.clientY)
1366
- ? event.clientY - stackRect.top
1367
- : height / 2;
1368
- const pointerLeft = metrics && Number.isFinite(metrics.left)
1369
- ? metrics.left
1370
- : Number.isFinite(event.clientX)
1371
- ? event.clientX - hostRect.left - pointerOffsetX
1372
- : null;
1373
- const pointerTop = metrics && Number.isFinite(metrics.top)
1374
- ? metrics.top
1375
- : Number.isFinite(event.clientY)
1376
- ? event.clientY - hostRect.top - pointerOffsetY
1377
- : null;
1378
- const left = pointerLeft !== null
1379
- ? pointerLeft
1380
- : stackRect
1381
- ? stackRect.left - hostRect.left
1382
- : (hostRect.width - width) / 2;
1383
- const top = pointerTop !== null
1384
- ? pointerTop
1385
- : stackRect
1386
- ? stackRect.top - hostRect.top
1387
- : (hostRect.height - height) / 2;
1388
- // Defer the DOM modification to prevent the browser from cancelling the drag operation.
1389
- setTimeout(() => {
1390
- const paneTitle = location.node.titles?.[pane];
1391
- this.removePaneFromLocation(location, pane);
1392
- const floatingStack = {
1393
- kind: 'stack',
1394
- panes: [pane],
1395
- activePane: pane,
1396
- };
1397
- if (paneTitle) {
1398
- floatingStack.titles = { [pane]: paneTitle };
1399
- }
1400
- const floatingLayout = {
1401
- bounds: {
1402
- left,
1403
- top,
1404
- width,
1405
- height,
1406
- },
1407
- root: floatingStack,
1408
- activePane: pane,
1409
- };
1410
- if (paneTitle) {
1411
- floatingLayout.titles = { [pane]: paneTitle };
1412
- }
1413
- this.floatingLayouts.push(floatingLayout);
1414
- const floatingIndex = this.floatingLayouts.length - 1;
1415
- // Defer rendering to avoid interrupting the drag-and-drop initialization in the browser.
1416
- // Synchronously re-rendering can cause the browser to lose track of the drag operation.
1417
- this.render();
1418
- const wrapper = this.getFloatingWrapper(floatingIndex);
1419
- if (wrapper) {
1420
- this.promoteFloatingPane(floatingIndex, wrapper);
1421
- }
1422
- this.dispatchLayoutChanged();
1423
- if (this.dragState) {
1424
- this.dragState.sourcePath = { type: 'floating', index: floatingIndex, segments: [] };
1425
- this.dragState.floatingIndex = floatingIndex;
1426
- }
1427
- }, 0);
1390
+ : 0;
1428
1391
  return {
1429
- path: { type: 'floating', index: -1, segments: [] }, // Placeholder path
1392
+ path,
1430
1393
  floatingIndex: -1,
1431
- pointerOffsetX,
1432
- pointerOffsetY,
1394
+ pointerOffsetX: pointerOffsetXVal,
1395
+ pointerOffsetY: pointerOffsetYVal,
1433
1396
  };
1434
1397
  }
1435
1398
  endPaneDrag() {
@@ -1448,17 +1411,39 @@ class MintDockManagerElement extends HTMLElement {
1448
1411
  return;
1449
1412
  }
1450
1413
  event.preventDefault();
1414
+ // Keep internal pointer tracking up-to-date.
1451
1415
  this.updateDraggedFloatingPosition(event);
1452
1416
  if (event.dataTransfer) {
1453
1417
  event.dataTransfer.dropEffect = 'move';
1454
1418
  }
1455
- const stack = this.findStackElement(event);
1419
+ // Some browsers intermittently report (0,0) for dragover coordinates.
1420
+ // Mirror the robust logic used in onDrop: prefer actual event coordinates
1421
+ // when valid, otherwise fall back to the last tracked pointer position.
1422
+ const pointFromEvent = Number.isFinite(event.clientX) && Number.isFinite(event.clientY)
1423
+ ? { clientX: event.clientX, clientY: event.clientY }
1424
+ : null;
1425
+ const point = pointFromEvent ??
1426
+ (this.lastDragPointerPosition
1427
+ ? { clientX: this.lastDragPointerPosition.x, clientY: this.lastDragPointerPosition.y }
1428
+ : null);
1429
+ const stack = this.findStackElement(event) ??
1430
+ (point ? this.findStackAtPoint(point.clientX, point.clientY) : null);
1456
1431
  if (!stack) {
1457
- this.hideDropIndicator();
1432
+ if (this.dropJoystick.dataset['visible'] !== 'true') {
1433
+ this.hideDropIndicator();
1434
+ }
1458
1435
  return;
1459
1436
  }
1460
1437
  const path = this.parsePath(stack.dataset['path']);
1461
- const zone = this.computeDropZone(stack, event, this.extractDropZoneFromEvent(event));
1438
+ // If the hovered stack changed, clear any sticky zone from the previous
1439
+ // target before computing the new zone.
1440
+ if (this.dropJoystickTarget && this.dropJoystickTarget !== stack) {
1441
+ delete this.dropJoystick.dataset['zone'];
1442
+ this.updateDropJoystickActiveZone(null);
1443
+ }
1444
+ const eventZoneHint = this.extractDropZoneFromEvent(event);
1445
+ const pointZoneHint = point ? this.findDropZoneByPoint(point.clientX, point.clientY) : null;
1446
+ const zone = this.computeDropZone(stack, point ?? event, pointZoneHint ?? eventZoneHint);
1462
1447
  this.showDropIndicator(stack, zone);
1463
1448
  }
1464
1449
  updateDraggedFloatingPosition(event) {
@@ -1507,6 +1492,22 @@ class MintDockManagerElement extends HTMLElement {
1507
1492
  if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) {
1508
1493
  return;
1509
1494
  }
1495
+ // If we are still dragging a tab inside its header, only convert to floating once we leave the header bounds.
1496
+ if (this.dragState.floatingIndex !== null && this.dragState.floatingIndex < 0) {
1497
+ const b = this.dragState.sourceHeaderBounds;
1498
+ const sx = this.dragState.startClientX ?? clientX;
1499
+ const sy = this.dragState.startClientY ?? clientY;
1500
+ const dist = Math.hypot(clientX - sx, clientY - sy);
1501
+ const threshold = 4; // pixels to move before converting
1502
+ let insideHeader = false;
1503
+ if (b) {
1504
+ insideHeader = clientX >= b.left && clientX <= b.right && clientY >= b.top && clientY <= b.bottom;
1505
+ }
1506
+ if (!insideHeader && dist > threshold) {
1507
+ // Convert to floating now using current pointer position
1508
+ this.convertPendingTabDragToFloating(clientX, clientY);
1509
+ }
1510
+ }
1510
1511
  this.updatePaneDragDropTargetFromPoint(clientX, clientY);
1511
1512
  const { floatingIndex, pointerOffsetX, pointerOffsetY } = this.dragState;
1512
1513
  if (floatingIndex === null || floatingIndex < 0) {
@@ -1532,14 +1533,20 @@ class MintDockManagerElement extends HTMLElement {
1532
1533
  return;
1533
1534
  }
1534
1535
  const stack = this.findStackAtPoint(clientX, clientY);
1535
- if (!stack) {
1536
- this.hideDropIndicator();
1536
+ const path = stack ? this.parsePath(stack.dataset['path']) : null;
1537
+ if (!stack || !path) {
1538
+ // While actively dragging, avoid hiding the indicator if it is visible.
1539
+ // Transient misses from hit-testing are common near the joystick.
1540
+ if (this.dropJoystick.dataset['visible'] !== 'true') {
1541
+ this.hideDropIndicator();
1542
+ }
1537
1543
  return;
1538
1544
  }
1539
- const path = this.parsePath(stack.dataset['path']);
1540
- if (!path) {
1541
- this.hideDropIndicator();
1542
- return;
1545
+ // If we moved to a different target stack, reset any sticky zone so
1546
+ // the new drop area doesn't inherit the previous side selection.
1547
+ if (this.dropJoystickTarget && this.dropJoystickTarget !== stack) {
1548
+ delete this.dropJoystick.dataset['zone'];
1549
+ this.updateDropJoystickActiveZone(null);
1543
1550
  }
1544
1551
  const zoneHint = this.findDropZoneByPoint(clientX, clientY);
1545
1552
  const zone = this.computeDropZone(stack, { clientX, clientY }, zoneHint);
@@ -1599,18 +1606,122 @@ class MintDockManagerElement extends HTMLElement {
1599
1606
  this.updateDraggedFloatingPositionFromPoint(touch.clientX, touch.clientY);
1600
1607
  }
1601
1608
  onDragMouseUp() {
1609
+ this.handleDragPointerUpCommon();
1610
+ }
1611
+ // Convert a currently in-header tab drag into a floating window
1612
+ convertPendingTabDragToFloating(clientX, clientY) {
1602
1613
  if (!this.dragState) {
1603
- this.stopDragPointerTracking();
1604
1614
  return;
1605
1615
  }
1606
- this.scheduleDeferredDragEnd();
1616
+ const state = this.dragState;
1617
+ if (state.floatingIndex !== null && state.floatingIndex >= 0) {
1618
+ return; // already floating
1619
+ }
1620
+ const location = this.resolveStackLocation(state.sourcePath);
1621
+ if (!location) {
1622
+ return;
1623
+ }
1624
+ const pane = state.pane;
1625
+ const stackEl = state.sourceStackEl ?? null;
1626
+ const hostRect = this.getBoundingClientRect();
1627
+ const stackRect = stackEl?.getBoundingClientRect() ?? null;
1628
+ const metrics = this.pendingTabDragMetrics;
1629
+ const fallbackWidth = 320;
1630
+ const fallbackHeight = 240;
1631
+ const width = metrics && Number.isFinite(metrics.width) && metrics.width > 0
1632
+ ? metrics.width
1633
+ : stackRect && Number.isFinite(stackRect.width)
1634
+ ? stackRect.width
1635
+ : fallbackWidth;
1636
+ const height = metrics && Number.isFinite(metrics.height) && metrics.height > 0
1637
+ ? metrics.height
1638
+ : stackRect && Number.isFinite(stackRect.height)
1639
+ ? stackRect.height
1640
+ : fallbackHeight;
1641
+ const pointerOffsetX = Number.isFinite(this.dragState?.pointerOffsetX)
1642
+ ? this.dragState.pointerOffsetX
1643
+ : metrics && Number.isFinite(metrics.pointerOffsetX)
1644
+ ? metrics.pointerOffsetX
1645
+ : width / 2;
1646
+ const pointerOffsetY = Number.isFinite(this.dragState?.pointerOffsetY)
1647
+ ? this.dragState.pointerOffsetY
1648
+ : metrics && Number.isFinite(metrics.pointerOffsetY)
1649
+ ? metrics.pointerOffsetY
1650
+ : height / 2;
1651
+ const pointerLeft = Number.isFinite(clientX)
1652
+ ? clientX - hostRect.left - pointerOffsetX
1653
+ : 0;
1654
+ const pointerTop = Number.isFinite(clientY)
1655
+ ? clientY - hostRect.top - pointerOffsetY
1656
+ : 0;
1657
+ // Remove pane from its current stack and create a new floating entry
1658
+ this.removePaneFromLocation(location, pane);
1659
+ const floatingStack = {
1660
+ kind: 'stack',
1661
+ panes: [pane],
1662
+ activePane: pane,
1663
+ };
1664
+ const floatingLayout = {
1665
+ bounds: {
1666
+ left: pointerLeft,
1667
+ top: pointerTop,
1668
+ width,
1669
+ height,
1670
+ },
1671
+ root: floatingStack,
1672
+ activePane: pane,
1673
+ };
1674
+ this.floatingLayouts.push(floatingLayout);
1675
+ const newIndex = this.floatingLayouts.length - 1;
1676
+ this.render();
1677
+ const wrapper = this.getFloatingWrapper(newIndex);
1678
+ if (wrapper) {
1679
+ this.promoteFloatingPane(newIndex, wrapper);
1680
+ }
1681
+ // Update drag state so subsequent moves reposition the floating window
1682
+ state.sourcePath = { type: 'floating', index: newIndex, segments: [] };
1683
+ state.floatingIndex = newIndex;
1684
+ state.pointerOffsetX = pointerOffsetX;
1685
+ state.pointerOffsetY = pointerOffsetY;
1686
+ this.dispatchLayoutChanged();
1607
1687
  }
1608
- onDragTouchEnd() {
1609
- if (!this.dragState) {
1610
- this.stopDragPointerTracking();
1688
+ // Compute the intended tab insert index within a header based on pointer X
1689
+ computeHeaderInsertIndex(header, clientX) {
1690
+ const tabs = Array.from(header.querySelectorAll('.dock-tab'));
1691
+ if (tabs.length === 0) {
1692
+ return 0;
1693
+ }
1694
+ for (let i = 0; i < tabs.length; i += 1) {
1695
+ const rect = tabs[i].getBoundingClientRect();
1696
+ const mid = rect.left + rect.width / 2;
1697
+ if (clientX < mid) {
1698
+ return i;
1699
+ }
1700
+ }
1701
+ return tabs.length; // insert at end
1702
+ }
1703
+ reorderPaneInLocationAtIndex(location, pane, targetIndex) {
1704
+ const panes = location.node.panes;
1705
+ const currentIndex = panes.indexOf(pane);
1706
+ if (currentIndex === -1) {
1611
1707
  return;
1612
1708
  }
1613
- this.scheduleDeferredDragEnd();
1709
+ const clampedTarget = Math.max(0, Math.min(targetIndex, panes.length - 1));
1710
+ if (clampedTarget === currentIndex) {
1711
+ return;
1712
+ }
1713
+ panes.splice(currentIndex, 1);
1714
+ panes.splice(clampedTarget, 0, pane);
1715
+ location.node.activePane = pane;
1716
+ if (location.context === 'floating') {
1717
+ const floating = this.floatingLayouts[location.index];
1718
+ if (floating) {
1719
+ floating.activePane = pane;
1720
+ }
1721
+ }
1722
+ }
1723
+ onDragTouchEnd() {
1724
+ this.handleDragPointerUpCommon();
1614
1725
  }
1615
1726
  clearPendingDragEndTimeout() {
1616
1727
  if (this.pendingDragEndTimeout !== null) {
@@ -1666,6 +1777,29 @@ class MintDockManagerElement extends HTMLElement {
1666
1777
  return;
1667
1778
  }
1668
1779
  const path = this.parsePath(stack.dataset['path']);
1780
+ // Allow reordering within the same stack header without selecting a zone
1781
+ if (this.dragState &&
1782
+ this.dragState.floatingIndex !== null &&
1783
+ this.dragState.floatingIndex < 0 &&
1784
+ path &&
1785
+ this.pathsEqual(path, this.dragState.sourcePath)) {
1786
+ const header = stack.querySelector('.dock-stack__header');
1787
+ if (header) {
1788
+ const x = (point ? point.clientX : event.clientX);
1789
+ if (Number.isFinite(x)) {
1790
+ const location = this.resolveStackLocation(path);
1791
+ if (location) {
1792
+ const idx = this.computeHeaderInsertIndex(header, x);
1793
+ this.reorderPaneInLocationAtIndex(location, this.dragState.pane, idx);
1794
+ this.render();
1795
+ this.dispatchLayoutChanged();
1796
+ this.dragState.dropHandled = true;
1797
+ this.endPaneDrag();
1798
+ return;
1799
+ }
1800
+ }
1801
+ }
1802
+ }
1669
1803
  const eventZoneHint = this.extractDropZoneFromEvent(event);
1670
1804
  const pointZoneHint = point ? this.findDropZoneByPoint(point.clientX, point.clientY) : null;
1671
1805
  const zone = this.computeDropZone(stack, point ?? event, pointZoneHint ?? eventZoneHint);
@@ -1679,6 +1813,21 @@ class MintDockManagerElement extends HTMLElement {
1679
1813
  }
1680
1814
  onDragLeave(event) {
1681
1815
  const related = event.relatedTarget;
1816
+ // During active drags, browsers can emit spurious dragleave with null
1817
+ // relatedTarget while the pointer is still over the joystick/buttons.
1818
+ // Be conservative: if we can resolve a stack/joystick at the last known
1819
+ // pointer position, don’t hide (prevents flicker of active state).
1820
+ if (this.dragState) {
1821
+ const pos = (Number.isFinite(event.clientX) && Number.isFinite(event.clientY))
1822
+ ? { x: event.clientX, y: event.clientY }
1823
+ : this.lastDragPointerPosition;
1824
+ if (pos) {
1825
+ const stackAtPoint = this.findStackAtPoint(pos.x, pos.y);
1826
+ if (stackAtPoint) {
1827
+ return; // still inside our drop area; ignore this dragleave
1828
+ }
1829
+ }
1830
+ }
1682
1831
  if (!related) {
1683
1832
  this.hideDropIndicator();
1684
1833
  return;
@@ -1696,6 +1845,28 @@ class MintDockManagerElement extends HTMLElement {
1696
1845
  const { pane, sourcePath } = this.dragState;
1697
1846
  const source = this.resolveStackLocation(sourcePath);
1698
1847
  const target = this.resolveStackLocation(targetPath);
1848
+ // Special case: allow dropping onto an empty main dock area (no root).
1849
+ if (!target && targetPath.type === 'docked' && !this.rootLayout) {
1850
+ if (!source) {
1851
+ return;
1852
+ }
1853
+ const stackEmptied = this.removePaneFromLocation(source, pane, true);
1854
+ const newRoot = {
1855
+ kind: 'stack',
1856
+ panes: [pane],
1857
+ activePane: pane,
1858
+ };
1859
+ this.rootLayout = newRoot;
1860
+ if (stackEmptied) {
1861
+ this.cleanupLocation(source);
1862
+ }
1863
+ this.render();
1864
+ this.dispatchLayoutChanged();
1865
+ if (this.dragState) {
1866
+ this.dragState.dropHandled = true;
1867
+ }
1868
+ return;
1869
+ }
1699
1870
  if (!source || !target) {
1700
1871
  return;
1701
1872
  }
@@ -1711,15 +1882,10 @@ class MintDockManagerElement extends HTMLElement {
1711
1882
  }
1712
1883
  return;
1713
1884
  }
1714
- const paneTitle = source.node.titles?.[pane];
1715
1885
  const stackEmptied = this.removePaneFromLocation(source, pane, true);
1716
1886
  if (zone === 'center') {
1717
1887
  this.addPaneToLocation(target, pane);
1718
1888
  this.setActivePaneForLocation(target, pane);
1719
- if (paneTitle) {
1720
- target.node.titles = target.node.titles ? { ...target.node.titles } : {};
1721
- target.node.titles[pane] = paneTitle;
1722
- }
1723
1889
  if (stackEmptied) {
1724
1890
  this.cleanupLocation(source);
1725
1891
  }
@@ -1735,9 +1901,6 @@ class MintDockManagerElement extends HTMLElement {
1735
1901
  panes: [pane],
1736
1902
  activePane: pane,
1737
1903
  };
1738
- if (paneTitle) {
1739
- newStack.titles = { [pane]: paneTitle };
1740
- }
1741
1904
  if (target.context === 'docked') {
1742
1905
  this.rootLayout = this.dockNodeBeside(this.rootLayout, target.node, newStack, zone);
1743
1906
  }
@@ -1769,6 +1932,15 @@ class MintDockManagerElement extends HTMLElement {
1769
1932
  return false;
1770
1933
  }
1771
1934
  const target = this.resolveStackLocation(targetPath);
1935
+ // Allow dropping an entire floating stack onto an empty main dock area
1936
+ // (no existing root).
1937
+ if (!target && targetPath.type === 'docked' && !this.rootLayout) {
1938
+ this.rootLayout = this.cloneLayoutNode(source.root);
1939
+ this.removeFloatingAt(sourceIndex);
1940
+ this.render();
1941
+ this.dispatchLayoutChanged();
1942
+ return true;
1943
+ }
1772
1944
  if (!target) {
1773
1945
  return false;
1774
1946
  }
@@ -1776,17 +1948,12 @@ class MintDockManagerElement extends HTMLElement {
1776
1948
  return false;
1777
1949
  }
1778
1950
  if (zone === 'center') {
1779
- const { panes, titles } = this.collectFloatingPaneMetadata(source.root);
1951
+ const panes = this.collectPaneNames(source.root);
1780
1952
  if (panes.length === 0) {
1781
1953
  return false;
1782
1954
  }
1783
1955
  panes.forEach((pane) => {
1784
1956
  this.addPaneToLocation(target, pane);
1785
- const title = titles[pane];
1786
- if (title) {
1787
- target.node.titles = target.node.titles ? { ...target.node.titles } : {};
1788
- target.node.titles[pane] = title;
1789
- }
1790
1957
  });
1791
1958
  const activePane = source.activePane && panes.includes(source.activePane)
1792
1959
  ? source.activePane
@@ -1873,6 +2040,8 @@ class MintDockManagerElement extends HTMLElement {
1873
2040
  return null;
1874
2041
  }
1875
2042
  computeDropZone(stack, point, zoneHint) {
2043
+ // Do not force a zone: even when the main area is empty we only
2044
+ // activate a zone when the pointer is actually over a joystick button.
1876
2045
  const hintedZone = this.isDropZone(zoneHint) ? zoneHint : null;
1877
2046
  if (hintedZone) {
1878
2047
  this.updateDropJoystickActiveZone(hintedZone);
@@ -1888,6 +2057,14 @@ class MintDockManagerElement extends HTMLElement {
1888
2057
  return pointZone;
1889
2058
  }
1890
2059
  if (this.dropJoystickTarget === stack) {
2060
+ // Be sticky while hovering the joystick: if we recently had a zone
2061
+ // selected, prefer keeping it instead of briefly clearing to null
2062
+ // when the browser reports transient coordinates/targets during drag.
2063
+ const sticky = this.dropJoystick.dataset['zone'];
2064
+ if (this.isDropZone(sticky)) {
2065
+ this.updateDropJoystickActiveZone(sticky);
2066
+ return sticky;
2067
+ }
1891
2068
  this.updateDropJoystickActiveZone(null);
1892
2069
  }
1893
2070
  return null;
@@ -1898,14 +2075,9 @@ class MintDockManagerElement extends HTMLElement {
1898
2075
  }
1899
2076
  if (typeof event.composedPath === 'function') {
1900
2077
  const path = event.composedPath();
1901
- for (const target of path) {
1902
- if (target instanceof HTMLElement &&
1903
- target.classList.contains('dock-drop-joystick__button')) {
1904
- const zone = target.dataset?.['zone'];
1905
- if (this.isDropZone(zone)) {
1906
- return zone;
1907
- }
1908
- }
2078
+ const zone = this.findDropZoneInTargets(path);
2079
+ if (zone) {
2080
+ return zone;
1909
2081
  }
1910
2082
  }
1911
2083
  if ('clientX' in event && 'clientY' in event) {
@@ -1917,6 +2089,25 @@ class MintDockManagerElement extends HTMLElement {
1917
2089
  }
1918
2090
  return null;
1919
2091
  }
2092
+ handleDragPointerUpCommon() {
2093
+ if (!this.dragState) {
2094
+ this.stopDragPointerTracking();
2095
+ return;
2096
+ }
2097
+ this.scheduleDeferredDragEnd();
2098
+ }
2099
+ findDropZoneInTargets(targets) {
2100
+ for (const target of targets) {
2101
+ if (target instanceof HTMLElement &&
2102
+ target.classList.contains('dock-drop-joystick__button')) {
2103
+ const zone = target.dataset?.['zone'];
2104
+ if (this.isDropZone(zone)) {
2105
+ return zone;
2106
+ }
2107
+ }
2108
+ }
2109
+ return null;
2110
+ }
1920
2111
  findDropZoneByPoint(clientX, clientY) {
1921
2112
  if (!this.dropJoystickButtons ||
1922
2113
  this.dropJoystick.dataset['visible'] !== 'true' ||
@@ -1924,6 +2115,10 @@ class MintDockManagerElement extends HTMLElement {
1924
2115
  return null;
1925
2116
  }
1926
2117
  for (const button of this.dropJoystickButtons) {
2118
+ // Skip hidden/inactive buttons (used in center-only mode)
2119
+ if ((button.dataset['hidden'] === 'true') || button.style.visibility === 'hidden' || button.style.display === 'none') {
2120
+ continue;
2121
+ }
1927
2122
  const rect = button.getBoundingClientRect();
1928
2123
  if (clientX >= rect.left &&
1929
2124
  clientX <= rect.right &&
@@ -1938,8 +2133,14 @@ class MintDockManagerElement extends HTMLElement {
1938
2133
  return null;
1939
2134
  }
1940
2135
  updateDropJoystickActiveZone(zone) {
2136
+ // If no zone is computed but the joystick is visible, keep the last
2137
+ // known active zone to avoid visual jitter while dragging across
2138
+ // small gaps where hit‑testing momentarily fails.
2139
+ const visible = this.dropJoystick.dataset['visible'] === 'true';
2140
+ const sticky = visible ? this.dropJoystick.dataset['zone'] : undefined;
2141
+ const effectiveZone = zone ?? (this.isDropZone(sticky) ? sticky : null);
1941
2142
  this.dropJoystickButtons.forEach((button) => {
1942
- const isActive = zone !== null && button.dataset['zone'] === zone;
2143
+ const isActive = effectiveZone !== null && button.dataset['zone'] === effectiveZone;
1943
2144
  if (isActive) {
1944
2145
  button.dataset['active'] = 'true';
1945
2146
  }
@@ -1947,8 +2148,8 @@ class MintDockManagerElement extends HTMLElement {
1947
2148
  delete button.dataset['active'];
1948
2149
  }
1949
2150
  });
1950
- if (zone) {
1951
- this.dropJoystick.dataset['zone'] = zone;
2151
+ if (effectiveZone) {
2152
+ this.dropJoystick.dataset['zone'] = effectiveZone;
1952
2153
  }
1953
2154
  else {
1954
2155
  delete this.dropJoystick.dataset['zone'];
@@ -2014,7 +2215,52 @@ class MintDockManagerElement extends HTMLElement {
2014
2215
  joystick.dataset['visible'] = 'true';
2015
2216
  this.dropJoystick.style.display = 'grid';
2016
2217
  joystick.dataset['path'] = stack.dataset['path'] ?? '';
2218
+ const changedTarget = this.dropJoystickTarget && this.dropJoystickTarget !== stack;
2017
2219
  this.dropJoystickTarget = stack;
2220
+ if (changedTarget) {
2221
+ // New target stack: forget any previously sticky zone.
2222
+ delete this.dropJoystick.dataset['zone'];
2223
+ }
2224
+ // If main dock area is empty, show only the center button and collapse the grid
2225
+ const isEmptyMainArea = !this.rootLayout && (stack === this.dockedEl || (targetPath && targetPath.type === 'docked' && targetPath.segments.length === 0));
2226
+ const spacers = Array.from(this.dropJoystick.querySelectorAll('.dock-drop-joystick__spacer'));
2227
+ if (isEmptyMainArea) {
2228
+ // Keep spacers visible so the joystick keeps its circular footprint.
2229
+ spacers.forEach((s) => {
2230
+ s.style.display = '';
2231
+ });
2232
+ this.dropJoystickButtons.forEach((btn) => {
2233
+ const isCenter = btn.dataset['zone'] === 'center';
2234
+ if (isCenter) {
2235
+ btn.style.visibility = '';
2236
+ btn.style.pointerEvents = '';
2237
+ delete btn.dataset['hidden'];
2238
+ btn.style.display = '';
2239
+ }
2240
+ else {
2241
+ // Hide visually but keep layout space; also prevent interaction.
2242
+ btn.style.visibility = 'hidden';
2243
+ btn.style.pointerEvents = 'none';
2244
+ btn.dataset['hidden'] = 'true';
2245
+ btn.style.display = '';
2246
+ }
2247
+ });
2248
+ // Keep default 3x3 grid so the circular background size stays the same.
2249
+ this.dropJoystick.style.gridTemplateColumns = '';
2250
+ this.dropJoystick.style.gridTemplateRows = '';
2251
+ // Do not set an active zone automatically; users must hover the button.
2252
+ }
2253
+ else {
2254
+ this.dropJoystickButtons.forEach((btn) => {
2255
+ btn.style.visibility = '';
2256
+ btn.style.pointerEvents = '';
2257
+ delete btn.dataset['hidden'];
2258
+ btn.style.display = '';
2259
+ });
2260
+ spacers.forEach((s) => (s.style.display = ''));
2261
+ this.dropJoystick.style.gridTemplateColumns = '';
2262
+ this.dropJoystick.style.gridTemplateRows = '';
2263
+ }
2018
2264
  this.updateDropJoystickActiveZone(zone);
2019
2265
  }
2020
2266
  hideDropIndicator() {
@@ -2024,6 +2270,16 @@ class MintDockManagerElement extends HTMLElement {
2024
2270
  delete this.dropJoystick.dataset['path'];
2025
2271
  this.dropJoystickTarget = null;
2026
2272
  this.updateDropJoystickActiveZone(null);
2273
+ // Restore joystick structure to default.
2274
+ this.dropJoystickButtons.forEach((btn) => {
2275
+ btn.style.display = '';
2276
+ btn.style.visibility = '';
2277
+ btn.style.pointerEvents = '';
2278
+ delete btn.dataset['hidden'];
2279
+ });
2280
+ Array.from(this.dropJoystick.querySelectorAll('.dock-drop-joystick__spacer')).forEach((s) => (s.style.display = ''));
2281
+ this.dropJoystick.style.gridTemplateColumns = '';
2282
+ this.dropJoystick.style.gridTemplateRows = '';
2027
2283
  }
2028
2284
  findStackAtPoint(clientX, clientY) {
2029
2285
  const shadow = this.shadowRoot;
@@ -2031,35 +2287,53 @@ class MintDockManagerElement extends HTMLElement {
2031
2287
  return null;
2032
2288
  }
2033
2289
  const elements = shadow.elementsFromPoint(clientX, clientY);
2034
- for (const element of elements) {
2035
- if (!(element instanceof HTMLElement)) {
2036
- continue;
2037
- }
2038
- if (element.classList.contains('dock-stack')) {
2039
- return element;
2040
- }
2041
- if (this.dropJoystickTarget &&
2042
- (element.classList.contains('dock-drop-joystick') ||
2043
- element.classList.contains('dock-drop-joystick__button') ||
2044
- element.classList.contains('dock-drop-joystick__spacer'))) {
2045
- return this.dropJoystickTarget;
2290
+ const stack = this.findStackInTargets(elements);
2291
+ if (stack) {
2292
+ return stack;
2293
+ }
2294
+ // If there are no docked stacks (all panes are floating), allow the
2295
+ // docked surface itself to serve as a drop target for the main zone.
2296
+ if (!this.rootLayout) {
2297
+ const dockRect = this.dockedEl.getBoundingClientRect();
2298
+ if (clientX >= dockRect.left &&
2299
+ clientX <= dockRect.right &&
2300
+ clientY >= dockRect.top &&
2301
+ clientY <= dockRect.bottom) {
2302
+ return this.dockedEl;
2046
2303
  }
2047
2304
  }
2048
2305
  return null;
2049
2306
  }
2050
2307
  findStackElement(event) {
2051
2308
  const path = event.composedPath();
2052
- for (const target of path) {
2053
- if (!(target instanceof HTMLElement)) {
2309
+ const stack = this.findStackInTargets(path);
2310
+ if (stack) {
2311
+ return stack;
2312
+ }
2313
+ // If the root dock area is empty, treat the docked surface as a valid
2314
+ // target when it appears in the composed path.
2315
+ if (!this.rootLayout) {
2316
+ for (const target of path) {
2317
+ if (target instanceof HTMLElement &&
2318
+ (target === this.dockedEl || target.classList.contains('dock-docked'))) {
2319
+ return this.dockedEl;
2320
+ }
2321
+ }
2322
+ }
2323
+ return null;
2324
+ }
2325
+ findStackInTargets(targets) {
2326
+ for (const element of targets) {
2327
+ if (!(element instanceof HTMLElement)) {
2054
2328
  continue;
2055
2329
  }
2056
- if (target.classList.contains('dock-stack')) {
2057
- return target;
2330
+ if (element.classList.contains('dock-stack')) {
2331
+ return element;
2058
2332
  }
2059
2333
  if (this.dropJoystickTarget &&
2060
- (target.classList.contains('dock-drop-joystick') ||
2061
- target.classList.contains('dock-drop-joystick__button') ||
2062
- target.classList.contains('dock-drop-joystick__spacer'))) {
2334
+ (element.classList.contains('dock-drop-joystick') ||
2335
+ element.classList.contains('dock-drop-joystick__button') ||
2336
+ element.classList.contains('dock-drop-joystick__spacer'))) {
2063
2337
  return this.dropJoystickTarget;
2064
2338
  }
2065
2339
  }
@@ -2234,19 +2508,25 @@ class MintDockManagerElement extends HTMLElement {
2234
2508
  return found;
2235
2509
  }
2236
2510
  collectFloatingPaneMetadata(node) {
2237
- const panes = [];
2511
+ // Deprecated method retained temporarily for signature compatibility.
2512
+ // Use collectPaneNames instead.
2513
+ const panes = this.collectPaneNames(node);
2238
2514
  const titles = {};
2239
- this.forEachStack(node, (stack) => {
2240
- stack.panes.forEach((pane) => {
2241
- panes.push(pane);
2242
- const title = stack.titles?.[pane];
2243
- if (title) {
2244
- titles[pane] = title;
2245
- }
2246
- });
2515
+ panes.forEach((p) => {
2516
+ const t = this.titles[p];
2517
+ if (t) {
2518
+ titles[p] = t;
2519
+ }
2247
2520
  });
2248
2521
  return { panes, titles };
2249
2522
  }
2523
+ collectPaneNames(node) {
2524
+ const panes = [];
2525
+ this.forEachStack(node, (stack) => {
2526
+ stack.panes.forEach((pane) => panes.push(pane));
2527
+ });
2528
+ return panes;
2529
+ }
2250
2530
  normalizeFloatingLayout(layout) {
2251
2531
  const bounds = layout.bounds ?? { left: 0, top: 0, width: 320, height: 200 };
2252
2532
  const normalizedBounds = {
@@ -2255,21 +2535,7 @@ class MintDockManagerElement extends HTMLElement {
2255
2535
  width: Number.isFinite(bounds.width) ? Math.max(bounds.width, 160) : 320,
2256
2536
  height: Number.isFinite(bounds.height) ? Math.max(bounds.height, 120) : 200,
2257
2537
  };
2258
- let root = layout.root ? this.cloneLayoutNode(layout.root) : null;
2259
- if (!root) {
2260
- const panes = Array.isArray(layout.panes) ? [...layout.panes] : [];
2261
- if (panes.length > 0) {
2262
- const active = layout.activePane && panes.includes(layout.activePane)
2263
- ? layout.activePane
2264
- : panes[0];
2265
- root = {
2266
- kind: 'stack',
2267
- panes,
2268
- titles: layout.titles ? { ...layout.titles } : undefined,
2269
- activePane: active,
2270
- };
2271
- }
2272
- }
2538
+ const root = layout.root ? this.cloneLayoutNode(layout.root) : null;
2273
2539
  return {
2274
2540
  id: layout.id,
2275
2541
  bounds: normalizedBounds,
@@ -2574,14 +2840,15 @@ class BsDockManagerComponent {
2574
2840
  }
2575
2841
  ensureSnapshot(value) {
2576
2842
  if (!value) {
2577
- return { root: null, floating: [] };
2843
+ return { root: null, floating: [], titles: {} };
2578
2844
  }
2579
2845
  if ('kind' in value) {
2580
- return { root: value, floating: [] };
2846
+ return { root: value, floating: [], titles: {} };
2581
2847
  }
2582
2848
  return {
2583
2849
  root: value.root ?? null,
2584
2850
  floating: Array.isArray(value.floating) ? [...value.floating] : [],
2851
+ titles: value.titles ? { ...value.titles } : {},
2585
2852
  };
2586
2853
  }
2587
2854
  stringifyLayout(layout) {