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