@cplieger/web-terminal-ui 5.3.0 → 5.4.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.
- package/LICENSE +371 -690
- package/README.md +1 -1
- package/css/00-tokens.css +20 -14
- package/css/01-scope.css +10 -0
- package/css/02-terminal.css +6 -0
- package/css/30-tabs.css +101 -16
- package/css/31-switcher.css +8 -6
- package/css/40-animations.css +34 -1
- package/css/page.css +38 -1
- package/package.json +3 -3
- package/src/features/tabs/index.ts +806 -123
- package/src/features/tabs/ink-centre.ts +37 -29
- package/src/features/tabs/model.ts +116 -90
- package/src/features/tabs/strip.ts +75 -7
- package/src/features/tabs/switch-anim.ts +29 -0
- package/src/kernel/kernel.ts +6 -3
|
@@ -15,6 +15,7 @@ import type { MobileToolbarApi } from "../mobile-toolbar.js";
|
|
|
15
15
|
import { fromHTML, holdFocusOnPress } from "../dom.js";
|
|
16
16
|
import { createClickSwallow, placeMenuAt } from "../menu-position.js";
|
|
17
17
|
import { centreChipLabels } from "./ink-centre.js";
|
|
18
|
+
import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
|
|
18
19
|
import type { CueStatus, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
19
20
|
import {
|
|
20
21
|
ACTIVE_TAB_KEY,
|
|
@@ -24,8 +25,8 @@ import {
|
|
|
24
25
|
PROGRESS_ABSENT,
|
|
25
26
|
SWIPE_HINT_KEY,
|
|
26
27
|
SessionAPIError,
|
|
27
|
-
TAB_ORDER_KEY,
|
|
28
28
|
baseLabel,
|
|
29
|
+
compareTabOrder,
|
|
29
30
|
createSessionAPI,
|
|
30
31
|
createTombstones,
|
|
31
32
|
hasPinnedName,
|
|
@@ -34,16 +35,19 @@ import {
|
|
|
34
35
|
normalizeProgress,
|
|
35
36
|
orderedInsertIndex,
|
|
36
37
|
parseCueSeen,
|
|
37
|
-
parseTabOrder,
|
|
38
38
|
renderedProgress,
|
|
39
39
|
sanitizePinnedName,
|
|
40
40
|
serializeCueSeen,
|
|
41
|
-
serializeTabOrder,
|
|
42
41
|
statusPhrase,
|
|
43
42
|
tabAccessibleName,
|
|
44
43
|
} from "./model.js";
|
|
45
44
|
import { browserNotifierEnv, createNotifier } from "./notify.js";
|
|
46
45
|
import {
|
|
46
|
+
REORDER_DWELL_MS,
|
|
47
|
+
REORDER_LEAN_PX,
|
|
48
|
+
REORDER_SETTLE_MS,
|
|
49
|
+
REORDER_SHIFT_TRANS,
|
|
50
|
+
REORDER_SLOT_FADE_MS,
|
|
47
51
|
TAB_HTML,
|
|
48
52
|
kbButtonHTML,
|
|
49
53
|
newButtonHTML,
|
|
@@ -183,38 +187,6 @@ const CATCHUP_SETTLE_MS = 250;
|
|
|
183
187
|
* and must never arm it (nor pay for the completion poll). */
|
|
184
188
|
const CATCHUP_MIN_BACKLOG = 400;
|
|
185
189
|
|
|
186
|
-
/** orderSignature collapses an arrangement into one comparable string, so the
|
|
187
|
-
* persist path can decide "did the order change?" with a string compare instead
|
|
188
|
-
* of re-serializing and re-writing on every status tick. NUL is the separator
|
|
189
|
-
* because a session id can never contain one. */
|
|
190
|
-
function orderSignature(ids: readonly string[]): string {
|
|
191
|
-
return ids.join("\u0000");
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** readSavedOrder loads the persisted tab arrangement, or [] when there is none
|
|
195
|
-
* to trust. Storage itself can throw — Safari in private mode, a disabled
|
|
196
|
-
* third-party context, an embedder's iframe — and an unreadable arrangement is
|
|
197
|
-
* never fatal: the strip falls back to the server's creation order, exactly as
|
|
198
|
-
* it behaved before arrangements were persisted. */
|
|
199
|
-
function readSavedOrder(): string[] {
|
|
200
|
-
try {
|
|
201
|
-
return parseTabOrder(localStorage.getItem(TAB_ORDER_KEY));
|
|
202
|
-
} catch {
|
|
203
|
-
return [];
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** writeSavedOrder persists an arrangement, best-effort: a full quota or an
|
|
208
|
-
* unavailable store must not break a reorder the user just performed on screen.
|
|
209
|
-
* The in-memory arrangement stays authoritative for this page either way. */
|
|
210
|
-
function writeSavedOrder(ids: readonly string[]): void {
|
|
211
|
-
try {
|
|
212
|
-
localStorage.setItem(TAB_ORDER_KEY, serializeTabOrder(ids));
|
|
213
|
-
} catch {
|
|
214
|
-
/* storage unavailable or full — the order still holds for this page */
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
190
|
/** readCueSeen loads the acknowledged background-tab cues, or an empty map when
|
|
219
191
|
* there is none to trust. Storage itself can throw (Safari private mode, a
|
|
220
192
|
* disabled third-party context, an embedder's iframe) and an unreadable map is
|
|
@@ -687,24 +659,37 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
687
659
|
// /api/sessions) that predates the server reaping the session does not
|
|
688
660
|
// re-adopt (flash back) a closed tab.
|
|
689
661
|
const tombstones = createTombstones();
|
|
690
|
-
// The user's persisted tab arrangement (model.ts TAB_ORDER_KEY): read ONCE
|
|
691
|
-
// at setup, before any session can be adopted, so every adopt — from the
|
|
692
|
-
// status-stream snapshot or from the bootstrap's list, in either order —
|
|
693
|
-
// can place its tab at the saved position. Read-only for the life of the
|
|
694
|
-
// page: it is the arrangement to RESTORE, and the live strip must not
|
|
695
|
-
// overwrite it (see persistOrder).
|
|
696
|
-
const savedOrder: readonly string[] = readSavedOrder();
|
|
697
|
-
// Signature of the arrangement currently on disk, so persistOrder can run
|
|
698
|
-
// from syncChrome (which fires on every status tick) and still write only
|
|
699
|
-
// when the sequence actually changed. Seeded from what was read, so a
|
|
700
|
-
// window that simply restored an arrangement writes nothing.
|
|
701
|
-
let persistedSig = orderSignature(savedOrder);
|
|
702
662
|
// The expanded mobile list's row elements, keyed by tab id. Rows are
|
|
703
663
|
// reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
|
|
704
664
|
// same elements from their old slots to their new ones (the rotation).
|
|
705
665
|
const rowEls = new Map<string, HTMLElement>();
|
|
706
666
|
let activeId: string | null = null;
|
|
707
667
|
let draggingEl: HTMLElement | null = null;
|
|
668
|
+
// --- Desktop reorder preview state (mechanism below, near dragTargetBefore) ---
|
|
669
|
+
// The chips currently carrying an inline transform: the lean during a hold,
|
|
670
|
+
// or the slide that commits it. One set, because both stages write the same
|
|
671
|
+
// two properties on the same elements and one settle function has to be able
|
|
672
|
+
// to hand every one of them back to the stylesheet.
|
|
673
|
+
const shifted = new Set<HTMLElement>();
|
|
674
|
+
let shiftTimer: ReturnType<typeof setTimeout> | null = null;
|
|
675
|
+
// The pending slot and its hold. `dwellTimer !== null` is the only "a slot is
|
|
676
|
+
// pending" flag — `dwellBefore` cannot be one, because null is a legitimate
|
|
677
|
+
// value there meaning "past the last chip".
|
|
678
|
+
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
|
|
679
|
+
let dwellBefore: HTMLElement | null = null;
|
|
680
|
+
// Whether a `drop` fired for the drag in flight. It is the exact signal for
|
|
681
|
+
// "the user released deliberately" as opposed to "the drag was abandoned":
|
|
682
|
+
// Escape and a refused release fire dragend with no drop at all. dragend
|
|
683
|
+
// without it reverts the preview, which is the cancel this reorder never had.
|
|
684
|
+
let dropped = false;
|
|
685
|
+
// The pending-drop rail: ONE element, moved to whichever edge the current hold
|
|
686
|
+
// is counting down on (see showDwellRail).
|
|
687
|
+
const dwellRail = document.createElement("span");
|
|
688
|
+
dwellRail.className = "wt-tab-dwell";
|
|
689
|
+
dwellRail.setAttribute("aria-hidden", "true");
|
|
690
|
+
// The chip mid slot-fade, and the timer that ends it.
|
|
691
|
+
let slotFadeEl: HTMLElement | null = null;
|
|
692
|
+
let slotFadeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
708
693
|
// Gate the new-tab enter animation: tabs present at initial population
|
|
709
694
|
// should not animate in (jarring on load); only tabs added at runtime do.
|
|
710
695
|
let started = false;
|
|
@@ -1131,7 +1116,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1131
1116
|
renderSwitcherList();
|
|
1132
1117
|
}
|
|
1133
1118
|
maybeSwipeHint();
|
|
1134
|
-
|
|
1119
|
+
applyServerOrder();
|
|
1135
1120
|
}
|
|
1136
1121
|
|
|
1137
1122
|
// The percentage is deliberately NOT written into the browser document
|
|
@@ -1144,41 +1129,50 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1144
1129
|
// window's. The per-chip prefix carries the same information without the
|
|
1145
1130
|
// conflict, because each chip shows its own session. Do not add it back.
|
|
1146
1131
|
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1132
|
+
// applyServerOrder re-sorts the strip into the order the SERVER holds, and
|
|
1133
|
+
// is the read half of tab-order sync: a drag on another device arrives as a
|
|
1134
|
+
// new `order` on that session's status event, and this is what turns it into
|
|
1135
|
+
// a moved chip here.
|
|
1136
|
+
//
|
|
1137
|
+
// It hangs off syncChrome — the one function every list mutation already
|
|
1138
|
+
// ends with (reorder, create, adopt, close, reconcile) — so no path can
|
|
1139
|
+
// forget it. Three properties make that safe to run that often:
|
|
1152
1140
|
//
|
|
1153
|
-
// -
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
// -
|
|
1159
|
-
//
|
|
1160
|
-
// - a non-empty list: an empty strip carries no arrangement, and a
|
|
1161
|
-
// transient empty state (the last tab closed just before its replacement
|
|
1162
|
-
// lands, a reconcile against a restarted server) must not erase the
|
|
1163
|
-
// stored order.
|
|
1141
|
+
// - It is a no-op when the strip already matches, decided by one pass over
|
|
1142
|
+
// the list, so the status tick pays a comparison and nothing else.
|
|
1143
|
+
// - It is skipped mid-drag. The pointer owns the strip then, and the DOM
|
|
1144
|
+
// holds a preview that is deliberately not yet in tabList; re-sorting
|
|
1145
|
+
// under the user's finger would fight the gesture.
|
|
1146
|
+
// - It sorts by the same total order adoption inserts by (compareTabOrder),
|
|
1147
|
+
// so the two cannot disagree about where a tab belongs.
|
|
1164
1148
|
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
if (!started || tabList.length === 0) {
|
|
1149
|
+
// A locally-committed reorder is already applied optimistically, and the
|
|
1150
|
+
// server echoes that same arrangement back, so the echo lands here as a
|
|
1151
|
+
// no-op rather than as a second visible move.
|
|
1152
|
+
function applyServerOrder(): void {
|
|
1153
|
+
if (draggingEl !== null) {
|
|
1171
1154
|
return;
|
|
1172
1155
|
}
|
|
1173
|
-
const
|
|
1174
|
-
|
|
1175
|
-
|
|
1156
|
+
const sorted = [...tabList].sort(compareTabOrder);
|
|
1157
|
+
let moved = false;
|
|
1158
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
1159
|
+
if (sorted[i] !== tabList[i]) {
|
|
1160
|
+
moved = true;
|
|
1161
|
+
break;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
if (!moved) {
|
|
1176
1165
|
return;
|
|
1177
1166
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
//
|
|
1181
|
-
|
|
1167
|
+
tabList.length = 0;
|
|
1168
|
+
tabList.push(...sorted);
|
|
1169
|
+
// appendChild MOVES an existing node, so one pass in the wanted sequence
|
|
1170
|
+
// both reorders the chips and leaves no duplicates behind.
|
|
1171
|
+
for (const tab of tabList) {
|
|
1172
|
+
scroller.appendChild(tab.el);
|
|
1173
|
+
}
|
|
1174
|
+
paintActive();
|
|
1175
|
+
syncMobile();
|
|
1182
1176
|
}
|
|
1183
1177
|
|
|
1184
1178
|
// closeKeyGrid closes the mobile key grid (if a keyboardToggle is wired and
|
|
@@ -1303,6 +1297,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1303
1297
|
nameSeq: 0,
|
|
1304
1298
|
display: "",
|
|
1305
1299
|
createdAt: info.createdAt,
|
|
1300
|
+
order: info.order,
|
|
1306
1301
|
// Through the kernel factory, never `new LineStore()`: the factory
|
|
1307
1302
|
// applies the consumer's scrollbackLines cap, so per-tab caches and
|
|
1308
1303
|
// the kernel's implicit store share one retained-line budget. The id
|
|
@@ -1437,16 +1432,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1437
1432
|
e.preventDefault();
|
|
1438
1433
|
showTabMenu(e.clientX, e.clientY, tab.id);
|
|
1439
1434
|
});
|
|
1440
|
-
// Drag-and-drop reorder on the desktop strip. The bar's dragover
|
|
1441
|
-
//
|
|
1435
|
+
// Drag-and-drop reorder on the desktop strip. The bar's dragover arms a
|
|
1436
|
+
// slot, the hold (or a drop) commits it, and drop/dragend commit the
|
|
1437
|
+
// resulting order into tabList. See the reorder-preview block below.
|
|
1442
1438
|
el.draggable = true;
|
|
1443
1439
|
el.addEventListener("dragstart", (e) => {
|
|
1444
1440
|
if (editingId === tab.id) {
|
|
1445
1441
|
e.preventDefault(); // renaming: a drag would fight caret selection
|
|
1446
1442
|
return;
|
|
1447
1443
|
}
|
|
1444
|
+
endReorderPreview(); // no residue from a previous drag
|
|
1445
|
+
endShift(); // ...and nothing mid-slide, so this gesture starts from rest
|
|
1446
|
+
dropped = false;
|
|
1448
1447
|
draggingEl = el;
|
|
1449
|
-
// Snapshot the preview from the PRISTINE chip, before the
|
|
1448
|
+
// Snapshot the preview from the PRISTINE chip, before the slot class.
|
|
1450
1449
|
setDragGhost(e, el);
|
|
1451
1450
|
el.classList.add("wt-tab-dragging");
|
|
1452
1451
|
if (e.dataTransfer) {
|
|
@@ -1460,9 +1459,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1460
1459
|
});
|
|
1461
1460
|
el.addEventListener("dragend", () => {
|
|
1462
1461
|
el.classList.remove("wt-tab-dragging");
|
|
1462
|
+
if (!dropped) {
|
|
1463
|
+
// No drop fired, so the drag was abandoned: Escape, or a release the
|
|
1464
|
+
// browser refused. Put the strip back — tabList still holds the order
|
|
1465
|
+
// the gesture started from, because only a drop writes to it.
|
|
1466
|
+
revertPreview();
|
|
1467
|
+
}
|
|
1463
1468
|
draggingEl = null;
|
|
1469
|
+
// The gesture's state goes; a slide started by the revert above is left to
|
|
1470
|
+
// finish under its own settle timer (see endReorderPreview).
|
|
1471
|
+
endReorderPreview();
|
|
1464
1472
|
clearDragGhost(); // belt-and-braces: the rAF normally got there first
|
|
1465
|
-
syncOrderFromDom();
|
|
1466
1473
|
});
|
|
1467
1474
|
return tab;
|
|
1468
1475
|
}
|
|
@@ -1668,21 +1675,104 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1668
1675
|
// direction the incoming content slides in from that side (the swipe
|
|
1669
1676
|
// feel); without one it is a plain cross-fade. The rAF re-adds the class a
|
|
1670
1677
|
// frame after clearing it so a rapid re-switch restarts the animation.
|
|
1678
|
+
//
|
|
1679
|
+
// The class comes off on the animation's OWN end, with the timer kept as
|
|
1680
|
+
// the net. Two numbers had to agree and did not: the CSS duration is
|
|
1681
|
+
// --dur-standard (0.2s) and this timer is 360ms, so the class outlived the
|
|
1682
|
+
// animation by ~144ms (the add is deferred one rAF), which on a tab above
|
|
1683
|
+
// ~3000 rows lands in the middle of the rebuild's drain. Reading the event
|
|
1684
|
+
// makes the timer a fallback instead of the primary signal.
|
|
1685
|
+
//
|
|
1686
|
+
// The timer CANNOT be dropped, and three cases are why:
|
|
1687
|
+
// - an interrupted animation fires no animationend, and animationcancel is
|
|
1688
|
+
// not reliably delivered in Blink. A rapid re-switch is exactly that
|
|
1689
|
+
// case, and it is the case the rAF above exists to serve.
|
|
1690
|
+
// - the animations feature is optional and removes .wt-animate under
|
|
1691
|
+
// reduced motion, so no animation runs and no event ever fires.
|
|
1692
|
+
// - a consumer stylesheet could drop the rules entirely.
|
|
1693
|
+
//
|
|
1694
|
+
// The listener filters on the ONE animation name this switch expects AND on
|
|
1695
|
+
// the class still being present. Three notes, because each was argued:
|
|
1696
|
+
// - The name must be the expected one, not any of the three. An animation
|
|
1697
|
+
// that COMPLETED just as a re-switch landed has its event already queued;
|
|
1698
|
+
// the old listener is gone by dispatch time, so the NEW listener receives
|
|
1699
|
+
// it, and a listener accepting all three names would let switch N's
|
|
1700
|
+
// completion end switch N+1's animation a frame in.
|
|
1701
|
+
// - The class check closes the window BEFORE the animation starts. The
|
|
1702
|
+
// listener is attached in this task and the class lands a frame later, so
|
|
1703
|
+
// for one frame a matching `animationend` from anywhere in the subtree
|
|
1704
|
+
// would cancel the pending class-add and skip the animation outright. An
|
|
1705
|
+
// animationend cannot precede its own animation, so requiring the class is
|
|
1706
|
+
// free.
|
|
1707
|
+
// - No generation counter, and no target check. A generation was tried and
|
|
1708
|
+
// removed: `endSwitchAnim` removes the listener synchronously before the
|
|
1709
|
+
// next one is added, so a stale listener cannot receive an event and the
|
|
1710
|
+
// counter could never fire (its red check could not be made to fail). A
|
|
1711
|
+
// target check would couple this feature to the kernel's `.term-output`
|
|
1712
|
+
// markup, which it does not own; the residual risk is a consumer applying
|
|
1713
|
+
// one of these three library-private keyframe names to another element
|
|
1714
|
+
// inside the surface, and the class check already reduces that to "ends a
|
|
1715
|
+
// running switch animation early" rather than "skips it".
|
|
1716
|
+
const SWITCH_ANIM_NET_MS = 360;
|
|
1671
1717
|
let switchAnimTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1718
|
+
let switchAnimFrame: number | null = null;
|
|
1719
|
+
let switchAnimOff: (() => void) | null = null;
|
|
1720
|
+
|
|
1721
|
+
// Drop the classes and every pending mechanism from the previous switch.
|
|
1722
|
+
// All three are torn down together: a stray timer or listener from switch N
|
|
1723
|
+
// would otherwise strip the class switch N+1 has just added, and a surviving
|
|
1724
|
+
// rAF would ADD the previous direction's class alongside the new one. That
|
|
1725
|
+
// last one predates this change, and its consequence is not "two animations
|
|
1726
|
+
// at once" — the cascade picks one winner for the `animation` property, and
|
|
1727
|
+
// the winner is whichever of the three rules comes LAST in the stylesheet
|
|
1728
|
+
// (`wt-switching-prev`), regardless of which switch the user actually made. So
|
|
1729
|
+
// a forward switch could animate backwards.
|
|
1730
|
+
function endSwitchAnim(surface: HTMLElement): void {
|
|
1731
|
+
surface.classList.remove(...SWITCH_CLASSES);
|
|
1676
1732
|
if (switchAnimTimer !== null) {
|
|
1677
1733
|
clearTimeout(switchAnimTimer);
|
|
1734
|
+
switchAnimTimer = null;
|
|
1678
1735
|
}
|
|
1679
|
-
|
|
1736
|
+
if (switchAnimFrame !== null) {
|
|
1737
|
+
cancelAnimationFrame(switchAnimFrame);
|
|
1738
|
+
switchAnimFrame = null;
|
|
1739
|
+
}
|
|
1740
|
+
if (switchAnimOff !== null) {
|
|
1741
|
+
switchAnimOff();
|
|
1742
|
+
switchAnimOff = null;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function flashSwitch(dir?: "next" | "prev"): void {
|
|
1747
|
+
const surface = ctx.surface();
|
|
1748
|
+
const cls = dir ? (`wt-switching-${dir}` as const) : ("wt-switching" as const);
|
|
1749
|
+
endSwitchAnim(surface);
|
|
1750
|
+
const expected = SWITCH_ANIMATIONS[cls];
|
|
1751
|
+
const onAnimEnd = (ev: AnimationEvent): void => {
|
|
1752
|
+
if (ev.animationName !== expected || !surface.classList.contains(cls)) {
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
endSwitchAnim(surface);
|
|
1756
|
+
};
|
|
1757
|
+
surface.addEventListener("animationend", onAnimEnd);
|
|
1758
|
+
switchAnimOff = (): void => {
|
|
1759
|
+
surface.removeEventListener("animationend", onAnimEnd);
|
|
1760
|
+
};
|
|
1761
|
+
// The net is armed INSIDE the frame that adds the class, never beside it.
|
|
1762
|
+
// Armed in this task it would be a deadline on a REQUEST to animate rather
|
|
1763
|
+
// than a net around an animation: rAF is paused in a hidden document and
|
|
1764
|
+
// deferred under a blocked main thread, so a 360ms timer could fire first,
|
|
1765
|
+
// and its `endSwitchAnim` would cancel the pending class-add and skip the
|
|
1766
|
+
// animation outright. Armed here, a frame that never runs arms nothing and
|
|
1767
|
+
// leaves no class to strip.
|
|
1768
|
+
switchAnimFrame = requestAnimationFrame(() => {
|
|
1769
|
+
switchAnimFrame = null;
|
|
1680
1770
|
surface.classList.add(cls);
|
|
1771
|
+
switchAnimTimer = setTimeout(() => {
|
|
1772
|
+
switchAnimTimer = null;
|
|
1773
|
+
endSwitchAnim(surface);
|
|
1774
|
+
}, SWITCH_ANIM_NET_MS);
|
|
1681
1775
|
});
|
|
1682
|
-
switchAnimTimer = setTimeout(() => {
|
|
1683
|
-
switchAnimTimer = null;
|
|
1684
|
-
surface.classList.remove("wt-switching", "wt-switching-next", "wt-switching-prev");
|
|
1685
|
-
}, 360);
|
|
1686
1776
|
}
|
|
1687
1777
|
|
|
1688
1778
|
// Any frame can reveal a backlog worth telling the user about: a resume
|
|
@@ -1736,20 +1826,503 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1736
1826
|
}
|
|
1737
1827
|
}
|
|
1738
1828
|
|
|
1739
|
-
//
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1829
|
+
// --- Desktop reorder preview: hold, lean, slide -------------------------
|
|
1830
|
+
//
|
|
1831
|
+
// The old reorder moved the dragged chip on EVERY dragover, so the strip
|
|
1832
|
+
// rearranged itself continuously under the pointer and — every chip being
|
|
1833
|
+
// the same width — nothing on screen said which slot a release would land
|
|
1834
|
+
// in. The chip you were dragging looked like the slot it left, the slot it
|
|
1835
|
+
// was over, and the slot it would end up in.
|
|
1836
|
+
//
|
|
1837
|
+
// The reorder now runs in three stages, and the dragged chip stays in the
|
|
1838
|
+
// flow throughout as the slot it will land in (`.wt-tab-dragging`, which
|
|
1839
|
+
// 30-tabs.css renders as an empty dashed outline rather than a dimmed copy
|
|
1840
|
+
// of the tab — the solid copy is the drag image under the pointer, and one
|
|
1841
|
+
// of the two had to stop pretending to be the tab):
|
|
1842
|
+
//
|
|
1843
|
+
// 1. dragover picks a candidate slot and ARMS a hold. Nothing reorders.
|
|
1844
|
+
// 2. Every chip the commit would displace LEANS a few px toward where it
|
|
1845
|
+
// is going, at once, so the hold reads as "held here, opening that way"
|
|
1846
|
+
// instead of as a second of nothing.
|
|
1847
|
+
// 3. After REORDER_DWELL_MS on the same candidate the slot COMMITS: one
|
|
1848
|
+
// DOM move, the displaced chips slide from their old positions to their
|
|
1849
|
+
// new ones, and the slot fades in at its new home.
|
|
1850
|
+
//
|
|
1851
|
+
// A release never waits for the hold: `drop` commits whatever slot is
|
|
1852
|
+
// pending (flushDwell), so a fast drag-and-drop reorders exactly as before.
|
|
1853
|
+
//
|
|
1854
|
+
// dropTargetBefore returns the first tab whose horizontal midpoint is past
|
|
1855
|
+
// x (the element the dragged tab should sit before), or null to drop at the
|
|
1856
|
+
// end of the tab list. syncOrderFromDom rebuilds tabList to match the
|
|
1857
|
+
// strip's DOM order after a drag, so position indicators, the switcher, and
|
|
1858
|
+
// close-to-the-right/left all follow the visible order.
|
|
1859
|
+
//
|
|
1860
|
+
// It hit-tests LAYOUT geometry (offsetLeft/offsetWidth), never
|
|
1861
|
+
// getBoundingClientRect. That is not an optimisation, it is what makes an
|
|
1862
|
+
// animated reorder hit-testable at all: a rect read while a chip is mid-lean or
|
|
1863
|
+
// mid-slide returns the INTERPOLATED position, so the preview's own motion feeds
|
|
1864
|
+
// back into the decision that produced it and the strip oscillates between two
|
|
1865
|
+
// slots for as long as the pointer sits near a boundary. Layout offsets are the
|
|
1866
|
+
// chip's position in the FLOW, which a transform does not affect at all.
|
|
1867
|
+
//
|
|
1868
|
+
// An earlier draft instead cached rects measured "at rest" and re-measured after
|
|
1869
|
+
// each commit. Two things killed it: the cache had to be invalidated on anything
|
|
1870
|
+
// that reflowed the strip (a window or embed resize changes every chip's midpoint
|
|
1871
|
+
// without changing the chip SET, which was the only thing the cache checked), and
|
|
1872
|
+
// it could capture a transform anyway, because a chip adopted mid-drag arrives
|
|
1873
|
+
// mid `.wt-tab-enter` scale. Reading layout needs no cache and cannot go stale.
|
|
1874
|
+
//
|
|
1875
|
+
// Coordinates: chips share the scroller's offsetParent (the scroller is not
|
|
1876
|
+
// positioned, so it is not one), which makes `el.offsetLeft - scroller.offsetLeft`
|
|
1877
|
+
// the chip's offset inside the scroller — and the scroller has no border for that
|
|
1878
|
+
// subtraction to skip. Layout offsets ignore scrolling, so clientX is converted
|
|
1879
|
+
// into the same space by adding scrollLeft back, which is what lets a scrolled
|
|
1880
|
+
// strip hit-test correctly.
|
|
1881
|
+
function dropTargetBefore(clientX: number): HTMLElement | null {
|
|
1882
|
+
const x = clientX - scroller.getBoundingClientRect().left + scroller.scrollLeft;
|
|
1883
|
+
const base = scroller.offsetLeft;
|
|
1884
|
+
for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
|
|
1885
|
+
if (el === draggingEl) {
|
|
1886
|
+
continue; // the slot does not displace itself
|
|
1887
|
+
}
|
|
1888
|
+
if (x < el.offsetLeft - base + el.offsetWidth / 2) {
|
|
1748
1889
|
return el;
|
|
1749
1890
|
}
|
|
1750
1891
|
}
|
|
1751
1892
|
return null;
|
|
1752
1893
|
}
|
|
1894
|
+
|
|
1895
|
+
// applyShift writes one displacement per chip and remembers which chips are
|
|
1896
|
+
// carrying one. `trans` is REORDER_SHIFT_TRANS to animate to the new value,
|
|
1897
|
+
// or "none" to plant a from-state the next write animates out of.
|
|
1898
|
+
//
|
|
1899
|
+
// It writes the individual `translate` property, NOT `transform`, and that is
|
|
1900
|
+
// load-bearing rather than stylistic. Declarations from a running CSS ANIMATION
|
|
1901
|
+
// out-rank normal author declarations, inline style included, so a chip in the
|
|
1902
|
+
// middle of `wt-slot-in` (`transform: scale(0.97)`) or `wt-tab-in`
|
|
1903
|
+
// (`transform: scale(0.82)`) would silently ignore an inline `transform` and
|
|
1904
|
+
// refuse to move: an Escape landing inside the slot fade would snap the dragged
|
|
1905
|
+
// chip home while its siblings slid. `translate` is a separate property that
|
|
1906
|
+
// composes with `transform` instead of fighting it, so the two animations can own
|
|
1907
|
+
// one chip at the same time.
|
|
1908
|
+
function applyShift(px: ReadonlyMap<HTMLElement, number>, trans: string): void {
|
|
1909
|
+
if (shiftTimer !== null) {
|
|
1910
|
+
clearTimeout(shiftTimer);
|
|
1911
|
+
shiftTimer = null;
|
|
1912
|
+
}
|
|
1913
|
+
for (const [el, dx] of px) {
|
|
1914
|
+
el.style.transition = trans;
|
|
1915
|
+
el.style.translate = `${String(Math.round(dx))}px`;
|
|
1916
|
+
shifted.add(el);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
// endShift hands every displaced chip back to the stylesheet. Both stages
|
|
1920
|
+
// write the same two inline properties, so this one function ends whichever
|
|
1921
|
+
// ran last — called before a fresh measurement, by the settle timer after a
|
|
1922
|
+
// slide, when the drag ends, and on teardown. Idempotent.
|
|
1923
|
+
function endShift(): void {
|
|
1924
|
+
if (shiftTimer !== null) {
|
|
1925
|
+
clearTimeout(shiftTimer);
|
|
1926
|
+
shiftTimer = null;
|
|
1927
|
+
}
|
|
1928
|
+
for (const el of shifted) {
|
|
1929
|
+
el.style.transition = "";
|
|
1930
|
+
el.style.translate = "";
|
|
1931
|
+
}
|
|
1932
|
+
shifted.clear();
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// leanToward tips the chips a commit would displace a few px toward their
|
|
1936
|
+
// destination, for as long as the hold lasts. No settle timer: the lean is a
|
|
1937
|
+
// held state, not an animation with an end, and a timer that expired mid-hold
|
|
1938
|
+
// would snap it away while the user is still waiting on it.
|
|
1939
|
+
//
|
|
1940
|
+
// Chips that were leaning and no longer would be are written back to zero in
|
|
1941
|
+
// the SAME pass, so a changed candidate eases from one lean into the other
|
|
1942
|
+
// rather than snapping through rest first.
|
|
1943
|
+
function leanToward(before: HTMLElement | null): void {
|
|
1944
|
+
if (!draggingEl) {
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
// The preference is read live, so it can flip mid-gesture. Clearing here rather
|
|
1948
|
+
// than returning bare is what honours a mid-hold opt-in: a bare return would
|
|
1949
|
+
// leave the lean already applied under the previous preference sitting there.
|
|
1950
|
+
if (prefersReduce()) {
|
|
1951
|
+
endShift();
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
const chips = [...scroller.querySelectorAll<HTMLElement>(".wt-tab")];
|
|
1955
|
+
const from = chips.indexOf(draggingEl);
|
|
1956
|
+
const to = before === null ? chips.length : chips.indexOf(before);
|
|
1957
|
+
if (from < 0 || to < 0) {
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
// Moving right past the chips in between shifts them LEFT, and moving left
|
|
1961
|
+
// shifts the chips it displaces RIGHT — the direction the user reads as
|
|
1962
|
+
// "that tab is getting out of the way".
|
|
1963
|
+
const movers = from < to ? chips.slice(from + 1, to) : chips.slice(to, from);
|
|
1964
|
+
const dx = from < to ? -REORDER_LEAN_PX : REORDER_LEAN_PX;
|
|
1965
|
+
const next = new Map<HTMLElement, number>();
|
|
1966
|
+
for (const el of movers) {
|
|
1967
|
+
next.set(el, dx);
|
|
1968
|
+
}
|
|
1969
|
+
for (const el of shifted) {
|
|
1970
|
+
if (!next.has(el)) {
|
|
1971
|
+
next.set(el, 0);
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
applyShift(next, REORDER_SHIFT_TRANS);
|
|
1975
|
+
}
|
|
1976
|
+
// releaseLean eases every leaning chip back to rest and settles it, for when
|
|
1977
|
+
// a candidate is withdrawn (the pointer came back to the committed slot, or
|
|
1978
|
+
// left the strip). endShift would be wrong here: it strips the transition in
|
|
1979
|
+
// the same write, so the lean would snap home.
|
|
1980
|
+
function releaseLean(): void {
|
|
1981
|
+
if (shifted.size === 0) {
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
// ...except under reduced motion, where snapping IS the requested behaviour and
|
|
1985
|
+
// easing back would animate the very motion the preference opted out of.
|
|
1986
|
+
if (prefersReduce()) {
|
|
1987
|
+
endShift();
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const rest = new Map<HTMLElement, number>();
|
|
1991
|
+
for (const el of shifted) {
|
|
1992
|
+
rest.set(el, 0);
|
|
1993
|
+
}
|
|
1994
|
+
applyShift(rest, REORDER_SHIFT_TRANS);
|
|
1995
|
+
shiftTimer = setTimeout(endShift, REORDER_SETTLE_MS);
|
|
1996
|
+
}
|
|
1997
|
+
// showDwellRail marks the exact boundary the tab will be inserted at, on the edge
|
|
1998
|
+
// of the chip beside it, and fills that mark over the hold's own duration.
|
|
1999
|
+
//
|
|
2000
|
+
// Without it the hold was a second of near-silence, and the silence was worse than
|
|
2001
|
+
// it sounds: the most authoritative thing on screen is the dashed slot, and the
|
|
2002
|
+
// slot sits at the tab's CURRENT position, so the strongest cue was describing the
|
|
2003
|
+
// state being left rather than the one being chosen. The 7px lean says which
|
|
2004
|
+
// DIRECTION the strip will open without saying which boundary, and nothing at all
|
|
2005
|
+
// said how much longer. The rail answers both of those, which makes the gesture
|
|
2006
|
+
// read as three ordered states: the tab under the pointer, the edge about to open,
|
|
2007
|
+
// then the gap that opened.
|
|
2008
|
+
//
|
|
2009
|
+
// The fill duration comes from REORDER_DWELL_MS, so the bar and the timer it
|
|
2010
|
+
// depicts cannot drift apart. That is also why the rail is a real element rather
|
|
2011
|
+
// than a pseudo-element on the chip: a pseudo-element's duration would have to
|
|
2012
|
+
// travel through a custom property, which is a second place to keep in step.
|
|
2013
|
+
function showDwellRail(before: HTMLElement | null): void {
|
|
2014
|
+
// The leading edge of the chip being pushed aside, or the trailing edge of the
|
|
2015
|
+
// last chip when the tab is heading past the end of the strip.
|
|
2016
|
+
const host = before ?? scroller.querySelector<HTMLElement>(".wt-tab:last-of-type");
|
|
2017
|
+
if (host === null || host === draggingEl) {
|
|
2018
|
+
hideDwellRail();
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
dwellRail.classList.toggle("wt-tab-dwell-end", before === null);
|
|
2022
|
+
host.appendChild(dwellRail);
|
|
2023
|
+
if (prefersReduce()) {
|
|
2024
|
+
// No countdown, but the boundary still has to be legible, so show it filled.
|
|
2025
|
+
dwellRail.style.transition = "none";
|
|
2026
|
+
dwellRail.style.transform = "scaleY(1)";
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
dwellRail.style.transition = "none";
|
|
2030
|
+
dwellRail.style.transform = "scaleY(0)";
|
|
2031
|
+
dwellRail.getBoundingClientRect(); // commit the from-state before the fill
|
|
2032
|
+
dwellRail.style.transition = `transform ${String(REORDER_DWELL_MS)}ms linear`;
|
|
2033
|
+
dwellRail.style.transform = "scaleY(1)";
|
|
2034
|
+
}
|
|
2035
|
+
function hideDwellRail(): void {
|
|
2036
|
+
dwellRail.remove(); // idempotent: removing a detached node does nothing
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// armDwell registers the slot a release would land in and starts (or keeps)
|
|
2040
|
+
// the hold that commits it. Called on every dragover, so it must be cheap
|
|
2041
|
+
// and idempotent for an unchanged candidate: restarting the timer on each
|
|
2042
|
+
// event would mean a hand that never holds perfectly still could never
|
|
2043
|
+
// commit anything, which is the bug a naive dwell always ships with.
|
|
2044
|
+
function armDwell(before: HTMLElement | null): void {
|
|
2045
|
+
if (!draggingEl) {
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
// Already the committed slot, so there is nothing to preview. Withdraw any
|
|
2049
|
+
// pending commitment — the pointer came back.
|
|
2050
|
+
if (before === draggingEl || before === draggingEl.nextElementSibling) {
|
|
2051
|
+
cancelDwell();
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
if (dwellTimer !== null && before === dwellBefore) {
|
|
2055
|
+
return; // same candidate, still holding
|
|
2056
|
+
}
|
|
2057
|
+
if (dwellTimer !== null) {
|
|
2058
|
+
clearTimeout(dwellTimer);
|
|
2059
|
+
}
|
|
2060
|
+
dwellBefore = before;
|
|
2061
|
+
leanToward(before);
|
|
2062
|
+
showDwellRail(before);
|
|
2063
|
+
dwellTimer = setTimeout(() => {
|
|
2064
|
+
dwellTimer = null;
|
|
2065
|
+
commitSlot(before);
|
|
2066
|
+
}, REORDER_DWELL_MS);
|
|
2067
|
+
}
|
|
2068
|
+
function cancelDwell(): void {
|
|
2069
|
+
hideDwellRail();
|
|
2070
|
+
if (dwellTimer === null) {
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
clearTimeout(dwellTimer);
|
|
2074
|
+
dwellTimer = null;
|
|
2075
|
+
dwellBefore = null;
|
|
2076
|
+
releaseLean();
|
|
2077
|
+
}
|
|
2078
|
+
// flushDwell commits a pending slot at once. A release is a decision, so a
|
|
2079
|
+
// drop does not have to wait out a hold it interrupted: this is what keeps a
|
|
2080
|
+
// quick drag-and-drop reordering exactly as it did before the preview
|
|
2081
|
+
// existed, and it is why the hold can afford to be a full second. Only a
|
|
2082
|
+
// `drop` calls it; an abandoned drag reaches dragend without one and reverts.
|
|
2083
|
+
function flushDwell(): void {
|
|
2084
|
+
if (dwellTimer === null) {
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
const before = dwellBefore;
|
|
2088
|
+
clearTimeout(dwellTimer);
|
|
2089
|
+
dwellTimer = null;
|
|
2090
|
+
dwellBefore = null;
|
|
2091
|
+
commitSlot(before);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// flipTo runs a rearranging mutation and animates its result: every chip that
|
|
2095
|
+
// ends up somewhere new slides there from where it was. One function for both
|
|
2096
|
+
// directions of the preview — committing a slot, and reverting the whole
|
|
2097
|
+
// gesture — because "the strip rearranged, show the rearrangement" is one job,
|
|
2098
|
+
// and a revert that snapped while a commit slid would read as two different
|
|
2099
|
+
// features.
|
|
2100
|
+
//
|
|
2101
|
+
// `hold` is the one chip that must NOT slide. On a commit that is the dragged
|
|
2102
|
+
// chip: it is the slot, the pointer is already carrying a solid copy of it,
|
|
2103
|
+
// and a hole travelling across the strip alongside that copy is two things
|
|
2104
|
+
// moving at once. On a revert nothing is held — the drag image is gone by
|
|
2105
|
+
// then, so the chip has to travel home itself.
|
|
2106
|
+
function flipTo(mutate: () => void, hold: HTMLElement | null): void {
|
|
2107
|
+
// Reduced motion: perform the rearrangement and animate none of it. The gate
|
|
2108
|
+
// belongs HERE and not only in the CSS, because these transitions are written
|
|
2109
|
+
// inline and no stylesheet gate can reach them; the switcher's release reel
|
|
2110
|
+
// guards its own call site the same way. Read live, so toggling the OS setting
|
|
2111
|
+
// mid-drag takes effect on the next commit and strips any existing lean.
|
|
2112
|
+
if (prefersReduce()) {
|
|
2113
|
+
endShift();
|
|
2114
|
+
mutate();
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
// FIRST — where each chip is right now, LEAN INCLUDED, so a slide continues
|
|
2118
|
+
// out of the lean instead of jumping back through rest first. The switcher's
|
|
2119
|
+
// release reel captures its live swipe preview the same way, and for the
|
|
2120
|
+
// same reason. Rects, not layout offsets: this is VISUAL position, which is
|
|
2121
|
+
// what the animation interpolates (hit-testing wants the opposite, see
|
|
2122
|
+
// dropTargetBefore).
|
|
2123
|
+
const first = new Map<HTMLElement, number>();
|
|
2124
|
+
for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
|
|
2125
|
+
if (el !== hold) {
|
|
2126
|
+
first.set(el, el.getBoundingClientRect().left);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
endShift(); // back to the resting layout before the DOM moves
|
|
2130
|
+
mutate();
|
|
2131
|
+
// LAST — the new resting layout. No style is written between the reads below,
|
|
2132
|
+
// so they are all served from one layout pass.
|
|
2133
|
+
const invert = new Map<HTMLElement, number>();
|
|
2134
|
+
for (const [el, was] of first) {
|
|
2135
|
+
const dx = el.isConnected ? was - el.getBoundingClientRect().left : 0;
|
|
2136
|
+
if (dx !== 0) {
|
|
2137
|
+
invert.set(el, dx);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
if (invert.size === 0) {
|
|
2141
|
+
return; // nothing moved on screen (a zero-rect DOM: happy-dom)
|
|
2142
|
+
}
|
|
2143
|
+
applyShift(invert, "none");
|
|
2144
|
+
// The read forces the reflow that COMMITS the from-state. Without it the
|
|
2145
|
+
// browser is free to collapse the from- and to-writes into one recalc and
|
|
2146
|
+
// no transition runs at all — the exact trap the reel documents.
|
|
2147
|
+
scroller.getBoundingClientRect();
|
|
2148
|
+
const rest = new Map<HTMLElement, number>();
|
|
2149
|
+
for (const el of invert.keys()) {
|
|
2150
|
+
rest.set(el, 0);
|
|
2151
|
+
}
|
|
2152
|
+
applyShift(rest, REORDER_SHIFT_TRANS);
|
|
2153
|
+
shiftTimer = setTimeout(endShift, REORDER_SETTLE_MS);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// commitSlot performs the reorder the hold earned: one DOM move, the slide
|
|
2157
|
+
// that shows it, and the slot fading in at its new home.
|
|
2158
|
+
//
|
|
2159
|
+
// It moves the DOM and NOT tabList. That split is what makes the preview a
|
|
2160
|
+
// preview: tabList stays the arrangement the gesture started from for the
|
|
2161
|
+
// whole drag, and only a drop writes it (syncOrderFromDom). Two things fall
|
|
2162
|
+
// out of it for free — a cancel is just "re-project tabList" (revertPreview),
|
|
2163
|
+
// and a syncChrome arriving mid-drag from an unrelated source (a status tick,
|
|
2164
|
+
// an OSC title) renders the committed order rather than flickering through
|
|
2165
|
+
// whatever the pointer is hovering.
|
|
2166
|
+
//
|
|
2167
|
+
// Every early return releases the lean, and that is not belt-and-braces. The
|
|
2168
|
+
// caller nulls `dwellTimer` BEFORE calling in (both the timer callback and
|
|
2169
|
+
// flushDwell do), so by the time control arrives here `cancelDwell` has already
|
|
2170
|
+
// become a no-op and nothing else would ever hand those chips back.
|
|
2171
|
+
function commitSlot(before: HTMLElement | null): void {
|
|
2172
|
+
// The countdown is over however this call ends: the rail's whole job was to
|
|
2173
|
+
// depict a hold that has now expired.
|
|
2174
|
+
hideDwellRail();
|
|
2175
|
+
const dragged = draggingEl;
|
|
2176
|
+
if (!dragged?.isConnected) {
|
|
2177
|
+
releaseLean();
|
|
2178
|
+
return; // the dragged session was closed elsewhere mid-hold
|
|
2179
|
+
}
|
|
2180
|
+
// The hold captured a live DOM node a whole second ago. A session closed in
|
|
2181
|
+
// another window (an SSE push, or the poll reconcile) removes chips through
|
|
2182
|
+
// dropTab while this gesture is still open, so the reference node may be gone
|
|
2183
|
+
// — and insertBefore throws NotFoundError on a reference that is no longer a
|
|
2184
|
+
// child, which would abandon the drop mid-way and leave DOM order and tabList
|
|
2185
|
+
// disagreeing. null stays legal: it means "past the last chip".
|
|
2186
|
+
if (before !== null && before.parentNode !== scroller) {
|
|
2187
|
+
releaseLean();
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
if (before === dragged || before === dragged.nextElementSibling) {
|
|
2191
|
+
releaseLean();
|
|
2192
|
+
return; // already in that slot
|
|
2193
|
+
}
|
|
2194
|
+
flipTo(() => {
|
|
2195
|
+
scroller.insertBefore(dragged, before);
|
|
2196
|
+
}, dragged);
|
|
2197
|
+
flashSlot(dragged);
|
|
2198
|
+
announceTarget(dragged);
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// revertPreview puts the strip back the way the gesture found it, and is what
|
|
2202
|
+
// makes Escape mean cancel.
|
|
2203
|
+
//
|
|
2204
|
+
// The old reorder had no revert path at all: dragover moved the chip on every
|
|
2205
|
+
// event and dragend committed whatever the strip happened to be showing, so an
|
|
2206
|
+
// abandoned drag left the tabs rearranged and the only way back was to drag
|
|
2207
|
+
// them again. Reverting needs no saved snapshot, because tabList IS the
|
|
2208
|
+
// snapshot — nothing but a drop writes to it — so the original arrangement is
|
|
2209
|
+
// recovered by re-projecting it, the same projection moveTab uses.
|
|
2210
|
+
function revertPreview(): void {
|
|
2211
|
+
const chips = [...scroller.querySelectorAll<HTMLElement>(".wt-tab")];
|
|
2212
|
+
const untouched =
|
|
2213
|
+
chips.length === tabList.length && chips.every((el, i) => tabList[i]?.el === el);
|
|
2214
|
+
if (untouched) {
|
|
2215
|
+
return; // no slot ever committed; there is nothing to put back
|
|
2216
|
+
}
|
|
2217
|
+
flipTo(() => {
|
|
2218
|
+
for (const t of tabList) {
|
|
2219
|
+
scroller.appendChild(t.el);
|
|
2220
|
+
}
|
|
2221
|
+
}, null);
|
|
2222
|
+
ctx.announce("Move cancelled");
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// flashSlot restarts the slot's fade at its new home. Removing and re-adding
|
|
2226
|
+
// the class is the restart (re-adding a class an element already carries
|
|
2227
|
+
// restarts nothing), and the read between them is what flushes the removal to
|
|
2228
|
+
// style so the two writes are not collapsed into one. It owns that read rather
|
|
2229
|
+
// than depending on a caller's, so a commit can call it either side of the
|
|
2230
|
+
// FLIP's own reflow.
|
|
2231
|
+
function flashSlot(el: HTMLElement): void {
|
|
2232
|
+
endSlotFade();
|
|
2233
|
+
el.classList.remove("wt-tab-slotted");
|
|
2234
|
+
el.getBoundingClientRect();
|
|
2235
|
+
el.classList.add("wt-tab-slotted");
|
|
2236
|
+
slotFadeEl = el;
|
|
2237
|
+
slotFadeTimer = setTimeout(() => {
|
|
2238
|
+
slotFadeTimer = null;
|
|
2239
|
+
endSlotFade();
|
|
2240
|
+
}, REORDER_SLOT_FADE_MS);
|
|
2241
|
+
}
|
|
2242
|
+
function endSlotFade(): void {
|
|
2243
|
+
if (slotFadeTimer !== null) {
|
|
2244
|
+
clearTimeout(slotFadeTimer);
|
|
2245
|
+
slotFadeTimer = null;
|
|
2246
|
+
}
|
|
2247
|
+
slotFadeEl?.classList.remove("wt-tab-slotted");
|
|
2248
|
+
slotFadeEl = null;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// The two things a reorder can say, and they are deliberately different
|
|
2252
|
+
// sentences. commitSlot moves the DOM but NOT tabList, so a committed slot is a
|
|
2253
|
+
// PREVIEW that Escape can still undo: announcing it as "Moved X to position 3"
|
|
2254
|
+
// told a screen-reader user that a reversible hover state was a finished action,
|
|
2255
|
+
// three times over on a drag that dwelt in three places, sometimes followed by
|
|
2256
|
+
// "Move cancelled" contradicting all of it. So the preview announces a TARGET and
|
|
2257
|
+
// only the drop announces a move.
|
|
2258
|
+
//
|
|
2259
|
+
// The position is read from the DOM because the DOM is what the preview moved;
|
|
2260
|
+
// tabList still holds the order the gesture started from.
|
|
2261
|
+
function slotPosition(el: HTMLElement): number {
|
|
2262
|
+
return [...scroller.querySelectorAll<HTMLElement>(".wt-tab")].indexOf(el) + 1;
|
|
2263
|
+
}
|
|
2264
|
+
function announceTarget(el: HTMLElement): void {
|
|
2265
|
+
const at = slotPosition(el);
|
|
2266
|
+
if (at > 0) {
|
|
2267
|
+
ctx.announce(`Drop position ${String(at)}`);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
// announceMoved is the completed move, on drop. The drag path announced nothing
|
|
2271
|
+
// at all before this, while the menu's moveTab announced every move — the same
|
|
2272
|
+
// reorder, one of them silent to anyone who cannot see the strip rearrange.
|
|
2273
|
+
function announceMoved(el: HTMLElement): void {
|
|
2274
|
+
const tab = tabList.find((t) => t.el === el);
|
|
2275
|
+
const at = slotPosition(el);
|
|
2276
|
+
if (tab && at > 0) {
|
|
2277
|
+
ctx.announce(`Moved ${tab.display} to position ${String(at)}`);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
// endReorderPreview ends the GESTURE's state: the pending hold (easing any
|
|
2282
|
+
// lean home as it goes) and the slot's fade.
|
|
2283
|
+
//
|
|
2284
|
+
// It deliberately does NOT stop a slide that is already playing. A slide is an
|
|
2285
|
+
// animation with its own settle timer, and cutting it here is exactly what
|
|
2286
|
+
// would make a revert snap home instead of run — dragend fires immediately
|
|
2287
|
+
// after the revert starts. dragstart and teardown add an explicit endShift()
|
|
2288
|
+
// for the two cases where nothing may be left in flight: a fresh gesture must
|
|
2289
|
+
// start from rest, and a torn-down feature may leave no inline style on an
|
|
2290
|
+
// element it no longer owns.
|
|
2291
|
+
function endReorderPreview(): void {
|
|
2292
|
+
cancelDwell();
|
|
2293
|
+
hideDwellRail(); // cancelDwell returns early when no hold was pending
|
|
2294
|
+
endSlotFade();
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// abortReorderFor is the one path that cannot wait for dragend, called by every
|
|
2298
|
+
// site that REMOVES a chip while a drag may be open (dropTab and the bulk-close
|
|
2299
|
+
// sweep). Two removals matter, for different reasons:
|
|
2300
|
+
//
|
|
2301
|
+
// - the pending reference chip: commitSlot would hand a detached node to
|
|
2302
|
+
// insertBefore. It guards that too, but standing the hold down here means the
|
|
2303
|
+
// user's lean comes off at the moment the target vanishes rather than a second
|
|
2304
|
+
// later, and a fresh dragover can arm a slot that still exists.
|
|
2305
|
+
// - the DRAGGED chip: the gesture is over and there is no source left to deliver
|
|
2306
|
+
// a dragend. A browser is not obliged to fire one for a removed source, and
|
|
2307
|
+
// without this the feature would keep `draggingEl` set to a detached node for
|
|
2308
|
+
// the rest of its life — which the document-level guard reads as "a tab drag is
|
|
2309
|
+
// in progress" and so would preventDefault every unrelated file or text drop on
|
|
2310
|
+
// the page from then on.
|
|
2311
|
+
function abortReorderFor(removed: HTMLElement): void {
|
|
2312
|
+
if (removed === dwellBefore) {
|
|
2313
|
+
cancelDwell();
|
|
2314
|
+
}
|
|
2315
|
+
if (removed !== draggingEl) {
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
removed.classList.remove("wt-tab-dragging");
|
|
2319
|
+
draggingEl = null;
|
|
2320
|
+
dropped = false;
|
|
2321
|
+
endReorderPreview();
|
|
2322
|
+
endShift();
|
|
2323
|
+
clearDragGhost();
|
|
2324
|
+
}
|
|
2325
|
+
|
|
1753
2326
|
function syncOrderFromDom(): void {
|
|
1754
2327
|
const order: Tab[] = [];
|
|
1755
2328
|
for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
|
|
@@ -1761,10 +2334,58 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1761
2334
|
if (order.length === tabList.length) {
|
|
1762
2335
|
tabList.length = 0;
|
|
1763
2336
|
tabList.push(...order);
|
|
2337
|
+
// Before syncChrome: it ends in applyServerOrder, which would undo this
|
|
2338
|
+
// move while the tabs still carry their old positions.
|
|
2339
|
+
publishOrder();
|
|
1764
2340
|
syncChrome();
|
|
1765
2341
|
}
|
|
1766
2342
|
}
|
|
1767
2343
|
|
|
2344
|
+
// publishOrder sends the strip's arrangement to the server, which is the
|
|
2345
|
+
// write half of tab-order sync: the order belongs to the session SET, so a
|
|
2346
|
+
// drag here has to reach the other devices watching the same server.
|
|
2347
|
+
//
|
|
2348
|
+
// Called only from the two paths that COMMIT a reorder (a finished drag, and
|
|
2349
|
+
// Move left/right), never from the drag preview, which repaints the DOM many
|
|
2350
|
+
// times per gesture. The local strip is already showing the new arrangement,
|
|
2351
|
+
// so this is optimistic; the server echoes the same order back and
|
|
2352
|
+
// applyServerOrder then has nothing to do.
|
|
2353
|
+
//
|
|
2354
|
+
// Best-effort by design, with one exception. A 409 means the server's
|
|
2355
|
+
// session set is not the one just sent — this client has not yet seen a
|
|
2356
|
+
// session created or closed elsewhere — and the answer is to take the
|
|
2357
|
+
// server's word: reconcileOnce adopts what it missed, and the arrangement
|
|
2358
|
+
// that survives is the server's. Re-sending would be a fight this client
|
|
2359
|
+
// cannot win, since its list is the stale one. Any other failure leaves the
|
|
2360
|
+
// strip as the user arranged it for this page and lets the next reorder try
|
|
2361
|
+
// again; a toast for a cosmetic write the user can simply repeat would be
|
|
2362
|
+
// noise, and the arrangement is not lost data.
|
|
2363
|
+
//
|
|
2364
|
+
// It renumbers the tabs BEFORE sending, and that is not bookkeeping: every
|
|
2365
|
+
// tab still carries the position the server gave it, so the applyServerOrder
|
|
2366
|
+
// pass inside the caller's syncChrome would sort the strip straight back and
|
|
2367
|
+
// the tab would snap to where it started. Adopting the new positions locally
|
|
2368
|
+
// is the optimistic half of the write — the server echoes these same numbers
|
|
2369
|
+
// and the echo then lands as a no-op. Against a server with no order route
|
|
2370
|
+
// the numbers simply stay local, which is the old per-page behaviour.
|
|
2371
|
+
function publishOrder(): void {
|
|
2372
|
+
if (tabList.length === 0) {
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
tabList.forEach((tab, i) => {
|
|
2376
|
+
tab.order = i;
|
|
2377
|
+
});
|
|
2378
|
+
if (!started) {
|
|
2379
|
+
return; // the bootstrap is still placing tabs; nothing to publish yet
|
|
2380
|
+
}
|
|
2381
|
+
const ids = tabList.map((t) => t.id);
|
|
2382
|
+
void api.setOrder(ids).catch((err: unknown) => {
|
|
2383
|
+
if (err instanceof SessionAPIError && err.status === 409) {
|
|
2384
|
+
void reconcileOnce();
|
|
2385
|
+
}
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
|
|
1768
2389
|
// --- Drag preview (the ghost) ---
|
|
1769
2390
|
// WebKit renders NO automatic drag image for an element whose ancestor
|
|
1770
2391
|
// carries a filter or transform (webkit.org/b/22787) — and the strip's own
|
|
@@ -1831,6 +2452,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1831
2452
|
for (const item of tabList) {
|
|
1832
2453
|
scroller.appendChild(item.el);
|
|
1833
2454
|
}
|
|
2455
|
+
publishOrder(); // before syncChrome, for the reason at its definition
|
|
1834
2456
|
syncChrome();
|
|
1835
2457
|
ctx.announce(`Moved ${tab.display} to position ${String(to + 1)}`);
|
|
1836
2458
|
}
|
|
@@ -1848,17 +2470,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1848
2470
|
return; // just closed here; ignore a stale listing until the server reaps it
|
|
1849
2471
|
}
|
|
1850
2472
|
const tab = addTabChrome(info);
|
|
1851
|
-
// Place the tab where the
|
|
1852
|
-
//
|
|
1853
|
-
//
|
|
1854
|
-
//
|
|
1855
|
-
//
|
|
1856
|
-
//
|
|
1857
|
-
const at = orderedInsertIndex(
|
|
1858
|
-
tabList.map((t) => t.id),
|
|
1859
|
-
savedOrder,
|
|
1860
|
-
info.id,
|
|
1861
|
-
);
|
|
2473
|
+
// Place the tab where the SERVER's shared order says it goes, not where it
|
|
2474
|
+
// happened to arrive: the status-stream snapshot and the bootstrap's
|
|
2475
|
+
// GET /api/sessions race each other, so arrival order is neither source's
|
|
2476
|
+
// order and is not stable between loads. Against a server that keeps no
|
|
2477
|
+
// order the same call falls back to age, which is creation order. See
|
|
2478
|
+
// compareTabOrder.
|
|
2479
|
+
const at = orderedInsertIndex(tabList, info);
|
|
1862
2480
|
tabList.splice(at, 0, tab);
|
|
1863
2481
|
// addTabChrome appended the chip to the scroller's end; move it to match
|
|
1864
2482
|
// the list when the arrangement put it earlier, so the DOM, the switcher
|
|
@@ -1999,6 +2617,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1999
2617
|
// Remove immediately (no exit animation): a lingering element made the
|
|
2000
2618
|
// "+" teleport after a delay, and made a last-tab replacement appear in
|
|
2001
2619
|
// the second slot before shifting left. The strip reflows in one frame.
|
|
2620
|
+
// A live desktop drag holds references to chips, so tell it first: this
|
|
2621
|
+
// element may be the one it is dragging or the one it means to insert
|
|
2622
|
+
// before, and both stop existing on the next line.
|
|
2623
|
+
abortReorderFor(tab.el);
|
|
2002
2624
|
tab.el.remove();
|
|
2003
2625
|
ctx.dropSession(id);
|
|
2004
2626
|
// If this close empties the expanded list (only one tab remains),
|
|
@@ -2074,6 +2696,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2074
2696
|
// to be listed here too.
|
|
2075
2697
|
forgetCueSeen(t.id);
|
|
2076
2698
|
t.aria.remove();
|
|
2699
|
+
abortReorderFor(t.el); // same reason as dropTab: a live drag holds this node
|
|
2077
2700
|
t.el.remove();
|
|
2078
2701
|
ctx.dropSession(t.id);
|
|
2079
2702
|
}
|
|
@@ -2535,6 +3158,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2535
3158
|
return;
|
|
2536
3159
|
}
|
|
2537
3160
|
t.reports = reports;
|
|
3161
|
+
// The server's shared position. Only an explicitly PRESENT value updates
|
|
3162
|
+
// the tab, for the same reason as progressValue below: an engine that
|
|
3163
|
+
// keeps no order sends no field, and reading that absence as "position 0"
|
|
3164
|
+
// would drag every tab to the front of the strip on every status tick.
|
|
3165
|
+
// syncChrome's applyServerOrder then moves the chip if this changed.
|
|
3166
|
+
if (rec.order !== undefined) {
|
|
3167
|
+
t.order = rec.order;
|
|
3168
|
+
}
|
|
2538
3169
|
paintStatusDot(t.dot, rec.status, reports);
|
|
2539
3170
|
// The OSC 9;4 percentage. Only an explicitly PRESENT value updates the
|
|
2540
3171
|
// tab: the polling fallback lists SessionInfo, which carries no
|
|
@@ -2729,7 +3360,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2729
3360
|
}
|
|
2730
3361
|
return false;
|
|
2731
3362
|
});
|
|
2732
|
-
//
|
|
3363
|
+
// Arm the reorder preview while dragging a tab over the strip. This no longer
|
|
3364
|
+
// reorders anything: it picks the slot a release would land in and starts the
|
|
3365
|
+
// hold that commits it (see the reorder-preview block above).
|
|
2733
3366
|
bar.addEventListener("dragover", (e) => {
|
|
2734
3367
|
if (!draggingEl) {
|
|
2735
3368
|
return;
|
|
@@ -2738,7 +3371,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2738
3371
|
if (e.dataTransfer) {
|
|
2739
3372
|
e.dataTransfer.dropEffect = "move";
|
|
2740
3373
|
}
|
|
2741
|
-
|
|
3374
|
+
armDwell(dropTargetBefore(e.clientX));
|
|
2742
3375
|
});
|
|
2743
3376
|
// Commit on drop — and, load-bearing, CANCEL the drop's default action. An
|
|
2744
3377
|
// uncancelled drop leaves the browser free to act on the drag payload
|
|
@@ -2747,22 +3380,72 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2747
3380
|
// instead of reordering. Chrome and Firefox ignore an unhandled reorder
|
|
2748
3381
|
// drop, which is why the strip looked fine on the desktop.
|
|
2749
3382
|
bar.addEventListener("drop", (e) => {
|
|
2750
|
-
|
|
3383
|
+
const moved = draggingEl;
|
|
3384
|
+
if (!moved) {
|
|
2751
3385
|
return;
|
|
2752
3386
|
}
|
|
2753
3387
|
e.preventDefault();
|
|
3388
|
+
dropped = true;
|
|
3389
|
+
flushDwell(); // a release decides: do not make a fast drag wait out the hold
|
|
2754
3390
|
syncOrderFromDom();
|
|
3391
|
+
announceMoved(moved); // the ONE completed-move announcement per gesture
|
|
3392
|
+
});
|
|
3393
|
+
// Leaving the strip stands a pending hold down. The document dragover below
|
|
3394
|
+
// does this too and covers more ground, but it cannot cover the case that
|
|
3395
|
+
// matters most here: the strip is docked at the viewport EDGE, so a pointer can
|
|
3396
|
+
// leave it by leaving the window entirely, and then no other element in the
|
|
3397
|
+
// document receives a dragover to notice with. Without this listener the hold
|
|
3398
|
+
// would run to completion and commit a slot the pointer had already abandoned.
|
|
3399
|
+
//
|
|
3400
|
+
// relatedTarget is what makes dragleave usable at all: it fires on every
|
|
3401
|
+
// child-to-child transition inside the bar (chip to label, label to close), and
|
|
3402
|
+
// the next target being inside the bar is exactly how those are told apart. A
|
|
3403
|
+
// null relatedTarget means the pointer left the window, which is the case this
|
|
3404
|
+
// exists for.
|
|
3405
|
+
bar.addEventListener("dragleave", (e) => {
|
|
3406
|
+
if (!draggingEl) {
|
|
3407
|
+
return;
|
|
3408
|
+
}
|
|
3409
|
+
const next = e.relatedTarget;
|
|
3410
|
+
if (next === null || !bar.contains(next as Node)) {
|
|
3411
|
+
cancelDwell();
|
|
3412
|
+
}
|
|
2755
3413
|
});
|
|
2756
3414
|
// A tab released anywhere OTHER than the strip — over the terminal, over
|
|
2757
|
-
// any other chrome — must be inert
|
|
2758
|
-
//
|
|
2759
|
-
//
|
|
2760
|
-
//
|
|
2761
|
-
// the page) is left entirely to the browser.
|
|
3415
|
+
// any other chrome — must be inert, not a navigation, so claim the whole
|
|
3416
|
+
// document as a drop target for the life of a tab drag and swallow the drop
|
|
3417
|
+
// there too. Gated on draggingEl so an unrelated drag (a file dropped on the
|
|
3418
|
+
// page) is left entirely to the browser.
|
|
2762
3419
|
const onDocTabDrop = (e: DragEvent): void => {
|
|
2763
|
-
if (draggingEl) {
|
|
2764
|
-
|
|
3420
|
+
if (!draggingEl) {
|
|
3421
|
+
return;
|
|
2765
3422
|
}
|
|
3423
|
+
e.preventDefault();
|
|
3424
|
+
if (bar.contains(e.target as Node)) {
|
|
3425
|
+
return; // on the strip: the bar's own handlers own it
|
|
3426
|
+
}
|
|
3427
|
+
if (e.type === "drop") {
|
|
3428
|
+
// Released off the strip, which CANCELS. The strip is the drop zone and it
|
|
3429
|
+
// is a generous one (a --touch-target chip plus the bar's own padding, ~61px
|
|
3430
|
+
// at the viewport edge), and once the pointer has left it this same handler
|
|
3431
|
+
// has already declared there is no candidate slot out here. Committing a
|
|
3432
|
+
// slot anyway would persist an arrangement chosen at a position the user
|
|
3433
|
+
// visibly left, and worse, the outcome would depend on whether the browser
|
|
3434
|
+
// chose to emit `drop` at all: an accepted document drop committed while a
|
|
3435
|
+
// refused release fell through to dragend and reverted. That distinction is
|
|
3436
|
+
// invisible to the person doing the dragging.
|
|
3437
|
+
//
|
|
3438
|
+
// So: leave `dropped` false, stand the hold down, and let dragend run the
|
|
3439
|
+
// revert. Nothing is lost silently — the revert animates and announces, so a
|
|
3440
|
+
// mis-release reads as "that did not take" rather than as a mystery.
|
|
3441
|
+
cancelDwell();
|
|
3442
|
+
return;
|
|
3443
|
+
}
|
|
3444
|
+
// dragover, off the strip: no candidate slot exists out here, so stand a
|
|
3445
|
+
// pending hold down rather than let it open a gap for a target the pointer
|
|
3446
|
+
// has already left. A slot already COMMITTED stays put; the pointer may yet
|
|
3447
|
+
// come back onto the strip and release there.
|
|
3448
|
+
cancelDwell();
|
|
2766
3449
|
};
|
|
2767
3450
|
document.addEventListener("dragover", onDocTabDrop);
|
|
2768
3451
|
document.addEventListener("drop", onDocTabDrop);
|
|
@@ -3351,9 +4034,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3351
4034
|
varRoot.style.removeProperty("--wt-tabbar-h");
|
|
3352
4035
|
root?.classList.remove("wt-tabbed");
|
|
3353
4036
|
varRoot.style.removeProperty("--wt-reserve-bottom");
|
|
3354
|
-
|
|
3355
|
-
clearTimeout(switchAnimTimer);
|
|
3356
|
-
}
|
|
4037
|
+
endSwitchAnim(surface);
|
|
3357
4038
|
if (collapseClearTimer !== null) {
|
|
3358
4039
|
clearTimeout(collapseClearTimer);
|
|
3359
4040
|
}
|
|
@@ -3361,6 +4042,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3361
4042
|
gestureAbort?.abort(); // drop any in-flight gesture's window listeners
|
|
3362
4043
|
endEdit(); // abandon an open rename without issuing a request
|
|
3363
4044
|
clearDragGhost();
|
|
4045
|
+
endReorderPreview();
|
|
4046
|
+
endShift(); // leave no inline style on a chip this feature no longer owns
|
|
3364
4047
|
clearCatchup();
|
|
3365
4048
|
hideTabMenu();
|
|
3366
4049
|
for (const t of tabList) {
|