@cplieger/web-terminal-ui 8.1.2 → 8.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/css/30-tabs.css +11 -14
- package/package.json +1 -1
- package/src/features/tabs/index.ts +428 -100
- package/src/features/tabs/strip.ts +85 -10
- package/src/kernel/pane.ts +47 -9
package/README.md
CHANGED
|
@@ -469,7 +469,8 @@ terminal with its own engine, so an open split holds up to two WebSocket
|
|
|
469
469
|
connections, one per shown pane. Closing and resizing the split is a display
|
|
470
470
|
re-arrangement only: no tab or session is ever created or closed by it, and a tab
|
|
471
471
|
that leaves a pane stays in the row as an ordinary tab. Opening it is one too,
|
|
472
|
-
except
|
|
472
|
+
except with a single tab: the split button, a snap or a drop that opens it then
|
|
473
|
+
creates the second tab.
|
|
473
474
|
|
|
474
475
|
Three ways in, all in the tab chrome, none a key chord:
|
|
475
476
|
|
|
@@ -490,12 +491,17 @@ Three ways in, all in the tab chrome, none a key chord:
|
|
|
490
491
|
an assistive technology's own command). A snapped tab shows on the named side
|
|
491
492
|
and its pane is selected. When the snap opens the split, the other pane keeps
|
|
492
493
|
the tab the single view showed (or, when that is the tab being moved, its
|
|
493
|
-
split partner), so neither pane opens empty.
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
494
|
+
split partner), so neither pane opens empty. With a single tab, a snap that
|
|
495
|
+
opens the split creates a new tab for the other pane, and the split opens
|
|
496
|
+
once that tab exists; a failed create leaves the single view as it was. A tab
|
|
497
|
+
the other pane shows trades places with the tab on the named side, so both
|
|
498
|
+
stay on screen (into an empty pane it simply moves); any other tab there
|
|
499
|
+
becomes an ordinary tab. In an open split, snapping a tab onto the side that
|
|
500
|
+
already shows it changes nothing.
|
|
497
501
|
- **Dragging a tab** out of the row onto either pane of the terminal area,
|
|
498
|
-
following the same rule.
|
|
502
|
+
following the same rule. By touch or pen, hold the tab still for a moment
|
|
503
|
+
before moving it; a swipe that moves straight away scrolls the row. While
|
|
504
|
+
the drag lasts the pane under the pointer is
|
|
499
505
|
highlighted; each highlight covers that pane's terminal area at the divider's
|
|
500
506
|
position (the two halves while the split is closed), inset a little with the
|
|
501
507
|
tab chips' rounded border, and a release decides by the divider's centre.
|
package/css/30-tabs.css
CHANGED
|
@@ -154,11 +154,10 @@
|
|
|
154
154
|
white-space: nowrap;
|
|
155
155
|
-webkit-user-select: none;
|
|
156
156
|
user-select: none;
|
|
157
|
-
/* Kills the 300ms double-tap-to-zoom delay, which
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
ordinary switch tap. */
|
|
157
|
+
/* Kills the 300ms double-tap-to-zoom delay, which keeps a double-TAP usable as
|
|
158
|
+
the touch rename entry and removes the tap delay for the ordinary switch
|
|
159
|
+
tap. Panning stays allowed, so a swipe along the strip scrolls it; a touch
|
|
160
|
+
reorder cancels the pan itself once its hold has lifted the chip. */
|
|
162
161
|
touch-action: manipulation;
|
|
163
162
|
}
|
|
164
163
|
|
|
@@ -616,15 +615,13 @@
|
|
|
616
615
|
}
|
|
617
616
|
|
|
618
617
|
/* The drag preview clone (setDragGhost): a full-opacity copy of the chip parked
|
|
619
|
-
under .wt-root
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
pointer. z-index clears the bar so the copy, not the dimmed original, is what
|
|
627
|
-
would show if it ever painted. */
|
|
618
|
+
under .wt-root, outside any backdrop-filter on the bar, which would stop WebKit
|
|
619
|
+
rendering the drag image (webkit.org/b/22787), and laid over the real chip via
|
|
620
|
+
inline left/top/width/height. A native drag snapshots it within a frame, so it
|
|
621
|
+
must not be hidden (an invisible element makes no snapshot); a touch or pen
|
|
622
|
+
drag keeps it under the finger and hit-tests the point beneath, so it must
|
|
623
|
+
never intercept the pointer. z-index clears the bar so the copy, not the
|
|
624
|
+
dimmed original, is what shows. */
|
|
628
625
|
:where(.wt-root) .wt-tab-ghost {
|
|
629
626
|
position: absolute;
|
|
630
627
|
z-index: 16;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "8.1
|
|
3
|
+
"version": "8.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -50,21 +50,28 @@ import {
|
|
|
50
50
|
tabAccessibleName,
|
|
51
51
|
} from "./model.js";
|
|
52
52
|
import {
|
|
53
|
-
REORDER_MOVE_EPS_PX,
|
|
54
53
|
REORDER_REST_MS,
|
|
55
54
|
REORDER_STILL_MS,
|
|
56
55
|
REORDER_SETTLE_MS,
|
|
57
56
|
REORDER_SHIFT_TRANS,
|
|
58
57
|
REORDER_SLOT_FADE_MS,
|
|
59
58
|
TAB_HTML,
|
|
59
|
+
exceedsSlop,
|
|
60
60
|
kbButtonHTML,
|
|
61
61
|
newButtonHTML,
|
|
62
|
+
noteRestSample,
|
|
62
63
|
paintActivityMark,
|
|
63
64
|
paintProgress,
|
|
64
65
|
paintStatusDot,
|
|
65
66
|
pick,
|
|
67
|
+
pointerDragActivation,
|
|
66
68
|
splitButtonHTML,
|
|
67
69
|
switchButtonHTML,
|
|
70
|
+
viewportBox,
|
|
71
|
+
viewportMoved,
|
|
72
|
+
type DragActivation,
|
|
73
|
+
type RestState,
|
|
74
|
+
type ViewportBox,
|
|
68
75
|
} from "./strip.js";
|
|
69
76
|
import {
|
|
70
77
|
AXIS_LOCK_PX,
|
|
@@ -106,13 +113,12 @@ export interface TabsApi {
|
|
|
106
113
|
* pane already shows has that pane selected instead. */
|
|
107
114
|
switchTo(id: string): void;
|
|
108
115
|
/** Show a tab on one side of the split, opening it when closed, and select its
|
|
109
|
-
* pane. Opening
|
|
110
|
-
*
|
|
111
|
-
* tab the other pane shows trades places with the
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* narrow to open. */
|
|
116
|
+
* pane. Opening keeps the single view's tab, or the moved tab's split partner,
|
|
117
|
+
* on the other side; with no other tab, one is created and the split opens
|
|
118
|
+
* once it exists. A tab the other pane shows trades places with the one on
|
|
119
|
+
* that side; any other tab there becomes ordinary. In an open split, a tab
|
|
120
|
+
* already shown on that side changes nothing. False when refused: an unknown
|
|
121
|
+
* id, an invalid side, or a split the pane row is too narrow to open. */
|
|
116
122
|
snap(id: string, side: PaneSide): boolean;
|
|
117
123
|
/** The current tabs, first-to-last by creation; `active` marks a tab a pane
|
|
118
124
|
* shows. */
|
|
@@ -269,6 +275,12 @@ interface ShowOptions {
|
|
|
269
275
|
readonly keepSelection?: boolean;
|
|
270
276
|
}
|
|
271
277
|
|
|
278
|
+
/** How a create opens the split once its tab exists. `button`: the single view
|
|
279
|
+
* keeps its tab and the new tab fills the other pane, selected. `snap`: `id`
|
|
280
|
+
* lands on `side`, selected, and the new tab fills the other pane. */
|
|
281
|
+
type SplitOpening =
|
|
282
|
+
{ readonly by: "button" } | { readonly by: "snap"; readonly id: string; readonly side: PaneSide };
|
|
283
|
+
|
|
272
284
|
function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
|
|
273
285
|
if (ev.ctrlKey || ev.metaKey || ev.altKey) {
|
|
274
286
|
return true;
|
|
@@ -781,6 +793,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
781
793
|
// The record is read once and never written back by a read.
|
|
782
794
|
let applyingRecord = false;
|
|
783
795
|
let warnedLayout = false;
|
|
796
|
+
// Twin of marotte static-src/tabs-drag.ts; when changing drag behaviour or its
|
|
797
|
+
// constants, check the other app. Both judge a live drag's viewport against the
|
|
798
|
+
// PRESS-time frame, so a move a hold lifted across still ends the drag.
|
|
784
799
|
let draggingEl: HTMLElement | null = null;
|
|
785
800
|
// The chips currently carrying an inline displacement from the commit slide.
|
|
786
801
|
// A set plus one settle function, so whatever a slide wrote can always be handed
|
|
@@ -789,18 +804,35 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
789
804
|
let shiftTimer: number | null = null;
|
|
790
805
|
// The no-events fallback timer. It carries its own target, so there is
|
|
791
806
|
// deliberately NO pending-slot field here: one was the cause of the reorder
|
|
792
|
-
// dropping commits (see trackRest).
|
|
793
|
-
//
|
|
807
|
+
// dropping commits (see trackRest). `rest` is how movement is told from
|
|
808
|
+
// stillness.
|
|
794
809
|
let restTimer: number | null = null;
|
|
795
|
-
|
|
796
|
-
// When the pointer last actually MOVED. A stationary dragover this long after it
|
|
797
|
-
// is believed as a stop (see trackRest).
|
|
798
|
-
let restMovedAt = 0;
|
|
810
|
+
const rest: RestState = { at: null, movedAt: 0 };
|
|
799
811
|
// Whether a `drop` fired for the drag in flight. It is the exact signal for
|
|
800
812
|
// "the user released deliberately" as opposed to "the drag was abandoned":
|
|
801
813
|
// Escape and a refused release fire dragend with no drop at all. dragend
|
|
802
814
|
// without it reverts the preview, which is the cancel this reorder never had.
|
|
803
815
|
let dropped = false;
|
|
816
|
+
// A touch or pen press on a chip, from pointerdown to the gesture's end.
|
|
817
|
+
// `live` once the hold lifted it into a drag; `moved` once it then left the
|
|
818
|
+
// slop, which is what makes the release a drop rather than a tap.
|
|
819
|
+
interface PointerPress {
|
|
820
|
+
readonly id: number;
|
|
821
|
+
readonly el: HTMLElement;
|
|
822
|
+
readonly x0: number;
|
|
823
|
+
readonly y0: number;
|
|
824
|
+
readonly rule: DragActivation;
|
|
825
|
+
readonly frame: ViewportBox;
|
|
826
|
+
readonly draggable: boolean;
|
|
827
|
+
readonly off: AbortController;
|
|
828
|
+
x: number;
|
|
829
|
+
y: number;
|
|
830
|
+
hold: number | null;
|
|
831
|
+
tick: number | null;
|
|
832
|
+
live: boolean;
|
|
833
|
+
moved: boolean;
|
|
834
|
+
}
|
|
835
|
+
let press: PointerPress | null = null;
|
|
804
836
|
// The chip mid slot-fade, and the timer that ends it.
|
|
805
837
|
let slotFadeEl: HTMLElement | null = null;
|
|
806
838
|
let slotFadeTimer: number | null = null;
|
|
@@ -1419,9 +1451,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1419
1451
|
}
|
|
1420
1452
|
switchTo(tab.id);
|
|
1421
1453
|
});
|
|
1422
|
-
// Also the TOUCH rename path
|
|
1423
|
-
//
|
|
1424
|
-
// touch-action: manipulation keeps iOS from reading the second tap as a zoom.
|
|
1454
|
+
// Also the TOUCH rename path. The chip's touch-action: manipulation keeps
|
|
1455
|
+
// iOS from reading the second tap as a zoom.
|
|
1425
1456
|
el.addEventListener("dblclick", (e) => {
|
|
1426
1457
|
if ((e.target as HTMLElement).closest(".wt-tab-close")) {
|
|
1427
1458
|
return;
|
|
@@ -1503,10 +1534,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1503
1534
|
void close_(tab.id);
|
|
1504
1535
|
}
|
|
1505
1536
|
});
|
|
1506
|
-
// Right-click opens the tab context menu
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1537
|
+
// Right-click opens the tab context menu, and so does a touch long-press
|
|
1538
|
+
// that has not dragged anywhere; one that has is a drop in progress, so
|
|
1539
|
+
// the platform's menu is refused and ours stays shut.
|
|
1509
1540
|
el.addEventListener("contextmenu", (e) => {
|
|
1541
|
+
if (press?.moved === true) {
|
|
1542
|
+
e.preventDefault();
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
cancelPointerPress();
|
|
1510
1546
|
if (editingId === tab.id) {
|
|
1511
1547
|
// Let the browser's own text menu open: it is where Paste, Select All
|
|
1512
1548
|
// and Undo live, and the tab menu would offer Close and Move mid-edit.
|
|
@@ -1517,20 +1553,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1517
1553
|
});
|
|
1518
1554
|
// Drag-and-drop reorder on the desktop strip. The bar's dragover arms a
|
|
1519
1555
|
// slot, the hold (or a drop) commits it, and drop/dragend commit the
|
|
1520
|
-
// resulting order into tabList. See the reorder-preview block below.
|
|
1556
|
+
// resulting order into tabList. See the reorder-preview block below. A
|
|
1557
|
+
// touch or pen press takes the pointer path instead (beginPointerPress),
|
|
1558
|
+
// so the platform's own drag must not start as well.
|
|
1521
1559
|
el.draggable = true;
|
|
1522
1560
|
el.addEventListener("dragstart", (e) => {
|
|
1523
|
-
if (editingId === tab.id) {
|
|
1524
|
-
e.preventDefault(); //
|
|
1561
|
+
if (editingId === tab.id || press !== null) {
|
|
1562
|
+
e.preventDefault(); // a rename owns the caret; a pointer press owns the drag
|
|
1525
1563
|
return;
|
|
1526
1564
|
}
|
|
1527
|
-
endReorderPreview(); // no residue from a previous drag
|
|
1528
|
-
endShift(); // ...and nothing mid-slide, so this gesture starts from rest
|
|
1529
|
-
dropped = false;
|
|
1530
|
-
draggingEl = el;
|
|
1531
1565
|
// Snapshot the preview from the PRISTINE chip, before the slot class.
|
|
1532
|
-
|
|
1533
|
-
|
|
1566
|
+
startTabDrag(el, () => {
|
|
1567
|
+
setDragGhost(e, el);
|
|
1568
|
+
});
|
|
1534
1569
|
if (e.dataTransfer) {
|
|
1535
1570
|
e.dataTransfer.effectAllowed = "move";
|
|
1536
1571
|
// Firefox requires drag data to be set for the drag to start; the
|
|
@@ -1538,22 +1573,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1538
1573
|
// TAB_DRAG_TYPE).
|
|
1539
1574
|
e.dataTransfer.setData(TAB_DRAG_TYPE, tab.id);
|
|
1540
1575
|
}
|
|
1541
|
-
hideTabMenu();
|
|
1542
1576
|
});
|
|
1543
1577
|
el.addEventListener("dragend", () => {
|
|
1544
|
-
el
|
|
1545
|
-
paintDropHalf(null);
|
|
1546
|
-
if (!dropped) {
|
|
1547
|
-
// No drop fired, so the drag was abandoned: Escape, or a release the
|
|
1548
|
-
// browser refused. Put the strip back — tabList still holds the order
|
|
1549
|
-
// the gesture started from, because only a drop writes to it.
|
|
1550
|
-
revertPreview();
|
|
1551
|
-
}
|
|
1552
|
-
draggingEl = null;
|
|
1553
|
-
// The gesture's state goes; a slide started by the revert above is left to
|
|
1554
|
-
// finish under its own settle timer (see endReorderPreview).
|
|
1555
|
-
endReorderPreview();
|
|
1556
|
-
clearDragGhost(); // belt-and-braces: the rAF normally got there first
|
|
1578
|
+
endTabDrag(el);
|
|
1557
1579
|
});
|
|
1558
1580
|
return tab;
|
|
1559
1581
|
}
|
|
@@ -1675,13 +1697,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1675
1697
|
if (!isPaneSide(side) || !tabList.some((t) => t.id === id)) {
|
|
1676
1698
|
return false;
|
|
1677
1699
|
}
|
|
1700
|
+
const split = ctx.shell.split;
|
|
1701
|
+
const opening = !split.isOpen();
|
|
1678
1702
|
const from = sideOf(id);
|
|
1679
|
-
if (from === side) {
|
|
1703
|
+
if (from === side && !opening) {
|
|
1680
1704
|
return true;
|
|
1681
1705
|
}
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1706
|
+
if (opening && !split.canOpen()) {
|
|
1707
|
+
return false;
|
|
1708
|
+
}
|
|
1709
|
+
// Nothing to pair the moved tab with: the split opens once a created tab
|
|
1710
|
+
// can fill the other pane.
|
|
1711
|
+
if (opening && splitPartner(id) === null) {
|
|
1712
|
+
void create({ by: "snap", id, side });
|
|
1713
|
+
return true;
|
|
1714
|
+
}
|
|
1715
|
+
if (opening && !split.open()) {
|
|
1685
1716
|
return false;
|
|
1686
1717
|
}
|
|
1687
1718
|
// Emptying the tab's old pane drops a keyboard its input held to the body.
|
|
@@ -1693,13 +1724,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1693
1724
|
// Opening pairs the moved tab with the single view's tab, or its partner
|
|
1694
1725
|
// when they are one, so no pane opens empty; an open split only re-homes
|
|
1695
1726
|
// the displaced tab, into a pane the move emptied.
|
|
1696
|
-
const
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
back !== null &&
|
|
1700
|
-
back !== id &&
|
|
1701
|
-
shownIn(otherSide(side)) === null
|
|
1702
|
-
) {
|
|
1727
|
+
const kept = displaced === id ? null : displaced;
|
|
1728
|
+
const back = kept ?? (opening ? splitPartner(id) : null);
|
|
1729
|
+
if ((from !== null || opening) && back !== null && shownIn(otherSide(side)) === null) {
|
|
1703
1730
|
showIn(otherSide(side), back, { keepSelection: true });
|
|
1704
1731
|
}
|
|
1705
1732
|
if (typing) {
|
|
@@ -1716,7 +1743,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1716
1743
|
}
|
|
1717
1744
|
const partner = splitPartner(selectedId());
|
|
1718
1745
|
if (partner === null) {
|
|
1719
|
-
void create(
|
|
1746
|
+
void create({ by: "button" });
|
|
1720
1747
|
} else if (split.open()) {
|
|
1721
1748
|
showIn("right", partner, { keepSelection: true });
|
|
1722
1749
|
}
|
|
@@ -2016,12 +2043,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2016
2043
|
if (!dragged) {
|
|
2017
2044
|
return;
|
|
2018
2045
|
}
|
|
2019
|
-
const
|
|
2020
|
-
const moved = restX === null || Math.abs(clientX - restX) > REORDER_MOVE_EPS_PX;
|
|
2021
|
-
restX = clientX;
|
|
2022
|
-
if (moved) {
|
|
2023
|
-
restMovedAt = now;
|
|
2024
|
-
}
|
|
2046
|
+
const still = noteRestSample(rest, clientX, win.Date.now());
|
|
2025
2047
|
// Recomputed from THIS event, never a stored pending target: a stored one
|
|
2026
2048
|
// was nulled by the already-there branch on the way past, so a stop after it
|
|
2027
2049
|
// committed NOTHING until the mouse was jiggled.
|
|
@@ -2030,9 +2052,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2030
2052
|
endRestNet();
|
|
2031
2053
|
return;
|
|
2032
2054
|
}
|
|
2033
|
-
|
|
2034
|
-
// of the previous (a reversal, or a mostly vertical frame).
|
|
2035
|
-
if (!moved && now - restMovedAt >= REORDER_STILL_MS) {
|
|
2055
|
+
if (still) {
|
|
2036
2056
|
endRestNet();
|
|
2037
2057
|
commitSlot(before);
|
|
2038
2058
|
return;
|
|
@@ -2188,15 +2208,45 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2188
2208
|
// revert snap home instead of run. dragstart and teardown call endShift().
|
|
2189
2209
|
function endReorderPreview(): void {
|
|
2190
2210
|
endRestNet();
|
|
2191
|
-
|
|
2211
|
+
rest.at = null;
|
|
2192
2212
|
endSlotFade();
|
|
2193
2213
|
}
|
|
2194
2214
|
|
|
2215
|
+
// The lift both drag paths share. `ghost` builds the preview copy, and runs
|
|
2216
|
+
// before the slot class so it copies the pristine chip.
|
|
2217
|
+
function startTabDrag(el: HTMLElement, ghost: () => void): void {
|
|
2218
|
+
endReorderPreview(); // no residue from a previous drag
|
|
2219
|
+
endShift(); // ...and nothing mid-slide, so this gesture starts from rest
|
|
2220
|
+
dropped = false;
|
|
2221
|
+
draggingEl = el;
|
|
2222
|
+
ghost();
|
|
2223
|
+
el.classList.add("wt-tab-dragging");
|
|
2224
|
+
hideTabMenu();
|
|
2225
|
+
}
|
|
2226
|
+
function endTabDrag(el: HTMLElement): void {
|
|
2227
|
+
el.classList.remove("wt-tab-dragging");
|
|
2228
|
+
paintDropHalf(null);
|
|
2229
|
+
if (!dropped) {
|
|
2230
|
+
// No drop fired, so the drag was abandoned: Escape, or a release the
|
|
2231
|
+
// browser refused. Put the strip back — tabList still holds the order
|
|
2232
|
+
// the gesture started from, because only a drop writes to it.
|
|
2233
|
+
revertPreview();
|
|
2234
|
+
}
|
|
2235
|
+
draggingEl = null;
|
|
2236
|
+
// The gesture's state goes; a slide started by the revert above is left to
|
|
2237
|
+
// finish under its own settle timer (see endReorderPreview).
|
|
2238
|
+
endReorderPreview();
|
|
2239
|
+
clearDragGhost(); // belt-and-braces: the rAF normally got there first
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2195
2242
|
// For every site that REMOVES a chip while a drag may be open. A browser is
|
|
2196
2243
|
// not obliged to fire dragend for a removed source, and a `draggingEl` left
|
|
2197
2244
|
// pointing at a detached node would make the document-level guard
|
|
2198
2245
|
// preventDefault every unrelated file or text drop from then on.
|
|
2199
2246
|
function abortReorderFor(removed: HTMLElement): void {
|
|
2247
|
+
if (press?.el === removed) {
|
|
2248
|
+
endPointerPress();
|
|
2249
|
+
}
|
|
2200
2250
|
if (removed !== draggingEl) {
|
|
2201
2251
|
return;
|
|
2202
2252
|
}
|
|
@@ -2253,21 +2303,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2253
2303
|
// transform (webkit.org/b/22787), and the strip's backdrop-filter is one, so
|
|
2254
2304
|
// the preview came out as broken white geometry on iPadOS. The clone is
|
|
2255
2305
|
// parked under .wt-root, outside the filtered subtree but inside the styling
|
|
2256
|
-
// boundary, laid exactly over the chip
|
|
2257
|
-
// which time the browser has snapshotted it
|
|
2306
|
+
// boundary, and laid exactly over the chip. A native drag drops it on the
|
|
2307
|
+
// next frame, by which time the browser has snapshotted it; the pointer path
|
|
2308
|
+
// keeps it under the finger for the whole drag.
|
|
2258
2309
|
let dragGhost: HTMLElement | null = null;
|
|
2259
2310
|
function clearDragGhost(): void {
|
|
2260
2311
|
dragGhost?.remove();
|
|
2261
2312
|
dragGhost = null;
|
|
2262
2313
|
}
|
|
2263
|
-
function
|
|
2314
|
+
function placeDragGhost(el: HTMLElement): { ghost: HTMLElement; rect: DOMRect } {
|
|
2264
2315
|
clearDragGhost();
|
|
2265
2316
|
const rect = el.getBoundingClientRect();
|
|
2266
2317
|
const rootRect = varRoot.getBoundingClientRect();
|
|
2267
2318
|
const ghost = el.cloneNode(true) as HTMLElement;
|
|
2268
2319
|
ghost.classList.add("wt-tab-ghost");
|
|
2269
|
-
// A duplicated role="tab" must not reach assistive tech
|
|
2270
|
-
//
|
|
2320
|
+
// A duplicated role="tab" must not reach assistive tech while the clone
|
|
2321
|
+
// exists (CSS keeps it non-interactive).
|
|
2271
2322
|
ghost.setAttribute("aria-hidden", "true");
|
|
2272
2323
|
ghost.style.left = `${String(rect.left - rootRect.left)}px`;
|
|
2273
2324
|
ghost.style.top = `${String(rect.top - rootRect.top)}px`;
|
|
@@ -2277,6 +2328,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2277
2328
|
ghost.style.height = `${String(rect.height)}px`;
|
|
2278
2329
|
varRoot.appendChild(ghost);
|
|
2279
2330
|
dragGhost = ghost;
|
|
2331
|
+
return { ghost, rect };
|
|
2332
|
+
}
|
|
2333
|
+
function setDragGhost(e: DragEvent, el: HTMLElement): void {
|
|
2334
|
+
const { ghost, rect } = placeDragGhost(el);
|
|
2280
2335
|
// Offset from the chip's own box, not e.offsetX/offsetY: those are
|
|
2281
2336
|
// relative to the event TARGET, which is the label or the close button
|
|
2282
2337
|
// when the drag starts on one of them.
|
|
@@ -2352,17 +2407,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2352
2407
|
// promise needs no threshold: a duplicate lands while the POST is open and
|
|
2353
2408
|
// collapses into it, while a deliberate second tap still opens a second
|
|
2354
2409
|
// terminal. It also stops "+" mashing from queueing sessions across the
|
|
2355
|
-
// server's retry window.
|
|
2410
|
+
// server's retry window. The first coalesced caller asking for the split
|
|
2411
|
+
// decides how it opens.
|
|
2356
2412
|
let creating: Promise<void> | null = null;
|
|
2357
|
-
let
|
|
2358
|
-
function create(
|
|
2413
|
+
let createOpening: SplitOpening | null = null;
|
|
2414
|
+
function create(opening: SplitOpening | null = null): Promise<void> {
|
|
2359
2415
|
if (creating === null) {
|
|
2360
|
-
|
|
2416
|
+
createOpening = opening;
|
|
2361
2417
|
creating = openNewSession().finally(() => {
|
|
2362
2418
|
creating = null;
|
|
2363
2419
|
});
|
|
2364
2420
|
} else {
|
|
2365
|
-
|
|
2421
|
+
createOpening ??= opening;
|
|
2366
2422
|
}
|
|
2367
2423
|
return creating;
|
|
2368
2424
|
}
|
|
@@ -2417,9 +2473,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2417
2473
|
tabList.push(tab);
|
|
2418
2474
|
}
|
|
2419
2475
|
// Opened once the tab exists, so a failed create leaves the single view
|
|
2420
|
-
// as it was rather than an empty pane.
|
|
2421
|
-
|
|
2422
|
-
|
|
2476
|
+
// as it was rather than an empty pane. A snap whose tab closed meanwhile
|
|
2477
|
+
// has nothing left to pair, so the new tab takes the single view.
|
|
2478
|
+
const opening = createOpening;
|
|
2479
|
+
const split = ctx.shell.split;
|
|
2480
|
+
if (opening?.by === "button" && !split.isOpen()) {
|
|
2481
|
+
split.open();
|
|
2482
|
+
} else if (
|
|
2483
|
+
opening?.by === "snap" &&
|
|
2484
|
+
!split.isOpen() &&
|
|
2485
|
+
tabList.some((t) => t.id === opening.id) &&
|
|
2486
|
+
split.open()
|
|
2487
|
+
) {
|
|
2488
|
+
const typing = keyboardInPaneInput();
|
|
2489
|
+
if (showIn(opening.side, opening.id)) {
|
|
2490
|
+
creatingTab = true;
|
|
2491
|
+
showIn(otherSide(opening.side), tab.id, { keepSelection: true });
|
|
2492
|
+
creatingTab = false;
|
|
2493
|
+
if (typing) {
|
|
2494
|
+
focusInput();
|
|
2495
|
+
}
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2423
2498
|
}
|
|
2424
2499
|
showNewTab(tab.id);
|
|
2425
2500
|
}
|
|
@@ -2951,7 +3026,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2951
3026
|
moveTab(id, 1);
|
|
2952
3027
|
});
|
|
2953
3028
|
if (ctx.shell.split.enabled) {
|
|
2954
|
-
const shownOn = sideOf(id);
|
|
3029
|
+
const shownOn = ctx.shell.split.isOpen() ? sideOf(id) : null;
|
|
2955
3030
|
const fits = rowFitsSplit();
|
|
2956
3031
|
tabMenuItem("Snap to left", !fits || shownOn === "left", () => {
|
|
2957
3032
|
snap(id, "left");
|
|
@@ -3321,18 +3396,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3321
3396
|
// drop is to LOAD the payload as a URL, so dropping a tab on iPadOS
|
|
3322
3397
|
// navigated the page to /<session-id>.
|
|
3323
3398
|
bar.addEventListener("drop", (e) => {
|
|
3399
|
+
if (!draggingEl) {
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
e.preventDefault();
|
|
3403
|
+
dropOnStrip(e.clientX);
|
|
3404
|
+
});
|
|
3405
|
+
function dropOnStrip(clientX: number): void {
|
|
3324
3406
|
const moved = draggingEl;
|
|
3325
3407
|
if (!moved) {
|
|
3326
3408
|
return;
|
|
3327
3409
|
}
|
|
3328
|
-
e.preventDefault();
|
|
3329
3410
|
dropped = true;
|
|
3330
3411
|
// A release decides by POSITION, not by whatever slot a timer had pending.
|
|
3331
3412
|
endRestNet();
|
|
3332
|
-
commitSlot(dropTargetBefore(
|
|
3413
|
+
commitSlot(dropTargetBefore(clientX));
|
|
3333
3414
|
syncOrderFromDom();
|
|
3334
3415
|
announceMoved(moved);
|
|
3335
|
-
}
|
|
3416
|
+
}
|
|
3336
3417
|
// The strip is docked at the viewport EDGE, so a pointer can leave it by
|
|
3337
3418
|
// leaving the window, and then no document dragover fires to withdraw the
|
|
3338
3419
|
// pending slot; the rest window would open a slot the pointer abandoned.
|
|
@@ -3349,9 +3430,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3349
3430
|
});
|
|
3350
3431
|
// The left width rounds as `--wt-split-left` does in 32-split.css, so the
|
|
3351
3432
|
// boundary is the one the highlights draw.
|
|
3352
|
-
function dropHalf(
|
|
3433
|
+
function dropHalf(clientX: number, target: Node): PaneSide | null {
|
|
3353
3434
|
const root = ctx.shell.root;
|
|
3354
|
-
const target = e.target as Node;
|
|
3355
3435
|
if (
|
|
3356
3436
|
!ctx.shell.split.enabled ||
|
|
3357
3437
|
!rowFitsSplit() ||
|
|
@@ -3362,7 +3442,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3362
3442
|
}
|
|
3363
3443
|
const { left, width } = root.getBoundingClientRect();
|
|
3364
3444
|
const leftPane = Math.round((width - SPLIT_GUTTER_PX) * ctx.shell.split.state().ratio);
|
|
3365
|
-
return
|
|
3445
|
+
return clientX < left + leftPane + SPLIT_GUTTER_PX / 2 ? "left" : "right";
|
|
3366
3446
|
}
|
|
3367
3447
|
function paintDropHalf(half: PaneSide | null): void {
|
|
3368
3448
|
ctx.shell.root.classList.toggle("wt-drop-left", half === "left");
|
|
@@ -3381,35 +3461,283 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3381
3461
|
paintDropHalf(null);
|
|
3382
3462
|
return;
|
|
3383
3463
|
}
|
|
3384
|
-
const half = dropHalf(e);
|
|
3464
|
+
const half = dropHalf(e.clientX, e.target as Node);
|
|
3385
3465
|
if (e.type === "drop") {
|
|
3386
|
-
|
|
3387
|
-
// dragend runs the revert. Committing a slot here would persist an
|
|
3388
|
-
// arrangement chosen at a position the user visibly left, and would
|
|
3389
|
-
// depend on whether the browser emitted `drop` at all.
|
|
3390
|
-
endRestNet();
|
|
3391
|
-
paintDropHalf(null);
|
|
3392
|
-
if (half !== null) {
|
|
3393
|
-
const id = tabList.find((t) => t.el === draggingEl)?.id;
|
|
3394
|
-
if (id !== undefined) {
|
|
3395
|
-
snap(id, half);
|
|
3396
|
-
}
|
|
3397
|
-
}
|
|
3466
|
+
dropOffStrip(half);
|
|
3398
3467
|
return;
|
|
3399
3468
|
}
|
|
3400
3469
|
if (e.dataTransfer && half !== null) {
|
|
3401
3470
|
e.dataTransfer.dropEffect = "move";
|
|
3402
3471
|
}
|
|
3472
|
+
overOffStrip(half);
|
|
3473
|
+
};
|
|
3474
|
+
// A release off the strip CANCELS the reorder: `dropped` stays false and
|
|
3475
|
+
// the drag's end runs the revert. Committing a slot here would persist an
|
|
3476
|
+
// arrangement chosen at a position the user visibly left, and would depend
|
|
3477
|
+
// on whether the browser emitted `drop` at all.
|
|
3478
|
+
function dropOffStrip(half: PaneSide | null): void {
|
|
3479
|
+
endRestNet();
|
|
3480
|
+
paintDropHalf(null);
|
|
3481
|
+
if (half !== null) {
|
|
3482
|
+
const id = tabList.find((t) => t.el === draggingEl)?.id;
|
|
3483
|
+
if (id !== undefined) {
|
|
3484
|
+
snap(id, half);
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
function overOffStrip(half: PaneSide | null): void {
|
|
3403
3489
|
paintDropHalf(half);
|
|
3404
3490
|
// No candidate slot exists off the strip; a COMMITTED slot stays put.
|
|
3405
3491
|
endRestNet();
|
|
3406
|
-
}
|
|
3492
|
+
}
|
|
3407
3493
|
doc.addEventListener("dragover", onDocTabDrop);
|
|
3408
3494
|
doc.addEventListener("drop", onDocTabDrop);
|
|
3409
3495
|
ctx.defer(() => {
|
|
3410
3496
|
doc.removeEventListener("dragover", onDocTabDrop);
|
|
3411
3497
|
doc.removeEventListener("drop", onDocTabDrop);
|
|
3412
3498
|
});
|
|
3499
|
+
|
|
3500
|
+
// Touch and pen reorder by Pointer Events: a native drag on those lifts only
|
|
3501
|
+
// at the platform's long press, which page script cannot shorten. A short
|
|
3502
|
+
// hold inside the slop lifts the chip (pointerDragActivation); leaving the
|
|
3503
|
+
// slop first leaves the gesture to the strip's own scroll. From the lift on,
|
|
3504
|
+
// every position runs through the handlers the native drag uses.
|
|
3505
|
+
bar.addEventListener("pointerdown", beginPointerPress, { passive: true });
|
|
3506
|
+
// Capture routes events but does not stop a pan, and touch-action is fixed
|
|
3507
|
+
// when the gesture starts, so only a cancelled touchmove keeps the strip
|
|
3508
|
+
// still under a lifted chip. Registered up front: Safari ignores a
|
|
3509
|
+
// non-passive listener added mid-gesture.
|
|
3510
|
+
bar.addEventListener(
|
|
3511
|
+
"touchmove",
|
|
3512
|
+
(e) => {
|
|
3513
|
+
if (press?.live === true) {
|
|
3514
|
+
e.preventDefault();
|
|
3515
|
+
}
|
|
3516
|
+
},
|
|
3517
|
+
{ passive: false },
|
|
3518
|
+
);
|
|
3519
|
+
// The release of a drop that moved is not a tap on whatever lies under it.
|
|
3520
|
+
const dropSwallow = createClickSwallow(win);
|
|
3521
|
+
let dropClickPending = false;
|
|
3522
|
+
bar.addEventListener(
|
|
3523
|
+
"click",
|
|
3524
|
+
(e) => {
|
|
3525
|
+
if (dropClickPending && dropSwallow.swallowing()) {
|
|
3526
|
+
e.preventDefault();
|
|
3527
|
+
e.stopPropagation();
|
|
3528
|
+
}
|
|
3529
|
+
dropClickPending = false;
|
|
3530
|
+
},
|
|
3531
|
+
{ capture: true },
|
|
3532
|
+
);
|
|
3533
|
+
ctx.defer(endPointerPress);
|
|
3534
|
+
function beginPointerPress(e: PointerEvent): void {
|
|
3535
|
+
const rule = pointerDragActivation(e.pointerType);
|
|
3536
|
+
const el =
|
|
3537
|
+
e.target instanceof win.Element ? e.target.closest<HTMLElement>(".wt-tab") : null;
|
|
3538
|
+
if (rule === null || !e.isPrimary || press !== null || el?.parentNode !== scroller) {
|
|
3539
|
+
return;
|
|
3540
|
+
}
|
|
3541
|
+
const tab = tabList.find((t) => t.el === el);
|
|
3542
|
+
if (!tab || editingId === tab.id) {
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
dropClickPending = false;
|
|
3546
|
+
const off = new AbortController();
|
|
3547
|
+
press = {
|
|
3548
|
+
id: e.pointerId,
|
|
3549
|
+
el,
|
|
3550
|
+
x0: e.clientX,
|
|
3551
|
+
y0: e.clientY,
|
|
3552
|
+
rule,
|
|
3553
|
+
frame: viewportBox(win),
|
|
3554
|
+
draggable: el.draggable,
|
|
3555
|
+
off,
|
|
3556
|
+
x: e.clientX,
|
|
3557
|
+
y: e.clientY,
|
|
3558
|
+
hold: win.setTimeout(liftPointerPress, rule.holdMs),
|
|
3559
|
+
tick: null,
|
|
3560
|
+
live: false,
|
|
3561
|
+
moved: false,
|
|
3562
|
+
};
|
|
3563
|
+
el.draggable = false;
|
|
3564
|
+
const opts = { capture: true, signal: off.signal };
|
|
3565
|
+
win.addEventListener("pointermove", onPressMove, opts);
|
|
3566
|
+
win.addEventListener("pointerup", onPressUp, opts);
|
|
3567
|
+
win.addEventListener("pointercancel", onPressCancel, opts);
|
|
3568
|
+
win.addEventListener("pointerdown", onPressSecond, opts);
|
|
3569
|
+
win.addEventListener("keydown", onPressKey, opts);
|
|
3570
|
+
win.addEventListener("blur", cancelPointerPress, { signal: off.signal });
|
|
3571
|
+
doc.addEventListener("visibilitychange", onPressVisibility, { signal: off.signal });
|
|
3572
|
+
bar.addEventListener("lostpointercapture", onPressLostCapture, { signal: off.signal });
|
|
3573
|
+
scroller.addEventListener("scroll", onPressScroll, { passive: true, signal: off.signal });
|
|
3574
|
+
win.visualViewport?.addEventListener("resize", onPressViewport, { signal: off.signal });
|
|
3575
|
+
win.visualViewport?.addEventListener("scroll", onPressViewport, { signal: off.signal });
|
|
3576
|
+
win.addEventListener("resize", onPressViewport, { signal: off.signal });
|
|
3577
|
+
}
|
|
3578
|
+
function liftPointerPress(): void {
|
|
3579
|
+
const p = press;
|
|
3580
|
+
if (p === null) {
|
|
3581
|
+
return;
|
|
3582
|
+
}
|
|
3583
|
+
p.hold = null;
|
|
3584
|
+
p.live = true;
|
|
3585
|
+
startTabDrag(p.el, () => {
|
|
3586
|
+
placeDragGhost(p.el);
|
|
3587
|
+
});
|
|
3588
|
+
try {
|
|
3589
|
+
bar.setPointerCapture(p.id);
|
|
3590
|
+
} catch {
|
|
3591
|
+
// An inactive pointer refuses capture; the window listeners still see it.
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
function onPressMove(e: PointerEvent): void {
|
|
3595
|
+
const p = press;
|
|
3596
|
+
if (p?.id !== e.pointerId) {
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3599
|
+
p.x = e.clientX;
|
|
3600
|
+
p.y = e.clientY;
|
|
3601
|
+
const away = exceedsSlop(p.x - p.x0, p.y - p.y0, p.rule);
|
|
3602
|
+
if (!p.live) {
|
|
3603
|
+
// A viewport event waits for the next rendering step, so this move can read
|
|
3604
|
+
// the moved geometry before it arrives.
|
|
3605
|
+
if (away || viewportMoved(viewportBox(win), p.frame)) {
|
|
3606
|
+
cancelPointerPress();
|
|
3607
|
+
}
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
if (dragGhost) {
|
|
3611
|
+
dragGhost.style.translate = `${String(p.x - p.x0)}px ${String(p.y - p.y0)}px`;
|
|
3612
|
+
}
|
|
3613
|
+
if (!p.moved && away) {
|
|
3614
|
+
p.moved = true;
|
|
3615
|
+
// A still pointer delivers no pointermove, so the position is re-reported
|
|
3616
|
+
// on a drag loop's cadence: that repeat is how trackRest sees a stop.
|
|
3617
|
+
p.tick = win.setInterval(() => {
|
|
3618
|
+
pointerDragAt(p.x, p.y, false);
|
|
3619
|
+
}, REORDER_STILL_MS);
|
|
3620
|
+
}
|
|
3621
|
+
if (p.moved) {
|
|
3622
|
+
pointerDragAt(p.x, p.y, false);
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
function onPressUp(e: PointerEvent): void {
|
|
3626
|
+
const p = press;
|
|
3627
|
+
if (p?.id !== e.pointerId) {
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
if (p.moved) {
|
|
3631
|
+
pointerDragAt(e.clientX, e.clientY, true);
|
|
3632
|
+
dropClickPending = true;
|
|
3633
|
+
dropSwallow.arm();
|
|
3634
|
+
}
|
|
3635
|
+
endPointerPress();
|
|
3636
|
+
// Unmoved, the release is a tap: no drop, nothing to revert, and the click
|
|
3637
|
+
// that follows activates the tab as any tap does.
|
|
3638
|
+
if (p.live) {
|
|
3639
|
+
endTabDrag(p.el);
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
function onPressCancel(e: PointerEvent): void {
|
|
3643
|
+
if (press?.id === e.pointerId) {
|
|
3644
|
+
cancelPointerPress();
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
function onPressSecond(e: PointerEvent): void {
|
|
3648
|
+
if (press !== null && press.id !== e.pointerId) {
|
|
3649
|
+
cancelPointerPress();
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
// lostpointercapture bubbles, and the chip's implicit touch capture ending
|
|
3653
|
+
// when the bar takes it over is not a loss.
|
|
3654
|
+
function onPressLostCapture(e: PointerEvent): void {
|
|
3655
|
+
if (e.target === bar && press?.id === e.pointerId) {
|
|
3656
|
+
cancelPointerPress();
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
function onPressScroll(): void {
|
|
3660
|
+
if (press?.live === false) {
|
|
3661
|
+
cancelPointerPress();
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
// A TRIGGER, not a decision: visualViewport fires `scroll` on an ordinary page
|
|
3665
|
+
// scroll with nothing moved. A press whose frame did move can no longer be
|
|
3666
|
+
// verified (the chip under the finger may not be under it now), so it ends and
|
|
3667
|
+
// the reader presses again.
|
|
3668
|
+
function onPressViewport(): void {
|
|
3669
|
+
if (press !== null && viewportMoved(viewportBox(win), press.frame)) {
|
|
3670
|
+
cancelPointerPress();
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
function onPressVisibility(): void {
|
|
3674
|
+
if (doc.visibilityState === "hidden") {
|
|
3675
|
+
cancelPointerPress();
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
// Capture on the window, ahead of the terminal's own keydown, so the Escape
|
|
3679
|
+
// that ends a lifted drag is not also typed into the shell.
|
|
3680
|
+
function onPressKey(e: KeyboardEvent): void {
|
|
3681
|
+
if (e.key !== "Escape" || press === null) {
|
|
3682
|
+
return;
|
|
3683
|
+
}
|
|
3684
|
+
if (press.live) {
|
|
3685
|
+
e.preventDefault();
|
|
3686
|
+
e.stopPropagation();
|
|
3687
|
+
}
|
|
3688
|
+
cancelPointerPress();
|
|
3689
|
+
}
|
|
3690
|
+
// The position decides as a native dragover or drop at it would.
|
|
3691
|
+
function pointerDragAt(x: number, y: number, release: boolean): void {
|
|
3692
|
+
if (!draggingEl) {
|
|
3693
|
+
return;
|
|
3694
|
+
}
|
|
3695
|
+
const target = doc.elementFromPoint(x, y);
|
|
3696
|
+
if (target !== null && bar.contains(target)) {
|
|
3697
|
+
paintDropHalf(null);
|
|
3698
|
+
if (release) {
|
|
3699
|
+
dropOnStrip(x);
|
|
3700
|
+
} else {
|
|
3701
|
+
trackRest(x);
|
|
3702
|
+
}
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3705
|
+
const half = target === null ? null : dropHalf(x, target);
|
|
3706
|
+
if (release) {
|
|
3707
|
+
dropOffStrip(half);
|
|
3708
|
+
} else {
|
|
3709
|
+
overOffStrip(half);
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
function cancelPointerPress(): void {
|
|
3713
|
+
const p = press;
|
|
3714
|
+
if (p === null) {
|
|
3715
|
+
return;
|
|
3716
|
+
}
|
|
3717
|
+
endPointerPress();
|
|
3718
|
+
if (p.live) {
|
|
3719
|
+
endTabDrag(p.el);
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
function endPointerPress(): void {
|
|
3723
|
+
const p = press;
|
|
3724
|
+
if (p === null) {
|
|
3725
|
+
return;
|
|
3726
|
+
}
|
|
3727
|
+
press = null;
|
|
3728
|
+
p.off.abort();
|
|
3729
|
+
if (p.hold !== null) {
|
|
3730
|
+
win.clearTimeout(p.hold);
|
|
3731
|
+
}
|
|
3732
|
+
if (p.tick !== null) {
|
|
3733
|
+
win.clearInterval(p.tick);
|
|
3734
|
+
}
|
|
3735
|
+
if (bar.hasPointerCapture(p.id)) {
|
|
3736
|
+
bar.releasePointerCapture(p.id);
|
|
3737
|
+
}
|
|
3738
|
+
const editing = tabList.find((t) => t.el === p.el)?.id === editingId;
|
|
3739
|
+
p.el.draggable = p.draggable && !editing;
|
|
3740
|
+
}
|
|
3413
3741
|
// Active-row close (x): closes the current tab (mirrors a listed row's x).
|
|
3414
3742
|
// stopPropagation so it is not read as a tap/swipe on the row surface.
|
|
3415
3743
|
swClose.addEventListener("click", (e) => {
|
|
@@ -3980,7 +4308,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3980
4308
|
|
|
3981
4309
|
return {
|
|
3982
4310
|
api: {
|
|
3983
|
-
create,
|
|
4311
|
+
create: () => create(),
|
|
3984
4312
|
close: close_,
|
|
3985
4313
|
switchTo,
|
|
3986
4314
|
snap,
|
|
@@ -14,11 +14,12 @@ import { activityPhrase, normalizeActivity, statusPhrase, statusRevealsDot } fro
|
|
|
14
14
|
// motion is spelled in — index.ts owns the mechanism, strip.ts owns the values,
|
|
15
15
|
// the same split switcher.ts keeps for the mobile swipe.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
// Twin of marotte static-src/tabs-drag.ts's constants; change both or neither.
|
|
18
|
+
/** How long (ms) the pointer must have been un-moved before a position report at an
|
|
19
|
+
* unchanged position (a `dragover`, or the pointer path's re-report on this tick) is
|
|
20
|
+
* believed as "stopped". Small, because such a report is positive evidence of rest;
|
|
21
|
+
* this only filters the coincidence where one report of a sweep lands within
|
|
22
|
+
* REORDER_MOVE_EPS_PX of the previous one. */
|
|
22
23
|
export const REORDER_STILL_MS = 50;
|
|
23
24
|
|
|
24
25
|
/** Fallback guard (ms): commit the pending slot this long after the last MOVEMENT, for
|
|
@@ -34,11 +35,85 @@ export const REORDER_REST_MS = 450;
|
|
|
34
35
|
* sweep would keep pushing the commit out for as long as someone held the tab. */
|
|
35
36
|
export const REORDER_MOVE_EPS_PX = 3;
|
|
36
37
|
|
|
37
|
-
/** The
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
/** The last position seen along the strip's axis, and when it last moved by more than
|
|
39
|
+
* REORDER_MOVE_EPS_PX. */
|
|
40
|
+
export interface RestState {
|
|
41
|
+
at: number | null;
|
|
42
|
+
movedAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Folds one position report into `rest` and answers whether the pointer has now been
|
|
46
|
+
* still for REORDER_STILL_MS. */
|
|
47
|
+
export function noteRestSample(rest: RestState, pos: number, now: number): boolean {
|
|
48
|
+
const moved = rest.at === null || Math.abs(pos - rest.at) > REORDER_MOVE_EPS_PX;
|
|
49
|
+
rest.at = pos;
|
|
50
|
+
if (moved) {
|
|
51
|
+
rest.movedAt = now;
|
|
52
|
+
}
|
|
53
|
+
return !moved && now - rest.movedAt >= REORDER_STILL_MS;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** How long a press must hold, and how far it may stray meanwhile, to become a drag. */
|
|
57
|
+
export interface DragActivation {
|
|
58
|
+
readonly holdMs: number;
|
|
59
|
+
readonly slopPx: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 8px is Android's touch slop; 150ms clears its 100ms tap window and wins the race
|
|
63
|
+
// against the platform long press. Source:
|
|
64
|
+
// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/view/ViewConfiguration.java
|
|
65
|
+
export const TOUCH_DRAG: DragActivation = { holdMs: 150, slopPx: 8 };
|
|
66
|
+
export const PEN_DRAG: DragActivation = { holdMs: 150, slopPx: 8 };
|
|
67
|
+
|
|
68
|
+
/** How a press on a chip becomes a drag, by `PointerEvent.pointerType`. A mouse
|
|
69
|
+
* keeps native drag-and-drop and its own drag threshold (null). Touch, pen and an
|
|
70
|
+
* unknown type hold `holdMs` without straying past `slopPx` (exceedsSlop): past
|
|
71
|
+
* the slop first, the gesture is a scroll and stays the browser's. */
|
|
72
|
+
export function pointerDragActivation(pointerType: string): DragActivation | null {
|
|
73
|
+
if (pointerType === "mouse") {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return pointerType === "pen" ? PEN_DRAG : TOUCH_DRAG;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether travel `(dx, dy)` from the press point is past `rule`'s slop: Euclidean,
|
|
80
|
+
* strictly greater, so a tremor of exactly the slop still holds. */
|
|
81
|
+
export function exceedsSlop(dx: number, dy: number, rule: DragActivation): boolean {
|
|
82
|
+
return Math.hypot(dx, dy) > rule.slopPx;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The visible viewport's box, under the VisualViewport API's own field names. */
|
|
86
|
+
export interface ViewportBox {
|
|
87
|
+
readonly offsetLeft: number;
|
|
88
|
+
readonly offsetTop: number;
|
|
89
|
+
readonly width: number;
|
|
90
|
+
readonly height: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Read `win`'s box now, falling back to the layout viewport where the API is absent. */
|
|
94
|
+
export function viewportBox(win: Window): ViewportBox {
|
|
95
|
+
const vv = win.visualViewport;
|
|
96
|
+
return vv === null
|
|
97
|
+
? { offsetLeft: 0, offsetTop: 0, width: win.innerWidth, height: win.innerHeight }
|
|
98
|
+
: { offsetLeft: vv.offsetLeft, offsetTop: vv.offsetTop, width: vv.width, height: vv.height };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether two boxes differ by a pixel or more on any field: enough to have moved a
|
|
102
|
+
* stationary pointer's coordinates, which a raised iOS keyboard or a pinch-zoom pan
|
|
103
|
+
* does without the finger moving. Sub-pixel jitter is absorbed. */
|
|
104
|
+
export function viewportMoved(a: ViewportBox, b: ViewportBox): boolean {
|
|
105
|
+
return (
|
|
106
|
+
Math.abs(a.height - b.height) >= 1 ||
|
|
107
|
+
Math.abs(a.offsetTop - b.offsetTop) >= 1 ||
|
|
108
|
+
Math.abs(a.width - b.width) >= 1 ||
|
|
109
|
+
Math.abs(a.offsetLeft - b.offsetLeft) >= 1
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The commit slide's transition: --dur-standard and --ease-standard as literals,
|
|
114
|
+
* since JS-driven motion here reads no token back out of the cascade (mirror a
|
|
115
|
+
* token edit by hand). The property is `translate`, NOT `transform`: declarations
|
|
116
|
+
* from a running CSS animation out-rank inline style, so a chip mid `wt-slot-in` or
|
|
42
117
|
* `wt-tab-in` (both animate `transform: scale`) would ignore an inline
|
|
43
118
|
* `transform`; `translate` composes with it instead of competing for it. */
|
|
44
119
|
export const REORDER_SHIFT_TRANS = "translate 0.2s cubic-bezier(0.2, 0, 0, 1)";
|
package/src/kernel/pane.ts
CHANGED
|
@@ -36,6 +36,8 @@ import type {
|
|
|
36
36
|
|
|
37
37
|
const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
38
38
|
|
|
39
|
+
type GridSize = ReturnType<TerminalEngine["renderer"]["computeSize"]>;
|
|
40
|
+
|
|
39
41
|
const DEFAULT_WS_PATH = "/ws";
|
|
40
42
|
// The family that carries the CELL METRICS, alone, never --font-mono's list:
|
|
41
43
|
// WebKit settles load() over a list as soon as the FIRST family covering the
|
|
@@ -468,7 +470,7 @@ function buildPaneInto(
|
|
|
468
470
|
function updateMousePointer(): void {
|
|
469
471
|
termWrap.classList.toggle("wt-mouse-app", engine.modes.getMouseMode() !== 0);
|
|
470
472
|
}
|
|
471
|
-
function measurableSize():
|
|
473
|
+
function measurableSize(): GridSize | null {
|
|
472
474
|
// A pane hidden by a closed split is `display: none`: the cell width it would
|
|
473
475
|
// measure is 0, and the first render after a reveal would lay out on it.
|
|
474
476
|
if (!fontsLoaded || hidden || viewport.isInTransition()) {
|
|
@@ -477,11 +479,25 @@ function buildPaneInto(
|
|
|
477
479
|
engine.renderer.updateFontMetrics();
|
|
478
480
|
return engine.renderer.computeSize();
|
|
479
481
|
}
|
|
482
|
+
let announced: GridSize | null = null;
|
|
483
|
+
let heldGrow = false;
|
|
484
|
+
function announce(size: GridSize): void {
|
|
485
|
+
announced = size;
|
|
486
|
+
heldGrow = false;
|
|
487
|
+
engine.connection.sendResize();
|
|
488
|
+
}
|
|
489
|
+
function releaseHeldGrow(): void {
|
|
490
|
+
const size = measurableSize();
|
|
491
|
+
if (size !== null) {
|
|
492
|
+
announce(size);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
480
495
|
function maybeSendFirstResize(): void {
|
|
481
|
-
|
|
496
|
+
const size = wsOpen ? measurableSize() : null;
|
|
497
|
+
if (size === null) {
|
|
482
498
|
return;
|
|
483
499
|
}
|
|
484
|
-
|
|
500
|
+
announce(size);
|
|
485
501
|
}
|
|
486
502
|
|
|
487
503
|
const engine: TerminalEngine = createTerminalEngine({
|
|
@@ -496,6 +512,9 @@ function buildPaneInto(
|
|
|
496
512
|
},
|
|
497
513
|
onUserScrollChange: (scrolledUp) => {
|
|
498
514
|
bus.emit("scroll:state", { scrolledUp });
|
|
515
|
+
if (heldGrow && !scrolledUp) {
|
|
516
|
+
releaseHeldGrow();
|
|
517
|
+
}
|
|
499
518
|
},
|
|
500
519
|
callbacks: {
|
|
501
520
|
computeSize: () => engine.renderer.computeSize(),
|
|
@@ -513,6 +532,11 @@ function buildPaneInto(
|
|
|
513
532
|
if (fontsLoaded) {
|
|
514
533
|
markReady();
|
|
515
534
|
}
|
|
535
|
+
// The engine applies a frame to the bound store before this callback,
|
|
536
|
+
// and the alternate screen has no scrollback to hold a size for.
|
|
537
|
+
if (heldGrow && engine.renderer.boundStore().isAlt()) {
|
|
538
|
+
releaseHeldGrow();
|
|
539
|
+
}
|
|
516
540
|
bus.emit("wire:screen", msg);
|
|
517
541
|
} else if (msg.type === "title") {
|
|
518
542
|
// A shell clears its window title when it redraws its prompt after
|
|
@@ -971,11 +995,24 @@ function buildPaneInto(
|
|
|
971
995
|
box: root,
|
|
972
996
|
keyboard: services.keyboardInsets,
|
|
973
997
|
scroll: engine.scroll,
|
|
974
|
-
onSettled() {
|
|
998
|
+
onSettled(wasAtBottom) {
|
|
975
999
|
// The settle is the one resize a keyboard slide or rotation should cost;
|
|
976
1000
|
// sendResize deduplicates.
|
|
977
|
-
|
|
978
|
-
|
|
1001
|
+
const size = measurableSize();
|
|
1002
|
+
if (size !== null) {
|
|
1003
|
+
// A program repaints on resize, and that repaint can erase or reflow the
|
|
1004
|
+
// scrollback being read, so a taller box waits until the reader follows.
|
|
1005
|
+
if (
|
|
1006
|
+
!wasAtBottom &&
|
|
1007
|
+
announced !== null &&
|
|
1008
|
+
size.cols === announced.cols &&
|
|
1009
|
+
size.rows > announced.rows &&
|
|
1010
|
+
!engine.renderer.boundStore().isAlt()
|
|
1011
|
+
) {
|
|
1012
|
+
heldGrow = true;
|
|
1013
|
+
} else {
|
|
1014
|
+
announce(size);
|
|
1015
|
+
}
|
|
979
1016
|
}
|
|
980
1017
|
composition.positionCompositionView();
|
|
981
1018
|
},
|
|
@@ -1039,10 +1076,11 @@ function buildPaneInto(
|
|
|
1039
1076
|
// The gate opened on fallback metrics and the swap period is infinite, so
|
|
1040
1077
|
// one corrective announce once the bytes land; sendResize deduplicates.
|
|
1041
1078
|
void doc.fonts.ready.then(() => {
|
|
1042
|
-
|
|
1079
|
+
const size = destroyed ? null : measurableSize();
|
|
1080
|
+
if (size === null) {
|
|
1043
1081
|
return;
|
|
1044
1082
|
}
|
|
1045
|
-
|
|
1083
|
+
announce(size);
|
|
1046
1084
|
});
|
|
1047
1085
|
}, onFontSettled);
|
|
1048
1086
|
} catch (err) {
|
|
@@ -1353,7 +1391,7 @@ function buildPaneInto(
|
|
|
1353
1391
|
clearActiveSession,
|
|
1354
1392
|
announceSize() {
|
|
1355
1393
|
if (!destroyed && fontsLoaded) {
|
|
1356
|
-
engine.
|
|
1394
|
+
announce(engine.renderer.computeSize());
|
|
1357
1395
|
}
|
|
1358
1396
|
},
|
|
1359
1397
|
focus: focusTerminal,
|