Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206import { adopt, define, setState, sheet } from "@nibling/web/internals";import "@nibling/web/icons";import type { IconName } from "@nibling/web/icons/names";import "@nibling/web/split";import type { PanelElement, SplitElement, SplitResizeEvent } from "@nibling/web/split";import "@nibling/web/tabs";import { TabSelectEvent, type TabElement, type TabListElement, type TabPanelElement } from "@nibling/web/tabs";import "@nibling/web/toolbar";import { isViewShown, showOrHideView, views as toggledViews, type ToggledView } from "../commands.ts";import { findStack, isFloating, isVisible, mainStack, owner, splitEdges, stacks, type Edge, type Layout, type LayoutNode, type NodeId, type Panel, type PanelId, type PanelKind, type Placement, type Split, type Stack } from "../layout.ts";import { keyOf, spec, titleOf } from "../kinds.ts";import type { Unsubscribe, Workbench } from "../workbench.ts";import { isEditingTarget, type EditingTarget } from "../../views/editing.ts";import { TablistAutoScroll } from "./autoscroll.ts";import { CompactChrome, compactSheet, type KeyAction } from "./compact.ts";import { DROP_CSS, DropIndicator, flash, morph, TAB_DRAG_CSS, transitionName, type Rect } from "./drop.ts";import { FloatingWindow, type Bounds } from "./floating.ts";import { regionLabel, StackElement } from "./stack.ts";
export interface PanelView { readonly element: HTMLElement; readonly dispose?: () => void;}
export type Views = { readonly [K in PanelKind]: (panel: Extract<Panel, { kind: K }>, workbench: Workbench) => PanelView;};
const DRAG_TYPE = "application/x-nib-panel";
/** How close to a window's edge a drag must last be seen for letting go to tear its panel off. */const TEAR_EDGE = 20;/** How long before dragend that last dragover must be: longer than the browser's repeat while over a window. */const TEAR_GAP = 250;
/** How long the pointer holds at an area's edge before a drop there splits it rather than joining it. */const SPLIT_DELAY = 400;
const WINDOW_DRAG_EVENTS = ["dragover", "dragleave", "drop"] as const;
/** Where a drag would put its panel, and what shows it, in viewport coordinates: the area it takes, and where its tab lands in a strip. */interface Target { readonly placement: Placement; readonly box?: Rect; readonly line?: Rect; /** The tablist under the pointer, which scrolls toward its end while held near one. */ readonly strip?: TabListElement; /** Letting go here leaves the panel where it is. */ readonly unchanged?: boolean;}
interface Dragged { readonly panel: PanelId; readonly tab: TabElement; last: { readonly view: Window; readonly x: number; readonly y: number; readonly screenX: number; readonly screenY: number; readonly time: number; readonly tearOff: boolean; readonly leaving: boolean } | undefined;}
/** A floating stack's window, the stack element it shows, and where it shows drop targets. */interface Float { readonly window: FloatingWindow; readonly stack: StackElement; readonly drop: DropIndicator; /** The active panel when the window last rendered, to bring it forward when that changes. */ active: PanelId | null;}
/** The window a drag is in: the stacks it can drop on, its drop indicator, and that indicator's origin in the viewport. */interface DragScope { readonly view: Window; readonly stacks: readonly StackElement[]; readonly drop: DropIndicator; readonly origin: { readonly left: number; readonly top: number };}
/** Below this shell width, a touch device gets the compact presentation. */const COMPACT_MAX_WIDTH = 600;
/** `auto` picks compact on a coarse pointer in a narrow shell; the others force a presentation. */export type ShellMode = "auto" | "desktop" | "compact";
const shellSheet = sheet(` :host { position: relative; display: block; container: nib-shell / size; overflow: hidden; background: var(--nib-color-canvas); color: var(--nib-color-text); font: var(--nib-font-ui); } [part="frame"] { position: absolute; inset: 0; } /* * The layout tree paints above the bodies so a gutter's pointer target can overhang the panels * beside it. The tree lets pointer events through, except on tablists, gutters, and (during a tab * drag) the content areas that take drops. */ [part="tree"] { position: absolute; inset: 0; inset-inline-start: var(--nib-size-activity-bar); z-index: 1; display: flex; pointer-events: none; } /* The activity bar: one button per view, so a closed or hidden view always has a way back. */ [part="activity"] { position: absolute; inset-block: 0; inset-inline-start: 0; width: var(--nib-size-activity-bar); padding-block: var(--nib-space-xs); box-sizing: border-box; background: var(--nib-color-surface); border-inline-end: var(--nib-size-border) solid var(--nib-color-border); } [part="activity"] button { width: var(--nib-size-activity-bar); height: var(--nib-size-activity-bar); display: flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--nib-color-text-muted); font: inherit; font-size: var(--nib-font-size-icon); } [part="activity"] button:hover { color: var(--nib-color-text); } [part="activity"] button[aria-pressed="true"] { color: var(--nib-color-text); box-shadow: inset var(--nib-size-indicator) 0 var(--nib-color-accent); } [part="activity"] button:focus-visible { outline: var(--nib-size-focus-ring) solid var(--nib-color-focus-ring); outline-offset: calc(-1 * var(--nib-size-focus-ring)); } :host(:state(compact)) [part="activity"] { display: none; } :host(:state(compact)) [part="tree"] { inset-inline-start: 0; } [part="tree"] > * { flex: 1; min-width: 0; min-height: 0; } nib-tablist { pointer-events: auto; } :host(:state(dragging)) nib-stack > div { pointer-events: auto; } [part="bodies"] { position: absolute; inset: 0; pointer-events: none; } /* Clipped, so a view that ignores its box can never paint over a neighbouring part. */ nib-tabpanel { position: absolute; display: flex; overflow: hidden; pointer-events: auto; } nib-tabpanel[hidden] { display: none; } nib-tabpanel > * { flex: 1; min-width: 0; min-height: 0; } :host(:is(:state(dragging), :state(resizing))) nib-tabpanel { pointer-events: none; } :host(:state(resizing)) { user-select: none; } ${DROP_CSS} ${TAB_DRAG_CSS}`);
/** * The app shell: renders a workbench's layout tree of splits and stacks, and every panel's body. * Bodies are created once per panel and live in one layer positioned over their stack, so moving a * panel never reparents it: reparenting reloads an iframe, and Safari has no `moveBefore()`. * * Focus follows the layout both ways. Focusing anything in a stack makes it the workbench's focused * stack, and a command that focuses a stack (F6) moves keyboard focus into it (see `#focusStack`). */export class ShellElement extends HTMLElement { readonly #internals = this.attachInternals(); readonly #root = this.attachShadow({ mode: "open" }); /** Holds every part, so a view transition scoped to it covers the whole shell: a host's shadow tree isn't its subtree. */ readonly #frame = part("frame"); readonly #tree = part("tree"); readonly #layer = part("bodies"); readonly #drop = new DropIndicator(); readonly #activity = document.createElement("nib-toolbar"); readonly #activityButtons = new Map<ToggledView, HTMLButtonElement>(); readonly #bodies = new Map<PanelId, { view: PanelView; tabpanel: TabPanelElement; input: Panel["input"] }>(); #stacks = new Map<NodeId, StackElement>(); #splits = new Map<NodeId, SplitElement>(); readonly #splitNodes = new WeakMap<SplitElement, Split>(); readonly #observer = new ResizeObserver(() => this.#onSize()); readonly #coarse = matchMedia("(pointer: coarse)"); #workbench: Workbench | undefined; #views: Views | undefined; #structure = ""; #focus: NodeId | undefined; /** The tab being dragged, and its last dragover in any window. */ #dragged: Dragged | undefined; /** The area edge the pointer is holding at, and since when: a split arms after `SPLIT_DELAY`. */ #arming: { readonly stack: NodeId; readonly edge: Edge; readonly since: number; readonly timer: ReturnType<typeof setTimeout> } | undefined; /** Scrolls an overflowing tablist toward the pointer's end while a tab drags over it. */ readonly #autoscroll = new TablistAutoScroll(); /** Each floating stack's window. */ readonly #floats = new Map<NodeId, Float>(); /** The tree stack each floating panel left, to dock it back where it was. */ readonly #homes = new Map<PanelId, NodeId>(); /** Where the next window opens: set by a tear-off for the render it dispatches. */ #tearAt: Bounds | undefined; /** A blank same-origin page floating windows open, so their address bars show this site, not `about:blank`. */ floatingPage = ""; #mode: ShellMode = "auto"; #compact = false; #chrome: CompactChrome | undefined; /** Compact view state: whether the editor area shows the preview, and whether the sheet is open. */ #previewShown = false; #sheetOpen = false; #lastMain: PanelId | null = null; /** The preview a server just opened or bound, which the toggle shows until another tab is picked. */ #servedPreview: PanelId | null = null; /** Each browser panel's address at the last render, to notice one opening on or binding to a server. */ #browserUrls: Map<PanelId, string | null> | undefined; /** The panel whose body is in the drawer while it's open. */ #drawerPanel: PanelId | undefined; /** The editing view the key bar drives: the last one that had focus. */ #editing: (EditingTarget & HTMLElement) | undefined;
constructor() { super(); adopt(this.#root, shellSheet, compactSheet); this.#root.addEventListener("keydown", (event) => this.#onKeydown(event as KeyboardEvent)); this.#buildActivity(); this.#frame.append(this.#activity, this.#tree, this.#layer, this.#drop.layer); this.#root.append(this.#frame); this.#root.addEventListener("focusin", (event) => this.#onFocusIn(event)); // Focus settles after focusout, so the key bar reads where it went on the next microtask. this.#root.addEventListener("focusin", () => this.#updateKeys()); this.#root.addEventListener("focusout", () => queueMicrotask(() => this.#updateKeys())); this.#layer.addEventListener("pointerdown", (event) => this.#onFocusIn(event)); this.#root.addEventListener("resize", (event) => this.#onResize(event as SplitResizeEvent)); this.#root.addEventListener("dragstart", (event) => this.#onDragStart(event as DragEvent)); this.#root.addEventListener("dragend", (event) => this.#onDragEnd(event as DragEvent)); }
/** Shows `workbench`, building each panel's body from `views`. Returns a function that tears it down. */ connect(workbench: Workbench, views: Views): Unsubscribe { this.#workbench = workbench; this.#views = views; this.#observer.observe(this); this.#render(workbench.layout); const unsubscribe = workbench.subscribe((layout) => this.#render(layout)); const onPointer = () => this.#onSize(); this.#coarse.addEventListener("change", onPointer); const viewport = window.visualViewport; const onViewport = () => this.#onViewport(); viewport?.addEventListener("resize", onViewport); viewport?.addEventListener("scroll", onViewport); // A floating window outliving this page would hold a dead view. const closeFloats = () => this.#closeFloats(); window.addEventListener("pagehide", closeFloats); // On the window, not the shell: a drag leaving the page past its header has to be seen there. const onDrag = (event: Event) => this.#onDrag(event as DragEvent); for (const type of WINDOW_DRAG_EVENTS) window.addEventListener(type, onDrag); return () => { unsubscribe(); this.#coarse.removeEventListener("change", onPointer); viewport?.removeEventListener("resize", onViewport); viewport?.removeEventListener("scroll", onViewport); window.removeEventListener("pagehide", closeFloats); for (const type of WINDOW_DRAG_EVENTS) window.removeEventListener(type, onDrag); this.#closeFloats(); this.#chrome?.drawer.close(); this.#observer.disconnect(); for (const body of this.#bodies.values()) body.view.dispose?.(); this.#bodies.clear(); this.#stacks.clear(); this.#splits.clear(); this.#tree.replaceChildren(); this.#layer.replaceChildren(); this.#structure = ""; this.#workbench = undefined; }; }
#buildActivity() { const icons: Record<ToggledView, IconName> = { explorer: "explorer", terminal: "terminal", processes: "processes", agent: "sparkle" }; this.#activity.part = "activity"; this.#activity.label = "Views"; this.#activity.orientation = "vertical"; for (const kind of Object.keys(toggledViews) as ToggledView[]) { const button = document.createElement("button"); button.append(Object.assign(document.createElement("nib-icon"), { name: icons[kind] })); button.ariaLabel = toggledViews[kind].title; button.title = toggledViews[kind].title; button.ariaPressed = "false"; button.addEventListener("click", () => this.#workbench && showOrHideView(this.#workbench, kind)); this.#activityButtons.set(kind, button); this.#activity.append(button); } }
/** Which presentation to use. `auto` (the default) switches to compact on touch devices in a narrow shell. */ get mode(): ShellMode { return this.#mode; }
set mode(value: ShellMode) { this.#mode = value; if (this.#workbench) this.#render(this.#workbench.layout); }
#wantsCompact(): boolean { if (this.#mode !== "auto") return this.#mode === "compact"; return this.#coarse.matches && this.getBoundingClientRect().width < COMPACT_MAX_WIDTH; }
#onSize() { const layout = this.#workbench?.layout; if (!layout) return; if (this.#wantsCompact() !== this.#compact) this.#render(layout); else this.#position(); }
#render(layout: Layout) { const workbench = this.#workbench!; const compact = this.#wantsCompact(); // On a phone the editor area shows one panel, so a page a server just started shows there, as it // would beside the editor on a wide screen. const served = this.#newlyServed(layout); if (compact && served) { this.#servedPreview = served; this.#previewShown = true; this.#lastMain = mainStack(layout.root).active; } if (compact !== this.#compact) { this.#compact = compact; setState(this.#internals, "compact", compact); if (!compact) this.#chrome?.drawer.close(); queueMicrotask(() => this.#onViewport()); } // Sizes, the active tab, and focus are state, not structure. Applying them in place keeps // focused gutters and tabs alive, so keyboard resizing and tab switching never drop focus. const replacer = (key: string, value: unknown) => (key === "active" || key === "sizes" ? undefined : value); const structure = JSON.stringify([compact, layout.root, layout.hidden], replacer); if (structure !== this.#structure) { this.#structure = structure; const restore = this.#focusedTab(); const kept = new Map<NodeId, StackElement>(); const splits = new Map<NodeId, SplitElement>(); this.#tree.replaceChildren(compact ? this.#compactTree(layout, kept) : this.#node(layout, layout.root, kept, splits)); this.#stacks = kept; this.#splits = splits; this.#observer.disconnect(); this.#observer.observe(this); for (const stack of kept.values()) this.#observer.observe(stack.content); if (restore) this.#restoreFocus(layout, restore); } else { for (const [id, el] of this.#splits) { const split = findSplit(layout.root, id); if (split) this.#size(layout, split, el); } }
for (const [id, body] of this.#bodies) { if (id in layout.panels) continue; body.view.dispose?.(); body.tabpanel.remove(); this.#bodies.delete(id); } for (const panel of Object.values(layout.panels)) { const body = this.#bodies.get(panel.id); if (body?.input === panel.input) continue; const view = (this.#views![panel.kind] as (p: Panel, wb: Workbench) => PanelView)(panel, workbench); if (body) { body.view.dispose?.(); body.tabpanel.replaceChildren(view.element); this.#bodies.set(panel.id, { ...body, view, input: panel.input }); continue; } const tabpanel = document.createElement("nib-tabpanel"); tabpanel.style.viewTransitionName = transitionName("body", panel.id); tabpanel.append(view.element); this.#layer.append(tabpanel); this.#bodies.set(panel.id, { view, tabpanel, input: panel.input }); } this.#reconcileFloats(layout);
const visible = stacks(layout.root).filter((stack) => isVisible(layout, stack)); let groups = 0; for (const stack of visible) { const label = regionLabel(stack, stack.slot ? 0 : groups++); const el = this.#stacks.get(stack.id); el?.update(layout, stack, workbench, label, (id) => this.#bodies.get(id)?.tabpanel, (id) => this.#focusPanel(id)); if (el) nameTabs(el, stack); } for (const [kind, button] of this.#activityButtons) button.ariaPressed = String(isViewShown(workbench, kind)); if (compact) this.#updateCompact(layout); this.#position();
// Move keyboard focus only when the focused stack changed and focus isn't already inside it, // so a click that focused the stack keeps its own target. Someone working the activity bar // stays there; Tab or F6 takes them into the content. const previous = this.#focus; this.#focus = layout.focus; const inActivity = this.#activity.contains(this.#root.activeElement); if (previous !== undefined && previous !== layout.focus && !inActivity && this.#stackOfFocus() !== layout.focus) { this.#focusStack(layout, layout.focus); } }
#node(layout: Layout, node: LayoutNode, stacksOut: Map<NodeId, StackElement>, splitsOut: Map<NodeId, SplitElement>): HTMLElement { if (node.type === "stack") { const el = this.#stacks.get(node.id) ?? document.createElement("nib-stack"); stacksOut.set(node.id, el); return el; } const el = document.createElement("nib-split"); el.axis = node.axis; this.#splitNodes.set(el, node); splitsOut.set(node.id, el); for (const child of node.children) { if (!isVisible(layout, child)) continue; const panel = document.createElement("nib-panel"); panel.label = describe(layout, child); panel.append(this.#node(layout, child, stacksOut, splitsOut)); el.append(panel); } this.#size(layout, node, el); return el; }
/** Applies a split's sizes to its shown children; hidden children keep their share for later. */ #size(layout: Layout, split: Split, el: SplitElement) { this.#splitNodes.set(el, split); const shown = split.children.flatMap((child, i) => (isVisible(layout, child) ? [split.sizes[i]!] : [])); el.sizes = shown; }
#onResize(event: SplitResizeEvent) { const el = event.target as SplitElement; const split = this.#splitNodes.get(el); if (!split || !this.#workbench) return; setState(this.#internals, "resizing", !event.committed); if (!event.committed) return; const layout = this.#workbench.layout; const indices = split.children.flatMap((child, i) => (isVisible(layout, child) ? [i] : [])); const total = indices.reduce((sum, i) => sum + split.sizes[i]!, 0); const sizes = [...split.sizes]; indices.forEach((i, n) => (sizes[i] = (event.sizes[n] ?? 0) * total)); this.#workbench.dispatch({ type: "resize", split: split.id, sizes }); }
/** Which panel's body sits over which area: each stack's active panel, or the compact regions'. */ #placements(layout: Layout): Map<PanelId, HTMLElement> { const placements = new Map<PanelId, HTMLElement>(); if (this.#compact) { const main = mainStack(layout.root); const shown = this.#mainShown(layout); const mainEl = this.#stacks.get(main.id); if (shown && mainEl) placements.set(shown, mainEl.content); const sheet = this.#sheetStack(layout); const sheetEl = sheet && this.#stacks.get(sheet.id); if (this.#sheetOpen && sheet?.active && sheetEl) placements.set(sheet.active, sheetEl.content); return placements; } for (const stack of stacks(layout.root)) { const el = this.#stacks.get(stack.id); if (el?.isConnected && stack.active) placements.set(stack.active, el.content); } return placements; }
#position() { const layout = this.#workbench?.layout; if (!layout) return; const origin = this.getBoundingClientRect(); const placements = this.#placements(layout); for (const [id, body] of this.#bodies) { // A body in the drawer or a floating window is laid out there, not positioned over an area. if (body.tabpanel.parentNode !== this.#layer) continue; const area = placements.get(id); body.tabpanel.hidden = !area; if (!area) continue; const rect = area.getBoundingClientRect(); Object.assign(body.tabpanel.style, { left: `${rect.left - origin.left}px`, top: `${rect.top - origin.top}px`, width: `${rect.width}px`, height: `${rect.height}px`, }); } }
/** The compact tree: the chrome, with the main stack in the editor area and the panel stack in the sheet. */ #compactTree(layout: Layout, kept: Map<NodeId, StackElement>): HTMLElement { const chrome = (this.#chrome ??= new CompactChrome({ files: () => this.#openDrawer(), preview: () => this.#togglePreview(), sheet: () => this.#toggleSheet(), chose: () => this.#choseFromDrawer(), closed: () => this.#closedDrawer(), key: (action) => this.#onKey(action), })); const reuse = (stack: Stack) => { const el = this.#stacks.get(stack.id) ?? document.createElement("nib-stack"); kept.set(stack.id, el); return el; }; chrome.main.replaceChildren(reuse(mainStack(layout.root))); const sheet = this.#sheetStack(layout); chrome.sheet.hidden = !sheet; chrome.sheetBody.replaceChildren(...(sheet ? [reuse(sheet)] : [])); return chrome.root; }
/** Refreshes the compact chrome's labels and toggles from the layout. */ #updateCompact(layout: Layout) { const chrome = this.#chrome!; const main = mainStack(layout.root); // Picking another tab in the editor area means "show me that", so it leaves the preview view. if (main.active !== this.#lastMain) { this.#previewShown = false; this.#servedPreview = null; } this.#lastMain = main.active; const preview = this.#previewPanel(layout); if (!preview) this.#previewShown = false; chrome.preview.hidden = !preview; chrome.preview.ariaPressed = String(this.#previewShown); const shown = this.#mainShown(layout); chrome.title.textContent = shown ? titleOf(layout.panels[shown]!) : ""; const sheet = this.#sheetStack(layout); const sheetPanel = sheet?.active ? layout.panels[sheet.active] : undefined; chrome.handle.textContent = sheetPanel ? titleOf(sheetPanel) : "Panel"; chrome.handle.ariaExpanded = String(this.#sheetOpen); chrome.sheetBody.hidden = !this.#sheetOpen; this.#updateKeys(); }
/** The panel the editor area shows: the preview while it's toggled on, otherwise the main stack's active panel. */ #mainShown(layout: Layout): PanelId | null { if (this.#previewShown) return this.#previewPanel(layout) ?? null; return mainStack(layout.root).active; }
/** * The preview the header toggles: the one a server just opened or bound, else the file preview for * the active editor's file, else a server's preview (one bound to a server first), else the first * file preview. */ #previewPanel(layout: Layout): PanelId | undefined { if (this.#servedPreview && layout.panels[this.#servedPreview]) return this.#servedPreview; const panels = Object.values(layout.panels); const files = panels.filter((p) => p.kind === "preview"); const servers = panels.filter((p) => p.kind === "browser"); const active = mainStack(layout.root).active; const editing = active ? layout.panels[active] : undefined; const path = editing?.kind === "editor" ? editing.input.path : undefined; return (files.find((p) => p.input.path === path) ?? servers.find((p) => p.input.url !== null) ?? servers[0] ?? files[0])?.id; }
/** A browser panel that opened on, or was bound to, a server since the last render. */ #newlyServed(layout: Layout): PanelId | undefined { const urls = new Map(Object.values(layout.panels).flatMap((p) => (p.kind === "browser" ? [[p.id, p.input.url] as const] : []))); const before = this.#browserUrls; this.#browserUrls = urls; if (!before) return undefined; return [...urls].find(([id, url]) => url !== null && before.get(id) !== url)?.[0]; }
#sheetStack(layout: Layout): Stack | undefined { return stacks(layout.root).find((stack) => stack.slot === "panel" && stack.panels.length > 0); }
/** The explorer the Files button shows: the primary side bar's active panel. */ #drawerTarget(layout: Layout): PanelId | undefined { return stacks(layout.root).find((stack) => stack.slot === "primary")?.active ?? undefined; }
#togglePreview() { this.#previewShown = !this.#previewShown; this.#render(this.#workbench!.layout); }
#toggleSheet(open = !this.#sheetOpen) { const layout = this.#workbench!.layout; this.#sheetOpen = open; this.#render(layout); const sheet = this.#sheetStack(layout); if (open && sheet) this.#focusStack(layout, sheet.id); else this.#chrome?.handle.focus(); }
/** Moves the explorer's body into the drawer and opens it as a modal dialog. */ #openDrawer() { const chrome = this.#chrome!; const workbench = this.#workbench!; // A closed explorer comes back: opening it renders its body, which the drawer then takes. if (!this.#drawerTarget(workbench.layout)) workbench.open(toggledViews.explorer.open()); const id = this.#drawerTarget(workbench.layout); const body = id && this.#bodies.get(id); if (!id || !body) return; this.#drawerPanel = id; body.tabpanel.removeAttribute("style"); body.tabpanel.style.viewTransitionName = transitionName("body", id); body.tabpanel.hidden = false; chrome.drawerBody.append(body.tabpanel); chrome.files.ariaExpanded = "true"; chrome.drawer.showModal(); body.view.element.focus(); }
/** Opening a file from the drawer is the point of opening it, so the drawer closes and the editor takes focus. */ #choseFromDrawer() { this.#chrome?.drawer.close(); const layout = this.#workbench?.layout; if (layout) this.#focusStack(layout, mainStack(layout.root).id); }
/** Puts the drawer's body back in the body layer. */ #closedDrawer() { const body = this.#drawerPanel && this.#bodies.get(this.#drawerPanel); this.#drawerPanel = undefined; if (this.#chrome) this.#chrome.files.ariaExpanded = "false"; if (body) this.#layer.append(body.tabpanel); this.#position(); }
/** * Keeps the compact layout inside the visual viewport. On iOS the on-screen keyboard shrinks only * the visual viewport, and may scroll it; whatever part of the shell it covers is inset away, so the * header and the key bar stay in view. (Android Chrome resizes the page itself, given the viewport * meta's interactive-widget=resizes-content, and the insets come out zero.) */ #onViewport() { const chrome = this.#chrome; const viewport = window.visualViewport; if (!chrome || !viewport) return; const rect = this.getBoundingClientRect(); const top = this.#compact ? Math.max(0, viewport.offsetTop - rect.top) : 0; const bottom = this.#compact ? Math.max(0, rect.bottom - (viewport.offsetTop + viewport.height)) : 0; chrome.root.style.setProperty("--keyboard-top", `${top}px`); chrome.root.style.setProperty("--keyboard-bottom", `${bottom}px`); this.#position(); }
/** The editing view that holds focus, if any: the editor or terminal under the caret. */ #focusedEditing(): (EditingTarget & HTMLElement) | undefined { const active = this.#root.activeElement; if (!active) return undefined; for (const body of this.#bodies.values()) { if (body.tabpanel.contains(active) && isEditingTarget(body.view.element)) return body.view.element; } return undefined; }
/** * Shows the key bar while a compact editing view, or the bar itself, has focus. While editing in * the editor area the sheet steps aside, so the editor, the bar, and the keyboard share the screen. */ #updateKeys() { const chrome = this.#chrome; if (!chrome) return; const editing = this.#focusedEditing(); if (editing) { this.#editing = editing; chrome.setLanguage(editing.language); } const inBar = chrome.keys.contains(this.#root.activeElement); const shown = this.#compact && (editing !== undefined || inBar); const layout = this.#workbench?.layout; const sheet = layout && this.#sheetStack(layout); const editingSheet = sheet && this.#editing && sheet.active && this.#bodies.get(sheet.active)?.view.element === this.#editing; const sheetHidden = !sheet || (shown && !editingSheet); if (chrome.keys.hidden === !shown && chrome.sheet.hidden === sheetHidden) return; chrome.keys.hidden = !shown; chrome.sheet.hidden = sheetHidden; this.#position(); }
#onKey(action: KeyAction) { const target = this.#editing; if (!target) return; switch (action.type) { case "insert": return target.insert(action.text, action.caret); case "move": return target.move(action.direction); case "indent": return target.indent(); case "undo": return target.undo(); case "redo": return target.redo(); case "dismiss": return target.blur(); } }
/** Escape closes the sheet when focus is in it, and returns focus to its handle. */ #onKeydown(event: KeyboardEvent) { if (event.key !== "Escape" || !this.#compact || !this.#sheetOpen) return; const layout = this.#workbench?.layout; const sheet = layout && this.#sheetStack(layout); const inSheet = this.#chrome?.sheet.contains(this.#root.activeElement) || (sheet && this.#stackOfFocus() === sheet.id); if (!inSheet) return; event.preventDefault(); this.#toggleSheet(false); }
#stackOfPanel(panel: PanelId): Stack | undefined { return this.#workbench && stacks(this.#workbench.layout.root).find((stack) => stack.panels.includes(panel)); }
/** The stack that holds keyboard focus, through either its tabs or its active body. */ #stackOfFocus(): NodeId | undefined { const active = this.#root.activeElement; if (!active) return undefined; const stack = active.closest("nib-stack"); if (stack) return (stack as StackElement).stack?.id; for (const [id, body] of this.#bodies) if (body.tabpanel.contains(active)) return this.#stackOfPanel(id)?.id; return undefined; }
#onFocusIn(event: Event) { const wb = this.#workbench; if (!wb) return; const path = event.composedPath(); const stackEl = path.find((node): node is StackElement => node instanceof StackElement); let id = stackEl?.stack?.id; if (!id) { for (const [panel, body] of this.#bodies) if (path.includes(body.tabpanel)) id = this.#stackOfPanel(panel)?.id; } if (!id || id === wb.layout.focus) return; // Recorded first, so the render that follows doesn't move focus away from what was clicked. this.#focus = id; wb.dispatch({ type: "focus", stack: id }); }
/** * Moves keyboard focus into a stack. Chrome panels get focus in their body. Sandbox panels get it * on their tab instead: keys pressed inside a sandboxed frame never reach the shell, so focus * placed there would strand F6 and every other shortcut. Tab still moves on into the body. */ #focusStack(layout: Layout, id: NodeId) { const stack = stacks(layout.root).find((s) => s.id === id); const el = this.#stacks.get(id); const active = stack?.active ? layout.panels[stack.active] : undefined; const body = active ? this.#bodies.get(active.id) : undefined; if (active && body && spec(active.kind).origin === "chrome") return body.view.element.focus(); const tab = (active && el?.tab(active.id)) || el?.tablist.querySelector<TabElement>("nib-tab"); tab?.focus(); }
/** Focuses a panel's tab wherever it now shows: the tab menu's way of returning focus after a move. */ #focusPanel(panel: PanelId): void { // The stack a tab just left recovers its own focus asynchronously (its tablist's own // MutationObserver, picking a neighbour for the one that's now gone): queued as a microtask by // the dispatch this runs right after. Queueing this call too, rather than focusing straight // away, puts it after that one in the microtask queue, so it's this call that wins. queueMicrotask(() => this.#focusPanelNow(panel)); }
#focusPanelNow(panel: PanelId): void { for (const el of this.#stackElements()) { const tab = el.tab(panel); if (!tab) continue; // A tab a move just brought into a stack has never been its tablist's roving-tabindex // "current" one, so it has no tabIndex yet and `.focus()` alone would do nothing. Dispatching // the same event a click fires makes the tablist adopt it, exactly as a pointer selection would. tab.dispatchEvent(new TabSelectEvent(tab)); tab.focus(); return; } }
#focusedTab(): { panel: PanelId | undefined; stack: NodeId } | undefined { const active = this.#root.activeElement; const stackEl = active?.closest("nib-stack") as StackElement | null | undefined; const stack = stackEl?.stack; if (!stack) return undefined; const panel = stack.panels.find((id) => stackEl!.tab(id) === active); return { panel, stack: stack.id }; }
/** After a rebuild detached a focused tab, puts focus back on it, or on its stack's selected tab if it closed. */ #restoreFocus(layout: Layout, { panel, stack }: { panel: PanelId | undefined; stack: NodeId }) { if (this.#root.activeElement) return; const home = (panel && this.#stackOfPanel(panel)) ?? stacks(layout.root).find((s) => s.id === stack); const el = home && this.#stacks.get(home.id); const target = (panel && el?.tab(panel)) || (home?.active && el?.tab(home.active)) || el?.tablist.querySelector("nib-tab"); target?.focus(); }
/** Every stack element a tab can be dragged from or dropped on: the layout tree's and the floating windows'. */ #stackElements(): StackElement[] { return [...this.#stacks.values(), ...[...this.#floats.values()].map((float) => float.stack)]; }
/** Every window's drag scope: this shell's first, then each floating window's. */ #scopes(): DragScope[] { const own = { view: window, stacks: [...this.#stacks.values()].filter((el) => el.isConnected), drop: this.#drop, origin: this.getBoundingClientRect() }; const floats = [...this.#floats.values()].map((float) => ({ view: float.window.window, stacks: [float.stack], drop: float.drop, origin: { left: 0, top: 0 } })); return [own, ...floats]; }
/** Which window a drag event happened in: one floating window's, or this shell's. */ #scope(event: DragEvent): DragScope { const view = (event.target as Node | null)?.ownerDocument?.defaultView; const scopes = this.#scopes(); return scopes.find((scope) => scope.view === view) ?? scopes[0]!; }
#onDragStart(event: DragEvent) { const tab = event.composedPath().find((node): node is TabElement => node instanceof Element && node.localName === "nib-tab"); if (!tab || !event.dataTransfer || !this.#workbench) return; for (const el of this.#stackElements()) { const panel = el.stack?.panels.find((id) => el.tab(id) === tab); if (!panel) continue; // The browser's own drag image is the tab, held where it was grabbed. It's painted once this // returns, so the tab is drawn whole for it, with every border, then dimmed in its place. this.#dragged = { panel, tab, last: undefined }; tab.toggleAttribute("data-lifting", true); event.dataTransfer.setData(DRAG_TYPE, panel); event.dataTransfer.effectAllowed = "move"; this.#setDragging(true); setTimeout(() => { tab.removeAttribute("data-lifting"); if (this.#dragged?.tab === tab) tab.toggleAttribute("data-dragging", true); }); return; } }
/** Bodies cover the content areas that take drops, so they stop taking pointer events while a tab drags. */ #setDragging(on: boolean) { setState(this.#internals, "dragging", on); for (const float of this.#floats.values()) float.window.dragging = on; }
/** Ends the drag. Resolves once the drop targets are gone, transitions and all. */ #endDrag(): Promise<unknown> { clearTimeout(this.#arming?.timer); this.#arming = undefined; this.#dragged?.tab.removeAttribute("data-dragging"); this.#dragged = undefined; const cleared = Promise.all(this.#scopes().map((scope) => scope.drop.clear())); this.#setDragging(false); this.#autoscroll.stop(); return cleared; }
/** * How far a drag at `event` is from the nearest edge of its window that tears off. Not the top: the * page's header and the browser's own tab strip are there, and dragging over them must not tear. */ #edgeDistance(event: DragEvent): number { const view = (event.target as Node | null)?.ownerDocument?.defaultView; if (!view || this.#compact) return Infinity; const { clientX: x, clientY: y } = event; return Math.min(x, view.innerWidth - x, view.innerHeight - y); }
#onDragOver(event: DragEvent) { const dragged = this.#dragged; if (!event.dataTransfer?.types.includes(DRAG_TYPE) || !dragged) return; const scope = this.#scope(event); const view = (event.target as Node).ownerDocument!.defaultView!; const last = dragged.last; const step = last && last.view === view ? Math.hypot(event.clientX - last.x, event.clientY - last.y) : 0; const distance = this.#edgeDistance(event); // Letting go within the band tears off. A fast pointer can leave from further in, which only the // dragend check allows for: two of its steps, so it can't widen the band a drop inside tears in. const tearOff = distance <= TEAR_EDGE; const leaving = distance <= Math.max(TEAR_EDGE, 2 * step); dragged.last = { view, x: event.clientX, y: event.clientY, screenX: event.screenX, screenY: event.screenY, time: performance.now(), tearOff, leaving }; this.#showTarget(scope, event.clientX, event.clientY, tearOff); // Every window of ours takes the drop, even where nothing would change, so the browser never // plays its cancelled-drop animation for a drop inside one. The edge band tears off. event.preventDefault(); event.dataTransfer.dropEffect = "move"; }
/** Shows where a drop at (x, y) in `scope`'s window would land, there and nowhere else. */ #showTarget(scope: DragScope, x: number, y: number, tearOff: boolean) { for (const other of this.#scopes()) if (other.view !== scope.view) other.drop.show(); const target = tearOff ? undefined : this.#target(x, y, scope); this.#autoscroll.update(target?.strip, x); if (!target || target.unchanged) return scope.drop.show(); scope.drop.show(target.box && offset(target.box, scope.origin), target.line && offset(target.line, scope.origin)); }
/** * The edge a drop at an area's edge splits toward, once the pointer has held there for * `SPLIT_DELAY`: until then the drop joins the area, so a tab passing over an edge on its way * somewhere doesn't flash a split. The browser's dragover repeats while the pointer rests, but not * reliably soon enough, so a timer looks again when the edge arms. */ #armedEdge(stack: NodeId, edge: Edge | undefined, at: { scope: DragScope; x: number; y: number }): Edge | undefined { const arming = this.#arming; if (!edge) { this.#arming = undefined; return undefined; } if (arming?.stack === stack && arming.edge === edge) return performance.now() - arming.since >= SPLIT_DELAY ? edge : undefined; clearTimeout(arming?.timer); const timer = setTimeout(() => { if (this.#dragged && this.#arming?.timer === timer) this.#showTarget(at.scope, at.x, at.y, false); }, SPLIT_DELAY); this.#arming = { stack, edge, since: performance.now(), timer }; return undefined; }
/** A drag that leaves a window for somewhere outside it no longer shows a target there. */ #onDragLeave(event: DragEvent) { const scope = this.#scope(event); // Chrome gives most dragleaves no relatedTarget, even between elements, so only a position at or // past the viewport's edge says the drag left. Trusting relatedTarget made the targets blink. const { clientX: x, clientY: y } = event; if (x > 0 && y > 0 && x < scope.view.innerWidth && y < scope.view.innerHeight) return; scope.drop.show(); this.#autoscroll.stop(); }
#onDrop(event: DragEvent) { const dragged = this.#dragged; if (!dragged) return; event.preventDefault(); const scope = this.#scope(event); const target = dragged.last?.tearOff ? undefined : this.#target(event.clientX, event.clientY, scope); const cleared = this.#endDrag(); // A tear-off opens a window, which needs the drop's gesture: it can't wait. if (!target) return dragged.last?.tearOff ? this.#tearOff(dragged.panel, event.screenX, event.screenY) : undefined; if (target.unchanged) return; // The move runs in a view transition over the window it lands in, so the tab, and the body it // brings, slide from where they were to where they land: FLIP, done by the browser. const root = scope.view === window ? this.#frame : scope.stacks[0]!; const move = () => this.#workbench?.dispatch({ type: "move", panel: dragged.panel, to: target.placement }); // Chrome sometimes aborts this ("Prepaint layout check failed"), mostly under load; the move // then just happens. Names in a shadow tree reach no document-wide transition, so it's scoped. void cleared .then(() => morph(root, move).finished) .then(() => { const tab = this.#stackElements().map((el) => el.tab(dragged.panel)).find(Boolean); if (tab) flash(tab); }); }
/** * A tab let go of past every window's edge tears off. Neither the dragend's position (wrong in some * browsers) nor screen coordinates (not comparable across displays) say where it ended, so it's the * last dragover: close to an edge, and a while ago, since a drag cancelled inside the window ends * soon after its last dragover. (The browser repeats dragover while the pointer is over a window.) */ #onDragEnd(event: DragEvent) { const dragged = this.#dragged; this.#endDrag(); const last = dragged?.last; if (!dragged || !last || event.dataTransfer?.dropEffect !== "none") return; if (!last.leaving || performance.now() - last.time < TEAR_GAP) return; this.#tearOff(dragged.panel, event.screenX || last.screenX, event.screenY || last.screenY); }
/** Floats a panel into a window at (screenX, screenY). A window's only tab moves its window instead. */ #tearOff(panel: PanelId, screenX: number, screenY: number) { const layout = this.#workbench?.layout; const body = this.#bodies.get(panel); if (!layout || !body) return; const { width, height } = body.tabpanel.getBoundingClientRect(); // The window opens under the pointer, as if the tab were still in hand. const bounds = { left: screenX - 24, top: screenY - 12, width: Math.max(width, 320), height: Math.max(height, 200) }; const from = owner(layout, panel); const float = from && this.#floats.get(from.id); if (float && from.panels.length === 1) return float.window.moveTo(bounds); this.#tearAt = bounds; this.#workbench!.dispatch({ type: "float", panel }); this.#tearAt = undefined; }
/** * Where a drag over `event` would put the panel, and the element that shows it. Found by position * rather than event target, because a gutter's pointer target overhangs the edges a drop splits toward. */ #target(x: number, y: number, scope: DragScope): Target | undefined { const layout = this.#workbench?.layout; if (!layout) return undefined; for (const stackEl of scope.stacks) { const stack = stackEl.stack; if (!stack) continue; const strip = stackEl.tablist.getBoundingClientRect(); if (contains(strip, x, y)) { const slot = tabSlot(stackEl.tablist, x, this.#dragged?.tab); const line = lineAt(strip, slot.boundary); return { placement: { stack: stack.id, index: slot.index }, line, strip: stackEl.tablist, ...(slot.unchanged && { unchanged: true }) }; } const rect = stackEl.content.getBoundingClientRect(); if (!contains(rect, x, y)) continue; const edge = this.#armedEdge(stack.id, edgeAt(rect, x, y, splitEdges(layout, stack)), { scope, x, y }); const placement: Placement = edge ? { beside: stack.id, edge } : { stack: stack.id }; const box = dropRect(rect, placement); if (edge) return { placement, box }; // Joining a stack puts the tab at the end of its strip, so the line shows there too. Its own // stack's middle is where the tab already is. if (stack.panels.includes(this.#dragged?.panel as PanelId)) return { placement, unchanged: true }; const tabs = [...stackEl.tablist.children].filter((el) => el.localName === "nib-tab"); const end = tabs.at(-1)?.getBoundingClientRect().right ?? strip.left; return { placement, box, line: lineAt(strip, end) }; } return undefined; }
/** * Gives each floating stack a window showing its panels, and puts the bodies of panels that left a * window back in the body layer. A window opens during the render a `float` command causes, so it * runs inside the gesture that tore the panel off; one the browser blocks anyway docks back. */ #reconcileFloats(layout: Layout) { const workbench = this.#workbench!; const floating = new Set(layout.floating.flatMap((stack) => stack.panels)); for (const id of floating) { const home = this.#homes.has(id) ? undefined : this.#previousStack(id); if (home) this.#homes.set(id, home); } for (const id of this.#homes.keys()) if (!floating.has(id)) this.#homes.delete(id);
for (const stack of layout.floating) { const float = this.#floats.get(stack.id) ?? this.#openFloat(stack); if (!float) { queueMicrotask(() => this.#dockAll(stack.id)); continue; } float.stack.update(layout, stack, workbench, "Floating Window", (id) => this.#bodies.get(id)?.tabpanel, (id) => this.#focusPanel(id)); nameTabs(float.stack, stack); const active = stack.active ? layout.panels[stack.active] : undefined; if (active) { float.window.title = titleOf(active); // This page's path, and which panel the window shows: /new#editor=src/index.ts. float.window.url = `${location.pathname}#${active.kind}=${encodeURI(keyOf(active))}`; } for (const id of stack.panels) { const tabpanel = this.#bodies.get(id)?.tabpanel; if (!tabpanel) continue; if (tabpanel.parentNode !== float.stack.content) float.stack.content.append(tabpanel); tabpanel.removeAttribute("style"); tabpanel.style.viewTransitionName = transitionName("body", id); tabpanel.hidden = id !== stack.active; } // Activating a floating panel from elsewhere (opening its file again, say) brings its window forward. if (stack.active !== float.active) { float.active = stack.active; float.window.focus(); } } for (const [id, body] of this.#bodies) { if (!floating.has(id) && id !== this.#drawerPanel && body.tabpanel.parentNode !== this.#layer) this.#layer.append(body.tabpanel); } for (const [id, float] of this.#floats) { if (layout.floating.some((stack) => stack.id === id)) continue; this.#floats.delete(id); float.window.close(); } }
#openFloat(stack: Stack): Float | undefined { const el = document.createElement("nib-stack"); const drop = new DropIndicator(); const window = FloatingWindow.open(`nib-window-${stack.id}`, { page: this.floatingPage, bounds: this.#tearAt ?? this.#defaultBounds(), content: [el, drop.layer], closed: () => this.#dockAll(stack.id), drag: (event) => this.#onDrag(event), }); if (!window) return undefined; const float: Float = { window, stack: el, drop, active: null }; this.#floats.set(stack.id, float); return float; }
#onDrag(event: DragEvent) { switch (event.type) { case "dragstart": return this.#onDragStart(event); case "dragend": return this.#onDragEnd(event); case "dragover": return this.#onDragOver(event); case "dragleave": return this.#onDragLeave(event); case "drop": return this.#onDrop(event); } }
/** Puts every panel of a floating stack back, as closing its window does. */ #dockAll(stack: NodeId) { const panels = this.#workbench?.layout.floating.find((s) => s.id === stack)?.panels ?? []; for (const panel of panels) this.#dock(panel); }
/** Puts a floating panel back in the stack it left, or in its kind's slot when that stack is gone. */ #dock(id: PanelId) { const workbench = this.#workbench; const panel = workbench?.layout.panels[id]; if (!workbench || !panel) return; const home = this.#homes.get(id); const back = home && findStack(workbench.layout, home) && !isFloating(workbench.layout, home); workbench.dispatch({ type: "move", panel: id, to: back ? { stack: home } : { slot: spec(panel.kind).slot } }); }
/** The tree stack a panel was in before the layout that floated it: the one this shell last rendered it in. */ #previousStack(id: PanelId): NodeId | undefined { for (const el of this.#stacks.values()) if (el.stack?.panels.includes(id)) return el.stack.id; return undefined; }
#defaultBounds(): Bounds { const width = 480; const height = 360; return { left: window.screenX + (window.outerWidth - width) / 2, top: window.screenY + (window.outerHeight - height) / 2, width, height }; }
#closeFloats() { for (const float of this.#floats.values()) { for (const tabpanel of [...float.stack.content.children]) this.#layer.append(tabpanel); float.window.close(); } this.#floats.clear(); }}
function part(name: string): HTMLDivElement { const el = document.createElement("div"); el.part = name; return el;}
function findSplit(node: LayoutNode, id: NodeId): Split | undefined { if (node.type === "stack") return undefined; if (node.id === id) return node; for (const child of node.children) { const found = findSplit(child, id); if (found) return found; } return undefined;}
/** Names a split child for the gutter that resizes it: its first stack's region. */function describe(layout: Layout, node: LayoutNode): string { const first = stacks(node).find((stack) => isVisible(layout, stack)); return first ? regionLabel(first, 0) : "group";}
/** * Where among a tablist's tabs a drop at `x` lands: the boundary between tabs it draws its line at, * and the index the panel takes once it has left its own place. Next to itself, nothing changes. */function tabSlot(tablist: Element, x: number, dragged: Element | undefined): { index: number; boundary: number; unchanged: boolean } { const tabs = [...tablist.children].filter((el) => el.localName === "nib-tab"); const found = tabs.findIndex((tab) => { const rect = tab.getBoundingClientRect(); return x < rect.left + rect.width / 2; }); const at = found === -1 ? tabs.length : found; const boundary = tabs[at]?.getBoundingClientRect().left ?? tabs.at(-1)?.getBoundingClientRect().right ?? tablist.getBoundingClientRect().left; const from = dragged ? tabs.indexOf(dragged) : -1; return { index: from !== -1 && from < at ? at - 1 : at, boundary, unchanged: from !== -1 && (at === from || at === from + 1) };}
/** * Names each tab for its panel, not its element, since a move to another stack makes the tab there * anew: a drop's view transition then slides the one tab from where it was to where it lands. */function nameTabs(el: StackElement, stack: Stack) { for (const id of stack.panels) { const tab = el.tab(id); if (tab) tab.style.viewTransitionName = transitionName("tab", id); }}
/** `rect`, from viewport coordinates into those of a layer at `origin`. */function offset(rect: Rect, origin: { left: number; top: number }): Rect { return { ...rect, left: rect.left - origin.left, top: rect.top - origin.top };}
function contains(rect: DOMRect, x: number, y: number): boolean { return x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;}
/** The edge a drop at (x, y) splits toward, of the ones `edges` allows, or none for the middle of the area. */function edgeAt(rect: DOMRect, x: number, y: number, edges: readonly Edge[]): Edge | undefined { const fx = (x - rect.left) / rect.width; const fy = (y - rect.top) / rect.height; const distance: Record<Edge, number> = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy }; const nearest = edges.reduce<Edge | undefined>((best, edge) => (best === undefined || distance[edge] < distance[best] ? edge : best), undefined); return nearest && distance[nearest] <= 0.25 ? nearest : undefined;}
/** The line between tabs at `x`, the height of the strip. */function lineAt(strip: DOMRect, x: number): Rect { return { left: x - 1, top: strip.top, width: 2, height: strip.height };}
/** The box a drop indicator shows for `placement` over an area: the half an edge splits toward, or the whole area. */function dropRect(rect: DOMRect, placement: Placement): Rect { let { left, top, width, height } = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; if ("edge" in placement) { if (placement.edge === "right") left += width / 2; if (placement.edge === "bottom") top += height / 2; if (placement.edge === "left" || placement.edge === "right") width /= 2; else height /= 2; } return { left, top, width, height };}
declare global { interface HTMLElementTagNameMap { "nib-shell": ShellElement; }}
define("nib-shell", ShellElement);