@n-uf/placement 26.11.5 → 26.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,61 @@
3
3
  Versions are calendar versions, `YY.M.R` (year, month of the release, counter within the month), shared with
4
4
  `@n-uf/motion`: both packages carry the same version, and a release bumps both.
5
5
 
6
+ ## 26.11.7
7
+
8
+ - New entry `@n-uf/placement/panels`: tool panels with string ids, as an app's side columns work. `./dock` is
9
+ unchanged.
10
+ - Model: `PanelDef` (width limits, float size limits, the sides it may dock on, whether it may float; no title:
11
+ words are the consumer's),
12
+ `PanelsState` (`sides` of full-height `Column`s from the outer edge inward, `floats`, `folded`, `kept`,
13
+ `closed`, a logical `clock` of opened and used), `createPanels(defs, start)`.
14
+ - `layoutPanels(defs, state, stage, options?)`: geometry and structure only. Each panel's rect, layer, `kind`
15
+ (`column`, `bar`, `segment`, `float`), folded, kept and pinned flags; the columns; the rails; the pages' rect;
16
+ notes as a `code` plus data (`column-shrunk`, `column-folded`, `kept-folded`, `pages-under-min`,
17
+ `float-resized`, `float-clamped`). Options: `giveWay` (`{ order: "oldest" | "newest", by: "used" | "opened" }`,
18
+ default oldest by use), `floats` (`"clamp"` or `"free"`), `floatVisible`, `pagesMin`, `view` (the page's offset
19
+ and scale), and the chrome sizes `bar`, `rail`, `segment`, `railInset`.
20
+ - Give-way shrinks every column toward its minimum, then folds whole columns to the rail: unkept columns first, in
21
+ policy order across both sides, then columns holding a kept panel, only when the pages still don't fit. It never
22
+ writes `folded`, so a wider stage brings a column back.
23
+ - Floats clamp at layout time (the state keeps the stored rect) so their screen rect stays in the view at any
24
+ scale: a pinned float where it is, an unpinned one as it shows at pan 0, so it still scrolls with the page.
25
+ - Changes: `open`, `close`, `focus`, `toggleFold`, `keep`, `dock`, `float`, `pin` (with `shown`, a clamped float
26
+ pins where it shows), `resizeColumn`, `resizeFloat`, `liftPanel`. Opening, focusing, moving, resizing and
27
+ unfolding are uses (they move the `used` clock). `samePlaces(a, b, defs?)` compares widths as laid out;
28
+ `columnWidth(defs, column)`.
29
+ - Targets: `panelTargets(input)` (`column:<side>:<i>`, `into:<side>:<i>`, `rail:<side>`, `float`) in the shape
30
+ `@n-uf/motion/drag` takes, each with a structured `place` and no words; a place the panel may not take and its
31
+ own place are no targets. `panelDrop(input, target, point, grab)` lays out a drop. `homePlace`, `panelStops`.
32
+ - `storePanels(state)` / `readPanels(data, defs?)`: a versioned stored form (`{ v: 1, … }`); reading never throws,
33
+ drops unknown or repeated ids and clamps to the defs. Version 1 only gains optional fields: a field the reader
34
+ doesn't know is skipped and listed in `dropped`.
35
+ - `@n-uf/placement/targets`: `keyboardStops` also takes panel targets (with `{ home: { place, state }, floats? }`)
36
+ and gives panel stops in reading order.
37
+
38
+ ## 26.11.6
39
+
40
+ - `@n-uf/placement/targets`:
41
+ - `keyboardStops(targets, { home?, floats? })`: a group's places in one list in reading order (hosts title, top,
42
+ left, right, bottom; zones start, centre, end; each insertion point in order), with its own place as a `home`
43
+ stop when given and floating last only when asked. Each stop has the target id, a label, its place and the
44
+ arrangement a move there makes: one list for the arrow keys and a "Move to" menu, no parsing of target ids.
45
+ - Host targets carry their `slot`: the group's rect in the host once it lands there. `hostAfter(arrangement)`
46
+ says where the host is then, when it grows or appears; default: as it is now.
47
+ - `allows(group, place | "float")` in `hostTargets`' input: a place it refuses is no target. Its `place.index`
48
+ counts the groups there without the dragged one (as `insertGroup` does). For floating, the same rule decides
49
+ whether you add `floatTarget` at all: `...(allows(g, "float") ? [floatTarget(input)] : [])`.
50
+ - `@n-uf/placement/toolbar`:
51
+ - `HostSpec.keep`: a per-group keep priority (default 0). When room runs out, a lower keep collapses first;
52
+ among equal keeps the trailing end still goes first, so a primary group at the end can stay open. `HostSpec`
53
+ takes the group id type.
54
+ - `storeArrangement(arrangement)` / `readArrangement(data, groups?)`: a versioned stored form (`{ v: 1, hosts,
55
+ collapsed, floating }`). Reading parsed data gives `{ ok: true, arrangement }` or `{ ok: false, error }`, never a
56
+ throw: an unknown version, a missing host or zone, a value of the wrong kind, a group in two places, an unknown
57
+ group. An arrangement lacking some known groups reads fine; place those with `resetGroup` or `dockGroup`.
58
+ - New exports: the types `FloatTargetInput`, `Allows`, `KeyboardStop`, `KeyboardStopsOptions` and the constant
59
+ `HOST_READING_ORDER`. `ToolbarTarget.slot` is a required field, so hand-built targets (fixtures, mocks) add it.
60
+
6
61
  ## 26.11.5
7
62
 
8
63
  - `@n-uf/placement/targets`: `paneZone(rect, point, previous, options?)`, a pane's five drop zones: the centre
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
  >
8
8
  > Version numbers are dates (YY.M.R), not semantic versions, so any release may break things. Pin an exact version and read the [CHANGELOG](https://github.com/n-uf/motion/blob/main/packages/placement/CHANGELOG.md) before you upgrade.
9
9
 
10
+ Site and live demos: [motion.n-uf.com](https://motion.n-uf.com)
11
+
10
12
  Where things go. State in, rects and drop targets out: a desk of docks, rails and palettes, toolbar groups in hosts
11
13
  and zones, and the drop targets for each. A tiling entry (a split tree with ratios, groups and workspaces) is
12
14
  planned. Pure functions over plain data: no DOM,
@@ -20,17 +22,21 @@ Its sibling [`@n-uf/motion`](https://www.npmjs.com/package/@n-uf/motion) owns ho
20
22
  without importing each other: a target list from `@n-uf/placement/targets` is exactly what
21
23
  `@n-uf/motion/drag`'s drag intent takes, and a rect from any layout here is what `@n-uf/motion/layout` glides to.
22
24
 
23
- - **`@n-uf/placement`**: the shapes (`Rect`, `Size`, `Point`) and a popup positioner (anchor, gravity, offset,
24
- then flip, slide, resize, as Wayland's xdg-positioner).
25
+ - **`@n-uf/placement`**: the shapes (`Rect`, `Size`, `Point`) and a popup positioner, as Wayland's xdg-positioner: an anchor
26
+ point, gravity and offset, then flip, slide and resize, each only when the rule allows it.
25
27
  - **`/dock`**: a desk of documents and tool panels. Side docks and a bottom dock (between the side docks or across
26
28
  the full width), rails, natural-height sections whose last one takes the rest, fold and unfold, the give-way
27
29
  rules (sections fold least recently used first, docks fold to their rails), floating palettes with corner stacks,
28
30
  tear-off and dock-back; and nine constructions built from these parts, as presets (`createDesk`).
29
31
  - **`/toolbar`**: toolbar groups in five hosts (title, top, bottom, left, right) and three zones (start, centre,
30
- end), floating groups, insertion points (before all, between, after all, an empty zone), trailing-first overflow,
31
- and "own place is home".
32
- - **`/targets`**: drop targets (`{ id, priority, hit, slot?, label?, free? }`) for the desk's constructions and
33
- toolbar hosts.
32
+ end), floating groups, insertion points (before all, between, after all, an empty zone), collapse when a host
33
+ runs out of room (lowest keep first, trailing groups first among equal keeps), and "own place is home".
34
+ - **`/targets`**: drop targets (`{ id, priority, hit, slot?, label?, free? }`) for the desk's constructions,
35
+ toolbar hosts (each with its `slot`, and `allows` to leave out places a group may not take) and pane zones; and
36
+ `keyboardStops`, one ordered list of places for the arrow keys and a "Move to" menu.
37
+ - **`/panels`**: tool panels with string ids: full-height columns per side, each with its own width; folding to a
38
+ title bar or a rail; give-way by age when the pages run out of room; resizable floats, some pinned to the view;
39
+ their drop targets; and a stored form.
34
40
 
35
41
  Rendering stays in the app: it draws the rects, measures its groups, and feeds pointer samples to a drag intent.
36
42
 
@@ -84,8 +90,26 @@ const a = arrangement({ top: { start: ["file", "edit"], end: ["share"] } });
84
90
  const size = (_id: string, _vertical: boolean, collapsed: boolean) => ({ along: collapsed ? 30 : 120, across: 28 });
85
91
  const top = layoutHost(a.top, "top", { length: 320, thickness: 34, lead: 8, tail: 8 }, size);
86
92
  console.log(top.overflow); // [ 'share' ]
93
+
94
+ // A primary group keeps its full form longest: a higher keep folds later (equal keeps fold from the trailing end).
95
+ const kept = layoutHost(a.top, "top", { length: 320, thickness: 34, lead: 8, tail: 8, keep: { share: 1 } }, size);
96
+ console.log(kept.overflow); // [ 'edit' ]
97
+ ```
98
+
99
+ An arrangement is stored as versioned plain data; reading it back never throws:
100
+
101
+ ```ts
102
+ import { arrangement, readArrangement, storeArrangement } from "@n-uf/placement/toolbar";
103
+
104
+ const saved = JSON.stringify(storeArrangement(arrangement({ top: { start: ["file", "edit"] } }))); // { "v": 1, "hosts": … }
105
+ console.log(readArrangement(JSON.parse(saved)).ok); // true
106
+ console.log(readArrangement({ v: 9 })); // { ok: false, error: 'unknown version 9 (this reads version 1)' }
87
107
  ```
88
108
 
109
+ `readArrangement` takes parsed data, not the string. It accepts an arrangement that lacks some of your groups (one
110
+ stored before you added a group, say): place each missing group yourself, with `resetGroup(read, group, defaults)`
111
+ to put it where your default arrangement has it, or `dockGroup` for a group that floated.
112
+
89
113
  ## Drop targets
90
114
 
91
115
  ```ts
@@ -106,7 +130,118 @@ console.log(drop.label, drop.slot.x, drop.slot.y); // Float here 580 290
106
130
  ```
107
131
 
108
132
  A toolbar group's targets come from `hostTargets` (a host's lines and empty zones, with its own place left out and
109
- returned as `own`, for the drag's home) and `floatTarget`.
133
+ returned as `own`, for the drag's home) and `floatTarget`. Each host target carries its `slot`, the group's rect
134
+ once it lands there; `allows(group, place)` leaves out places a group may not go (its `place.index` counts the
135
+ groups there without the dragged one, as `insertGroup` does). The same rule says whether a group may float: add
136
+ `floatTarget` only when `allows(group, "float")`. `keyboardStops` puts the targets in reading order (title, top,
137
+ left, right, bottom; start, centre, end) for the arrow keys and a "Move to" menu, with the group's own place as
138
+ `home`:
139
+
140
+ ```ts
141
+ import { floatTarget, hostTargets, keyboardStops } from "@n-uf/placement/targets";
142
+ import { arrangement, hostOf, type Place } from "@n-uf/placement/toolbar";
143
+
144
+ const a = arrangement({ top: { start: ["file", "edit"], end: ["share"] } });
145
+ const size = (_id: string, _vertical: boolean, collapsed: boolean) => ({ along: collapsed ? 30 : 120, across: 28 });
146
+ const allows = (g: string, place: Place | "float") => g !== "share" || (place !== "float" && place.host === "top"); // share stays in the top bar
147
+ const top = { origin: { x: 0, y: 40 }, spec: { length: 800, thickness: 34, lead: 8, tail: 8 } };
148
+ const { targets } = hostTargets({ group: "share", arrangement: a, host: "top", ...top, size, allows });
149
+ console.log(targets[0]!.slot); // { x: 8, y: 43, width: 120, height: 28 }
150
+ // Floating, by the same rule: share may not float, so it gets no float target.
151
+ const stage = { x: 0, y: 80, width: 800, height: 500 };
152
+ const all = [...targets, ...(allows("share", "float") ? [floatTarget({ group: "share", arrangement: a, stage, width: 120, height: 28 })] : [])];
153
+ console.log(all.length === targets.length); // true
154
+ const stops = keyboardStops(targets, { home: { place: hostOf(a, "share")!, arrangement: a } });
155
+ console.log(stops.map((s) => (s.home ? "home" : s.id))); // [ 'g:top:start:0', 'g:top:start:1', 'g:top:start:2', 'g:top:center:0', 'home' ]
156
+ ```
157
+
158
+ ## Panels
159
+
160
+ `@n-uf/placement/panels` lays out tool panels the way an app's side columns work: each side holds full-height
161
+ columns from the outer edge inward, each column one or more panels and its own width (clamped to its panels'
162
+ `min` and `max`). A panel folded by hand is a title bar; a column whose panels are all folded becomes segments of
163
+ its side's rail. Panels may float instead, in page space (they move with the page's offset and scale) or pinned to
164
+ the view. The output is geometry and structure only: each panel's rect, layer, `kind` (`column`, `bar`, `segment`,
165
+ `float`) and its folded, kept and pinned flags, the columns, the rails, the pages' rect and coded notes. Chrome sizes
166
+ (`bar`, `rail`, `segment`) are options.
167
+
168
+ ```ts
169
+ import { createPanels, keep, layoutPanels, readPanels, storePanels, type PanelDef } from "@n-uf/placement/panels";
170
+
171
+ const defs: PanelDef[] = [
172
+ { id: "files", width: { min: 180, natural: 220, max: 360 } },
173
+ { id: "outline", width: { min: 160, natural: 200, max: 320 } },
174
+ { id: "notes", width: { min: 200, natural: 260, max: 420 } },
175
+ ];
176
+ // Files was used first, so it is the oldest.
177
+ const state = createPanels(defs, { left: [["files"], ["outline"]], right: [["notes"]] });
178
+ const narrow = layoutPanels(defs, state, { width: 780, height: 600 });
179
+ console.log(narrow.notes.map((n) => n.code), narrow.items.get("files")!.kind); // [ 'column-shrunk', 'column-folded' ] segment
180
+ const kept = layoutPanels(defs, keep(state, "files", true), { width: 780, height: 600 });
181
+ console.log(kept.items.get("files")!.kind, kept.items.get("outline")!.kind); // column segment
182
+ const read = readPanels(JSON.parse(JSON.stringify(storePanels(state))), defs);
183
+ console.log(read.ok); // true
184
+ ```
185
+
186
+ **Give-way** (`options.giveWay`, default `{ order: "oldest", by: "used" }`): when the open columns, the rails and
187
+ `pagesMin` (default 320) don't fit the stage width, columns shrink toward their `min`, then whole columns fold to the
188
+ rail, in the policy's order, until the pages fit. A column's age is its newest panel's (`used` counts opening,
189
+ focusing, moving, resizing and unfolding; `opened` only opening). Ties go to the outer column, then the left side.
190
+ Every column shrinks before any folds; then the unkept columns fold before any column holding a kept panel
191
+ (`keep(state, id, true)`), each group in the policy's order across both sides. When only kept columns are left and the
192
+ pages still don't fit, they fold too. Give-way never writes `folded`, so widening the stage brings a column back.
193
+
194
+ **Floats** (`options.floats`, default `"clamp"`): a float that fits the view stays wholly inside it; one that doesn't
195
+ even at its minimum size keeps its title bar and `floatVisible` (default 48) px of its width in the view. A float's
196
+ size is screen px and never scales. A pinned float is clamped where it is; an unpinned one has its page-space position
197
+ clamped so that its screen rect at pan 0 fits the view, so it still scrolls with the page. The clamp runs at layout
198
+ time and the state keeps the stored rect, so a larger view brings a float back where it was. `"free"` never moves a
199
+ float.
200
+
201
+ **Changes** are pure: `open`, `close`, `focus`, `toggleFold`, `keep`, `dock(state, id, side, index, { into?, width? })`,
202
+ `float`, `pin(state, id, on, view?, shown?)` (pass the layout's rect as `shown` so a clamped float pins where it
203
+ shows), `resizeColumn`, `resizeFloat`.
204
+
205
+ **Drop targets**: `panelTargets({ defs, state, id, stage, options?, allows? })` gives `column:<side>:<i>` (a new
206
+ column as wide as the panel's own width), `into:<side>:<i>` (stack into a column), `rail:<side>` (dock folded) and
207
+ `float` (free), in the shape `@n-uf/motion/drag`'s `createDragIntent` takes. Each carries a structured `place`, not
208
+ words. A place the panel may not take (its def's `sides` and `floats`, or `allows`) and its own place are no targets.
209
+ `panelDrop(input, target, point, grab)` lays out the drop: its `slot` is where the panel lands. `keyboardStops` from
210
+ `@n-uf/placement/targets` puts panel targets in reading order, with `homePlace(state, id)` as `home`:
211
+
212
+ ```ts
213
+ import { createPanels, homePlace, panelDrop, panelTargets, type PanelDef } from "@n-uf/placement/panels";
214
+ import { keyboardStops } from "@n-uf/placement/targets";
215
+
216
+ const defs: PanelDef[] = [
217
+ { id: "files", width: { min: 180, natural: 220, max: 360 } },
218
+ { id: "outline", width: { min: 160, natural: 200, max: 320 }, floats: false },
219
+ ];
220
+ const state = createPanels(defs, { left: [["files"], ["outline"]] });
221
+ const input = { defs, state, id: "outline", stage: { width: 1200, height: 600 } };
222
+ const targets = panelTargets(input); // hand these to createDragIntent
223
+ console.log(targets.map((t) => t.id)); // [ 'rail:left', 'column:left:0', 'into:left:0', 'column:right:0', 'rail:right' ]
224
+ const drop = panelDrop(input, targets[3]!, { x: 1190, y: 300 }, { x: 0, y: 0 });
225
+ console.log(drop.slot); // { x: 1000, y: 0, width: 200, height: 600 }
226
+ const stops = keyboardStops(targets, { home: { place: homePlace(state, "outline")!, state } });
227
+ console.log(stops.map((s) => (s.home ? "home" : s.id))); // [ 'rail:left', 'column:left:0', 'into:left:0', 'home', 'column:right:0', 'rail:right' ]
228
+ ```
229
+
230
+ `storePanels(state)` gives `{ v: 1, sides, floats, folded, kept, closed, clock }`; `readPanels(data, defs?)` reads
231
+ parsed data back and never throws. Anything but an object of version 1 is `{ ok: false, error }`; inside one, unknown
232
+ or repeated ids and malformed entries are dropped and listed in `dropped`, and widths and float sizes are clamped to
233
+ the defs. Version 1 only gains optional fields: a field this reader doesn't know is skipped and listed in `dropped`
234
+ too (`"theme"`, `"sides.bottom"`, `"column.<field>"`, `"float.<field>"`, `"clock.<id>"`). The note codes
235
+ (`PanelsNoteCode`):
236
+
237
+ | Code | Says | Also |
238
+ |---|---|---|
239
+ | `column-shrunk` | a column narrowed toward its minimum | `side`, `column`, `items`, `from`, `to` |
240
+ | `column-folded` | a column folded to its side's rail | `side`, `column`, `items` |
241
+ | `kept-folded` | a kept column folded: the pages needed the room | `side`, `column`, `items` |
242
+ | `pages-under-min` | the pages are under `pagesMin` with every column folded | `width`, `min` |
243
+ | `float-resized` | a float shrank to fit the view | `items` |
244
+ | `float-clamped` | a float moved to stay in the view | `items` |
110
245
 
111
246
  ## Pane zones
112
247
 
@@ -0,0 +1,412 @@
1
+ import {
2
+ rect
3
+ } from "./chunk-QMTNB2C3.js";
4
+
5
+ // src/panels/model.ts
6
+ var SIDES = ["left", "right"];
7
+ var IDENTITY_VIEW = { x: 0, y: 0, scale: 1 };
8
+ var DEFAULT_PANELS_OPTIONS = {
9
+ giveWay: { order: "oldest", by: "used" },
10
+ floats: "clamp",
11
+ floatVisible: 48,
12
+ pagesMin: 320,
13
+ view: IDENTITY_VIEW,
14
+ bar: 28,
15
+ rail: 36,
16
+ segment: 36,
17
+ railInset: 4
18
+ };
19
+ var FLOAT_HEIGHT = 360;
20
+ var FLOAT_MIN = { width: 160, height: 120 };
21
+ var without = (list, x) => list.filter((y) => y !== x);
22
+ var withOnce = (list, x) => list.includes(x) ? [...list] : [...list, x];
23
+ function whereIs(state, id) {
24
+ for (const side of SIDES) {
25
+ const index = state.sides[side].findIndex((c) => c.panels.includes(id));
26
+ if (index >= 0) return { kind: "column", side, index };
27
+ }
28
+ const float2 = state.floats.find((f) => f.id === id);
29
+ return float2 ? { kind: "float", float: float2 } : { kind: "none" };
30
+ }
31
+ var tick = (state) => Object.values(state.clock).reduce((m, c) => Math.max(m, c.opened, c.used), 0) + 1;
32
+ var bump = (state, id, opened) => {
33
+ const t = tick(state);
34
+ const was = state.clock[id] ?? { opened: 0, used: 0 };
35
+ return { ...state, clock: { ...state.clock, [id]: { opened: opened ? t : was.opened, used: t } } };
36
+ };
37
+ var defOf = (defs, id) => defs.find((d) => d.id === id);
38
+ function floatSize(def) {
39
+ if (def?.float) return { min: def.float.min, natural: def.float.natural };
40
+ const width = Math.max(FLOAT_MIN.width, def?.width.natural ?? FLOAT_MIN.width);
41
+ return { min: FLOAT_MIN, natural: { width, height: FLOAT_HEIGHT } };
42
+ }
43
+ function ownWidth(defs, state, id) {
44
+ const def = defOf(defs, id);
45
+ const w = whereIs(state, id);
46
+ const lo = def?.width.min ?? 0;
47
+ const hi = Math.max(lo, def?.width.max ?? Infinity);
48
+ if (w.kind === "column") return Math.min(Math.max(state.sides[w.side][w.index].width, lo), hi);
49
+ return def?.width.natural ?? lo;
50
+ }
51
+ function createPanels(defs, start) {
52
+ const column = (ids) => {
53
+ const ds = ids.map((id) => defOf(defs, id));
54
+ const lo = Math.max(0, ...ds.map((d) => d?.width.min ?? 0));
55
+ const hi = Math.max(lo, Math.min(...ds.map((d) => d?.width.max ?? Infinity)));
56
+ const natural = Math.max(0, ...ds.map((d) => d?.width.natural ?? 0));
57
+ return { panels: [...ids], width: Math.min(Math.max(natural, lo), hi) };
58
+ };
59
+ const floats = (start.floats ?? []).map((f) => {
60
+ const n = floatSize(defOf(defs, f.id)).natural;
61
+ return { id: f.id, rect: { x: f.x, y: f.y, width: f.width ?? n.width, height: f.height ?? n.height }, ...f.pinned ? { pinned: true } : {} };
62
+ });
63
+ const sides = { left: (start.left ?? []).map(column), right: (start.right ?? []).map(column) };
64
+ const order = [...sides.left, ...sides.right].flatMap((c) => c.panels).concat(floats.map((f) => f.id));
65
+ const clock = {};
66
+ order.forEach((id, i) => clock[id] = { opened: i + 1, used: i + 1 });
67
+ return { sides, floats, folded: [...start.folded ?? []], kept: [...start.kept ?? []], closed: [...start.closed ?? []], clock };
68
+ }
69
+ function liftPanel(state, id) {
70
+ const lift = (cols) => cols.map((c) => ({ ...c, panels: without(c.panels, id) })).filter((c) => c.panels.length > 0);
71
+ return { ...state, sides: { left: lift(state.sides.left), right: lift(state.sides.right) }, floats: state.floats.filter((f) => f.id !== id) };
72
+ }
73
+ function open(state, id, at) {
74
+ let s = { ...state, closed: without(state.closed, id) };
75
+ if (at && whereIs(s, id).kind === "none") s = { ...s, sides: { ...s.sides, [at.side]: [...s.sides[at.side], { panels: [id], width: at.width }] } };
76
+ return bump(s, id, true);
77
+ }
78
+ var close = (state, id) => ({ ...state, closed: withOnce(state.closed, id), kept: without(state.kept, id) });
79
+ function focus(state, id) {
80
+ const f = state.floats.find((x) => x.id === id);
81
+ const s = f ? { ...state, floats: [...state.floats.filter((x) => x !== f), f] } : state;
82
+ return bump(s, id, false);
83
+ }
84
+ function toggleFold(state, id) {
85
+ if (state.folded.includes(id)) return bump({ ...state, folded: without(state.folded, id) }, id, false);
86
+ return { ...state, folded: [...state.folded, id] };
87
+ }
88
+ function keep(state, id, on) {
89
+ if (!on) return { ...state, kept: without(state.kept, id) };
90
+ return whereIs(state, id).kind === "column" ? { ...state, kept: withOnce(state.kept, id) } : state;
91
+ }
92
+ function dock(state, id, side, index, options = {}) {
93
+ const w = whereIs(state, id);
94
+ const from = w.kind === "column" ? state.sides[w.side][w.index].width : 0;
95
+ const s = liftPanel(state, id);
96
+ const cols = [...s.sides[side]];
97
+ if (options.into && cols[index]) cols[index] = { ...cols[index], panels: [...cols[index].panels, id] };
98
+ else {
99
+ const at = Math.max(0, Math.min(index, cols.length));
100
+ cols.splice(at, 0, { panels: [id], width: options.width ?? from });
101
+ }
102
+ return bump({ ...s, sides: { ...s.sides, [side]: cols }, closed: without(s.closed, id) }, id, false);
103
+ }
104
+ function float(state, id, rect2, pinned) {
105
+ const was = state.floats.find((f2) => f2.id === id);
106
+ const on = pinned ?? !!was?.pinned;
107
+ const s = liftPanel(state, id);
108
+ const f = { id, rect: { ...rect2 }, ...on ? { pinned: true } : {} };
109
+ return bump({ ...s, floats: [...s.floats, f], kept: without(s.kept, id), closed: without(s.closed, id) }, id, false);
110
+ }
111
+ var toView = (r, v) => ({ ...r, x: v.x + r.x * v.scale, y: v.y + r.y * v.scale });
112
+ var toPage = (r, v) => ({ ...r, x: (r.x - v.x) / v.scale, y: (r.y - v.y) / v.scale });
113
+ function pin(state, id, on, view = IDENTITY_VIEW, shown) {
114
+ return {
115
+ ...state,
116
+ floats: state.floats.map((f) => {
117
+ if (f.id !== id || !!f.pinned === on) return f;
118
+ const seen = shown ? { ...shown } : f.pinned ? { ...f.rect } : toView(f.rect, view);
119
+ return on ? { id, rect: seen, pinned: true } : { id, rect: toPage(seen, view) };
120
+ })
121
+ };
122
+ }
123
+ function resizeColumn(state, side, index, width) {
124
+ const col = state.sides[side][index];
125
+ if (!col || !Number.isFinite(width)) return state;
126
+ const t = tick(state);
127
+ const clock = { ...state.clock };
128
+ for (const id of col.panels) clock[id] = { opened: clock[id]?.opened ?? 0, used: t };
129
+ return { ...state, sides: { ...state.sides, [side]: state.sides[side].map((c, i) => i === index ? { ...c, width: Math.max(0, width) } : c) }, clock };
130
+ }
131
+ function resizeFloat(state, id, rect2) {
132
+ if (!state.floats.some((f) => f.id === id)) return state;
133
+ return bump({ ...state, floats: state.floats.map((f) => f.id === id ? { ...f, rect: { ...rect2 } } : f) }, id, false);
134
+ }
135
+ function columnWidth(defs, col) {
136
+ if (!defs) return col.width;
137
+ const ds = col.panels.map((id) => defOf(defs, id));
138
+ const lo = Math.max(0, ...ds.map((d) => d?.width.min ?? 0));
139
+ const hi = Math.max(lo, Math.min(...ds.map((d) => d?.width.max ?? Infinity)));
140
+ return Math.min(Math.max(Number.isFinite(col.width) ? col.width : lo, lo), hi);
141
+ }
142
+ function samePlaces(a, b, defs) {
143
+ const cols = (s) => SIDES.map((side) => s.sides[side].map((c) => `${c.panels.join(",")}@${Math.round(columnWidth(defs, c))}`).join("|")).join("\u2016");
144
+ const fl = (s) => s.floats.map((f) => `${f.id}@${Math.round(f.rect.x)},${Math.round(f.rect.y)},${Math.round(f.rect.width)},${Math.round(f.rect.height)}${f.pinned ? "p" : ""}`).join("|");
145
+ const set = (l) => [...l].sort().join(",");
146
+ return cols(a) === cols(b) && fl(a) === fl(b) && set(a.folded) === set(b.folded) && set(a.kept) === set(b.kept) && set(a.closed) === set(b.closed);
147
+ }
148
+
149
+ // src/panels/layout.ts
150
+ var clamp = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
151
+ function giveWayOrder(cols, order) {
152
+ return [...cols].sort((a, b) => Number(a.kept) - Number(b.kept) || (order === "oldest" ? a.age - b.age : b.age - a.age) || a.index - b.index || SIDES.indexOf(a.side) - SIDES.indexOf(b.side));
153
+ }
154
+ function layoutPanels(defs, state, stage, options = {}) {
155
+ const opt = { ...DEFAULT_PANELS_OPTIONS, ...options };
156
+ const W = stage.width;
157
+ const H = stage.height;
158
+ const closed = new Set(state.closed);
159
+ const folded = new Set(state.folded);
160
+ const kept = new Set(state.kept);
161
+ const by = opt.giveWay.by;
162
+ const cols = [];
163
+ for (const side of SIDES) {
164
+ state.sides[side].forEach((c, index) => {
165
+ const panels = c.panels.filter((id) => !closed.has(id));
166
+ if (panels.length === 0) return;
167
+ const ds = panels.map((id) => defOf(defs, id));
168
+ const min = Math.max(0, ...ds.map((d) => d?.width.min ?? 0));
169
+ const max = Math.max(min, Math.min(...ds.map((d) => d?.width.max ?? Infinity)));
170
+ const width = clamp(Number.isFinite(c.width) ? c.width : min, min, max);
171
+ const age = Math.max(...panels.map((id) => state.clock[id]?.[by] ?? 0));
172
+ cols.push({ side, index, panels, min, max, width, hand: panels.every((id) => folded.has(id)), kept: panels.some((id) => kept.has(id)), age });
173
+ });
174
+ }
175
+ const order = giveWayOrder(
176
+ cols.filter((c) => !c.hand),
177
+ opt.giveWay.order
178
+ );
179
+ const fit = (k) => {
180
+ const auto = new Set(order.slice(0, k));
181
+ const railed = (side) => cols.some((c) => c.side === side && (c.hand || auto.has(c)));
182
+ const widths = new Map(order.filter((c) => !auto.has(c)).map((c) => [c, c.width]));
183
+ let excess = [...widths.values()].reduce((a, b) => a + b, 0) + SIDES.filter(railed).length * opt.rail + opt.pagesMin - W;
184
+ for (const c of order) {
185
+ if (excess <= 0 || auto.has(c)) continue;
186
+ const take = Math.min(excess, c.width - c.min);
187
+ if (take > 0) {
188
+ widths.set(c, c.width - take);
189
+ excess -= take;
190
+ }
191
+ }
192
+ return { auto, widths, excess };
193
+ };
194
+ let g = fit(0);
195
+ for (let k = 1; g.excess > 0 && k <= order.length; k++) g = fit(k);
196
+ const notes = [];
197
+ for (const c of order) {
198
+ const w = g.widths.get(c);
199
+ if (w !== void 0 && w < c.width) notes.push({ code: "column-shrunk", side: c.side, column: c.index, items: c.panels, from: c.width, to: w });
200
+ }
201
+ for (const c of order) if (g.auto.has(c)) notes.push({ code: c.kept ? "kept-folded" : "column-folded", side: c.side, column: c.index, items: c.panels });
202
+ const items = /* @__PURE__ */ new Map();
203
+ const placedCols = [];
204
+ const rails = {};
205
+ let x0 = 0;
206
+ let x1 = W;
207
+ for (const side of SIDES) {
208
+ const mine = cols.filter((c) => c.side === side);
209
+ const inRail = mine.filter((c) => c.hand || g.auto.has(c));
210
+ if (inRail.length) {
211
+ const r = side === "left" ? rect(0, 0, opt.rail, H) : rect(W - opt.rail, 0, opt.rail, H);
212
+ const segments = inRail.flatMap((c) => c.panels).map((id, i) => ({ id, rect: rect(r.x, opt.railInset + i * opt.segment, opt.rail, opt.segment) }));
213
+ rails[side] = { side, rect: r, segments };
214
+ for (const s of segments) {
215
+ const c = inRail.find((x) => x.panels.includes(s.id));
216
+ items.set(s.id, { rect: s.rect, kind: "segment", layer: 0, hidden: true, folded: c.hand ? "hand" : "auto", kept: kept.has(s.id), pinned: false, side, column: c.index });
217
+ }
218
+ if (side === "left") x0 = opt.rail;
219
+ else x1 = W - opt.rail;
220
+ }
221
+ for (const c of mine) {
222
+ const w = g.widths.get(c);
223
+ let r;
224
+ if (w === void 0) r = rect(side === "left" ? x0 : x1, 0, 0, H);
225
+ else if (side === "left") {
226
+ r = rect(x0, 0, w, H);
227
+ x0 += w;
228
+ } else {
229
+ x1 -= w;
230
+ r = rect(x1, 0, w, H);
231
+ }
232
+ placedCols.push({ side, index: c.index, rect: r, width: c.width, min: c.min, max: c.max, folded: c.hand ? "hand" : g.auto.has(c) ? "auto" : null, shrunk: w !== void 0 && w < c.width, kept: c.kept, panels: c.panels });
233
+ if (w === void 0) continue;
234
+ const bars = c.panels.filter((id) => folded.has(id)).length;
235
+ const share = (H - bars * opt.bar) / (c.panels.length - bars);
236
+ let y = 0;
237
+ for (const id of c.panels) {
238
+ const bar = folded.has(id);
239
+ const h = bar ? opt.bar : share;
240
+ items.set(id, { rect: rect(r.x, y, r.width, h), kind: bar ? "bar" : "column", layer: 0, hidden: false, folded: bar ? "hand" : null, kept: kept.has(id), pinned: false, side, column: c.index });
241
+ y += h;
242
+ }
243
+ }
244
+ }
245
+ const pages = rect(x0, 0, Math.max(0, x1 - x0), H);
246
+ if (pages.width < opt.pagesMin) notes.push({ code: "pages-under-min", width: pages.width, min: opt.pagesMin });
247
+ state.floats.forEach((f, i) => {
248
+ if (closed.has(f.id)) return;
249
+ const at = f.rect;
250
+ const k = f.pinned ? 1 : opt.view.scale;
251
+ const r = { ...at };
252
+ if (opt.floats === "clamp") {
253
+ const lim = floatSize(defOf(defs, f.id)).min;
254
+ r.width = Math.max(lim.width, Math.min(r.width, W));
255
+ r.height = Math.max(lim.height, Math.min(r.height, H));
256
+ if (r.width !== at.width || r.height !== at.height) notes.push({ code: "float-resized", items: [f.id] });
257
+ r.x = r.width <= W ? clamp(r.x, 0, (W - r.width) / k) : clamp(r.x, (opt.floatVisible - r.width) / k, (W - opt.floatVisible) / k);
258
+ r.y = r.height <= H ? clamp(r.y, 0, (H - r.height) / k) : clamp(r.y, 0, (H - opt.bar) / k);
259
+ if (r.x !== at.x || r.y !== at.y) notes.push({ code: "float-clamped", items: [f.id] });
260
+ }
261
+ const clamped = r.x !== at.x || r.y !== at.y || r.width !== at.width || r.height !== at.height;
262
+ items.set(f.id, { rect: f.pinned ? r : toView(r, opt.view), kind: "float", layer: 10 + i, hidden: false, folded: null, kept: false, pinned: !!f.pinned, ...clamped ? { clamped: true } : {} });
263
+ });
264
+ return { items, columns: placedCols, rails, notes, pages };
265
+ }
266
+
267
+ // src/panels/targets.ts
268
+ var COLUMN_REACH = 22;
269
+ var RAIL_EDGE = 10;
270
+ function placeRank(place) {
271
+ switch (place.kind) {
272
+ case "float":
273
+ return 3e3;
274
+ case "rail":
275
+ return place.side === "left" ? 0 : 1999;
276
+ case "column":
277
+ return place.side === "left" ? 1 + 2 * place.index : 1998 - 2 * place.index;
278
+ case "into":
279
+ return place.side === "left" ? 2 + 2 * place.index : 1997 - 2 * place.index;
280
+ }
281
+ }
282
+ function homePlace(state, id) {
283
+ const w = whereIs(state, id);
284
+ if (w.kind === "float") return { kind: "float" };
285
+ if (w.kind === "none") return null;
286
+ return state.sides[w.side][w.index].panels.length === 1 ? { kind: "column", side: w.side, index: w.index } : { kind: "into", side: w.side, index: w.index };
287
+ }
288
+ var cut = (x0, x1, lo, hi) => [Math.max(x0, lo), Math.min(x1, hi)];
289
+ function panelTargets(input) {
290
+ const { defs, state, id, stage } = input;
291
+ const opt = { ...DEFAULT_PANELS_OPTIONS, ...input.options };
292
+ const def = defOf(defs, id);
293
+ const s0 = liftPanel(state, id);
294
+ const l0 = layoutPanels(defs, s0, stage, opt);
295
+ const width = ownWidth(defs, state, id);
296
+ const W = stage.width;
297
+ const H = stage.height;
298
+ const R = COLUMN_REACH;
299
+ const out = [];
300
+ const add = (place, priority, hit, make, free = false) => {
301
+ if (hit.width <= 0 || hit.height <= 0) return;
302
+ if (input.allows && !input.allows(id, place)) return;
303
+ if (place.kind !== "float" && samePlaces(make({ x: 0, y: 0 }, { x: 0, y: 0 }), state, defs)) return;
304
+ out.push({ id: place.kind === "float" ? "float" : place.kind === "rail" ? `rail:${place.side}` : `${place.kind}:${place.side}:${place.index}`, priority, hit, panel: id, place, order: placeRank(place), make, ...free ? { free: true } : {} });
305
+ };
306
+ for (const side of SIDES) {
307
+ if (def?.sides && !def.sides.includes(side)) continue;
308
+ const left = side === "left";
309
+ const rail = l0.rails[side];
310
+ const edge = rail ? rail.rect.width : RAIL_EDGE;
311
+ const railHit = rail ? rail.rect : left ? rect(0, 0, RAIL_EDGE, H) : rect(W - RAIL_EDGE, 0, RAIL_EDGE, H);
312
+ add({ kind: "rail", side }, 1, railHit, () => {
313
+ const s = dock(state, id, side, 0, { width });
314
+ return { ...s, folded: [...s.folded.filter((x) => x !== id), id] };
315
+ });
316
+ const open2 = l0.columns.filter((c) => c.side === side && c.folded === null);
317
+ const lo = left ? edge : 0;
318
+ const hi = left ? W : W - edge;
319
+ const band = (x, outer, i) => {
320
+ const [a, b] = outer ? left ? cut(x, x + 2 * R, lo, hi) : cut(x - 2 * R, x, lo, hi) : cut(x - R, x + R, lo, hi);
321
+ add({ kind: "column", side, index: i }, 2, rect(a, 0, b - a, H), () => dock(state, id, side, i, { width }));
322
+ };
323
+ if (open2.length === 0) band(left ? l0.pages.x : l0.pages.x + l0.pages.width, true, s0.sides[side].length);
324
+ open2.forEach((c, k) => {
325
+ band(left ? c.rect.x : c.rect.x + c.rect.width, k === 0, c.index);
326
+ add({ kind: "into", side, index: c.index }, 2, c.rect, () => dock(state, id, side, c.index, { into: true }));
327
+ });
328
+ const last = open2.at(-1);
329
+ if (last) band(left ? last.rect.x + last.rect.width : last.rect.x, false, last.index + 1);
330
+ }
331
+ if (def?.floats !== false) {
332
+ const was = state.floats.find((f) => f.id === id);
333
+ const size = was ? { width: was.rect.width, height: was.rect.height } : floatSize(def).natural;
334
+ const pinned = !!was?.pinned;
335
+ add(
336
+ { kind: "float" },
337
+ 3,
338
+ rect(0, 0, W, H),
339
+ (p, grab) => {
340
+ const at = rect(p.x - grab.x, p.y - grab.y, size.width, size.height);
341
+ const s = float(state, id, pinned ? at : toPage(at, opt.view), pinned);
342
+ const shown = layoutPanels(defs, s, stage, opt).items.get(id).rect;
343
+ return float(state, id, pinned ? shown : toPage(shown, opt.view), pinned);
344
+ },
345
+ true
346
+ );
347
+ }
348
+ return out.sort((a, b) => a.order - b.order);
349
+ }
350
+ function panelDrop(input, target, p, grab) {
351
+ const opt = { ...DEFAULT_PANELS_OPTIONS, ...input.options };
352
+ const state = target.make(p, grab);
353
+ const layout = layoutPanels(input.defs, state, input.stage, opt);
354
+ const placed = layout.items.get(input.id);
355
+ const key = target.free ? `float:${Math.round(placed.rect.x)},${Math.round(placed.rect.y)}` : target.id;
356
+ return { target: target.id, place: target.place, key, state, layout, slot: placed.rect, hidden: placed.hidden, free: !!target.free };
357
+ }
358
+ function panelStops(targets, options = {}) {
359
+ const stops = [];
360
+ for (const t of targets) {
361
+ if (t.place.kind === "float") {
362
+ if (!options.floats) continue;
363
+ const at = options.floats === true ? { x: t.hit.x + t.hit.width / 2, y: t.hit.y + t.hit.height / 2 } : options.floats.at;
364
+ const grab = options.floats === true ? { x: 0, y: 0 } : options.floats.grab ?? { x: 0, y: 0 };
365
+ stops.push({ id: t.id, place: t.place, state: t.make(at, grab), home: false, order: t.order });
366
+ continue;
367
+ }
368
+ stops.push({ id: t.id, place: t.place, state: t.make({ x: 0, y: 0 }, { x: 0, y: 0 }), home: false, order: t.order });
369
+ }
370
+ const h = options.home;
371
+ if (h) stops.push({ id: "home", place: h.place, state: h.state, home: true, order: placeRank(h.place) + (h.place.kind === "float" ? 1 : 0.5) });
372
+ return stops.sort((a, b) => a.order - b.order).map(({ order: _order, ...s }) => s);
373
+ }
374
+
375
+ export {
376
+ SIDES,
377
+ IDENTITY_VIEW,
378
+ DEFAULT_PANELS_OPTIONS,
379
+ FLOAT_HEIGHT,
380
+ FLOAT_MIN,
381
+ whereIs,
382
+ tick,
383
+ defOf,
384
+ floatSize,
385
+ ownWidth,
386
+ createPanels,
387
+ liftPanel,
388
+ open,
389
+ close,
390
+ focus,
391
+ toggleFold,
392
+ keep,
393
+ dock,
394
+ float,
395
+ toView,
396
+ toPage,
397
+ pin,
398
+ resizeColumn,
399
+ resizeFloat,
400
+ columnWidth,
401
+ samePlaces,
402
+ giveWayOrder,
403
+ layoutPanels,
404
+ COLUMN_REACH,
405
+ RAIL_EDGE,
406
+ placeRank,
407
+ homePlace,
408
+ panelTargets,
409
+ panelDrop,
410
+ panelStops
411
+ };
412
+ //# sourceMappingURL=chunk-NNFNMIW4.js.map