feat(overlay): drag a panel's height, and reset both axes
Half of this was already built. A floating dock's height is written at tear-off, clamped, persisted and restored — and nothing could change it, because there was no handle. A docked dock had no height at all, being `position: fixed` with both `top` and `bottom` anchored. `grep row-resize src/` was empty. Both docks get a bottom-edge splitter, the width mechanism turned ninety degrees: one `Draggable` with no feedback, one monitor triple, one clamp. Its own `DND.dockHeight` type rather than a suffix on `splitter`, because `watchSplitters` selects with `startsWith(DND.splitter)` and an `"airship:splitter-v"` would match it and be parsed as a width drag. A docked panel keeps filling its edge until you pin it: `.dock-h` drops the `bottom` anchor and takes `--*-h`, and `h: 0` means unpinned. That sentinel is the load-bearing part. `clampHeight`'s floor is `MIN_DOCK_H`, so a stored 0 run through it comes back as 200 — every docked panel in every install silently pinning itself to a fifth of the window on the first reload. `restoreSizes` preserves it, `applyPlacement` removes the custom property rather than publishing a clamped 0 (so a class regression degrades to the anchors instead of snapping to 200px), and a cancelled drag writes the raw stored pair straight back rather than going through the clamp — `dockHeight` substitutes `offsetHeight` for the sentinel, so restoring from it would end an Escape by pinning a panel that was filling its edge, and persist it. Both branches of `dockHeight` start from what the panel is *painted* at. `applyPlacement` paints `min(h, room below y)` and deliberately leaves `h` alone, so a panel torn off at full height and carried down the screen had a stored 860 against a painted 380 — and the grip did nothing for the first 480px of travel. Docked height goes in the size store, renamed from `dock-widths` with a migration off the old key, rather than joining the placement blob: `restoreDocks` skips anything that is not floating, so a docked height stored there would be written on every drop and never read back — a worse failure than not persisting it, because it looks like it works until you reload. `resetWidth` becomes `resetSize` and answers both axes. Width goes back to a literal because a panel's default width is one; height *unpins* instead, because a docked column's right height is "the edge" — a fact about the window that a literal would get wrong the moment it changed size, and that `bottom` answers for free. Both resize gestures shipped undocumented — the splitter is a 7px transparent strip that shows a hairline on hover — so the catalog gains rows for them and they appear in the sheet and `CONTROLS.md` like everything else.
This commit is contained in:
@@ -106,6 +106,8 @@ the editor also answers to a plain `+` and `-`.
|
|||||||
| Jump the camera | Press or drag the minimap | Press or drag the minimap | view mode, canvas only | Press anywhere on the minimap to jump there, and keep dragging to keep moving. |
|
| Jump the camera | Press or drag the minimap | Press or drag the minimap | view mode, canvas only | Press anywhere on the minimap to jump there, and keep dragging to keep moving. |
|
||||||
| Scrub a number | Drag a field's glyph | Drag a field's glyph | edit mode | Drag a field's glyph sideways. Shift for ten at a time, Alt for a tenth. |
|
| Scrub a number | Drag a field's glyph | Drag a field's glyph | edit mode | Drag a field's glyph sideways. Shift for ten at a time, Alt for a tenth. |
|
||||||
| Re-dock a panel | Double-click a panel header | Double-click a panel header | anywhere | Double-click a floating panel's header to put it back against the edge. |
|
| Re-dock a panel | Double-click a panel header | Double-click a panel header | anywhere | Double-click a floating panel's header to put it back against the edge. |
|
||||||
|
| Resize a panel | Drag a panel edge | Drag a panel edge | anywhere | Drag the inner edge for width, the bottom edge for height. |
|
||||||
|
| Reset a panel's size | Double-click a panel edge | Double-click a panel edge | anywhere | Width goes back to its default; a docked panel goes back to filling its edge. |
|
||||||
| Scroll the pending changes | Wheel over the strip | Wheel over the strip | anywhere | A vertical wheel scrolls the strip sideways, because a mouse has no sideways. |
|
| Scroll the pending changes | Wheel over the strip | Wheel over the strip | anywhere | A vertical wheel scrolls the strip sideways, because a mouse has no sideways. |
|
||||||
|
|
||||||
## In any field
|
## In any field
|
||||||
|
|||||||
+341
-61
@@ -79,7 +79,7 @@ import {
|
|||||||
} from "./popover-host";
|
} from "./popover-host";
|
||||||
import { StructureSet } from "./structure-set";
|
import { StructureSet } from "./structure-set";
|
||||||
import { injectStyles } from "./styles";
|
import { injectStyles } from "./styles";
|
||||||
import { MIN_DOCK_W } from "./styles/const";
|
import { MIN_DOCK_H, MIN_DOCK_W } from "./styles/const";
|
||||||
import { InlineResolver, type Surface, type SurfaceResolver } from "./surface";
|
import { InlineResolver, type Surface, type SurfaceResolver } from "./surface";
|
||||||
import { mountToastHost, type ToastOptions, toast } from "./toast";
|
import { mountToastHost, type ToastOptions, toast } from "./toast";
|
||||||
import { setRuntimeTokens, setStaticTokens } from "./tokens/registry";
|
import { setRuntimeTokens, setStaticTokens } from "./tokens/registry";
|
||||||
@@ -114,13 +114,21 @@ const NUDGE_AXIS: Readonly<Record<string, readonly [number, number]>> = {
|
|||||||
const LEFT_W = 340;
|
const LEFT_W = 340;
|
||||||
const RIGHT_W = 360;
|
const RIGHT_W = 360;
|
||||||
/** Resize bounds: narrow enough to be useful, never more than half the viewport.
|
/** Resize bounds: narrow enough to be useful, never more than half the viewport.
|
||||||
* `MIN_DOCK_W` lives in `styles/const.ts` — the stories need it too. */
|
* `MIN_DOCK_W` and `MIN_DOCK_H` live in `styles/const.ts` — the stories and the
|
||||||
|
* stylesheet need them too. */
|
||||||
const MAX_DOCK_W = 720;
|
const MAX_DOCK_W = 720;
|
||||||
const WIDTH_KEY = `${PREFIX}-dock-widths`;
|
/**
|
||||||
|
* Both dock sizes, in one store.
|
||||||
|
*
|
||||||
|
* Renamed from `${PREFIX}-dock-widths`, which held a bare number per side and is
|
||||||
|
* still read once as a migration — see `restoreSizes`. Height joined width here
|
||||||
|
* rather than joining `DOCKS_KEY` for two reasons. It is written by the same
|
||||||
|
* gesture, so the write cadence argument below applies to it identically; and
|
||||||
|
* `restoreDocks` deliberately skips any entry that is not floating, so a docked
|
||||||
|
* height put there would be persisted and then never read back.
|
||||||
|
*/
|
||||||
|
const SIZE_KEY = `${PREFIX}-dock-size`;
|
||||||
|
|
||||||
/** Floor for a floating panel's height. Below this a panel is a title bar with
|
|
||||||
* a sliver under it, which is worse than not being resizable at all. */
|
|
||||||
const MIN_DOCK_H = 200;
|
|
||||||
/** Fallback header height. A collapsed dock is `display: none` and every metric
|
/** Fallback header height. A collapsed dock is `display: none` and every metric
|
||||||
* inside it measures 0, so clamping one needs a number to fall back on. */
|
* inside it measures 0, so clamping one needs a number to fall back on. */
|
||||||
const HEAD_H = 40;
|
const HEAD_H = 40;
|
||||||
@@ -143,7 +151,7 @@ type Side = "left" | "right";
|
|||||||
type DockMode = "docked" | "floating";
|
type DockMode = "docked" | "floating";
|
||||||
|
|
||||||
/** Where a dock is. `x`/`y`/`h` are ignored while docked; the edge anchors in
|
/** Where a dock is. `x`/`y`/`h` are ignored while docked; the edge anchors in
|
||||||
* `docks.css.ts` drive it there. */
|
* `docks.css.ts` drive it there, and its docked height is `DockSize.h`. */
|
||||||
interface DockPlacement {
|
interface DockPlacement {
|
||||||
h: number;
|
h: number;
|
||||||
mode: DockMode;
|
mode: DockMode;
|
||||||
@@ -151,6 +159,26 @@ interface DockPlacement {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How big a dock is on its edge.
|
||||||
|
*
|
||||||
|
* `h` of 0 means *unpinned*, not "zero tall": a docked panel is anchored `top`
|
||||||
|
* and `bottom` and fills its edge until a drag pins it, which is why the reset
|
||||||
|
* removes the pin rather than replacing it with a literal. A number for "the
|
||||||
|
* height of the window, less two insets" would be wrong the moment the window
|
||||||
|
* changed size, and would need re-deriving on every resize; `bottom` answers the
|
||||||
|
* same question for free and keeps answering it.
|
||||||
|
*
|
||||||
|
* A docked height and a *floating* height are deliberately two different
|
||||||
|
* numbers, held in two different stores. The useful height of a column on an
|
||||||
|
* edge and of a card over the page are not the same, and `floatHeight` has
|
||||||
|
* always derived one from the other at tear-off rather than carrying it across.
|
||||||
|
*/
|
||||||
|
interface DockSize {
|
||||||
|
h: number;
|
||||||
|
w: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The backends the picker offers, each with its own product mark.
|
* The backends the picker offers, each with its own product mark.
|
||||||
*
|
*
|
||||||
@@ -504,10 +532,10 @@ export class AirshipApp {
|
|||||||
private pendingSeen = 0;
|
private pendingSeen = 0;
|
||||||
private rightOpen = false;
|
private rightOpen = false;
|
||||||
|
|
||||||
/** Live dock widths, persisted across reloads and reset from the splitters. */
|
/** Live dock sizes, persisted across reloads and reset from the splitters. */
|
||||||
private readonly width: Record<Side, number> = {
|
private readonly size: Record<Side, DockSize> = {
|
||||||
left: LEFT_W,
|
left: { h: 0, w: LEFT_W },
|
||||||
right: RIGHT_W,
|
right: { h: 0, w: RIGHT_W },
|
||||||
};
|
};
|
||||||
/** The dock a splitter drag is currently resizing, plus its start geometry.
|
/** The dock a splitter drag is currently resizing, plus its start geometry.
|
||||||
* `startX` matters only while floating — see `watchSplitters`. */
|
* `startX` matters only while floating — see `watchSplitters`. */
|
||||||
@@ -517,6 +545,16 @@ export class AirshipApp {
|
|||||||
startX: number;
|
startX: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
private readonly splitterDelta = new DragDelta();
|
private readonly splitterDelta = new DragDelta();
|
||||||
|
/** The dock a bottom-edge drag is resizing, the painted height it started
|
||||||
|
* from, and the stored pair a cancel has to put back. Separate from `resizing`
|
||||||
|
* so a cancel restores the axis it belongs to. */
|
||||||
|
private resizingH: {
|
||||||
|
side: Side;
|
||||||
|
startH: number;
|
||||||
|
wasDocked: number;
|
||||||
|
wasFloat: number;
|
||||||
|
} | null = null;
|
||||||
|
private readonly heightDelta = new DragDelta();
|
||||||
|
|
||||||
/** Where each dock is: pinned to its edge, or floating and where. */
|
/** Where each dock is: pinned to its edge, or floating and where. */
|
||||||
private readonly placement: Record<Side, DockPlacement> = {
|
private readonly placement: Record<Side, DockPlacement> = {
|
||||||
@@ -937,7 +975,7 @@ export class AirshipApp {
|
|||||||
mount(): void {
|
mount(): void {
|
||||||
this.stage.bindSelection?.(() => this.selected);
|
this.stage.bindSelection?.(() => this.selected);
|
||||||
this.root = el("div", { id: `${PREFIX}-root` });
|
this.root = el("div", { id: `${PREFIX}-root` });
|
||||||
this.restoreWidths();
|
this.restoreSizes();
|
||||||
this.restoreDocks();
|
this.restoreDocks();
|
||||||
// Before the docks are built: `buildAgentButton` paints the tooltip from
|
// Before the docks are built: `buildAgentButton` paints the tooltip from
|
||||||
// this state, and a restore afterwards would leave the first frame showing
|
// this state, and a restore afterwards would leave the first frame showing
|
||||||
@@ -978,6 +1016,7 @@ export class AirshipApp {
|
|||||||
this.clampPlacements();
|
this.clampPlacements();
|
||||||
this.applyWidths();
|
this.applyWidths();
|
||||||
this.watchSplitters();
|
this.watchSplitters();
|
||||||
|
this.watchHeightSplitters();
|
||||||
this.watchDockDrag();
|
this.watchDockDrag();
|
||||||
window.addEventListener("resize", this.onViewportResize);
|
window.addEventListener("resize", this.onViewportResize);
|
||||||
this.stage.onLayoutChange(() => {
|
this.stage.onLayoutChange(() => {
|
||||||
@@ -1152,13 +1191,13 @@ export class AirshipApp {
|
|||||||
/**
|
/**
|
||||||
* A splitter on the dock's inner edge. dnd-kit drives the gesture so it picks
|
* A splitter on the dock's inner edge. dnd-kit drives the gesture so it picks
|
||||||
* up the same activation threshold and Escape-to-cancel as every other drag;
|
* up the same activation threshold and Escape-to-cancel as every other drag;
|
||||||
* double-clicking snaps the dock back to its default width.
|
* double-clicking snaps the dock back to its default size.
|
||||||
*/
|
*/
|
||||||
private buildSplitter(side: Side): HTMLElement {
|
private buildSplitter(side: Side): HTMLElement {
|
||||||
const handle = el("div", {
|
const handle = el("div", {
|
||||||
class: `${cls("splitter")} ${cls(`splitter-${side}`)}`,
|
class: `${cls("splitter")} ${cls(`splitter-${side}`)}`,
|
||||||
"data-tip": "Drag to resize, double-click to reset",
|
"data-tip": "Drag to resize, double-click to reset",
|
||||||
onDblclick: () => this.resetWidth(side),
|
onDblclick: () => this.resetSize(side),
|
||||||
});
|
});
|
||||||
this.dockScope.add(
|
this.dockScope.add(
|
||||||
new Draggable(
|
new Draggable(
|
||||||
@@ -1176,6 +1215,145 @@ export class AirshipApp {
|
|||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A splitter on the dock's bottom edge, for its height.
|
||||||
|
*
|
||||||
|
* The same gesture on the other axis, and deliberately the same shape: one
|
||||||
|
* `Draggable` with no feedback, one monitor triple, one clamp. What it writes
|
||||||
|
* differs by mode — a docked panel's height is `size[side].h` and a floating
|
||||||
|
* one's is `placement[side].h` — because those are two different numbers with
|
||||||
|
* two different defaults, and `applyPlacement` picks between them.
|
||||||
|
*
|
||||||
|
* There is no `trackFloatingResize` twin. That exists because the right dock
|
||||||
|
* floats from a `left` anchor, so widening it from the *inner* edge would slide
|
||||||
|
* the grip out from under the cursor; a bottom edge leaves the top where it is
|
||||||
|
* in both modes, so neither dock needs compensating.
|
||||||
|
*/
|
||||||
|
private buildHeightSplitter(side: Side): HTMLElement {
|
||||||
|
const handle = el("div", {
|
||||||
|
class: `${cls("splitter")} ${cls("splitter-bottom")}`,
|
||||||
|
"data-tip": "Drag to resize, double-click to reset",
|
||||||
|
onDblclick: () => this.resetSize(side),
|
||||||
|
});
|
||||||
|
this.dockScope.add(
|
||||||
|
new Draggable(
|
||||||
|
{
|
||||||
|
element: handle,
|
||||||
|
id: `${DND.dockHeight}:${side}`,
|
||||||
|
// As above: the handle must not travel, and only `y` is read.
|
||||||
|
plugins: FEEDBACK.none,
|
||||||
|
type: DND.dockHeight,
|
||||||
|
},
|
||||||
|
manager
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The height a bottom-edge drag starts from, by mode.
|
||||||
|
*
|
||||||
|
* Both branches answer with what the panel is *painted* at rather than with
|
||||||
|
* what is stored, and both need to for the same reason: a drag is a gesture
|
||||||
|
* against what you can see, so a stored number the paint does not match makes
|
||||||
|
* the grip inert until the delta has covered the difference.
|
||||||
|
*
|
||||||
|
* Floating is the case that bites. `applyPlacement` paints
|
||||||
|
* `min(h, room below y)` and deliberately leaves `h` alone — carrying a panel
|
||||||
|
* down the screen must not shrink it permanently — so a panel torn off at full
|
||||||
|
* height and dragged to the middle has `h` of 860 while showing 380. Starting
|
||||||
|
* from 860 would mean 480px of travel before the clamp let go.
|
||||||
|
*
|
||||||
|
* Docked, the stored 0 is the *unpinned* sentinel, so there is nothing to
|
||||||
|
* start from at all and the measured box is the only honest answer.
|
||||||
|
*/
|
||||||
|
private dockHeight(side: Side): number {
|
||||||
|
const p = this.placement[side];
|
||||||
|
if (p.mode === "floating") {
|
||||||
|
return Math.min(
|
||||||
|
p.h,
|
||||||
|
Math.max(MIN_DOCK_H, window.innerHeight - p.y - this.inset)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.size[side].h || this.dockEl(side).offsetHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setDockHeight(side: Side, h: number): void {
|
||||||
|
const clamped = clampHeight(
|
||||||
|
h,
|
||||||
|
this.placement[side].mode === "floating"
|
||||||
|
? this.placement[side].y
|
||||||
|
: this.inset,
|
||||||
|
this.inset
|
||||||
|
);
|
||||||
|
if (this.placement[side].mode === "floating") {
|
||||||
|
this.placement[side].h = clamped;
|
||||||
|
} else {
|
||||||
|
this.size[side].h = clamped;
|
||||||
|
}
|
||||||
|
this.applyPlacement(side);
|
||||||
|
}
|
||||||
|
|
||||||
|
private watchHeightSplitters(): void {
|
||||||
|
// Captured for the reason `watchSplitters` gives below, and separate from it
|
||||||
|
// so a cancelled drag restores the axis it belongs to rather than the other.
|
||||||
|
this.disposers.push(
|
||||||
|
manager.monitor.addEventListener("dragstart", () => {
|
||||||
|
const id = String(manager.dragOperation.source?.id ?? "");
|
||||||
|
if (!id.startsWith(DND.dockHeight)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const side: Side = id.endsWith("left") ? "left" : "right";
|
||||||
|
this.resizingH = {
|
||||||
|
side,
|
||||||
|
startH: this.dockHeight(side),
|
||||||
|
// The *stored* pair, kept alongside the painted `startH` so Escape can
|
||||||
|
// put back exactly what was there. `startH` is a measurement and often
|
||||||
|
// a stand-in — for an unpinned docked panel it is `offsetHeight`, where
|
||||||
|
// the stored value is the 0 sentinel — so restoring from it would end a
|
||||||
|
// cancelled drag by pinning a panel that was filling its edge, and
|
||||||
|
// `saveSizes` below would write that pin to disk. A cancel has to be a
|
||||||
|
// no-op, and only the raw numbers can make it one.
|
||||||
|
wasDocked: this.size[side].h,
|
||||||
|
wasFloat: this.placement[side].h,
|
||||||
|
};
|
||||||
|
this.heightDelta.start();
|
||||||
|
this.controller.guard.setDragging(true, "row-resize");
|
||||||
|
}),
|
||||||
|
manager.monitor.addEventListener("dragmove", (e) => {
|
||||||
|
const rz = this.resizingH;
|
||||||
|
if (!rz) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No sign flip: both docks are anchored at the top and grow downwards.
|
||||||
|
this.setDockHeight(rz.side, rz.startH + this.heightDelta.update(e).y);
|
||||||
|
}),
|
||||||
|
manager.monitor.addEventListener("dragend", (e) => {
|
||||||
|
const rz = this.resizingH;
|
||||||
|
if (!rz) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.canceled) {
|
||||||
|
// Written straight back rather than through `setDockHeight`, which
|
||||||
|
// clamps — and a clamp is exactly what must not happen here, because
|
||||||
|
// the 0 sentinel would come back as `MIN_DOCK_H`.
|
||||||
|
this.size[rz.side].h = rz.wasDocked;
|
||||||
|
this.placement[rz.side].h = rz.wasFloat;
|
||||||
|
this.applyPlacement(rz.side);
|
||||||
|
}
|
||||||
|
this.resizingH = null;
|
||||||
|
this.controller.guard.setDragging(false);
|
||||||
|
this.saveSizes();
|
||||||
|
this.saveDocks();
|
||||||
|
// The composer's cap is a fraction of the left dock's height, so a
|
||||||
|
// height drag is exactly the gesture that has to re-run it.
|
||||||
|
this.autoGrow();
|
||||||
|
// The outline lives in viewport coords; realign after layout settles.
|
||||||
|
requestAnimationFrame(() => this.controller.drawOutline());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private watchSplitters(): void {
|
private watchSplitters(): void {
|
||||||
// Captured, like every other monitor listener in the repo. `manager` is a
|
// Captured, like every other monitor listener in the repo. `manager` is a
|
||||||
// module singleton, so `dockScope.clear()` in `destroy` takes the draggable
|
// module singleton, so `dockScope.clear()` in `destroy` takes the draggable
|
||||||
@@ -1190,7 +1368,7 @@ export class AirshipApp {
|
|||||||
const side: Side = id.endsWith("left") ? "left" : "right";
|
const side: Side = id.endsWith("left") ? "left" : "right";
|
||||||
this.resizing = {
|
this.resizing = {
|
||||||
side,
|
side,
|
||||||
startW: this.width[side],
|
startW: this.size[side].w,
|
||||||
startX: this.placement[side].x,
|
startX: this.placement[side].x,
|
||||||
};
|
};
|
||||||
this.splitterDelta.start();
|
this.splitterDelta.start();
|
||||||
@@ -1204,7 +1382,7 @@ export class AirshipApp {
|
|||||||
// The left dock grows rightwards, the right dock grows leftwards.
|
// The left dock grows rightwards, the right dock grows leftwards.
|
||||||
const dx =
|
const dx =
|
||||||
this.splitterDelta.update(e).x * (rz.side === "left" ? 1 : -1);
|
this.splitterDelta.update(e).x * (rz.side === "left" ? 1 : -1);
|
||||||
this.width[rz.side] = clampWidth(rz.startW + dx);
|
this.size[rz.side].w = clampWidth(rz.startW + dx);
|
||||||
this.trackFloatingResize(rz);
|
this.trackFloatingResize(rz);
|
||||||
this.applyWidths();
|
this.applyWidths();
|
||||||
}),
|
}),
|
||||||
@@ -1214,13 +1392,13 @@ export class AirshipApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.canceled) {
|
if (e.canceled) {
|
||||||
this.width[rz.side] = rz.startW;
|
this.size[rz.side].w = rz.startW;
|
||||||
this.placement[rz.side].x = rz.startX;
|
this.placement[rz.side].x = rz.startX;
|
||||||
this.applyWidths();
|
this.applyWidths();
|
||||||
}
|
}
|
||||||
this.resizing = null;
|
this.resizing = null;
|
||||||
this.controller.guard.setDragging(false);
|
this.controller.guard.setDragging(false);
|
||||||
this.saveWidths();
|
this.saveSizes();
|
||||||
this.saveDocks();
|
this.saveDocks();
|
||||||
this.autoGrow();
|
this.autoGrow();
|
||||||
// The outline lives in viewport coords; realign after the layout settles.
|
// The outline lives in viewport coords; realign after the layout settles.
|
||||||
@@ -1252,7 +1430,7 @@ export class AirshipApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const right = rz.startX + rz.startW;
|
const right = rz.startX + rz.startW;
|
||||||
p.x = this.clampFloat("right", right - this.width.right, p.y).x;
|
p.x = this.clampFloat("right", right - this.size.right.w, p.y).x;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Dock dragging ---------------------------------------------------------
|
// -- Dock dragging ---------------------------------------------------------
|
||||||
@@ -1371,10 +1549,30 @@ export class AirshipApp {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetWidth(side: Side): void {
|
/**
|
||||||
this.width[side] = side === "left" ? LEFT_W : RIGHT_W;
|
* Put a panel back to its default size, on both axes.
|
||||||
|
*
|
||||||
|
* Width goes back to a literal because a panel's default width *is* one — 340
|
||||||
|
* and 360 are choices about how much room a chat and an inspector need, and
|
||||||
|
* they do not follow from anything about the window.
|
||||||
|
*
|
||||||
|
* Height does not, and that is why the docked case unpins rather than
|
||||||
|
* resetting. A docked column's right height is "the edge", which is a fact
|
||||||
|
* about the window: a literal would be stale the moment it changed size and
|
||||||
|
* would need re-deriving on every resize, where dropping the pin hands the
|
||||||
|
* question back to the `bottom` anchor that answers it for free. A floating
|
||||||
|
* panel has no edge to fill, so it does get a number — the same one
|
||||||
|
* `floatHeight` derives for a panel torn off closed.
|
||||||
|
*/
|
||||||
|
private resetSize(side: Side): void {
|
||||||
|
this.size[side].w = side === "left" ? LEFT_W : RIGHT_W;
|
||||||
|
this.size[side].h = 0;
|
||||||
|
if (this.placement[side].mode === "floating") {
|
||||||
|
this.placement[side].h = clampHeight(window.innerHeight - 2 * this.inset);
|
||||||
|
this.saveDocks();
|
||||||
|
}
|
||||||
this.applyWidths();
|
this.applyWidths();
|
||||||
this.saveWidths();
|
this.saveSizes();
|
||||||
this.autoGrow();
|
this.autoGrow();
|
||||||
requestAnimationFrame(() => this.controller.drawOutline());
|
requestAnimationFrame(() => this.controller.drawOutline());
|
||||||
}
|
}
|
||||||
@@ -1405,7 +1603,7 @@ export class AirshipApp {
|
|||||||
for (const side of ["left", "right"] as const) {
|
for (const side of ["left", "right"] as const) {
|
||||||
root.style.setProperty(
|
root.style.setProperty(
|
||||||
`--${PREFIX}-${side}-w`,
|
`--${PREFIX}-${side}-w`,
|
||||||
`${clampWidth(this.width[side])}px`
|
`${clampWidth(this.size[side].w)}px`
|
||||||
);
|
);
|
||||||
this.applyPlacement(side);
|
this.applyPlacement(side);
|
||||||
}
|
}
|
||||||
@@ -1417,7 +1615,7 @@ export class AirshipApp {
|
|||||||
// every fit and every centring quietly off-centre to the left.
|
// every fit and every centring quietly off-centre to the left.
|
||||||
const covers = (side: Side): number =>
|
const covers = (side: Side): number =>
|
||||||
this.isVisible(side) && this.placement[side].mode === "docked"
|
this.isVisible(side) && this.placement[side].mode === "docked"
|
||||||
? clampWidth(this.width[side]) + gutter
|
? clampWidth(this.size[side].w) + gutter
|
||||||
: 0;
|
: 0;
|
||||||
this.stage.setSafeInset?.({
|
this.stage.setSafeInset?.({
|
||||||
left: covers("left"),
|
left: covers("left"),
|
||||||
@@ -1426,26 +1624,52 @@ export class AirshipApp {
|
|||||||
this.stage.relayout?.();
|
this.stage.relayout?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreWidths(): void {
|
/**
|
||||||
|
* Both sizes, and the old width-only store read once on the way past.
|
||||||
|
*
|
||||||
|
* The previous key held a bare number per side. Migrating rather than dropping
|
||||||
|
* it costs six lines and is the difference between an upgrade nobody notices
|
||||||
|
* and every existing install silently losing the panel widths it was set up
|
||||||
|
* with. The old key is left in place rather than removed — a read that finds
|
||||||
|
* nothing is the same as a read that finds a stale number nothing looks at,
|
||||||
|
* and deleting somebody's data to tidy up is a bad trade.
|
||||||
|
*/
|
||||||
|
private restoreSizes(): void {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(WIDTH_KEY);
|
const raw =
|
||||||
|
localStorage.getItem(SIZE_KEY) ??
|
||||||
|
localStorage.getItem(`${PREFIX}-dock-widths`);
|
||||||
const saved = raw
|
const saved = raw
|
||||||
? (JSON.parse(raw) as Partial<Record<Side, number>>)
|
? (JSON.parse(raw) as Partial<Record<Side, number | Partial<DockSize>>>)
|
||||||
: null;
|
: null;
|
||||||
if (saved?.left) {
|
for (const side of ["left", "right"] as const) {
|
||||||
this.width.left = clampWidth(saved.left);
|
const s = saved?.[side];
|
||||||
}
|
if (typeof s === "number") {
|
||||||
if (saved?.right) {
|
this.size[side].w = clampWidth(s);
|
||||||
this.width.right = clampWidth(saved.right);
|
continue;
|
||||||
|
}
|
||||||
|
if (s?.w) {
|
||||||
|
this.size[side].w = clampWidth(s.w);
|
||||||
|
}
|
||||||
|
// Unclamped, and the sentinel preserved. Two separate points.
|
||||||
|
//
|
||||||
|
// A bare `clampHeight(Number(s.h) || 0)` would floor the 0 to
|
||||||
|
// `MIN_DOCK_H`, silently pinning every docked panel to a fifth of the
|
||||||
|
// window on the first reload. And clamping a *real* stored height here
|
||||||
|
// would make a smaller window permanent — the same one-way trip
|
||||||
|
// `clampPlacements` refuses to take with the floating height, for the
|
||||||
|
// same reason. `applyPlacement` clamps what is painted; the store keeps
|
||||||
|
// what the user asked for.
|
||||||
|
this.size[side].h = Number(s?.h) || 0;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// A malformed or blocked store just means the defaults stand.
|
// A malformed or blocked store just means the defaults stand.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveWidths(): void {
|
private saveSizes(): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(WIDTH_KEY, JSON.stringify(this.width));
|
localStorage.setItem(SIZE_KEY, JSON.stringify(this.size));
|
||||||
} catch {
|
} catch {
|
||||||
// Private mode / quota — resizing still works for this session.
|
// Private mode / quota — resizing still works for this session.
|
||||||
}
|
}
|
||||||
@@ -1489,10 +1713,17 @@ export class AirshipApp {
|
|||||||
* one-way trip, shrinking the panel a little on every downward drag and never
|
* one-way trip, shrinking the panel a little on every downward drag and never
|
||||||
* giving it back when you carry it up again. So `h` keeps what the user asked
|
* giving it back when you carry it up again. So `h` keeps what the user asked
|
||||||
* for and only what is *painted* is fitted to the room below `y`.
|
* for and only what is *painted* is fitted to the room below `y`.
|
||||||
|
*
|
||||||
|
* Docked, `--*-h` comes from `size[side].h` instead, and only `.dock-h` — set
|
||||||
|
* when that is non-zero — makes the stylesheet read it. A docked panel with no
|
||||||
|
* pinned height keeps its `bottom` anchor and fills its edge, which is a fact
|
||||||
|
* about the window that CSS can maintain for free and JS would have to
|
||||||
|
* recompute on every resize.
|
||||||
*/
|
*/
|
||||||
private applyPlacement(side: Side): void {
|
private applyPlacement(side: Side): void {
|
||||||
const p = this.placement[side];
|
const p = this.placement[side];
|
||||||
const floating = p.mode === "floating";
|
const floating = p.mode === "floating";
|
||||||
|
const pinned = this.size[side].h;
|
||||||
const room = Math.max(MIN_DOCK_H, window.innerHeight - p.y - this.inset);
|
const room = Math.max(MIN_DOCK_H, window.innerHeight - p.y - this.inset);
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.style.setProperty(`--${PREFIX}-${side}-x`, `${Math.round(p.x)}px`);
|
root.style.setProperty(`--${PREFIX}-${side}-x`, `${Math.round(p.x)}px`);
|
||||||
@@ -1501,13 +1732,30 @@ export class AirshipApp {
|
|||||||
// an unclamped number here would put the two edges out of step.
|
// an unclamped number here would put the two edges out of step.
|
||||||
root.style.setProperty(
|
root.style.setProperty(
|
||||||
`--${PREFIX}-${side}-r`,
|
`--${PREFIX}-${side}-r`,
|
||||||
`${Math.round(window.innerWidth - p.x - clampWidth(this.width[side]))}px`
|
`${Math.round(window.innerWidth - p.x - clampWidth(this.size[side].w))}px`
|
||||||
);
|
|
||||||
root.style.setProperty(
|
|
||||||
`--${PREFIX}-${side}-h`,
|
|
||||||
`${Math.round(Math.min(p.h, room))}px`
|
|
||||||
);
|
);
|
||||||
|
// Removed rather than set to something arbitrary when there is no height to
|
||||||
|
// publish, because of how the two consumers fail. `.dock-h`'s `var()` has no
|
||||||
|
// fallback, so an absent property makes `height` invalid at computed-value
|
||||||
|
// time and the declaration is dropped — leaving the `top`/`bottom` anchors in
|
||||||
|
// charge, which is exactly what an unpinned panel wants. Writing a clamped 0
|
||||||
|
// instead would publish `MIN_DOCK_H`, so the day that class is applied when
|
||||||
|
// it should not be, the panel snaps to 200px rather than looking untouched.
|
||||||
|
const painted = floating
|
||||||
|
? Math.min(p.h, room)
|
||||||
|
: pinned && clampHeight(pinned, this.inset, this.inset);
|
||||||
|
if (painted) {
|
||||||
|
root.style.setProperty(
|
||||||
|
`--${PREFIX}-${side}-h`,
|
||||||
|
`${Math.round(painted)}px`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
root.style.removeProperty(`--${PREFIX}-${side}-h`);
|
||||||
|
}
|
||||||
this.dockEl(side).classList.toggle(cls("dock-float"), floating);
|
this.dockEl(side).classList.toggle(cls("dock-float"), floating);
|
||||||
|
// Only a *docked* panel needs telling to read the height — a floating one
|
||||||
|
// has no `bottom` anchor to drop, and always reads it.
|
||||||
|
this.dockEl(side).classList.toggle(cls("dock-h"), !floating && pinned > 0);
|
||||||
this.pillEl(side).classList.toggle(cls("pill-float"), floating);
|
this.pillEl(side).classList.toggle(cls("pill-float"), floating);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1542,7 +1790,7 @@ export class AirshipApp {
|
|||||||
y: number
|
y: number
|
||||||
): { x: number; y: number } {
|
): { x: number; y: number } {
|
||||||
const w = this.isOpen(side)
|
const w = this.isOpen(side)
|
||||||
? clampWidth(this.width[side])
|
? clampWidth(this.size[side].w)
|
||||||
: this.pillEl(side).offsetWidth || MIN_DOCK_W;
|
: this.pillEl(side).offsetWidth || MIN_DOCK_W;
|
||||||
// `offsetHeight` reads 0 on a collapsed dock — it is `display: none` — which
|
// `offsetHeight` reads 0 on a collapsed dock — it is `display: none` — which
|
||||||
// is why the header is held in `this.heads` and why there is a floor.
|
// is why the header is held in `this.heads` and why there is a floor.
|
||||||
@@ -1583,25 +1831,29 @@ export class AirshipApp {
|
|||||||
*/
|
*/
|
||||||
private readonly onViewportResize = (): void => {
|
private readonly onViewportResize = (): void => {
|
||||||
for (const side of ["left", "right"] as const) {
|
for (const side of ["left", "right"] as const) {
|
||||||
this.width[side] = clampWidth(this.width[side]);
|
this.size[side].w = clampWidth(this.size[side].w);
|
||||||
}
|
}
|
||||||
this.clampPlacements();
|
this.clampPlacements();
|
||||||
this.applyWidths();
|
this.applyWidths();
|
||||||
this.autoGrow();
|
this.autoGrow();
|
||||||
this.saveDocks();
|
this.saveDocks();
|
||||||
this.saveWidths();
|
this.saveSizes();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placement gets its own store key rather than joining `WIDTH_KEY`.
|
* Placement gets its own store key rather than joining `SIZE_KEY`.
|
||||||
*
|
*
|
||||||
* Not only because the payload shape differs — `restoreWidths` would read an
|
* Because of write cadence. `saveSizes` fires from the splitter's drop and
|
||||||
* object where it expects a number — but because of write cadence. `saveWidths`
|
* from `resetSize`, whose entire job is to put a size back; folding placement
|
||||||
* fires from the splitter's drop and from `resetWidth`, whose entire job is to
|
* into the same blob would put the float state one careless
|
||||||
* put a width back; folding placement into the same blob would put the float
|
* `JSON.stringify(this.size)` away from being erased by a double-click on a
|
||||||
* state one careless `JSON.stringify(this.width)` away from being erased by a
|
* splitter. Two keys make that impossible.
|
||||||
* double-click on a splitter. Two keys make that impossible, and an existing
|
*
|
||||||
* install keeps its widths across the upgrade with no migration to write.
|
* It is also why the *docked* height went to `SIZE_KEY` rather than joining
|
||||||
|
* `h` here. The loop below `continue`s past anything that is not floating, so
|
||||||
|
* a docked height stored in this blob would be written on every drop and never
|
||||||
|
* read back — which is a worse failure than not persisting it at all, because
|
||||||
|
* it looks like it works until you reload.
|
||||||
*/
|
*/
|
||||||
private restoreDocks(): void {
|
private restoreDocks(): void {
|
||||||
try {
|
try {
|
||||||
@@ -1734,7 +1986,6 @@ export class AirshipApp {
|
|||||||
this.bar = el("div", { class: cls("bar") }, [
|
this.bar = el("div", { class: cls("bar") }, [
|
||||||
...this.editOnlyBar,
|
...this.editOnlyBar,
|
||||||
...this.viewOnlyBar,
|
...this.viewOnlyBar,
|
||||||
// Outside both mode lists on purpose: the shortcuts sheet documents both
|
|
||||||
this.buildSurfaceToggle(),
|
this.buildSurfaceToggle(),
|
||||||
el("div", { class: cls("bar-sep") }),
|
el("div", { class: cls("bar-sep") }),
|
||||||
this.buildEditToggle(),
|
this.buildEditToggle(),
|
||||||
@@ -2456,8 +2707,8 @@ export class AirshipApp {
|
|||||||
this.buildAgentButton(),
|
this.buildAgentButton(),
|
||||||
this.buildNewChatButton(),
|
this.buildNewChatButton(),
|
||||||
this.iconButton("history", "Past chats", () => this.toggleHistory()),
|
this.iconButton("history", "Past chats", () => this.toggleHistory()),
|
||||||
this.iconButton("rotate-ccw", "Reset width", () =>
|
this.iconButton("rotate-ccw", "Reset size", () =>
|
||||||
this.resetWidth("left")
|
this.resetSize("left")
|
||||||
),
|
),
|
||||||
this.panelToggle("left", "chat", false),
|
this.panelToggle("left", "chat", false),
|
||||||
]),
|
]),
|
||||||
@@ -2517,6 +2768,7 @@ export class AirshipApp {
|
|||||||
this.chatBody,
|
this.chatBody,
|
||||||
this.framesBody,
|
this.framesBody,
|
||||||
this.buildSplitter("left"),
|
this.buildSplitter("left"),
|
||||||
|
this.buildHeightSplitter("left"),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
this.leftBrand = el("span", {
|
this.leftBrand = el("span", {
|
||||||
@@ -2569,8 +2821,8 @@ export class AirshipApp {
|
|||||||
el("span", { class: cls("brand-name"), text: "Frames" }),
|
el("span", { class: cls("brand-name"), text: "Frames" }),
|
||||||
]),
|
]),
|
||||||
el("div", { class: cls("head-actions") }, [
|
el("div", { class: cls("head-actions") }, [
|
||||||
this.iconButton("rotate-ccw", "Reset width", () =>
|
this.iconButton("rotate-ccw", "Reset size", () =>
|
||||||
this.resetWidth("left")
|
this.resetSize("left")
|
||||||
),
|
),
|
||||||
this.panelToggle("left", "frames", false),
|
this.panelToggle("left", "frames", false),
|
||||||
]),
|
]),
|
||||||
@@ -2752,8 +3004,8 @@ export class AirshipApp {
|
|||||||
el("span", { class: cls("brand-name"), text: "Design" }),
|
el("span", { class: cls("brand-name"), text: "Design" }),
|
||||||
]),
|
]),
|
||||||
el("div", { class: cls("head-actions") }, [
|
el("div", { class: cls("head-actions") }, [
|
||||||
this.iconButton("rotate-ccw", "Reset width", () =>
|
this.iconButton("rotate-ccw", "Reset size", () =>
|
||||||
this.resetWidth("right")
|
this.resetSize("right")
|
||||||
),
|
),
|
||||||
this.panelToggle("right", "design", false),
|
this.panelToggle("right", "design", false),
|
||||||
]),
|
]),
|
||||||
@@ -2762,7 +3014,12 @@ export class AirshipApp {
|
|||||||
this.rightDock = el(
|
this.rightDock = el(
|
||||||
"div",
|
"div",
|
||||||
{ class: `${cls("dock")} ${cls("dock-right")} ${cls("hidden")}` },
|
{ class: `${cls("dock")} ${cls("dock-right")} ${cls("hidden")}` },
|
||||||
[head, this.panel.element, this.buildSplitter("right")]
|
[
|
||||||
|
head,
|
||||||
|
this.panel.element,
|
||||||
|
this.buildSplitter("right"),
|
||||||
|
this.buildHeightSplitter("right"),
|
||||||
|
]
|
||||||
);
|
);
|
||||||
this.rightPill = this.buildPill("right", "design", [
|
this.rightPill = this.buildPill("right", "design", [
|
||||||
el("div", { class: cls("brand") }, [
|
el("div", { class: cls("brand") }, [
|
||||||
@@ -4428,9 +4685,32 @@ function floatHeight(
|
|||||||
return clampHeight(open ? measured : window.innerHeight - 2 * inset);
|
return clampHeight(open ? measured : window.innerHeight - 2 * inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep a floating panel taller than a header and never taller than the window. */
|
/**
|
||||||
function clampHeight(h: number): number {
|
* Keep a panel taller than a header and inside the room below its top edge.
|
||||||
return clamp(h, MIN_DOCK_H, window.innerHeight);
|
*
|
||||||
|
* Exported so the rule can be asserted directly, the way `dockVisible` below is
|
||||||
|
* and for the reason it gives: standing up an `AirshipApp` to check a clamp is a
|
||||||
|
* test of the mount path, not of the clamp.
|
||||||
|
*
|
||||||
|
* There is no `MAX_DOCK_H` twin to `MAX_DOCK_W`, and that asymmetry is
|
||||||
|
* deliberate rather than an omission. Half the viewport is a sensible ceiling
|
||||||
|
* for a side column's *width* and a nonsense one for its height — a docked panel
|
||||||
|
* wants the whole edge, and the only real ceiling is the room it has.
|
||||||
|
*
|
||||||
|
* Both insets default to the constant and both are parameters, so a caller that
|
||||||
|
* has the *measured* one — `this.inset`, read off `--ap-space-md` at mount —
|
||||||
|
* can pass it and keep the drag clamp and the paint clamp on the same number.
|
||||||
|
* They agree today because the token is 20; a theme that moved it would put them
|
||||||
|
* out of step, which is the sort of thing that shows up as a panel that will not
|
||||||
|
* quite reach the bottom of the window.
|
||||||
|
*/
|
||||||
|
export function clampHeight(
|
||||||
|
h: number,
|
||||||
|
top = DOCK_INSET,
|
||||||
|
bottom = DOCK_INSET
|
||||||
|
): number {
|
||||||
|
const room = Math.max(MIN_DOCK_H, window.innerHeight - top - bottom);
|
||||||
|
return clamp(h, MIN_DOCK_H, room);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ export interface Coordinates {
|
|||||||
*/
|
*/
|
||||||
export const DND = {
|
export const DND = {
|
||||||
canvasNode: "airship:canvas-node",
|
canvasNode: "airship:canvas-node",
|
||||||
|
/**
|
||||||
|
* Dragging a dock's bottom edge to set its height.
|
||||||
|
*
|
||||||
|
* Its own type rather than a suffix on `splitter`, and that is load-bearing:
|
||||||
|
* `watchSplitters` selects a width drag with `id.startsWith(DND.splitter)`, so
|
||||||
|
* an `"airship:splitter-v"` would match it and be read as one — with the *side*
|
||||||
|
* then parsed off the end of the wrong id.
|
||||||
|
*/
|
||||||
|
dockHeight: "airship:dock-height",
|
||||||
/** Dragging a dock's header — or its collapsed pill — to float or move it. */
|
/** Dragging a dock's header — or its collapsed pill — to float or move it. */
|
||||||
dockMove: "airship:dock-move",
|
dockMove: "airship:dock-move",
|
||||||
/** Dragging a frame's edge or corner grip to resize the frame itself. */
|
/** Dragging a frame's edge or corner grip to resize the frame itself. */
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* How tall a panel is allowed to be, and what "no height" means.
|
||||||
|
*
|
||||||
|
* Tested as a free function rather than through `AirshipApp`, for the reason
|
||||||
|
* `dock-gate.test.ts` gives about its own: standing up a socket, a dnd-kit
|
||||||
|
* manager and a live document to check a clamp is a test of the mount path.
|
||||||
|
*
|
||||||
|
* The interesting case is not the clamp, it is the **0 sentinel**. A docked
|
||||||
|
* panel is anchored `top` and `bottom` and fills its edge until a drag pins it,
|
||||||
|
* so `size[side].h` of 0 means *unpinned* — and `clampHeight`'s floor is
|
||||||
|
* `MIN_DOCK_H`, which would turn a stored 0 into 200 on the way back in. That
|
||||||
|
* was harmless for as long as docked mode ignored the number; the moment
|
||||||
|
* `.dock-h` started reading it, a round trip through `localStorage` would have
|
||||||
|
* silently pinned every panel in every install to a fifth of the window. Hence
|
||||||
|
* the guard in `restoreSizes`, and hence this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AirshipApp, clampHeight, type Stage } from "./app";
|
||||||
|
import { ChromeLayer } from "./chrome-layer";
|
||||||
|
import { cls, PREFIX } from "./dom";
|
||||||
|
import { MIN_DOCK_H } from "./styles/const";
|
||||||
|
import { InlineResolver } from "./surface";
|
||||||
|
|
||||||
|
/** The dock's inset from the viewport edge — `--ap-space-md`. */
|
||||||
|
const INSET = 20;
|
||||||
|
|
||||||
|
/** A stage with nothing in it, which is all `mount` requires. */
|
||||||
|
function stubStage(): Stage {
|
||||||
|
const layer = new ChromeLayer();
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
// Nothing to take down.
|
||||||
|
},
|
||||||
|
layer,
|
||||||
|
mount() {
|
||||||
|
layer.mount(document.body);
|
||||||
|
},
|
||||||
|
onLayoutChange() {
|
||||||
|
// Nothing moves under a stage with no content.
|
||||||
|
},
|
||||||
|
resolver: new InlineResolver(),
|
||||||
|
swallowPresses: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubStorage(): void {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
clear: () => store.clear(),
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
key: () => null,
|
||||||
|
length: 0,
|
||||||
|
removeItem: (k: string) => store.delete(k),
|
||||||
|
setItem: (k: string, v: string) => store.set(k, v),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps: AirshipApp[] = [];
|
||||||
|
|
||||||
|
function mount(): AirshipApp {
|
||||||
|
const app = new AirshipApp(
|
||||||
|
{ mode: "inline", wsPath: "/__airship/ws" },
|
||||||
|
stubStage()
|
||||||
|
);
|
||||||
|
app.mount();
|
||||||
|
apps.push(app);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seed = (key: string, value: unknown): void => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// happy-dom reports 768 by default; pinned here so the arithmetic below is
|
||||||
|
// about the clamp rather than about the environment.
|
||||||
|
window.innerHeight = 900;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
stubStorage();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"WebSocket",
|
||||||
|
class {
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
addEventListener() {
|
||||||
|
// Never opens; nothing here is about what the socket carries.
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
send() {
|
||||||
|
// Nothing to send.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const app of apps.splice(0)) {
|
||||||
|
app.destroy();
|
||||||
|
}
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clampHeight", () => {
|
||||||
|
it("keeps a panel taller than a header", () => {
|
||||||
|
expect(clampHeight(10)).toBe(MIN_DOCK_H);
|
||||||
|
expect(clampHeight(0)).toBe(MIN_DOCK_H);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps it inside the room below its top edge", () => {
|
||||||
|
// The window less the top offset and the bottom inset — not the whole
|
||||||
|
// window, which is what it used to be and which let a docked panel be
|
||||||
|
// clamped to a height that ran its foot past the edge it sits on.
|
||||||
|
expect(clampHeight(5000)).toBe(900 - INSET - INSET);
|
||||||
|
expect(clampHeight(5000, 300)).toBe(900 - 300 - INSET);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a height that already fits alone", () => {
|
||||||
|
expect(clampHeight(420)).toBe(420);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns less than the floor, however little room there is", () => {
|
||||||
|
// A panel dragged to the bottom of a short window has negative room. The
|
||||||
|
// floor has to win, or the clamp inverts and `Math.min` picks the wrong end.
|
||||||
|
expect(clampHeight(400, 890)).toBe(MIN_DOCK_H);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no ceiling of its own, unlike the width clamp", () => {
|
||||||
|
// `clampWidth` caps at half the viewport, which is right for a side column's
|
||||||
|
// width and nonsense for its height: a docked panel wants the whole edge.
|
||||||
|
window.innerHeight = 2000;
|
||||||
|
expect(clampHeight(1800)).toBe(1800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The store, exercised through the real thing.
|
||||||
|
*
|
||||||
|
* `restoreSizes` is private, so this mounts an app to reach it — the cost the
|
||||||
|
* `dockVisible` docstring warns about, paid on purpose here because a *copy* of
|
||||||
|
* the guard is exactly what this file must not be. The one edit it exists to
|
||||||
|
* prevent is somebody rewriting the sentinel line as
|
||||||
|
* `clampHeight(Number(s.h) || 0)`, and a reimplementation of that line in the
|
||||||
|
* test cannot fail when the source changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEY = `${PREFIX}-dock-size`;
|
||||||
|
const LEGACY_KEY = `${PREFIX}-dock-widths`;
|
||||||
|
|
||||||
|
/** Whether a docked panel came back pinned, read off the DOM it drives. */
|
||||||
|
function pinned(side: "left" | "right"): boolean {
|
||||||
|
const el = document.querySelector<HTMLElement>(`.${cls(`dock-${side}`)}`);
|
||||||
|
if (!el) {
|
||||||
|
throw new Error(`No ${side} dock.`);
|
||||||
|
}
|
||||||
|
return el.classList.contains(cls("dock-h"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("restoring the persisted size", () => {
|
||||||
|
it("brings the unpinned sentinel back as unpinned", () => {
|
||||||
|
// The regression: `clampHeight`'s floor is `MIN_DOCK_H`, so a stored 0 run
|
||||||
|
// through it comes back as 200 — and every docked panel in every install
|
||||||
|
// silently pins itself to a fifth of the window on the first reload.
|
||||||
|
seed(KEY, { left: { h: 0, w: 340 }, right: { h: 0, w: 360 } });
|
||||||
|
mount();
|
||||||
|
|
||||||
|
expect(pinned("left")).toBe(false);
|
||||||
|
expect(pinned("right")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("brings a real stored height back pinned, and unclamped", () => {
|
||||||
|
// Unclamped in the *store*: clamping on read would make a smaller window
|
||||||
|
// permanent, which is the one-way trip `clampPlacements` refuses to take
|
||||||
|
// with the floating height. `applyPlacement` clamps what is painted.
|
||||||
|
seed(KEY, { left: { h: 5000, w: 340 }, right: { h: 300, w: 360 } });
|
||||||
|
mount();
|
||||||
|
|
||||||
|
expect(pinned("left")).toBe(true);
|
||||||
|
expect(pinned("right")).toBe(true);
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue(`--${PREFIX}-right-h`)
|
||||||
|
).toBe("300px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates the old width-only store rather than dropping it", () => {
|
||||||
|
// The previous key held a bare number per side. Losing it would reset every
|
||||||
|
// existing install's panel widths on upgrade, which is a rude way to ship a
|
||||||
|
// rename.
|
||||||
|
seed(LEGACY_KEY, { left: 300, right: 400 });
|
||||||
|
mount();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue(`--${PREFIX}-left-w`)
|
||||||
|
).toBe("300px");
|
||||||
|
expect(pinned("left")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a store that is not the shape it expects", () => {
|
||||||
|
seed(KEY, { left: { h: "nonsense", w: null }, right: 12 });
|
||||||
|
|
||||||
|
expect(() => mount()).not.toThrow();
|
||||||
|
expect(pinned("left")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips what it wrote", () => {
|
||||||
|
seed(KEY, { left: { h: 420, w: 300 }, right: { h: 0, w: 360 } });
|
||||||
|
mount();
|
||||||
|
const written = localStorage.getItem(KEY);
|
||||||
|
|
||||||
|
document.body.replaceChildren();
|
||||||
|
for (const app of apps.splice(0)) {
|
||||||
|
app.destroy();
|
||||||
|
}
|
||||||
|
localStorage.setItem(KEY, written ?? "");
|
||||||
|
mount();
|
||||||
|
|
||||||
|
expect(pinned("left")).toBe(true);
|
||||||
|
expect(pinned("right")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/html-vite";
|
||||||
|
import { cls, el, PREFIX } from "./dom";
|
||||||
|
import { dock, inspectorBody, plainStage } from "./stories/chrome";
|
||||||
|
import { MIN_DOCK_H, MIN_DOCK_W } from "./styles/const";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The docks, and the two axes they can be sized on.
|
||||||
|
*
|
||||||
|
* Both panels resized in width and only in width, from a splitter on the inner
|
||||||
|
* edge, and "Reset width" put that one number back. Height was half built: a
|
||||||
|
* *floating* panel's height was written at tear-off, clamped, persisted and
|
||||||
|
* restored, and nothing could change it; a *docked* panel had no height at all,
|
||||||
|
* being anchored `top` and `bottom` with no `height` between them.
|
||||||
|
*
|
||||||
|
* So there are two shapes here, and the difference between them is the whole
|
||||||
|
* design. Unpinned, a docked panel fills its edge and re-fits a resized window
|
||||||
|
* with no JS at all. Pinned, it drops the `bottom` anchor and takes
|
||||||
|
* `--*-h`. That is also why the reset *removes* the pin rather than replacing
|
||||||
|
* it with a number: "the edge" is a fact about the window, and a literal for it
|
||||||
|
* would be stale the moment the window changed size.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: "Chrome/Docks",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
/** Enough rows to give the panel something to be a height *of*. */
|
||||||
|
function filler(rows: number): HTMLElement {
|
||||||
|
return inspectorBody(
|
||||||
|
Array.from({ length: rows }, (_, i) =>
|
||||||
|
el("div", { class: cls("sect") }, [
|
||||||
|
el("div", { class: cls("sect-head") }, [
|
||||||
|
el("span", { class: cls("sect-title"), text: `Section ${i + 1}` }),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpinned and pinned, side by side.
|
||||||
|
*
|
||||||
|
* The left panel has no height of its own and runs the full stage; the right one
|
||||||
|
* carries `.dock-h` and stops where it was dragged to. Both carry the two
|
||||||
|
* splitters, which is the only place in the catalogue you can see them: they are
|
||||||
|
* 7px transparent strips that show a 2px hairline on hover and nothing
|
||||||
|
* otherwise, which is why both resize gestures shipped undocumented until
|
||||||
|
* `keys/catalog.ts` grew rows for them.
|
||||||
|
*/
|
||||||
|
export const Resizing: StoryObj = {
|
||||||
|
play: ({ canvasElement }) => {
|
||||||
|
const docks = [
|
||||||
|
...canvasElement.querySelectorAll<HTMLElement>("[data-story-dock]"),
|
||||||
|
];
|
||||||
|
if (docks.length !== 2) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected two docks in the stage, found ${docks.length}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [filling, pinned] = docks;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* What is asserted, and what deliberately is not.
|
||||||
|
*
|
||||||
|
* `stories/chrome.ts` neutralises `position` and `inset` on
|
||||||
|
* `[data-story-dock]` so a dock can sit in a story stage rather than pinned
|
||||||
|
* to the viewport. That takes the *edge-filling* half of the design out of
|
||||||
|
* reach here — neither of these has an edge to fill, so comparing their
|
||||||
|
* heights would only be measuring how tall eight section headers happen to
|
||||||
|
* be, and it would flip the day somebody changed the filler. Better stated
|
||||||
|
* than faked; the real anchoring is `app.ts`'s business and
|
||||||
|
* `dock-size.test.ts` covers the state that drives it.
|
||||||
|
*
|
||||||
|
* What this can prove is the *branch*, which is what the `h: 0` sentinel
|
||||||
|
* exists to keep honest: pinned carries `.dock-h` and is exactly its
|
||||||
|
* `--*-h`, unpinned carries neither and is sized by its content.
|
||||||
|
*/
|
||||||
|
if (!pinned.classList.contains(cls("dock-h"))) {
|
||||||
|
throw new Error("The pinned dock is missing `.dock-h`.");
|
||||||
|
}
|
||||||
|
if (pinned.offsetHeight !== 260) {
|
||||||
|
throw new Error(
|
||||||
|
`A pinned dock should be exactly its --*-h, but it is ${pinned.offsetHeight}px.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filling.classList.contains(cls("dock-h"))) {
|
||||||
|
throw new Error("The unpinned dock should not carry `.dock-h`.");
|
||||||
|
}
|
||||||
|
if (filling.style.getPropertyValue(`--${PREFIX}-right-h`) !== "") {
|
||||||
|
throw new Error(
|
||||||
|
"The unpinned dock published a height it has no rule to read."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both strips are present and neither is lying across the panel. The bottom
|
||||||
|
// one resets `top` and `width`, which `.splitter` sets — without that it
|
||||||
|
// would be a 7px full-height column eating clicks down the panel's left
|
||||||
|
// edge, and it would still look correct in a screenshot.
|
||||||
|
for (const node of docks) {
|
||||||
|
const bottom = node.querySelector<HTMLElement>(
|
||||||
|
`.${cls("splitter-bottom")}`
|
||||||
|
);
|
||||||
|
if (!bottom) {
|
||||||
|
throw new Error("No bottom splitter on the dock.");
|
||||||
|
}
|
||||||
|
if (bottom.offsetHeight !== 7) {
|
||||||
|
throw new Error(
|
||||||
|
`The bottom splitter is ${bottom.offsetHeight}px tall, not 7 — it is ` +
|
||||||
|
"still taking `.splitter`'s `top: 0; bottom: 0`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (bottom.offsetWidth < node.offsetWidth - 2) {
|
||||||
|
throw new Error(
|
||||||
|
"The bottom splitter does not span the dock — it is still 7px wide."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render: () =>
|
||||||
|
plainStage(
|
||||||
|
[
|
||||||
|
dock(filler(8), { label: "Fills its edge", splitters: true }),
|
||||||
|
dock(filler(8), {
|
||||||
|
height: 260,
|
||||||
|
label: "Pinned height",
|
||||||
|
splitters: true,
|
||||||
|
width: 300,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
try: "hover each edge — the inner strip is width, the bottom one is height, and a double-click on either resets both",
|
||||||
|
what: "A docked panel with no height of its own, beside one whose bottom edge has been dragged.",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The floors, on both axes.
|
||||||
|
*
|
||||||
|
* `MIN_DOCK_W` is what a splitter clamps to and `MIN_DOCK_H` is its twin, and
|
||||||
|
* both live in `styles/const.ts` rather than in `app.ts` so the stylesheet, the
|
||||||
|
* clamp and this story cannot disagree about them. There is deliberately no
|
||||||
|
* `MAX_DOCK_H` to match `MAX_DOCK_W`: half the viewport is a sensible ceiling
|
||||||
|
* for a side column's width and a nonsense one for its height.
|
||||||
|
*/
|
||||||
|
export const AtTheFloor: StoryObj = {
|
||||||
|
render: () =>
|
||||||
|
plainStage(
|
||||||
|
[
|
||||||
|
dock(inspectorBody([]), {
|
||||||
|
height: MIN_DOCK_H,
|
||||||
|
label: "Smallest",
|
||||||
|
narrow: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
what: `The narrowest and shortest a panel can be dragged — ${MIN_DOCK_W} × ${MIN_DOCK_H}. Below either it is a title bar with a sliver under it.`,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -929,6 +929,30 @@ export const GESTURES = [
|
|||||||
surface: "both",
|
surface: "both",
|
||||||
title: "Re-dock a panel",
|
title: "Re-dock a panel",
|
||||||
},
|
},
|
||||||
|
// Both of these shipped undocumented. The splitter is a 7px strip that shows
|
||||||
|
// a hairline on hover and nothing otherwise, so a reader who does not already
|
||||||
|
// know it is there has no way to find out — which is the exact case a gesture
|
||||||
|
// table exists for.
|
||||||
|
{
|
||||||
|
device: "any",
|
||||||
|
doc: "Drag the inner edge for width, the bottom edge for height.",
|
||||||
|
id: "gesture.dockResize",
|
||||||
|
impl: "app.ts#watchSplitters",
|
||||||
|
input: "Drag a panel edge",
|
||||||
|
mode: "any",
|
||||||
|
surface: "both",
|
||||||
|
title: "Resize a panel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
device: "any",
|
||||||
|
doc: "Width goes back to its default; a docked panel goes back to filling its edge.",
|
||||||
|
id: "gesture.dockReset",
|
||||||
|
impl: "app.ts#buildSplitter",
|
||||||
|
input: "Double-click a panel edge",
|
||||||
|
mode: "any",
|
||||||
|
surface: "both",
|
||||||
|
title: "Reset a panel's size",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
device: "mouse",
|
device: "mouse",
|
||||||
doc: "A vertical wheel scrolls the strip sideways, because a mouse has no sideways.",
|
doc: "A vertical wheel scrolls the strip sideways, because a mouse has no sideways.",
|
||||||
|
|||||||
@@ -171,6 +171,15 @@ function dockHead(label: string): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DockOptions {
|
export interface DockOptions {
|
||||||
|
/**
|
||||||
|
* Pin the dock's height, the way a bottom-edge drag does.
|
||||||
|
*
|
||||||
|
* Omitted, the dock keeps its `top`/`bottom` anchors and fills the stage —
|
||||||
|
* which is the product's default and what every story before this one showed.
|
||||||
|
* Passing a number applies `.dock-h`, so the story sees exactly what a user
|
||||||
|
* who has dragged the bottom edge sees.
|
||||||
|
*/
|
||||||
|
height?: number;
|
||||||
/** Header label. Defaults to "Design", matching the right dock. */
|
/** Header label. Defaults to "Design", matching the right dock. */
|
||||||
label?: string;
|
label?: string;
|
||||||
/**
|
/**
|
||||||
@@ -182,6 +191,14 @@ export interface DockOptions {
|
|||||||
* after somebody changes the floor to 260.
|
* after somebody changes the floor to 260.
|
||||||
*/
|
*/
|
||||||
narrow?: boolean;
|
narrow?: boolean;
|
||||||
|
/**
|
||||||
|
* Render the two resize splitters.
|
||||||
|
*
|
||||||
|
* Off by default: they are invisible until hovered and they sit on the dock's
|
||||||
|
* edges, so in a story about a *control* they are two dead strips over the
|
||||||
|
* thing being looked at. On for the story that is about them.
|
||||||
|
*/
|
||||||
|
splitters?: boolean;
|
||||||
/**
|
/**
|
||||||
* Override the dock width outright. The default is the product's own 360px.
|
* Override the dock width outright. The default is the product's own 360px.
|
||||||
* Prefer `narrow` for the common case; this is for a story that wants some
|
* Prefer `narrow` for the common case; this is for a story that wants some
|
||||||
@@ -209,15 +226,39 @@ export function dock(body: HTMLElement, opts: DockOptions = {}): HTMLElement {
|
|||||||
const node = el(
|
const node = el(
|
||||||
"div",
|
"div",
|
||||||
{
|
{
|
||||||
class: `${cls("dock")} ${cls("dock-right")}`,
|
class: `${cls("dock")} ${cls("dock-right")}${
|
||||||
|
opts.height === undefined ? "" : ` ${cls("dock-h")}`
|
||||||
|
}`,
|
||||||
"data-story-dock": "",
|
"data-story-dock": "",
|
||||||
},
|
},
|
||||||
[dockHead(opts.label ?? "Design"), body]
|
[
|
||||||
|
dockHead(opts.label ?? "Design"),
|
||||||
|
body,
|
||||||
|
// Inert copies, not `AirshipApp.buildSplitter`'s: that registers a dnd-kit
|
||||||
|
// `Draggable` against the module-singleton manager, which a story has no
|
||||||
|
// business adding entities to and no teardown for. What is worth showing
|
||||||
|
// here is the strip and its hairline; the gesture belongs to the app.
|
||||||
|
...(opts.splitters
|
||||||
|
? [
|
||||||
|
el("div", {
|
||||||
|
class: `${cls("splitter")} ${cls("splitter-right")}`,
|
||||||
|
"data-tip": "Drag to resize, double-click to reset",
|
||||||
|
}),
|
||||||
|
el("div", {
|
||||||
|
class: `${cls("splitter")} ${cls("splitter-bottom")}`,
|
||||||
|
"data-tip": "Drag to resize, double-click to reset",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
);
|
);
|
||||||
const width = dockWidth(opts);
|
const width = dockWidth(opts);
|
||||||
if (width !== undefined) {
|
if (width !== undefined) {
|
||||||
node.style.setProperty(`--${PREFIX}-right-w`, `${width}px`);
|
node.style.setProperty(`--${PREFIX}-right-w`, `${width}px`);
|
||||||
}
|
}
|
||||||
|
if (opts.height !== undefined) {
|
||||||
|
node.style.setProperty(`--${PREFIX}-right-h`, `${opts.height}px`);
|
||||||
|
}
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,12 @@ html[data-${PREFIX}-drag], html[data-${PREFIX}-drag] body, html[data-${PREFIX}-d
|
|||||||
user-select: none !important;
|
user-select: none !important;
|
||||||
}
|
}
|
||||||
html[data-${PREFIX}-drag="col-resize"] { --${PREFIX}-drag-cursor: col-resize; }
|
html[data-${PREFIX}-drag="col-resize"] { --${PREFIX}-drag-cursor: col-resize; }
|
||||||
|
/* The horizontal twin of \`col-resize\`, for a dock's bottom edge. Not
|
||||||
|
\`ns-resize\`, which is beside it: that is the cursor for a *handle* that moves
|
||||||
|
an edge in space, and this is a splitter that redistributes room between two
|
||||||
|
things — the distinction the pointer-cursor spec draws, and the one the width
|
||||||
|
splitter already relies on. */
|
||||||
|
html[data-${PREFIX}-drag="row-resize"] { --${PREFIX}-drag-cursor: row-resize; }
|
||||||
html[data-${PREFIX}-drag="ns-resize"] { --${PREFIX}-drag-cursor: ns-resize; }
|
html[data-${PREFIX}-drag="ns-resize"] { --${PREFIX}-drag-cursor: ns-resize; }
|
||||||
html[data-${PREFIX}-drag="ew-resize"] { --${PREFIX}-drag-cursor: ew-resize; }
|
html[data-${PREFIX}-drag="ew-resize"] { --${PREFIX}-drag-cursor: ew-resize; }
|
||||||
html[data-${PREFIX}-drag="nwse-resize"] { --${PREFIX}-drag-cursor: nwse-resize; }
|
html[data-${PREFIX}-drag="nwse-resize"] { --${PREFIX}-drag-cursor: nwse-resize; }
|
||||||
|
|||||||
@@ -97,6 +97,22 @@ export const css = `
|
|||||||
.${PREFIX}-dock-left { left: var(--ap-space-md); width: var(--${PREFIX}-left-w, 340px); }
|
.${PREFIX}-dock-left { left: var(--ap-space-md); width: var(--${PREFIX}-left-w, 340px); }
|
||||||
.${PREFIX}-dock-right { right: var(--ap-space-md); width: var(--${PREFIX}-right-w, 360px); }
|
.${PREFIX}-dock-right { right: var(--ap-space-md); width: var(--${PREFIX}-right-w, 360px); }
|
||||||
|
|
||||||
|
/* A docked panel with a pinned height.
|
||||||
|
|
||||||
|
\`.dock\` anchors \`top\` *and* \`bottom\`, so a docked column fills its edge and
|
||||||
|
re-fits a resized window with no JS at all — which is why the default is to
|
||||||
|
have no height here, and why \`resetSize\` *removes* the pin rather than
|
||||||
|
replacing it with a number. A literal for "the window, less two insets" would
|
||||||
|
be stale the moment the window changed and would need re-deriving on every
|
||||||
|
resize; \`bottom\` answers the same question for free and keeps answering it.
|
||||||
|
|
||||||
|
\`bottom\` and \`height\` cannot both win, so the pinned case drops the anchor
|
||||||
|
rather than fighting it. Two classes, so this beats the single-class edge
|
||||||
|
anchors above without \`!important\`, the same bargain \`.dock-float\` makes. */
|
||||||
|
.${PREFIX}-dock-h { bottom: auto; }
|
||||||
|
.${PREFIX}-dock-left.${PREFIX}-dock-h { height: var(--${PREFIX}-left-h); }
|
||||||
|
.${PREFIX}-dock-right.${PREFIX}-dock-h { height: var(--${PREFIX}-right-h); }
|
||||||
|
|
||||||
/* A swappable dock body — the chat and the frame list share the left dock, one
|
/* A swappable dock body — the chat and the frame list share the left dock, one
|
||||||
per mode (see \`AirshipApp.syncDocks\`).
|
per mode (see \`AirshipApp.syncDocks\`).
|
||||||
|
|
||||||
@@ -158,6 +174,19 @@ export const css = `
|
|||||||
.${PREFIX}-splitter-left { right: -1px; }
|
.${PREFIX}-splitter-left { right: -1px; }
|
||||||
.${PREFIX}-splitter-right { left: -1px; }
|
.${PREFIX}-splitter-right { left: -1px; }
|
||||||
|
|
||||||
|
/* The same strip, turned. \`top\` and \`width\` are *reset*, not merely unset:
|
||||||
|
\`.splitter\` sets both, so a rule that only added \`height\` would leave a
|
||||||
|
7px-wide full-height column lying across the panel's left edge, eating clicks
|
||||||
|
on everything under it. Same for the hairline's \`top\`/\`bottom\`/\`width\`. */
|
||||||
|
.${PREFIX}-splitter-bottom {
|
||||||
|
top: auto; bottom: -1px; left: 0; right: 0;
|
||||||
|
width: auto; height: 7px; cursor: row-resize;
|
||||||
|
}
|
||||||
|
.${PREFIX}-splitter-bottom::after {
|
||||||
|
top: 50%; bottom: auto; left: 0; right: 0;
|
||||||
|
width: auto; height: 2px; transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Collapsed panel — the dock's header row, left floating in the corner the dock
|
/* Collapsed panel — the dock's header row, left floating in the corner the dock
|
||||||
itself would occupy. Same surface recipe as \`.dock\` and, crucially, the same
|
itself would occupy. Same surface recipe as \`.dock\` and, crucially, the same
|
||||||
\`top\`/\`left\`/\`right\`: expanding grows a panel downwards from here rather than
|
\`top\`/\`left\`/\`right\`: expanding grows a panel downwards from here rather than
|
||||||
|
|||||||
Reference in New Issue
Block a user