@gjermundgaraba-designs/pixel-paper 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +18 -12
- package/dist/src/bench/bench.d.ts +14 -0
- package/dist/src/bench/bench.d.ts.map +1 -0
- package/dist/src/bench/bench.js +9 -0
- package/dist/src/bench/index.d.ts +3 -0
- package/dist/src/bench/index.d.ts.map +1 -0
- package/dist/src/bench/index.js +1 -0
- package/dist/src/chip/chip.d.ts +10 -0
- package/dist/src/chip/chip.d.ts.map +1 -1
- package/dist/src/chip/chip.js +19 -0
- package/dist/src/chip/index.d.ts +2 -2
- package/dist/src/chip/index.d.ts.map +1 -1
- package/dist/src/chip/index.js +1 -1
- package/dist/src/drag/drag.d.ts +18 -0
- package/dist/src/drag/drag.d.ts.map +1 -1
- package/dist/src/drag/drag.js +35 -7
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/rail/index.d.ts +1 -1
- package/dist/src/rail/index.d.ts.map +1 -1
- package/dist/src/rail/index.js +1 -1
- package/dist/src/rail/rail.d.ts +217 -17
- package/dist/src/rail/rail.d.ts.map +1 -1
- package/dist/src/rail/rail.js +133 -15
- package/dist/src/reel/index.d.ts +1 -1
- package/dist/src/reel/index.d.ts.map +1 -1
- package/dist/src/reel/index.js +1 -1
- package/dist/src/reel/layout.d.ts +21 -26
- package/dist/src/reel/layout.d.ts.map +1 -1
- package/dist/src/reel/layout.js +84 -106
- package/dist/src/reel/reel.d.ts +30 -172
- package/dist/src/reel/reel.d.ts.map +1 -1
- package/dist/src/reel/reel.js +144 -195
- package/dist/src/rows/rows.d.ts +9 -1
- package/dist/src/rows/rows.d.ts.map +1 -1
- package/dist/src/rows/rows.js +1 -1
- package/package.json +1 -1
- package/src/bench/bench.ts +24 -0
- package/src/bench/index.ts +2 -0
- package/src/chip/chip.ts +31 -0
- package/src/chip/index.ts +2 -2
- package/src/drag/drag.ts +60 -1
- package/src/index.ts +1 -0
- package/src/pixel-paper.css +196 -32
- package/src/rail/index.ts +2 -0
- package/src/rail/rail.ts +181 -16
- package/src/reel/index.ts +0 -1
- package/src/reel/layout.ts +78 -118
- package/src/reel/reel.ts +108 -216
- package/src/rows/rows.ts +14 -2
- package/src/tokens.css +2 -1
package/src/rail/rail.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Array, Cause, Effect, Option, Schema, Stream } from "effect";
|
|
1
|
+
import { Array, Cause, Effect, Number, Option, Schema, Stream } from "effect";
|
|
2
2
|
import * as Command from "foldkit/command";
|
|
3
3
|
import * as Dom from "foldkit/dom";
|
|
4
4
|
import type { Html } from "foldkit/html";
|
|
@@ -7,18 +7,32 @@ import * as Mount from "foldkit/mount";
|
|
|
7
7
|
import * as Render from "foldkit/render";
|
|
8
8
|
import { modifyFields } from "foldkit/struct";
|
|
9
9
|
import { defineView } from "foldkit/submodel";
|
|
10
|
+
import * as Subscription from "foldkit/subscription";
|
|
10
11
|
import type * as Update from "foldkit/update";
|
|
11
12
|
|
|
12
13
|
import * as Button from "../button/button.ts";
|
|
14
|
+
import { escapeKeys, markDragging, pointerMessages, splitterAttributes } from "../drag/drag.ts";
|
|
13
15
|
import * as IconButton from "../iconButton/iconButton.ts";
|
|
16
|
+
import { prefixKeys } from "../subscription/prefixKeys.ts";
|
|
14
17
|
|
|
15
18
|
// MODEL
|
|
16
19
|
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
20
|
+
/** A splitter a pointer drags: pressed at `pressX` on a rail `fromWidth` wide, and the width
|
|
21
|
+
* it previews. */
|
|
22
|
+
const Resizing = Schema.Struct({
|
|
23
|
+
pointerId: Schema.Number,
|
|
24
|
+
pressX: Schema.Number,
|
|
25
|
+
fromWidth: Schema.Number,
|
|
26
|
+
width: Schema.Number,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** Schema for a rail: whether it is open as a drawer (at 900px and under; wider, it is always
|
|
30
|
+
* in the page and this stays false), and its splitter while a pointer drags it. Its width is
|
|
31
|
+
* the parent's. */
|
|
19
32
|
export const Model = Schema.Struct({
|
|
20
33
|
id: Schema.String,
|
|
21
34
|
isOpen: Schema.Boolean,
|
|
35
|
+
maybeResizing: Schema.Option(Resizing),
|
|
22
36
|
});
|
|
23
37
|
export type Model = typeof Model.Type;
|
|
24
38
|
|
|
@@ -29,6 +43,12 @@ export type Model = typeof Model.Type;
|
|
|
29
43
|
* the window growing past 900px while it is open, where the rail is back in the page. */
|
|
30
44
|
export const Message = defineMessageUnion({
|
|
31
45
|
ClickedOpen: {},
|
|
46
|
+
PressedSplitter: { pointerId: Schema.Number, clientX: Schema.Number, width: Schema.Number },
|
|
47
|
+
MovedSplitter: { clientX: Schema.Number },
|
|
48
|
+
ReleasedSplitter: { clientX: Schema.Number },
|
|
49
|
+
CancelledSplitter: {},
|
|
50
|
+
PressedEscape: {},
|
|
51
|
+
PressedSplitterKey: { width: Schema.Number },
|
|
32
52
|
ClickedClose: {},
|
|
33
53
|
ClosedDrawer: {},
|
|
34
54
|
BlurredDrawer: {},
|
|
@@ -38,14 +58,39 @@ export const Message = defineMessageUnion({
|
|
|
38
58
|
});
|
|
39
59
|
export type Message = typeof Message.Type;
|
|
40
60
|
|
|
61
|
+
/** What a rail asks of its parent. `ResizedRail`: its splitter was let go, or stepped by a
|
|
62
|
+
* key, at `width` pixels, within the rail's bounds; the parent keeps it (per client) and
|
|
63
|
+
* passes it back as the view's `maybeWidth`. */
|
|
64
|
+
export const OutMessage = defineMessageUnion({
|
|
65
|
+
ResizedRail: { width: Schema.Number },
|
|
66
|
+
});
|
|
67
|
+
export type OutMessage = typeof OutMessage.Type;
|
|
68
|
+
|
|
41
69
|
// INIT
|
|
42
70
|
|
|
43
71
|
/** Configuration for creating a rail with `init`. The `id` is the rail's, unique in the
|
|
44
72
|
* document. */
|
|
45
73
|
export type InitConfig = Readonly<{ id: string }>;
|
|
46
74
|
|
|
47
|
-
/** Creates a rail, closed. */
|
|
48
|
-
export const init = (config: InitConfig): Model => ({
|
|
75
|
+
/** Creates a rail, closed, its splitter at rest. */
|
|
76
|
+
export const init = (config: InitConfig): Model => ({
|
|
77
|
+
id: config.id,
|
|
78
|
+
isOpen: false,
|
|
79
|
+
maybeResizing: Option.none(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// WIDTH
|
|
83
|
+
|
|
84
|
+
/** The rail's widths, in pixels: before the reader sizes it (as the stylesheet's `--rail` has
|
|
85
|
+
* it, which the splitter says as its value until a width is given), the least its panels
|
|
86
|
+
* still fit in (a tree's row keeps a glyph, a dot and a few words of its title), the most
|
|
87
|
+
* (which leaves the deck at least 421px of a 901px window, the narrowest one the rail stands
|
|
88
|
+
* in, more than a phone has), and what an arrow on its splitter moves it. The bounds and the
|
|
89
|
+
* step are the component's, not tokens: the splitter says them as its values. */
|
|
90
|
+
const WIDTH = { start: 272, least: 220, most: 480, step: 16 };
|
|
91
|
+
|
|
92
|
+
const bounded = (width: number): number =>
|
|
93
|
+
Math.round(Number.clamp(width, { minimum: WIDTH.least, maximum: WIDTH.most }));
|
|
49
94
|
|
|
50
95
|
// ACCESSORS
|
|
51
96
|
|
|
@@ -135,10 +180,48 @@ const open = (model: Model): UpdateReturn => ({
|
|
|
135
180
|
/** Closes the rail's drawer, focus back on its button. */
|
|
136
181
|
export const close = (model: Model): UpdateReturn => closeDrawer(model, true);
|
|
137
182
|
|
|
138
|
-
|
|
183
|
+
const withResizing = (model: Model, maybeResizing: Option.Option<typeof Resizing.Type>): Model =>
|
|
184
|
+
modifyFields(model, { maybeResizing: () => maybeResizing });
|
|
185
|
+
|
|
186
|
+
/** The width a dragged splitter previews with the pointer at `clientX`. */
|
|
187
|
+
const dragTo = (model: Model, clientX: number): Model =>
|
|
188
|
+
withResizing(
|
|
189
|
+
model,
|
|
190
|
+
Option.map(model.maybeResizing, (resizing) => ({
|
|
191
|
+
...resizing,
|
|
192
|
+
width: bounded(resizing.fromWidth + clientX - resizing.pressX),
|
|
193
|
+
})),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
/** Processes a rail Message and returns the next Model, Commands and OutMessage: a splitter let
|
|
197
|
+
* go where it changes the width, or stepped by a key, asks for the width. */
|
|
139
198
|
export const update = (model: Model, message: Message) =>
|
|
140
|
-
Message.match<
|
|
199
|
+
Message.match<Update.ReturnWithOutMessage<Model, Message, OutMessage>>(message, {
|
|
141
200
|
ClickedOpen: () => open(model),
|
|
201
|
+
PressedSplitter: ({ pointerId, clientX, width }) =>
|
|
202
|
+
Option.isSome(model.maybeResizing)
|
|
203
|
+
? { model }
|
|
204
|
+
: {
|
|
205
|
+
model: withResizing(
|
|
206
|
+
model,
|
|
207
|
+
Option.some({ pointerId, pressX: clientX, fromWidth: width, width }),
|
|
208
|
+
),
|
|
209
|
+
},
|
|
210
|
+
MovedSplitter: ({ clientX }) => ({ model: dragTo(model, clientX) }),
|
|
211
|
+
ReleasedSplitter: ({ clientX }) =>
|
|
212
|
+
Option.match(dragTo(model, clientX).maybeResizing, {
|
|
213
|
+
onNone: () => ({ model }),
|
|
214
|
+
onSome: ({ fromWidth, width }) => ({
|
|
215
|
+
model: withResizing(model, Option.none()),
|
|
216
|
+
...(width === fromWidth ? {} : { outMessage: OutMessage.ResizedRail({ width }) }),
|
|
217
|
+
}),
|
|
218
|
+
}),
|
|
219
|
+
CancelledSplitter: () => ({ model: withResizing(model, Option.none()) }),
|
|
220
|
+
PressedEscape: () => ({ model: withResizing(model, Option.none()) }),
|
|
221
|
+
PressedSplitterKey: ({ width }) => ({
|
|
222
|
+
model,
|
|
223
|
+
outMessage: OutMessage.ResizedRail({ width: bounded(width) }),
|
|
224
|
+
}),
|
|
142
225
|
ClickedClose: () => close(model),
|
|
143
226
|
ClosedDrawer: () => close(model),
|
|
144
227
|
BlurredDrawer: () => closeDrawer(model, false),
|
|
@@ -216,37 +299,95 @@ export const FollowRailDrawer = Mount.defineStream("FollowRailDrawer", {
|
|
|
216
299
|
},
|
|
217
300
|
});
|
|
218
301
|
|
|
302
|
+
// SUBSCRIPTION
|
|
303
|
+
|
|
304
|
+
const railSubscriptions = Subscription.make<Model, Message>()((entry) => ({
|
|
305
|
+
resize: entry(
|
|
306
|
+
{ maybePointerId: Schema.Option(Schema.Number) },
|
|
307
|
+
{
|
|
308
|
+
modelToDependencies: (model) => ({
|
|
309
|
+
maybePointerId: Option.map(model.maybeResizing, ({ pointerId }) => pointerId),
|
|
310
|
+
}),
|
|
311
|
+
dependenciesToStream: ({ maybePointerId }) =>
|
|
312
|
+
Option.match(maybePointerId, {
|
|
313
|
+
onNone: () => Stream.empty,
|
|
314
|
+
onSome: (pointerId) =>
|
|
315
|
+
Stream.merge(
|
|
316
|
+
pointerMessages<Message>(pointerId, {
|
|
317
|
+
moved: ({ clientX }) => Message.MovedSplitter({ clientX }),
|
|
318
|
+
released: ({ clientX }) => Message.ReleasedSplitter({ clientX }),
|
|
319
|
+
cancelled: () => Message.CancelledSplitter(),
|
|
320
|
+
}),
|
|
321
|
+
markDragging,
|
|
322
|
+
),
|
|
323
|
+
}),
|
|
324
|
+
},
|
|
325
|
+
),
|
|
326
|
+
escape: entry(
|
|
327
|
+
{ isResizing: Schema.Boolean },
|
|
328
|
+
{
|
|
329
|
+
modelToDependencies: (model) => ({ isResizing: Option.isSome(model.maybeResizing) }),
|
|
330
|
+
dependenciesToStream: ({ isResizing }) =>
|
|
331
|
+
isResizing ? escapeKeys(Message.PressedEscape()) : Stream.empty,
|
|
332
|
+
},
|
|
333
|
+
),
|
|
334
|
+
}));
|
|
335
|
+
|
|
336
|
+
/** A dragged splitter follows the pointer, and Escape puts it back before a dialog or the
|
|
337
|
+
* page sees it, from listeners that run only while it is dragged. Lift them into the
|
|
338
|
+
* application's Subscriptions behind a key prefix of the parent's: `subscriptions("rail")` is
|
|
339
|
+
* keyed `railResize` and `railEscape`. */
|
|
340
|
+
export const subscriptions = <Prefix extends string>(keyPrefix: Prefix) =>
|
|
341
|
+
prefixKeys(keyPrefix, railSubscriptions);
|
|
342
|
+
|
|
219
343
|
// VIEW
|
|
220
344
|
|
|
221
345
|
/** Per-render inputs passed to `view` through `h.submodel`'s `viewInputs`. */
|
|
222
346
|
export type ViewInputs = Readonly<{
|
|
223
|
-
/** Names the rail for screen readers, and its button
|
|
347
|
+
/** Names the rail for screen readers, and its button, drawer head and splitter
|
|
348
|
+
* (`"collections"`). */
|
|
224
349
|
label: string;
|
|
350
|
+
/** The width the reader gave the rail, in pixels, as the parent keeps it from `ResizedRail`
|
|
351
|
+
* (for this client: another reader's window is not this one's); none for the stylesheet's.
|
|
352
|
+
* A width out of the rail's bounds prints at the nearer bound. */
|
|
353
|
+
maybeWidth: Option.Option<number>;
|
|
225
354
|
/** The rail's panels: a tree, places, filters. A function because a Submodel's viewInputs
|
|
226
355
|
* may hold functions only at the top level, and Foldkit runs this one in the parent's
|
|
227
356
|
* boundary, so the handlers in what it returns dispatch the parent's Messages. */
|
|
228
357
|
toAsideView: () => ReadonlyArray<Html>;
|
|
229
|
-
/** The
|
|
230
|
-
* drawer to print first in it. A function because it receives that button, and
|
|
231
|
-
* parent's boundary like `toAsideView`. */
|
|
358
|
+
/** The page beside the rail, `main.deck#main` or `main.bench#main`, given the button that
|
|
359
|
+
* opens the drawer to print first in it. A function because it receives that button, and
|
|
360
|
+
* runs in the parent's boundary like `toAsideView`. */
|
|
232
361
|
toDeckView: (openButton: Html) => Html;
|
|
233
362
|
}>;
|
|
234
363
|
|
|
235
364
|
/**
|
|
236
365
|
* A column of panels beside the deck, fixed under the top bar and scrolling itself:
|
|
237
|
-
* `div.railed >` the deck, then `aside.rail[popover="auto"][aria-label]
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
366
|
+
* `div.railed >` the deck, then `aside.rail[popover="auto"][aria-label]`, then its splitter,
|
|
367
|
+
* `div.rail-resize[role="separator"]`, on its right edge. One element serves both layouts, so
|
|
368
|
+
* nothing in it renders twice or loses its state. At 900px and under it leaves the page, and
|
|
369
|
+
* `span.rail-open > button.btn.small` at the top of the deck opens the same element as a drawer
|
|
370
|
+
* over a veil, its head (the name and a close glyph) showing. Opening focuses its first
|
|
241
371
|
* control; Escape, a press on the veil, the close glyph, focus leaving or the window growing
|
|
242
372
|
* past 900px closes it, and the first three put focus back on the button.
|
|
243
373
|
*
|
|
374
|
+
* Its splitter sizes it, by a pointer (previewed as it moves) or the side arrows, within its
|
|
375
|
+
* bounds, and reports the width as `ResizedRail` for the parent to keep and pass back as
|
|
376
|
+
* `maybeWidth`, which the view sets as `--rail` on `div.railed`, so the page's padding and the
|
|
377
|
+
* rail's width follow it. The drawer keeps its own width. Lift `subscriptions(keyPrefix)` into
|
|
378
|
+
* the application's, or a dragged splitter follows nothing.
|
|
379
|
+
*
|
|
244
380
|
* Its state is its own, not `@foldkit/ui`'s Popover: that panel is placed by Floating UI and
|
|
245
381
|
* rendered only while open, and this one is always in the page, placed by the stylesheet, and
|
|
246
382
|
* opened as a native popover so the browser keeps it in the top layer.
|
|
247
383
|
*/
|
|
248
384
|
export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h): Html => {
|
|
249
385
|
const { label, toAsideView, toDeckView } = viewInputs;
|
|
386
|
+
const maybeWidth = Option.orElse(
|
|
387
|
+
Option.map(model.maybeResizing, ({ width }) => width),
|
|
388
|
+
() => Option.map(viewInputs.maybeWidth, bounded),
|
|
389
|
+
);
|
|
390
|
+
const width = Option.getOrElse(maybeWidth, () => WIDTH.start);
|
|
250
391
|
|
|
251
392
|
const openButton = h.span(
|
|
252
393
|
[h.Class("rail-open")],
|
|
@@ -271,7 +412,10 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
|
|
|
271
412
|
// NOTE: the deck comes first so the page's main content, and its h1, precede the rail's
|
|
272
413
|
// panels in reading order; the stylesheet places the rail, fixed, at the left.
|
|
273
414
|
return h.div(
|
|
274
|
-
[
|
|
415
|
+
[
|
|
416
|
+
h.Class("railed"),
|
|
417
|
+
...Array.fromOption(Option.map(maybeWidth, (shown) => h.Style({ "--rail": `${shown}px` }))),
|
|
418
|
+
],
|
|
275
419
|
[
|
|
276
420
|
toDeckView(openButton),
|
|
277
421
|
h.aside(
|
|
@@ -296,6 +440,27 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
|
|
|
296
440
|
...toAsideView(),
|
|
297
441
|
],
|
|
298
442
|
),
|
|
443
|
+
h.div([
|
|
444
|
+
h.Class("rail-resize"),
|
|
445
|
+
h.Role("separator"),
|
|
446
|
+
h.AriaLabel(`Width of ${label}`),
|
|
447
|
+
h.AriaControls(model.id),
|
|
448
|
+
h.AriaValuemin(WIDTH.least),
|
|
449
|
+
h.AriaValuemax(WIDTH.most),
|
|
450
|
+
h.AriaValuenow(width),
|
|
451
|
+
h.AriaValuetext(`${width} pixels`),
|
|
452
|
+
h.Tabindex(0),
|
|
453
|
+
...splitterAttributes(
|
|
454
|
+
{
|
|
455
|
+
orientation: "vertical",
|
|
456
|
+
isIdle: Option.isNone(model.maybeResizing),
|
|
457
|
+
onPress: (pointerId, clientX) => Message.PressedSplitter({ pointerId, clientX, width }),
|
|
458
|
+
onStep: (direction) =>
|
|
459
|
+
Message.PressedSplitterKey({ width: bounded(width + direction * WIDTH.step) }),
|
|
460
|
+
},
|
|
461
|
+
h,
|
|
462
|
+
),
|
|
463
|
+
]),
|
|
299
464
|
],
|
|
300
465
|
);
|
|
301
466
|
});
|
package/src/reel/index.ts
CHANGED
package/src/reel/layout.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The reel's layout, apart from its Model: the panes, columns and requests the application
|
|
2
|
-
// holds and applies, where a held pane
|
|
2
|
+
// holds and applies, where a held pane may land, and how places are said.
|
|
3
3
|
|
|
4
4
|
import { Array, Equal, Match, Number, Option, Predicate, Record, Schema, pipe } from "effect";
|
|
5
5
|
import { defineMessageUnion } from "foldkit/message";
|
|
@@ -34,15 +34,15 @@ export type Column = typeof Column.Type;
|
|
|
34
34
|
export const Layout = Schema.Array(Column);
|
|
35
35
|
export type Layout = typeof Layout.Type;
|
|
36
36
|
|
|
37
|
-
/** What is
|
|
37
|
+
/** What a move is said of: a pane, or a column (a key moves one), by its id. */
|
|
38
38
|
export const Subject = defineTaggedUnion({
|
|
39
39
|
Pane: { paneId: Schema.String },
|
|
40
40
|
Column: { columnId: Schema.String },
|
|
41
41
|
});
|
|
42
42
|
export type Subject = typeof Subject.Type;
|
|
43
43
|
|
|
44
|
-
/** Where a held pane
|
|
45
|
-
*
|
|
44
|
+
/** Where a held pane would land: into a column, at a place among its other panes, or in the
|
|
45
|
+
* gap before a column (none: after the last), as a column of its own. */
|
|
46
46
|
export const Target = defineTaggedUnion({
|
|
47
47
|
Into: { columnId: Schema.String, index: Schema.Number },
|
|
48
48
|
Gap: { maybeBeforeColumnId: Schema.Option(Schema.String) },
|
|
@@ -101,10 +101,10 @@ export const placeAt = (layout: Layout, columnIndex: number, row: number): Optio
|
|
|
101
101
|
|
|
102
102
|
export const firstPlace = (layout: Layout): Option.Option<Place> => placeAt(layout, 0, 0);
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
const columnIndexOf = (layout: Layout, columnId: string): Option.Option<number> =>
|
|
105
105
|
Array.findFirstIndex(layout, (column) => column.id === columnId);
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
const columnOf = (layout: Layout, columnId: string): Option.Option<Column> =>
|
|
108
108
|
Array.findFirst(layout, (column) => column.id === columnId);
|
|
109
109
|
|
|
110
110
|
/** The id of the column after the one at `columnIndex`: none after the last. */
|
|
@@ -253,15 +253,13 @@ export const apply = (layout: Layout, request: OutMessage): Layout =>
|
|
|
253
253
|
const PRESET_TOLERANCE = 0.01;
|
|
254
254
|
|
|
255
255
|
/** The widths a column cycles through, as fractions of the reel's visible width, with the
|
|
256
|
-
* words they are said in
|
|
257
|
-
*
|
|
258
|
-
const PRESETS: Array.NonEmptyReadonlyArray<
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
{ width:
|
|
262
|
-
{ width: 1
|
|
263
|
-
{ width: 2 / 3, words: "two thirds of the width", mark: "2/3" },
|
|
264
|
-
{ width: 1, words: "the full width", mark: "full" },
|
|
256
|
+
* words they are said in. They live here, not in the tokens, because they are words as much
|
|
257
|
+
* as sizes. */
|
|
258
|
+
const PRESETS: Array.NonEmptyReadonlyArray<Readonly<{ width: number; words: string }>> = [
|
|
259
|
+
{ width: 1 / 3, words: "a third of the width" },
|
|
260
|
+
{ width: 1 / 2, words: "half the width" },
|
|
261
|
+
{ width: 2 / 3, words: "two thirds of the width" },
|
|
262
|
+
{ width: 1, words: "the full width" },
|
|
265
263
|
];
|
|
266
264
|
|
|
267
265
|
const presetOf = (width: number) =>
|
|
@@ -275,12 +273,6 @@ export const widthWords = (width: number): string =>
|
|
|
275
273
|
onSome: ({ words }) => words,
|
|
276
274
|
});
|
|
277
275
|
|
|
278
|
-
export const widthMark = (width: number): string =>
|
|
279
|
-
Option.match(presetOf(width), {
|
|
280
|
-
onNone: () => `${percent(width)}%`,
|
|
281
|
-
onSome: ({ mark }) => mark,
|
|
282
|
-
});
|
|
283
|
-
|
|
284
276
|
/** The next preset wider than `width`, or the narrowest past the widest. */
|
|
285
277
|
export const nextPreset = (width: number): number =>
|
|
286
278
|
Option.getOrElse(
|
|
@@ -298,7 +290,7 @@ const WORDS = {
|
|
|
298
290
|
stackedPlace: "{row} of {rows} in column {column} of {columns}",
|
|
299
291
|
};
|
|
300
292
|
|
|
301
|
-
/** What a place is called when a drop would leave the pane or column where it is. */
|
|
293
|
+
/** What a place is called when a drop or a move would leave the pane or column where it is. */
|
|
302
294
|
export const WHERE_IT_WAS = "where it was";
|
|
303
295
|
|
|
304
296
|
export const fill = (template: string, fields: Readonly<Record<string, string | number>>): string =>
|
|
@@ -338,57 +330,39 @@ export const whereWords = (layout: Layout, subject: Subject): Option.Option<stri
|
|
|
338
330
|
|
|
339
331
|
// TARGETS
|
|
340
332
|
|
|
341
|
-
/** Where `
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
Option.map(placeOf(layout, paneId), ({ column, row }) =>
|
|
347
|
-
Target.Into({ columnId: column.id, index: row }),
|
|
348
|
-
),
|
|
349
|
-
Column: ({ columnId }) =>
|
|
350
|
-
Option.map(columnIndexOf(layout, columnId), () =>
|
|
351
|
-
Target.Gap({ maybeBeforeColumnId: Option.some(columnId) }),
|
|
352
|
-
),
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
export const isPresent = (layout: Layout, subject: Subject): boolean =>
|
|
356
|
-
Option.isSome(originOf(layout, subject));
|
|
333
|
+
/** Where the pane `paneId` is now, as a target. */
|
|
334
|
+
export const originOf = (layout: Layout, paneId: string): Option.Option<Target> =>
|
|
335
|
+
Option.map(placeOf(layout, paneId), ({ column, row }) =>
|
|
336
|
+
Target.Into({ columnId: column.id, index: row }),
|
|
337
|
+
);
|
|
357
338
|
|
|
358
|
-
/** What dropping `
|
|
359
|
-
* nothing (where it is, or a target whose column is gone). */
|
|
339
|
+
/** What dropping the pane `paneId` at `target` asks of the layout: none where it would
|
|
340
|
+
* change nothing (where it is, or a target whose column is gone). */
|
|
360
341
|
export const requestOf = (
|
|
361
342
|
layout: Layout,
|
|
362
|
-
|
|
343
|
+
paneId: string,
|
|
363
344
|
target: Target,
|
|
364
345
|
): Option.Option<OutMessage> =>
|
|
365
346
|
Option.filter(
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
OutMessage.PulledOutPane({ paneId, maybeBeforeColumnId }),
|
|
374
|
-
}),
|
|
375
|
-
),
|
|
376
|
-
Column: ({ columnId }) =>
|
|
377
|
-
Target.match(target, {
|
|
378
|
-
Into: () => Option.none<OutMessage>(),
|
|
379
|
-
Gap: ({ maybeBeforeColumnId }) =>
|
|
380
|
-
Option.some(OutMessage.MovedColumn({ columnId, maybeBeforeColumnId })),
|
|
381
|
-
}),
|
|
382
|
-
}),
|
|
347
|
+
Option.some(
|
|
348
|
+
Target.match(target, {
|
|
349
|
+
Into: ({ columnId, index }) =>
|
|
350
|
+
OutMessage.MovedPane({ paneId, toColumnId: columnId, toIndex: index }),
|
|
351
|
+
Gap: ({ maybeBeforeColumnId }) => OutMessage.PulledOutPane({ paneId, maybeBeforeColumnId }),
|
|
352
|
+
}),
|
|
353
|
+
),
|
|
383
354
|
(request) => !Equal.equals(apply(layout, request), layout),
|
|
384
355
|
);
|
|
385
356
|
|
|
386
|
-
/** Where `
|
|
387
|
-
export const landingWords = (layout: Layout,
|
|
388
|
-
Option.match(requestOf(layout,
|
|
357
|
+
/** Where the pane `paneId` would be once dropped at `target`, in words. */
|
|
358
|
+
export const landingWords = (layout: Layout, paneId: string, target: Target): string =>
|
|
359
|
+
Option.match(requestOf(layout, paneId, target), {
|
|
389
360
|
onNone: () => WHERE_IT_WAS,
|
|
390
361
|
onSome: (request) =>
|
|
391
|
-
Option.getOrElse(
|
|
362
|
+
Option.getOrElse(
|
|
363
|
+
whereWords(apply(layout, request), Subject.Pane({ paneId })),
|
|
364
|
+
() => WHERE_IT_WAS,
|
|
365
|
+
),
|
|
392
366
|
});
|
|
393
367
|
|
|
394
368
|
/** The column a target sits in or before (the last, for the gap after it), which a keyboard
|
|
@@ -404,7 +378,7 @@ export const Step = Schema.Literals(["Up", "Down", "Left", "Right"]);
|
|
|
404
378
|
export type Step = typeof Step.Type;
|
|
405
379
|
|
|
406
380
|
// A keyboard step reads the layout as a row of stops: the gap before each column (even), then
|
|
407
|
-
// the column itself (odd), and the gap after the last.
|
|
381
|
+
// the column itself (odd), and the gap after the last.
|
|
408
382
|
const stopOf = (layout: Layout, target: Target): Option.Option<number> =>
|
|
409
383
|
Target.match(target, {
|
|
410
384
|
Into: ({ columnId }) => Option.map(columnIndexOf(layout, columnId), (column) => column * 2 + 1),
|
|
@@ -416,7 +390,7 @@ const stopOf = (layout: Layout, target: Target): Option.Option<number> =>
|
|
|
416
390
|
export const isTargetPresent = (layout: Layout, target: Target): boolean =>
|
|
417
391
|
Option.isSome(stopOf(layout, target));
|
|
418
392
|
|
|
419
|
-
const targetAtStop = (layout: Layout,
|
|
393
|
+
const targetAtStop = (layout: Layout, paneId: string, stop: number, row: number): Target =>
|
|
420
394
|
stop % 2 === 0
|
|
421
395
|
? Target.Gap({
|
|
422
396
|
maybeBeforeColumnId: Option.map(Array.get(layout, stop / 2), ({ id }) => id),
|
|
@@ -424,28 +398,23 @@ const targetAtStop = (layout: Layout, subject: Subject, stop: number, row: numbe
|
|
|
424
398
|
: Option.match(Array.get(layout, (stop - 1) / 2), {
|
|
425
399
|
onNone: () => Target.Gap({ maybeBeforeColumnId: Option.none() }),
|
|
426
400
|
onSome: (column) =>
|
|
427
|
-
Target.Into({ columnId: column.id, index: Math.min(row, roomIn(column,
|
|
401
|
+
Target.Into({ columnId: column.id, index: Math.min(row, roomIn(column, paneId)) }),
|
|
428
402
|
});
|
|
429
403
|
|
|
430
|
-
/** The last index `
|
|
431
|
-
const roomIn = (column: Column,
|
|
432
|
-
Subject.match(subject, {
|
|
433
|
-
Pane: ({ paneId }) => othersIn(column, paneId).length,
|
|
434
|
-
Column: () => 0,
|
|
435
|
-
});
|
|
404
|
+
/** The last index the pane `paneId` may take among a column's panes. */
|
|
405
|
+
const roomIn = (column: Column, paneId: string): number => othersIn(column, paneId).length;
|
|
436
406
|
|
|
437
|
-
const stepOnce = (layout: Layout,
|
|
407
|
+
const stepOnce = (layout: Layout, paneId: string, target: Target, step: Step): Target => {
|
|
438
408
|
const row = Target.match(target, { Into: ({ index }) => index, Gap: () => 0 });
|
|
439
|
-
const stride = subject._tag === "Column" ? 2 : 1;
|
|
440
409
|
|
|
441
410
|
return Option.match(stopOf(layout, target), {
|
|
442
411
|
onNone: () => target,
|
|
443
412
|
onSome: (stop) =>
|
|
444
413
|
Match.value(step).pipe(
|
|
445
414
|
Match.withReturnType<Target>(),
|
|
446
|
-
Match.when("Left", () => targetAtStop(layout,
|
|
415
|
+
Match.when("Left", () => targetAtStop(layout, paneId, Math.max(stop - 1, 0), row)),
|
|
447
416
|
Match.when("Right", () =>
|
|
448
|
-
targetAtStop(layout,
|
|
417
|
+
targetAtStop(layout, paneId, Math.min(stop + 1, layout.length * 2), row),
|
|
449
418
|
),
|
|
450
419
|
Match.whenOr("Up", "Down", () =>
|
|
451
420
|
Target.match(target, {
|
|
@@ -458,7 +427,7 @@ const stepOnce = (layout: Layout, subject: Subject, target: Target, step: Step):
|
|
|
458
427
|
columnId,
|
|
459
428
|
index: Number.clamp(step === "Up" ? row - 1 : row + 1, {
|
|
460
429
|
minimum: 0,
|
|
461
|
-
maximum: roomIn(column,
|
|
430
|
+
maximum: roomIn(column, paneId),
|
|
462
431
|
}),
|
|
463
432
|
}),
|
|
464
433
|
}),
|
|
@@ -469,24 +438,25 @@ const stepOnce = (layout: Layout, subject: Subject, target: Target, step: Step):
|
|
|
469
438
|
});
|
|
470
439
|
};
|
|
471
440
|
|
|
472
|
-
/** A keyboard step from `target`: left and right cross from a column to
|
|
473
|
-
* on to the next column, up and down move among a column's panes. A
|
|
474
|
-
* change nothing is passed over, but for where
|
|
475
|
-
* way, it stays (where
|
|
441
|
+
/** A keyboard step of the pane `paneId` from `target`: left and right cross from a column to
|
|
442
|
+
* the gap beside it and on to the next column, up and down move among a column's panes. A
|
|
443
|
+
* stop where the drop would change nothing is passed over, but for where the pane started;
|
|
444
|
+
* with nothing further that way, it stays (where the pane started, if the target's column has
|
|
445
|
+
* gone). */
|
|
476
446
|
export const steppedTarget = (
|
|
477
447
|
layout: Layout,
|
|
478
|
-
|
|
448
|
+
paneId: string,
|
|
479
449
|
target: Target,
|
|
480
450
|
step: Step,
|
|
481
451
|
): Target => {
|
|
482
|
-
const origin = originOf(layout,
|
|
483
|
-
// a target whose column has gone steps from where
|
|
452
|
+
const origin = originOf(layout, paneId);
|
|
453
|
+
// a target whose column has gone steps from where the pane started instead
|
|
484
454
|
const start = isTargetPresent(layout, target) ? target : Option.getOrElse(origin, () => target);
|
|
485
455
|
const isStop = (candidate: Target): boolean =>
|
|
486
|
-
Option.isSome(requestOf(layout,
|
|
456
|
+
Option.isSome(requestOf(layout, paneId, candidate)) ||
|
|
487
457
|
Option.exists(origin, (place) => Equal.equals(place, candidate));
|
|
488
458
|
const walk = (from: Target, left: number): Target => {
|
|
489
|
-
const next = stepOnce(layout,
|
|
459
|
+
const next = stepOnce(layout, paneId, from, step);
|
|
490
460
|
if (Equal.equals(next, from) || left === 0) {
|
|
491
461
|
return start;
|
|
492
462
|
}
|
|
@@ -514,44 +484,34 @@ const gapBefore = (columns: ReadonlyArray<ColumnBox>, gap: number): Target =>
|
|
|
514
484
|
Target.Gap({ maybeBeforeColumnId: Option.map(Array.get(columns, gap), ({ id }) => id) });
|
|
515
485
|
|
|
516
486
|
/** Where a pointer at (`clientX`, `clientY`) over a reel whose columns sit at `columns`, left
|
|
517
|
-
* to right, would drop
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
* it lands among that column's other panes, after each whose middle is above the pointer. */
|
|
487
|
+
* to right, would drop a pane. Over a column's edge band (or over a gap, or past the last
|
|
488
|
+
* column) it lands in that gap, as a column of its own; over the rest of a column it lands
|
|
489
|
+
* among that column's other panes, after each whose middle is above the pointer. */
|
|
521
490
|
export const targetAtPoint = (
|
|
522
|
-
subject: Subject,
|
|
523
491
|
columns: ReadonlyArray<ColumnBox>,
|
|
524
492
|
clientX: number,
|
|
525
493
|
clientY: number,
|
|
526
494
|
): Target =>
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
)
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
columnId: id,
|
|
548
|
-
index: Array.filter(slotMiddles, (middle) => middle < clientY).length,
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
},
|
|
552
|
-
}),
|
|
553
|
-
),
|
|
554
|
-
});
|
|
495
|
+
pipe(
|
|
496
|
+
Array.findFirstIndex(columns, ({ right }) => clientX <= right),
|
|
497
|
+
Option.flatMap((index) => Option.map(Array.get(columns, index), (box) => ({ index, box }))),
|
|
498
|
+
Option.match({
|
|
499
|
+
onNone: () => gapBefore(columns, columns.length),
|
|
500
|
+
onSome: ({ index, box: { id, left, right, slotMiddles } }) => {
|
|
501
|
+
const band = Math.min((right - left) * EDGE_SHARE, EDGE_MAX);
|
|
502
|
+
if (clientX < left + band) {
|
|
503
|
+
return gapBefore(columns, index);
|
|
504
|
+
} else if (clientX > right - band) {
|
|
505
|
+
return gapBefore(columns, index + 1);
|
|
506
|
+
} else {
|
|
507
|
+
return Target.Into({
|
|
508
|
+
columnId: id,
|
|
509
|
+
index: Array.filter(slotMiddles, (middle) => middle < clientY).length,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
555
515
|
|
|
556
516
|
// RESIZE
|
|
557
517
|
|