@mintplayer/ng-bootstrap 20.4.0 → 20.6.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) {
@@ -1198,7 +1222,6 @@ class MintDockManagerElement extends HTMLElement {
1198
1222
  this.handleFloatingResizeMove(event);
1199
1223
  }
1200
1224
  if (this.floatingDragState && event.pointerId === this.floatingDragState.pointerId) {
1201
- console.warn('state', this.floatingDragState);
1202
1225
  this.handleFloatingDragMove(event);
1203
1226
  }
1204
1227
  }
@@ -1241,6 +1264,8 @@ class MintDockManagerElement extends HTMLElement {
1241
1264
  top,
1242
1265
  width,
1243
1266
  height,
1267
+ startClientX: event.clientX,
1268
+ startClientY: event.clientY,
1244
1269
  };
1245
1270
  }
1246
1271
  clearPendingTabDragMetrics() {
@@ -1261,11 +1286,20 @@ class MintDockManagerElement extends HTMLElement {
1261
1286
  this.shadowRoot?.appendChild(ghost);
1262
1287
  // Use the ghost element as the drag image.
1263
1288
  // The offset is set to where the user's cursor is on the original element.
1264
- event.dataTransfer.setDragImage(ghost, event.offsetX, event.offsetY);
1289
+ const dragImgOffsetX = Number.isFinite(event.offsetX) ? event.offsetX : 0;
1290
+ const dragImgOffsetY = Number.isFinite(event.offsetY) ? event.offsetY : 0;
1291
+ event.dataTransfer.setDragImage(ghost, dragImgOffsetX, dragImgOffsetY);
1265
1292
  // The ghost element is no longer needed after the drag image is set.
1266
1293
  // We defer its removal to ensure the browser has captured it.
1267
1294
  setTimeout(() => ghost.remove(), 0);
1268
1295
  const { path: sourcePath, floatingIndex, pointerOffsetX, pointerOffsetY, } = this.preparePaneDragSource(path, pane, stackEl, event);
1296
+ // Capture header bounds for detecting when to convert to floating
1297
+ const headerEl = stackEl?.querySelector('.dock-stack__header') ?? null;
1298
+ const headerRect = headerEl ? headerEl.getBoundingClientRect() : null;
1299
+ const headerBounds = headerRect
1300
+ ? { left: headerRect.left, top: headerRect.top, right: headerRect.right, bottom: headerRect.bottom }
1301
+ : null;
1302
+ const metrics = this.pendingTabDragMetrics;
1269
1303
  this.dragState = {
1270
1304
  pane,
1271
1305
  sourcePath: this.clonePath(sourcePath),
@@ -1273,7 +1307,36 @@ class MintDockManagerElement extends HTMLElement {
1273
1307
  pointerOffsetX,
1274
1308
  pointerOffsetY,
1275
1309
  dropHandled: false,
1310
+ sourceStackEl: stackEl,
1311
+ sourceHeaderBounds: headerBounds,
1312
+ startClientX: metrics && Number.isFinite(metrics.startClientX)
1313
+ ? metrics.startClientX
1314
+ : Number.isFinite(event.clientX)
1315
+ ? event.clientX
1316
+ : undefined,
1317
+ startClientY: metrics && Number.isFinite(metrics.startClientY)
1318
+ ? metrics.startClientY
1319
+ : Number.isFinite(event.clientY)
1320
+ ? event.clientY
1321
+ : undefined,
1276
1322
  };
1323
+ // Seed last known pointer position from pointerdown metrics to avoid (0,0) glitches in Firefox
1324
+ if (this.dragState.startClientX !== undefined &&
1325
+ this.dragState.startClientY !== undefined &&
1326
+ Number.isFinite(this.dragState.startClientX) &&
1327
+ Number.isFinite(this.dragState.startClientY)) {
1328
+ this.lastDragPointerPosition = {
1329
+ x: this.dragState.startClientX,
1330
+ y: this.dragState.startClientY,
1331
+ };
1332
+ }
1333
+ // Prefer the pointer offset relative to the dragged tab to avoid jumps on conversion
1334
+ if (Number.isFinite(event.offsetX)) {
1335
+ this.dragState.pointerOffsetX = event.offsetX;
1336
+ }
1337
+ if (Number.isFinite(event.offsetY)) {
1338
+ this.dragState.pointerOffsetY = event.offsetY;
1339
+ }
1277
1340
  this.updateDraggedFloatingPosition(event);
1278
1341
  this.startDragPointerTracking();
1279
1342
  event.dataTransfer.effectAllowed = 'move';
@@ -1282,7 +1345,6 @@ class MintDockManagerElement extends HTMLElement {
1282
1345
  preparePaneDragSource(path, pane, stackEl, event) {
1283
1346
  const location = this.resolveStackLocation(path);
1284
1347
  if (!location || !location.node.panes.includes(pane)) {
1285
- this.clearPendingTabDragMetrics();
1286
1348
  return {
1287
1349
  path,
1288
1350
  floatingIndex: null,
@@ -1291,7 +1353,6 @@ class MintDockManagerElement extends HTMLElement {
1291
1353
  };
1292
1354
  }
1293
1355
  const metrics = this.pendingTabDragMetrics;
1294
- this.pendingTabDragMetrics = null;
1295
1356
  const domHasSibling = !!stackEl &&
1296
1357
  Array.from(stackEl.querySelectorAll('.dock-tab')).some((button) => button.dataset['pane'] && button.dataset['pane'] !== pane);
1297
1358
  const hasSiblingInStack = location.node.panes.some((existing) => existing !== pane);
@@ -1341,95 +1402,19 @@ class MintDockManagerElement extends HTMLElement {
1341
1402
  pointerOffsetY: 0,
1342
1403
  };
1343
1404
  }
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)
1405
+ // Do not convert to floating yet; allow in-header reordering first.
1406
+ // We return placeholder values and will convert once the pointer leaves the tab header.
1407
+ const pointerOffsetXVal = metrics && Number.isFinite(metrics.pointerOffsetX)
1359
1408
  ? metrics.pointerOffsetX
1360
- : stackRect && Number.isFinite(event.clientX)
1361
- ? event.clientX - stackRect.left
1362
- : width / 2;
1363
- const pointerOffsetY = metrics && Number.isFinite(metrics.pointerOffsetY)
1409
+ : 0;
1410
+ const pointerOffsetYVal = metrics && Number.isFinite(metrics.pointerOffsetY)
1364
1411
  ? 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);
1412
+ : 0;
1428
1413
  return {
1429
- path: { type: 'floating', index: -1, segments: [] }, // Placeholder path
1414
+ path,
1430
1415
  floatingIndex: -1,
1431
- pointerOffsetX,
1432
- pointerOffsetY,
1416
+ pointerOffsetX: pointerOffsetXVal,
1417
+ pointerOffsetY: pointerOffsetYVal,
1433
1418
  };
1434
1419
  }
1435
1420
  endPaneDrag() {
@@ -1437,6 +1422,7 @@ class MintDockManagerElement extends HTMLElement {
1437
1422
  const state = this.dragState;
1438
1423
  this.dragState = null;
1439
1424
  this.hideDropIndicator();
1425
+ this.clearHeaderDragPlaceholder();
1440
1426
  this.stopDragPointerTracking();
1441
1427
  this.lastDragPointerPosition = null;
1442
1428
  if (state && state.floatingIndex !== null && !state.dropHandled) {
@@ -1448,27 +1434,64 @@ class MintDockManagerElement extends HTMLElement {
1448
1434
  return;
1449
1435
  }
1450
1436
  event.preventDefault();
1437
+ // Keep internal pointer tracking up-to-date.
1451
1438
  this.updateDraggedFloatingPosition(event);
1452
1439
  if (event.dataTransfer) {
1453
1440
  event.dataTransfer.dropEffect = 'move';
1454
1441
  }
1455
- const stack = this.findStackElement(event);
1442
+ // Some browsers intermittently report (0,0) for dragover coordinates.
1443
+ // Mirror the robust logic used in onDrop: prefer actual event coordinates
1444
+ // when valid, otherwise fall back to the last tracked pointer position.
1445
+ const pointFromEvent = Number.isFinite(event.clientX) && Number.isFinite(event.clientY)
1446
+ ? { clientX: event.clientX, clientY: event.clientY }
1447
+ : null;
1448
+ const point = pointFromEvent ??
1449
+ (this.lastDragPointerPosition
1450
+ ? { clientX: this.lastDragPointerPosition.x, clientY: this.lastDragPointerPosition.y }
1451
+ : null);
1452
+ const stack = this.findStackElement(event) ??
1453
+ (point ? this.findStackAtPoint(point.clientX, point.clientY) : null);
1456
1454
  if (!stack) {
1457
- this.hideDropIndicator();
1455
+ if (this.dropJoystick.dataset['visible'] !== 'true') {
1456
+ this.hideDropIndicator();
1457
+ }
1458
1458
  return;
1459
1459
  }
1460
1460
  const path = this.parsePath(stack.dataset['path']);
1461
- const zone = this.computeDropZone(stack, event, this.extractDropZoneFromEvent(event));
1461
+ // While reordering within the same header, suppress the joystick/indicator entirely
1462
+ if (this.dragState &&
1463
+ this.dragState.floatingIndex !== null &&
1464
+ this.dragState.floatingIndex < 0 &&
1465
+ path &&
1466
+ this.pathsEqual(path, this.dragState.sourcePath)) {
1467
+ const px = (point ? point.clientX : event.clientX);
1468
+ const py = (point ? point.clientY : event.clientY);
1469
+ if (Number.isFinite(px) && Number.isFinite(py) && this.isPointerOverSourceHeader(px, py)) {
1470
+ // Drive live reorder using the unified path so we update instantly.
1471
+ this.updatePaneDragDropTargetFromPoint(px, py);
1472
+ this.hideDropIndicator();
1473
+ return;
1474
+ }
1475
+ }
1476
+ // If the hovered stack changed, clear any sticky zone from the previous
1477
+ // target before computing the new zone.
1478
+ if (this.dropJoystickTarget && this.dropJoystickTarget !== stack) {
1479
+ delete this.dropJoystick.dataset['zone'];
1480
+ this.updateDropJoystickActiveZone(null);
1481
+ }
1482
+ const eventZoneHint = this.extractDropZoneFromEvent(event);
1483
+ const pointZoneHint = point ? this.findDropZoneByPoint(point.clientX, point.clientY) : null;
1484
+ const zone = this.computeDropZone(stack, point ?? event, pointZoneHint ?? eventZoneHint);
1462
1485
  this.showDropIndicator(stack, zone);
1463
1486
  }
1464
1487
  updateDraggedFloatingPosition(event) {
1465
1488
  if (!this.dragState) {
1466
1489
  return;
1467
1490
  }
1468
- const { clientX, clientY, screenX, screenY } = event;
1491
+ const { clientX, clientY } = event;
1469
1492
  const hasValidCoordinates = Number.isFinite(clientX) &&
1470
1493
  Number.isFinite(clientY) &&
1471
- !(clientX === 0 && clientY === 0 && screenX === 0 && screenY === 0);
1494
+ !(clientX === 0 && clientY === 0);
1472
1495
  if (hasValidCoordinates) {
1473
1496
  this.lastDragPointerPosition = { x: clientX, y: clientY };
1474
1497
  this.updateDraggedFloatingPositionFromPoint(clientX, clientY);
@@ -1492,7 +1515,31 @@ class MintDockManagerElement extends HTMLElement {
1492
1515
  this.updateDraggedFloatingPosition(event);
1493
1516
  }
1494
1517
  onGlobalDragEnd() {
1495
- this.hideDropIndicator();
1518
+ // Attempt to finalize a drop even if the drop event doesn't reach us (Firefox/edge cases)
1519
+ const state = this.dragState;
1520
+ const pos = this.lastDragPointerPosition;
1521
+ if (state && pos) {
1522
+ const stack = this.findStackAtPoint(pos.x, pos.y);
1523
+ const joystickVisible = this.dropJoystick.dataset['visible'] === 'true';
1524
+ const joystickPath = this.parsePath(this.dropJoystick.dataset['path']);
1525
+ const joystickTarget = this.dropJoystickTarget;
1526
+ const joystickTargetPath = joystickTarget ? this.parsePath(joystickTarget.dataset['path']) : null;
1527
+ const path = stack ? this.parsePath(stack.dataset['path']) : (joystickPath ?? joystickTargetPath);
1528
+ const joystickZone = this.dropJoystick.dataset['zone'];
1529
+ const zone = this.isDropZone(joystickZone)
1530
+ ? joystickZone
1531
+ : (stack ? this.computeDropZone(stack, { clientX: pos.x, clientY: pos.y }, null) : null);
1532
+ if (path && this.isDropZone(zone)) {
1533
+ this.handleDrop(path, zone);
1534
+ this.hideDropIndicator();
1535
+ if (this.dragState) {
1536
+ this.dragState.dropHandled = true;
1537
+ }
1538
+ }
1539
+ }
1540
+ else {
1541
+ this.hideDropIndicator();
1542
+ }
1496
1543
  if (!this.dragState) {
1497
1544
  this.clearPendingTabDragMetrics();
1498
1545
  return;
@@ -1507,6 +1554,27 @@ class MintDockManagerElement extends HTMLElement {
1507
1554
  if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) {
1508
1555
  return;
1509
1556
  }
1557
+ // Ignore obviously bogus coordinates sometimes seen during HTML5 drag
1558
+ if (clientX === 0 && clientY === 0) {
1559
+ return;
1560
+ }
1561
+ // If still dragging a tab inside its header, only convert to floating once we leave the header.
1562
+ if (this.dragState.floatingIndex !== null && this.dragState.floatingIndex < 0) {
1563
+ const b = this.dragState.sourceHeaderBounds;
1564
+ const sx = this.dragState.startClientX ?? clientX;
1565
+ const sy = this.dragState.startClientY ?? clientY;
1566
+ const dist = Math.hypot(clientX - sx, clientY - sy);
1567
+ const threshold = 8; // pixels to move before converting (tuned up)
1568
+ // Default to inside while bounds are unknown to avoid premature floating
1569
+ let insideHeader = true;
1570
+ const insideByBounds = b ? this.isPointWithinBounds(b, clientX, clientY) : true;
1571
+ const insideByHitTest = this.isPointerOverSourceHeader(clientX, clientY);
1572
+ insideHeader = insideByBounds || insideByHitTest;
1573
+ if (!insideHeader && dist > threshold) {
1574
+ // Convert to floating now using current pointer position
1575
+ this.convertPendingTabDragToFloating(clientX, clientY);
1576
+ }
1577
+ }
1510
1578
  this.updatePaneDragDropTargetFromPoint(clientX, clientY);
1511
1579
  const { floatingIndex, pointerOffsetX, pointerOffsetY } = this.dragState;
1512
1580
  if (floatingIndex === null || floatingIndex < 0) {
@@ -1532,19 +1600,135 @@ class MintDockManagerElement extends HTMLElement {
1532
1600
  return;
1533
1601
  }
1534
1602
  const stack = this.findStackAtPoint(clientX, clientY);
1535
- if (!stack) {
1536
- this.hideDropIndicator();
1603
+ const path = stack ? this.parsePath(stack.dataset['path']) : null;
1604
+ if (!stack || !path) {
1605
+ // While actively dragging, avoid hiding the indicator if it is visible.
1606
+ // Transient misses from hit-testing are common near the joystick.
1607
+ if (this.dropJoystick.dataset['visible'] !== 'true') {
1608
+ this.hideDropIndicator();
1609
+ }
1537
1610
  return;
1538
1611
  }
1539
- const path = this.parsePath(stack.dataset['path']);
1540
- if (!path) {
1541
- this.hideDropIndicator();
1542
- return;
1612
+ // If we moved to a different target stack, reset any sticky zone so
1613
+ // the new drop area doesn't inherit the previous side selection.
1614
+ if (this.dropJoystickTarget && this.dropJoystickTarget !== stack) {
1615
+ delete this.dropJoystick.dataset['zone'];
1616
+ this.updateDropJoystickActiveZone(null);
1617
+ }
1618
+ // Previous behavior hid the indicator and returned early here; instead,
1619
+ // 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
1621
+ if (this.dragState &&
1622
+ this.dragState.floatingIndex !== null &&
1623
+ this.dragState.floatingIndex < 0 &&
1624
+ path &&
1625
+ this.pathsEqual(path, this.dragState.sourcePath)) {
1626
+ const inHeaderByBounds = !!this.dragState.sourceHeaderBounds && this.isPointWithinBounds(this.dragState.sourceHeaderBounds, clientX, clientY);
1627
+ const inHeaderByHitTest = this.isPointerOverSourceHeader(clientX, clientY);
1628
+ if (inHeaderByBounds || inHeaderByHitTest) {
1629
+ const header = stack.querySelector('.dock-stack__header');
1630
+ if (header) {
1631
+ const idx = this.computeHeaderInsertIndex(header, clientX);
1632
+ if (this.dragState.liveReorderIndex !== idx) {
1633
+ this.ensureHeaderDragPlaceholder(header, this.dragState.pane);
1634
+ 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
+ }
1641
+ this.dragState.liveReorderIndex = idx;
1642
+ }
1643
+ }
1644
+ this.hideDropIndicator();
1645
+ return;
1646
+ }
1543
1647
  }
1544
1648
  const zoneHint = this.findDropZoneByPoint(clientX, clientY);
1545
1649
  const zone = this.computeDropZone(stack, { clientX, clientY }, zoneHint);
1546
1650
  this.showDropIndicator(stack, zone);
1547
1651
  }
1652
+ // Returns true when the pointer is currently over the source stack's header (tab strip)
1653
+ isPointerOverSourceHeader(clientX, clientY) {
1654
+ const state = this.dragState;
1655
+ if (!state) {
1656
+ return false;
1657
+ }
1658
+ const stackEl = state.sourceStackEl ?? null;
1659
+ const header = stackEl?.querySelector('.dock-stack__header');
1660
+ if (!header) {
1661
+ // Be conservative: if we cannot resolve the header, treat as inside
1662
+ return true;
1663
+ }
1664
+ const sr = this.shadowRoot;
1665
+ const elements = sr ? sr.elementsFromPoint(clientX, clientY) : [];
1666
+ for (const el of elements) {
1667
+ if (el instanceof HTMLElement && header.contains(el)) {
1668
+ return true;
1669
+ }
1670
+ }
1671
+ return false;
1672
+ }
1673
+ isPointWithinBounds(bounds, x, y) {
1674
+ return x >= bounds.left && x <= bounds.right && y >= bounds.top && y <= bounds.bottom;
1675
+ }
1676
+ // Ensure a placeholder tab exists during in-header drag and hide the real dragged tab visually
1677
+ ensureHeaderDragPlaceholder(header, pane) {
1678
+ if (this.dragState?.placeholderHeader === header && this.dragState.placeholderEl) {
1679
+ return;
1680
+ }
1681
+ const dragged = Array.from(header.querySelectorAll('.dock-tab')).find((t) => t.dataset['pane'] === pane) ?? null;
1682
+ if (!dragged) {
1683
+ return;
1684
+ }
1685
+ // Create placeholder
1686
+ const placeholder = this.documentRef.createElement('button');
1687
+ placeholder.type = 'button';
1688
+ placeholder.classList.add('dock-tab');
1689
+ 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);
1695
+ if (this.dragState) {
1696
+ this.dragState.placeholderHeader = header;
1697
+ this.dragState.placeholderEl = placeholder;
1698
+ }
1699
+ }
1700
+ // Move the placeholder to the computed target index within the header
1701
+ updateHeaderDragPlaceholderPosition(header, targetIndex) {
1702
+ const placeholder = this.dragState?.placeholderEl ?? null;
1703
+ if (!placeholder) {
1704
+ return;
1705
+ }
1706
+ const tabs = Array.from(header.querySelectorAll('.dock-tab'))
1707
+ .filter((t) => t !== placeholder);
1708
+ const clampedTarget = Math.max(0, Math.min(targetIndex, tabs.length));
1709
+ const ref = tabs[clampedTarget] ?? null;
1710
+ header.insertBefore(placeholder, ref);
1711
+ }
1712
+ // Remove placeholder and restore original tab visibility
1713
+ clearHeaderDragPlaceholder() {
1714
+ const ph = this.dragState?.placeholderEl ?? null;
1715
+ const header = this.dragState?.placeholderHeader ?? null;
1716
+ if (header) {
1717
+ const dragged = this.dragState?.pane
1718
+ ? (Array.from(header.querySelectorAll('.dock-tab')).find((t) => t.dataset['pane'] === this.dragState?.pane) ?? null)
1719
+ : null;
1720
+ if (dragged) {
1721
+ dragged.style.visibility = '';
1722
+ }
1723
+ }
1724
+ if (ph && ph.parentElement) {
1725
+ ph.parentElement.removeChild(ph);
1726
+ }
1727
+ if (this.dragState) {
1728
+ this.dragState.placeholderEl = null;
1729
+ this.dragState.placeholderHeader = null;
1730
+ }
1731
+ }
1548
1732
  startDragPointerTracking() {
1549
1733
  if (this.dragPointerTrackingActive) {
1550
1734
  return;
@@ -1599,18 +1783,175 @@ class MintDockManagerElement extends HTMLElement {
1599
1783
  this.updateDraggedFloatingPositionFromPoint(touch.clientX, touch.clientY);
1600
1784
  }
1601
1785
  onDragMouseUp() {
1786
+ // Prefer committing a drop from pointer-up since some browsers suppress drop events
1787
+ if (this.dragState) {
1788
+ const pos = this.lastDragPointerPosition;
1789
+ if (pos) {
1790
+ this.finalizeDropFromPoint(pos.x, pos.y);
1791
+ }
1792
+ }
1793
+ this.handleDragPointerUpCommon();
1794
+ }
1795
+ // Convert a currently in-header tab drag into a floating window
1796
+ convertPendingTabDragToFloating(clientX, clientY) {
1602
1797
  if (!this.dragState) {
1603
- this.stopDragPointerTracking();
1604
1798
  return;
1605
1799
  }
1606
- this.scheduleDeferredDragEnd();
1800
+ const state = this.dragState;
1801
+ if (state.floatingIndex !== null && state.floatingIndex >= 0) {
1802
+ return; // already floating
1803
+ }
1804
+ // Clean up any placeholder before converting
1805
+ this.clearHeaderDragPlaceholder();
1806
+ const location = this.resolveStackLocation(state.sourcePath);
1807
+ if (!location) {
1808
+ return;
1809
+ }
1810
+ const pane = state.pane;
1811
+ const stackEl = state.sourceStackEl ?? null;
1812
+ const hostRect = this.getBoundingClientRect();
1813
+ const stackRect = stackEl?.getBoundingClientRect() ?? null;
1814
+ const metrics = this.pendingTabDragMetrics;
1815
+ const fallbackWidth = 320;
1816
+ const fallbackHeight = 240;
1817
+ const width = metrics && Number.isFinite(metrics.width) && metrics.width > 0
1818
+ ? metrics.width
1819
+ : stackRect && Number.isFinite(stackRect.width)
1820
+ ? stackRect.width
1821
+ : fallbackWidth;
1822
+ const height = metrics && Number.isFinite(metrics.height) && metrics.height > 0
1823
+ ? metrics.height
1824
+ : stackRect && Number.isFinite(stackRect.height)
1825
+ ? stackRect.height
1826
+ : fallbackHeight;
1827
+ const pointerOffsetX = Number.isFinite(this.dragState?.pointerOffsetX)
1828
+ ? this.dragState.pointerOffsetX
1829
+ : metrics && Number.isFinite(metrics.pointerOffsetX)
1830
+ ? metrics.pointerOffsetX
1831
+ : width / 2;
1832
+ const pointerOffsetY = Number.isFinite(this.dragState?.pointerOffsetY)
1833
+ ? this.dragState.pointerOffsetY
1834
+ : metrics && Number.isFinite(metrics.pointerOffsetY)
1835
+ ? metrics.pointerOffsetY
1836
+ : height / 2;
1837
+ const pointerLeft = Number.isFinite(clientX)
1838
+ ? clientX - hostRect.left - pointerOffsetX
1839
+ : 0;
1840
+ const pointerTop = Number.isFinite(clientY)
1841
+ ? clientY - hostRect.top - pointerOffsetY
1842
+ : 0;
1843
+ // Remove pane from its current stack and create a new floating entry
1844
+ this.removePaneFromLocation(location, pane);
1845
+ const floatingStack = {
1846
+ kind: 'stack',
1847
+ panes: [pane],
1848
+ activePane: pane,
1849
+ };
1850
+ const floatingLayout = {
1851
+ bounds: {
1852
+ left: pointerLeft,
1853
+ top: pointerTop,
1854
+ width,
1855
+ height,
1856
+ },
1857
+ root: floatingStack,
1858
+ activePane: pane,
1859
+ };
1860
+ this.floatingLayouts.push(floatingLayout);
1861
+ const newIndex = this.floatingLayouts.length - 1;
1862
+ this.render();
1863
+ const wrapper = this.getFloatingWrapper(newIndex);
1864
+ if (wrapper) {
1865
+ this.promoteFloatingPane(newIndex, wrapper);
1866
+ }
1867
+ // Update drag state so subsequent moves reposition the floating window
1868
+ state.sourcePath = { type: 'floating', index: newIndex, segments: [] };
1869
+ state.floatingIndex = newIndex;
1870
+ state.pointerOffsetX = pointerOffsetX;
1871
+ state.pointerOffsetY = pointerOffsetY;
1872
+ this.dispatchLayoutChanged();
1873
+ }
1874
+ // Compute the intended tab insert index within a header based on pointer X
1875
+ computeHeaderInsertIndex(header, clientX) {
1876
+ const tabs = Array.from(header.querySelectorAll('.dock-tab'))
1877
+ .filter((t) => t.dataset['placeholder'] !== 'true');
1878
+ if (tabs.length === 0) {
1879
+ return 0;
1880
+ }
1881
+ for (let i = 0; i < tabs.length; i += 1) {
1882
+ const rect = tabs[i].getBoundingClientRect();
1883
+ const mid = rect.left + rect.width / 2;
1884
+ if (clientX < mid) {
1885
+ return i;
1886
+ }
1887
+ }
1888
+ return tabs.length; // insert at end
1889
+ }
1890
+ reorderPaneInLocationAtIndex(location, pane, targetIndex) {
1891
+ const panes = location.node.panes;
1892
+ const currentIndex = panes.indexOf(pane);
1893
+ if (currentIndex === -1) {
1894
+ return;
1895
+ }
1896
+ const clampedTarget = Math.max(0, Math.min(targetIndex, panes.length - 1));
1897
+ if (clampedTarget === currentIndex) {
1898
+ return;
1899
+ }
1900
+ panes.splice(currentIndex, 1);
1901
+ panes.splice(clampedTarget, 0, pane);
1902
+ location.node.activePane = pane;
1903
+ if (location.context === 'floating') {
1904
+ const floating = this.floatingLayouts[location.index];
1905
+ if (floating) {
1906
+ floating.activePane = pane;
1907
+ }
1908
+ }
1607
1909
  }
1608
1910
  onDragTouchEnd() {
1911
+ this.handleDragPointerUpCommon();
1912
+ }
1913
+ // Commit a drop using current pointer coordinates and joystick state
1914
+ finalizeDropFromPoint(clientX, clientY) {
1609
1915
  if (!this.dragState) {
1610
- this.stopDragPointerTracking();
1611
1916
  return;
1612
1917
  }
1613
- this.scheduleDeferredDragEnd();
1918
+ const stack = this.findStackAtPoint(clientX, clientY);
1919
+ const stackPath = stack ? this.parsePath(stack.dataset['path']) : null;
1920
+ const joystickVisible = this.dropJoystick.dataset['visible'] === 'true';
1921
+ const joystickStoredPath = this.parsePath(this.dropJoystick.dataset['path']);
1922
+ const joystickTarget = this.dropJoystickTarget;
1923
+ const joystickTargetPath = joystickTarget ? this.parsePath(joystickTarget.dataset['path']) : null;
1924
+ const path = (joystickVisible ? (joystickStoredPath ?? joystickTargetPath) : null) ?? stackPath;
1925
+ const joystickZone = this.dropJoystick.dataset['zone'];
1926
+ const zone = this.isDropZone(joystickZone)
1927
+ ? joystickZone
1928
+ : (stack ? this.computeDropZone(stack, { clientX, clientY }, null) : null);
1929
+ // Same-header reorder case when no side zone is chosen
1930
+ if (this.dragState &&
1931
+ this.dragState.floatingIndex !== null &&
1932
+ this.dragState.floatingIndex < 0 &&
1933
+ stack &&
1934
+ path &&
1935
+ stackPath &&
1936
+ this.pathsEqual(stackPath, this.dragState.sourcePath) &&
1937
+ (!zone || zone === 'center')) {
1938
+ const header = stack.querySelector('.dock-stack__header');
1939
+ if (header) {
1940
+ const location = this.resolveStackLocation(path);
1941
+ if (location) {
1942
+ const idx = this.computeHeaderInsertIndex(header, clientX);
1943
+ this.reorderPaneInLocationAtIndex(location, this.dragState.pane, idx);
1944
+ this.render();
1945
+ this.dispatchLayoutChanged();
1946
+ this.dragState.dropHandled = true;
1947
+ return;
1948
+ }
1949
+ }
1950
+ }
1951
+ if (path && this.isDropZone(zone)) {
1952
+ this.handleDrop(path, zone);
1953
+ this.dragState.dropHandled = true;
1954
+ }
1614
1955
  }
1615
1956
  clearPendingDragEndTimeout() {
1616
1957
  if (this.pendingDragEndTimeout !== null) {
@@ -1660,15 +2001,60 @@ class MintDockManagerElement extends HTMLElement {
1660
2001
  : null);
1661
2002
  const stack = this.findStackElement(event) ??
1662
2003
  (point ? this.findStackAtPoint(point.clientX, point.clientY) : null);
1663
- if (!stack) {
1664
- this.hideDropIndicator();
2004
+ // Prefer joystick's stored target path when the joystick is visible (drop over buttons)
2005
+ const joystickVisible = this.dropJoystick.dataset['visible'] === 'true';
2006
+ const joystickPath = this.parsePath(this.dropJoystick.dataset['path']);
2007
+ const joystickTarget = this.dropJoystickTarget;
2008
+ const joystickTargetPath = joystickTarget ? this.parsePath(joystickTarget.dataset['path']) : null;
2009
+ let path = stack
2010
+ ? this.parsePath(stack.dataset['path'])
2011
+ : (joystickPath ?? joystickTargetPath);
2012
+ if (!path && joystickVisible) {
2013
+ // As a last resort, target the main dock surface only when empty
2014
+ const dockPath = this.parsePath(this.dockedEl.dataset['path']);
2015
+ path = (!this.rootLayout ? dockPath : null);
2016
+ }
2017
+ // Defer same-header reorder decision until after zone resolution below
2018
+ // Prefer joystick's active zone if available, else infer from event/point
2019
+ const joystickZone = this.dropJoystick.dataset['zone'];
2020
+ const eventZoneHint = this.extractDropZoneFromEvent(event);
2021
+ const pointZoneHint = point ? this.findDropZoneByPoint(point.clientX, point.clientY) : null;
2022
+ const zone = this.isDropZone(joystickZone)
2023
+ ? joystickZone
2024
+ : stack
2025
+ ? this.computeDropZone(stack, point ?? event, pointZoneHint ?? eventZoneHint)
2026
+ : (this.isDropZone(pointZoneHint ?? eventZoneHint) ? (pointZoneHint ?? eventZoneHint) : null);
2027
+ // If still in same header and no side zone chosen, treat as in-header reorder
2028
+ if (this.dragState &&
2029
+ this.dragState.floatingIndex !== null &&
2030
+ this.dragState.floatingIndex < 0 &&
2031
+ stack &&
2032
+ path &&
2033
+ this.pathsEqual(path, this.dragState.sourcePath) &&
2034
+ (!zone || zone === 'center')) {
2035
+ const header = stack.querySelector('.dock-stack__header');
2036
+ if (header) {
2037
+ const x = (point ? point.clientX : event.clientX);
2038
+ if (Number.isFinite(x)) {
2039
+ const location = this.resolveStackLocation(path);
2040
+ if (location) {
2041
+ const idx = this.computeHeaderInsertIndex(header, x);
2042
+ this.reorderPaneInLocationAtIndex(location, this.dragState.pane, idx);
2043
+ this.render();
2044
+ this.dispatchLayoutChanged();
2045
+ this.dragState.dropHandled = true;
2046
+ this.endPaneDrag();
2047
+ return;
2048
+ }
2049
+ }
2050
+ }
2051
+ }
2052
+ // If joystick is visible and both path and zone are resolved, force using joystick as authoritative
2053
+ if (joystickVisible && path && this.isDropZone(joystickZone)) {
2054
+ this.handleDrop(path, joystickZone);
1665
2055
  this.endPaneDrag();
1666
2056
  return;
1667
2057
  }
1668
- const path = this.parsePath(stack.dataset['path']);
1669
- const eventZoneHint = this.extractDropZoneFromEvent(event);
1670
- const pointZoneHint = point ? this.findDropZoneByPoint(point.clientX, point.clientY) : null;
1671
- const zone = this.computeDropZone(stack, point ?? event, pointZoneHint ?? eventZoneHint);
1672
2058
  if (!zone) {
1673
2059
  this.hideDropIndicator();
1674
2060
  this.endPaneDrag();
@@ -1679,6 +2065,21 @@ class MintDockManagerElement extends HTMLElement {
1679
2065
  }
1680
2066
  onDragLeave(event) {
1681
2067
  const related = event.relatedTarget;
2068
+ // During active drags, browsers can emit spurious dragleave with null
2069
+ // relatedTarget while the pointer is still over the joystick/buttons.
2070
+ // Be conservative: if we can resolve a stack/joystick at the last known
2071
+ // pointer position, don’t hide (prevents flicker of active state).
2072
+ if (this.dragState) {
2073
+ const pos = (Number.isFinite(event.clientX) && Number.isFinite(event.clientY))
2074
+ ? { x: event.clientX, y: event.clientY }
2075
+ : this.lastDragPointerPosition;
2076
+ if (pos) {
2077
+ const stackAtPoint = this.findStackAtPoint(pos.x, pos.y);
2078
+ if (stackAtPoint) {
2079
+ return; // still inside our drop area; ignore this dragleave
2080
+ }
2081
+ }
2082
+ }
1682
2083
  if (!related) {
1683
2084
  this.hideDropIndicator();
1684
2085
  return;
@@ -1696,6 +2097,28 @@ class MintDockManagerElement extends HTMLElement {
1696
2097
  const { pane, sourcePath } = this.dragState;
1697
2098
  const source = this.resolveStackLocation(sourcePath);
1698
2099
  const target = this.resolveStackLocation(targetPath);
2100
+ // Special case: allow dropping onto an empty main dock area (no root).
2101
+ if (!target && targetPath.type === 'docked' && !this.rootLayout) {
2102
+ if (!source) {
2103
+ return;
2104
+ }
2105
+ const stackEmptied = this.removePaneFromLocation(source, pane, true);
2106
+ const newRoot = {
2107
+ kind: 'stack',
2108
+ panes: [pane],
2109
+ activePane: pane,
2110
+ };
2111
+ this.rootLayout = newRoot;
2112
+ if (stackEmptied) {
2113
+ this.cleanupLocation(source);
2114
+ }
2115
+ this.render();
2116
+ this.dispatchLayoutChanged();
2117
+ if (this.dragState) {
2118
+ this.dragState.dropHandled = true;
2119
+ }
2120
+ return;
2121
+ }
1699
2122
  if (!source || !target) {
1700
2123
  return;
1701
2124
  }
@@ -1711,15 +2134,10 @@ class MintDockManagerElement extends HTMLElement {
1711
2134
  }
1712
2135
  return;
1713
2136
  }
1714
- const paneTitle = source.node.titles?.[pane];
1715
2137
  const stackEmptied = this.removePaneFromLocation(source, pane, true);
1716
2138
  if (zone === 'center') {
1717
2139
  this.addPaneToLocation(target, pane);
1718
2140
  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
2141
  if (stackEmptied) {
1724
2142
  this.cleanupLocation(source);
1725
2143
  }
@@ -1735,9 +2153,6 @@ class MintDockManagerElement extends HTMLElement {
1735
2153
  panes: [pane],
1736
2154
  activePane: pane,
1737
2155
  };
1738
- if (paneTitle) {
1739
- newStack.titles = { [pane]: paneTitle };
1740
- }
1741
2156
  if (target.context === 'docked') {
1742
2157
  this.rootLayout = this.dockNodeBeside(this.rootLayout, target.node, newStack, zone);
1743
2158
  }
@@ -1769,6 +2184,15 @@ class MintDockManagerElement extends HTMLElement {
1769
2184
  return false;
1770
2185
  }
1771
2186
  const target = this.resolveStackLocation(targetPath);
2187
+ // Allow dropping an entire floating stack onto an empty main dock area
2188
+ // (no existing root).
2189
+ if (!target && targetPath.type === 'docked' && !this.rootLayout) {
2190
+ this.rootLayout = this.cloneLayoutNode(source.root);
2191
+ this.removeFloatingAt(sourceIndex);
2192
+ this.render();
2193
+ this.dispatchLayoutChanged();
2194
+ return true;
2195
+ }
1772
2196
  if (!target) {
1773
2197
  return false;
1774
2198
  }
@@ -1776,17 +2200,12 @@ class MintDockManagerElement extends HTMLElement {
1776
2200
  return false;
1777
2201
  }
1778
2202
  if (zone === 'center') {
1779
- const { panes, titles } = this.collectFloatingPaneMetadata(source.root);
2203
+ const panes = this.collectPaneNames(source.root);
1780
2204
  if (panes.length === 0) {
1781
2205
  return false;
1782
2206
  }
1783
2207
  panes.forEach((pane) => {
1784
2208
  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
2209
  });
1791
2210
  const activePane = source.activePane && panes.includes(source.activePane)
1792
2211
  ? source.activePane
@@ -1873,6 +2292,8 @@ class MintDockManagerElement extends HTMLElement {
1873
2292
  return null;
1874
2293
  }
1875
2294
  computeDropZone(stack, point, zoneHint) {
2295
+ // Do not force a zone: even when the main area is empty we only
2296
+ // activate a zone when the pointer is actually over a joystick button.
1876
2297
  const hintedZone = this.isDropZone(zoneHint) ? zoneHint : null;
1877
2298
  if (hintedZone) {
1878
2299
  this.updateDropJoystickActiveZone(hintedZone);
@@ -1888,6 +2309,14 @@ class MintDockManagerElement extends HTMLElement {
1888
2309
  return pointZone;
1889
2310
  }
1890
2311
  if (this.dropJoystickTarget === stack) {
2312
+ // Be sticky while hovering the joystick: if we recently had a zone
2313
+ // selected, prefer keeping it instead of briefly clearing to null
2314
+ // when the browser reports transient coordinates/targets during drag.
2315
+ const sticky = this.dropJoystick.dataset['zone'];
2316
+ if (this.isDropZone(sticky)) {
2317
+ this.updateDropJoystickActiveZone(sticky);
2318
+ return sticky;
2319
+ }
1891
2320
  this.updateDropJoystickActiveZone(null);
1892
2321
  }
1893
2322
  return null;
@@ -1898,14 +2327,9 @@ class MintDockManagerElement extends HTMLElement {
1898
2327
  }
1899
2328
  if (typeof event.composedPath === 'function') {
1900
2329
  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
- }
2330
+ const zone = this.findDropZoneInTargets(path);
2331
+ if (zone) {
2332
+ return zone;
1909
2333
  }
1910
2334
  }
1911
2335
  if ('clientX' in event && 'clientY' in event) {
@@ -1917,6 +2341,25 @@ class MintDockManagerElement extends HTMLElement {
1917
2341
  }
1918
2342
  return null;
1919
2343
  }
2344
+ handleDragPointerUpCommon() {
2345
+ if (!this.dragState) {
2346
+ this.stopDragPointerTracking();
2347
+ return;
2348
+ }
2349
+ this.scheduleDeferredDragEnd();
2350
+ }
2351
+ findDropZoneInTargets(targets) {
2352
+ for (const target of targets) {
2353
+ if (target instanceof HTMLElement &&
2354
+ target.classList.contains('dock-drop-joystick__button')) {
2355
+ const zone = target.dataset?.['zone'];
2356
+ if (this.isDropZone(zone)) {
2357
+ return zone;
2358
+ }
2359
+ }
2360
+ }
2361
+ return null;
2362
+ }
1920
2363
  findDropZoneByPoint(clientX, clientY) {
1921
2364
  if (!this.dropJoystickButtons ||
1922
2365
  this.dropJoystick.dataset['visible'] !== 'true' ||
@@ -1924,6 +2367,10 @@ class MintDockManagerElement extends HTMLElement {
1924
2367
  return null;
1925
2368
  }
1926
2369
  for (const button of this.dropJoystickButtons) {
2370
+ // Skip hidden/inactive buttons (used in center-only mode)
2371
+ if ((button.dataset['hidden'] === 'true') || button.style.visibility === 'hidden' || button.style.display === 'none') {
2372
+ continue;
2373
+ }
1927
2374
  const rect = button.getBoundingClientRect();
1928
2375
  if (clientX >= rect.left &&
1929
2376
  clientX <= rect.right &&
@@ -1938,8 +2385,14 @@ class MintDockManagerElement extends HTMLElement {
1938
2385
  return null;
1939
2386
  }
1940
2387
  updateDropJoystickActiveZone(zone) {
2388
+ // If no zone is computed but the joystick is visible, keep the last
2389
+ // known active zone to avoid visual jitter while dragging across
2390
+ // small gaps where hit‑testing momentarily fails.
2391
+ const visible = this.dropJoystick.dataset['visible'] === 'true';
2392
+ const sticky = visible ? this.dropJoystick.dataset['zone'] : undefined;
2393
+ const effectiveZone = zone ?? (this.isDropZone(sticky) ? sticky : null);
1941
2394
  this.dropJoystickButtons.forEach((button) => {
1942
- const isActive = zone !== null && button.dataset['zone'] === zone;
2395
+ const isActive = effectiveZone !== null && button.dataset['zone'] === effectiveZone;
1943
2396
  if (isActive) {
1944
2397
  button.dataset['active'] = 'true';
1945
2398
  }
@@ -1947,8 +2400,8 @@ class MintDockManagerElement extends HTMLElement {
1947
2400
  delete button.dataset['active'];
1948
2401
  }
1949
2402
  });
1950
- if (zone) {
1951
- this.dropJoystick.dataset['zone'] = zone;
2403
+ if (effectiveZone) {
2404
+ this.dropJoystick.dataset['zone'] = effectiveZone;
1952
2405
  }
1953
2406
  else {
1954
2407
  delete this.dropJoystick.dataset['zone'];
@@ -2014,7 +2467,52 @@ class MintDockManagerElement extends HTMLElement {
2014
2467
  joystick.dataset['visible'] = 'true';
2015
2468
  this.dropJoystick.style.display = 'grid';
2016
2469
  joystick.dataset['path'] = stack.dataset['path'] ?? '';
2470
+ const changedTarget = this.dropJoystickTarget && this.dropJoystickTarget !== stack;
2017
2471
  this.dropJoystickTarget = stack;
2472
+ if (changedTarget) {
2473
+ // New target stack: forget any previously sticky zone.
2474
+ delete this.dropJoystick.dataset['zone'];
2475
+ }
2476
+ // If main dock area is empty, show only the center button and collapse the grid
2477
+ const isEmptyMainArea = !this.rootLayout && (stack === this.dockedEl || (targetPath && targetPath.type === 'docked' && targetPath.segments.length === 0));
2478
+ const spacers = Array.from(this.dropJoystick.querySelectorAll('.dock-drop-joystick__spacer'));
2479
+ if (isEmptyMainArea) {
2480
+ // Keep spacers visible so the joystick keeps its circular footprint.
2481
+ spacers.forEach((s) => {
2482
+ s.style.display = '';
2483
+ });
2484
+ this.dropJoystickButtons.forEach((btn) => {
2485
+ const isCenter = btn.dataset['zone'] === 'center';
2486
+ if (isCenter) {
2487
+ btn.style.visibility = '';
2488
+ btn.style.pointerEvents = '';
2489
+ delete btn.dataset['hidden'];
2490
+ btn.style.display = '';
2491
+ }
2492
+ else {
2493
+ // Hide visually but keep layout space; also prevent interaction.
2494
+ btn.style.visibility = 'hidden';
2495
+ btn.style.pointerEvents = 'none';
2496
+ btn.dataset['hidden'] = 'true';
2497
+ btn.style.display = '';
2498
+ }
2499
+ });
2500
+ // Keep default 3x3 grid so the circular background size stays the same.
2501
+ this.dropJoystick.style.gridTemplateColumns = '';
2502
+ this.dropJoystick.style.gridTemplateRows = '';
2503
+ // Do not set an active zone automatically; users must hover the button.
2504
+ }
2505
+ else {
2506
+ this.dropJoystickButtons.forEach((btn) => {
2507
+ btn.style.visibility = '';
2508
+ btn.style.pointerEvents = '';
2509
+ delete btn.dataset['hidden'];
2510
+ btn.style.display = '';
2511
+ });
2512
+ spacers.forEach((s) => (s.style.display = ''));
2513
+ this.dropJoystick.style.gridTemplateColumns = '';
2514
+ this.dropJoystick.style.gridTemplateRows = '';
2515
+ }
2018
2516
  this.updateDropJoystickActiveZone(zone);
2019
2517
  }
2020
2518
  hideDropIndicator() {
@@ -2024,6 +2522,16 @@ class MintDockManagerElement extends HTMLElement {
2024
2522
  delete this.dropJoystick.dataset['path'];
2025
2523
  this.dropJoystickTarget = null;
2026
2524
  this.updateDropJoystickActiveZone(null);
2525
+ // Restore joystick structure to default.
2526
+ this.dropJoystickButtons.forEach((btn) => {
2527
+ btn.style.display = '';
2528
+ btn.style.visibility = '';
2529
+ btn.style.pointerEvents = '';
2530
+ delete btn.dataset['hidden'];
2531
+ });
2532
+ Array.from(this.dropJoystick.querySelectorAll('.dock-drop-joystick__spacer')).forEach((s) => (s.style.display = ''));
2533
+ this.dropJoystick.style.gridTemplateColumns = '';
2534
+ this.dropJoystick.style.gridTemplateRows = '';
2027
2535
  }
2028
2536
  findStackAtPoint(clientX, clientY) {
2029
2537
  const shadow = this.shadowRoot;
@@ -2031,35 +2539,53 @@ class MintDockManagerElement extends HTMLElement {
2031
2539
  return null;
2032
2540
  }
2033
2541
  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;
2542
+ const stack = this.findStackInTargets(elements);
2543
+ if (stack) {
2544
+ return stack;
2545
+ }
2546
+ // If there are no docked stacks (all panes are floating), allow the
2547
+ // docked surface itself to serve as a drop target for the main zone.
2548
+ if (!this.rootLayout) {
2549
+ const dockRect = this.dockedEl.getBoundingClientRect();
2550
+ if (clientX >= dockRect.left &&
2551
+ clientX <= dockRect.right &&
2552
+ clientY >= dockRect.top &&
2553
+ clientY <= dockRect.bottom) {
2554
+ return this.dockedEl;
2046
2555
  }
2047
2556
  }
2048
2557
  return null;
2049
2558
  }
2050
2559
  findStackElement(event) {
2051
2560
  const path = event.composedPath();
2052
- for (const target of path) {
2053
- if (!(target instanceof HTMLElement)) {
2561
+ const stack = this.findStackInTargets(path);
2562
+ if (stack) {
2563
+ return stack;
2564
+ }
2565
+ // If the root dock area is empty, treat the docked surface as a valid
2566
+ // target when it appears in the composed path.
2567
+ if (!this.rootLayout) {
2568
+ for (const target of path) {
2569
+ if (target instanceof HTMLElement &&
2570
+ (target === this.dockedEl || target.classList.contains('dock-docked'))) {
2571
+ return this.dockedEl;
2572
+ }
2573
+ }
2574
+ }
2575
+ return null;
2576
+ }
2577
+ findStackInTargets(targets) {
2578
+ for (const element of targets) {
2579
+ if (!(element instanceof HTMLElement)) {
2054
2580
  continue;
2055
2581
  }
2056
- if (target.classList.contains('dock-stack')) {
2057
- return target;
2582
+ if (element.classList.contains('dock-stack')) {
2583
+ return element;
2058
2584
  }
2059
2585
  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'))) {
2586
+ (element.classList.contains('dock-drop-joystick') ||
2587
+ element.classList.contains('dock-drop-joystick__button') ||
2588
+ element.classList.contains('dock-drop-joystick__spacer'))) {
2063
2589
  return this.dropJoystickTarget;
2064
2590
  }
2065
2591
  }
@@ -2234,19 +2760,25 @@ class MintDockManagerElement extends HTMLElement {
2234
2760
  return found;
2235
2761
  }
2236
2762
  collectFloatingPaneMetadata(node) {
2237
- const panes = [];
2763
+ // Deprecated method retained temporarily for signature compatibility.
2764
+ // Use collectPaneNames instead.
2765
+ const panes = this.collectPaneNames(node);
2238
2766
  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
- });
2767
+ panes.forEach((p) => {
2768
+ const t = this.titles[p];
2769
+ if (t) {
2770
+ titles[p] = t;
2771
+ }
2247
2772
  });
2248
2773
  return { panes, titles };
2249
2774
  }
2775
+ collectPaneNames(node) {
2776
+ const panes = [];
2777
+ this.forEachStack(node, (stack) => {
2778
+ stack.panes.forEach((pane) => panes.push(pane));
2779
+ });
2780
+ return panes;
2781
+ }
2250
2782
  normalizeFloatingLayout(layout) {
2251
2783
  const bounds = layout.bounds ?? { left: 0, top: 0, width: 320, height: 200 };
2252
2784
  const normalizedBounds = {
@@ -2255,21 +2787,7 @@ class MintDockManagerElement extends HTMLElement {
2255
2787
  width: Number.isFinite(bounds.width) ? Math.max(bounds.width, 160) : 320,
2256
2788
  height: Number.isFinite(bounds.height) ? Math.max(bounds.height, 120) : 200,
2257
2789
  };
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
- }
2790
+ const root = layout.root ? this.cloneLayoutNode(layout.root) : null;
2273
2791
  return {
2274
2792
  id: layout.id,
2275
2793
  bounds: normalizedBounds,
@@ -2574,14 +3092,15 @@ class BsDockManagerComponent {
2574
3092
  }
2575
3093
  ensureSnapshot(value) {
2576
3094
  if (!value) {
2577
- return { root: null, floating: [] };
3095
+ return { root: null, floating: [], titles: {} };
2578
3096
  }
2579
3097
  if ('kind' in value) {
2580
- return { root: value, floating: [] };
3098
+ return { root: value, floating: [], titles: {} };
2581
3099
  }
2582
3100
  return {
2583
3101
  root: value.root ?? null,
2584
3102
  floating: Array.isArray(value.floating) ? [...value.floating] : [],
3103
+ titles: value.titles ? { ...value.titles } : {},
2585
3104
  };
2586
3105
  }
2587
3106
  stringifyLayout(layout) {