@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/reel/reel.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Array, Effect, Equal, Match, Number, Option, Order, Schema, Stream, String } from "effect";
|
|
2
2
|
import * as Command from "foldkit/command";
|
|
3
|
-
import type { Attribute, Html, HtmlBuilder
|
|
3
|
+
import type { Attribute, Html, HtmlBuilder } from "foldkit/html";
|
|
4
4
|
import { defineMessageUnion } from "foldkit/message";
|
|
5
5
|
import * as Mount from "foldkit/mount";
|
|
6
6
|
import * as Render from "foldkit/render";
|
|
@@ -17,10 +17,12 @@ import {
|
|
|
17
17
|
escapeKeys,
|
|
18
18
|
markDragging,
|
|
19
19
|
measureLift,
|
|
20
|
-
|
|
20
|
+
pointerMessages as dragMessages,
|
|
21
|
+
splitterAttributes,
|
|
21
22
|
} from "../drag/drag.ts";
|
|
22
23
|
import * as Glyph from "../glyph/glyph.ts";
|
|
23
24
|
import * as IconButton from "../iconButton/iconButton.ts";
|
|
25
|
+
import { isPlain } from "../keys/keys.ts";
|
|
24
26
|
import { prefixKeys } from "../subscription/prefixKeys.ts";
|
|
25
27
|
|
|
26
28
|
import {
|
|
@@ -37,11 +39,9 @@ import {
|
|
|
37
39
|
Target,
|
|
38
40
|
WHERE_IT_WAS,
|
|
39
41
|
apply,
|
|
40
|
-
columnOf,
|
|
41
42
|
columnOfTarget,
|
|
42
43
|
fill,
|
|
43
44
|
firstPlace,
|
|
44
|
-
isPresent,
|
|
45
45
|
isSplitterPresent,
|
|
46
46
|
isTargetPresent,
|
|
47
47
|
landingWords,
|
|
@@ -56,17 +56,16 @@ import {
|
|
|
56
56
|
subjectWords,
|
|
57
57
|
targetAtPoint,
|
|
58
58
|
whereWords,
|
|
59
|
-
widthMark,
|
|
60
59
|
widthWords,
|
|
61
60
|
} from "./layout.ts";
|
|
62
61
|
|
|
63
62
|
// MODEL
|
|
64
63
|
|
|
65
|
-
/** The words a reel says. `{pane}` is a pane's label, `{subject}` what
|
|
66
|
-
* column by its first pane), `{where}` a place and `{column}` a column's
|
|
64
|
+
/** The words a reel says. `{pane}` is a pane's label, `{subject}` what moves (the held pane,
|
|
65
|
+
* or a column a key moves, by its first pane), `{where}` a place and `{column}` a column's
|
|
66
|
+
* number. */
|
|
67
67
|
const COPY = {
|
|
68
68
|
paneGrip: "Move {pane}",
|
|
69
|
-
columnGrip: "Move column {column}",
|
|
70
69
|
gripHelp:
|
|
71
70
|
"Press space to pick it up. Arrow keys move it, space drops it, escape or tab puts it back.",
|
|
72
71
|
widthLabel: "Width of column {column}",
|
|
@@ -102,9 +101,9 @@ const By = defineTaggedUnion({
|
|
|
102
101
|
});
|
|
103
102
|
type By = typeof By.Type;
|
|
104
103
|
|
|
105
|
-
/**
|
|
104
|
+
/** The pane held, how, and where it would land: none while a pointer is outside the reel. */
|
|
106
105
|
const Held = Schema.Struct({
|
|
107
|
-
|
|
106
|
+
paneId: Schema.String,
|
|
108
107
|
by: By,
|
|
109
108
|
maybeTarget: Schema.Option(Target),
|
|
110
109
|
});
|
|
@@ -142,12 +141,12 @@ type PointerAt = typeof PointerAt.Type;
|
|
|
142
141
|
/** Union of all Messages a reel can produce. */
|
|
143
142
|
export const Message = defineMessageUnion({
|
|
144
143
|
PressedGrip: {
|
|
145
|
-
|
|
144
|
+
paneId: Schema.String,
|
|
146
145
|
pointerId: Schema.Number,
|
|
147
146
|
clientX: Schema.Number,
|
|
148
147
|
clientY: Schema.Number,
|
|
149
148
|
},
|
|
150
|
-
PressedGripKey: {
|
|
149
|
+
PressedGripKey: { paneId: Schema.String },
|
|
151
150
|
MovedPointer: PointerAt.fields,
|
|
152
151
|
ReleasedPointer: PointerAt.fields,
|
|
153
152
|
CancelledPointer: {},
|
|
@@ -169,7 +168,6 @@ export const Message = defineMessageUnion({
|
|
|
169
168
|
CompletedAnnounceReel: {},
|
|
170
169
|
CompletedFocusPane: {},
|
|
171
170
|
CompletedRestoreFocus: {},
|
|
172
|
-
CompletedFocusGrip: {},
|
|
173
171
|
CompletedScrollIntoView: {},
|
|
174
172
|
CompletedSizeLiftedPane: {},
|
|
175
173
|
});
|
|
@@ -223,12 +221,6 @@ const paneHook = (model: Model, paneId: string): string => `${model.id}/pane/${p
|
|
|
223
221
|
|
|
224
222
|
const columnHook = (model: Model, columnId: string): string => `${model.id}/column/${columnId}`;
|
|
225
223
|
|
|
226
|
-
const subjectHook = (model: Model, subject: Subject): string =>
|
|
227
|
-
Subject.match(subject, {
|
|
228
|
-
Pane: ({ paneId }) => paneHook(model, paneId),
|
|
229
|
-
Column: ({ columnId }) => columnHook(model, columnId),
|
|
230
|
-
});
|
|
231
|
-
|
|
232
224
|
const scrollerOf = (reelHook: string): Option.Option<HTMLElement> =>
|
|
233
225
|
Option.fromNullishOr(
|
|
234
226
|
document.querySelector<HTMLElement>(`[data-reel="${CSS.escape(reelHook)}"] > .reel-view`),
|
|
@@ -295,23 +287,6 @@ export const RestoreFocus = Command.define("RestoreFocus", {
|
|
|
295
287
|
}),
|
|
296
288
|
});
|
|
297
289
|
|
|
298
|
-
/** Puts focus back on a column's grip once the render that moved its column is in the page:
|
|
299
|
-
* a column's element moves with it, which takes focus off its grip. */
|
|
300
|
-
export const FocusGrip = Command.define("FocusGrip", {
|
|
301
|
-
args: { gripHook: Schema.String },
|
|
302
|
-
messages: [Message.CompletedFocusGrip],
|
|
303
|
-
execute: ({ gripHook }) =>
|
|
304
|
-
Effect.gen(function* () {
|
|
305
|
-
yield* Render.afterCommit;
|
|
306
|
-
yield* Effect.sync(() => {
|
|
307
|
-
document
|
|
308
|
-
.querySelector<HTMLElement>(`[data-reel-grip="${CSS.escape(gripHook)}"]`)
|
|
309
|
-
?.focus({ preventScroll: true });
|
|
310
|
-
});
|
|
311
|
-
return Message.CompletedFocusGrip();
|
|
312
|
-
}),
|
|
313
|
-
});
|
|
314
|
-
|
|
315
290
|
/** Scrolls the pane or column `hook` names into view, by the least distance either way, its
|
|
316
291
|
* gutter kept by the stylesheet's `scroll-margin`, once the render that placed it is in the
|
|
317
292
|
* page. It jumps: motion is stepped or instant, and a follow is instant. */
|
|
@@ -358,7 +333,9 @@ const focusOn = (model: Model, place: Place): Model =>
|
|
|
358
333
|
const announce = (text: string) => AnnounceReel({ text });
|
|
359
334
|
|
|
360
335
|
const heldWords = (layout: Layout, held: Held): string =>
|
|
361
|
-
String.capitalize(
|
|
336
|
+
String.capitalize(
|
|
337
|
+
Option.getOrElse(subjectWords(layout, Subject.Pane({ paneId: held.paneId })), () => ""),
|
|
338
|
+
);
|
|
362
339
|
|
|
363
340
|
const scrollToTarget = (model: Model, layout: Layout, target: Target) =>
|
|
364
341
|
Array.fromOption(
|
|
@@ -367,9 +344,8 @@ const scrollToTarget = (model: Model, layout: Layout, target: Target) =>
|
|
|
367
344
|
),
|
|
368
345
|
);
|
|
369
346
|
|
|
370
|
-
/** Lets go of
|
|
371
|
-
* something (reported as the request), put back otherwise, said in `putBack`'s words.
|
|
372
|
-
* column dropped by the keyboard gets its grip's focus back. */
|
|
347
|
+
/** Lets go of the held pane: dropped at its target when it is carried and the drop changes
|
|
348
|
+
* something (reported as the request), put back otherwise, said in `putBack`'s words. */
|
|
373
349
|
const letGo = (model: Model, layout: Layout, held: Held, putBack: string): UpdateReturn => {
|
|
374
350
|
const released = withHeld(model, Option.none());
|
|
375
351
|
if (!isCarried(held)) {
|
|
@@ -378,13 +354,8 @@ const letGo = (model: Model, layout: Layout, held: Held, putBack: string): Updat
|
|
|
378
354
|
|
|
379
355
|
const subject = heldWords(layout, held);
|
|
380
356
|
const maybeRequest = Option.flatMap(held.maybeTarget, (target) =>
|
|
381
|
-
requestOf(layout, held.
|
|
357
|
+
requestOf(layout, held.paneId, target),
|
|
382
358
|
);
|
|
383
|
-
// a column that moves takes focus off its grip; one put back never left its place
|
|
384
|
-
const refocus =
|
|
385
|
-
held.subject._tag === "Column" && held.by._tag === "Keyboard"
|
|
386
|
-
? [FocusGrip({ gripHook: subjectHook(model, held.subject) })]
|
|
387
|
-
: [];
|
|
388
359
|
|
|
389
360
|
return Option.match(maybeRequest, {
|
|
390
361
|
onNone: () => ({
|
|
@@ -394,12 +365,11 @@ const letGo = (model: Model, layout: Layout, held: Held, putBack: string): Updat
|
|
|
394
365
|
onSome: (request) => ({
|
|
395
366
|
model: released,
|
|
396
367
|
commands: [
|
|
397
|
-
...refocus,
|
|
398
368
|
announce(
|
|
399
369
|
fill(COPY.dropped, {
|
|
400
370
|
subject,
|
|
401
371
|
where: Option.getOrElse(
|
|
402
|
-
whereWords(apply(layout, request), held.
|
|
372
|
+
whereWords(apply(layout, request), Subject.Pane({ paneId: held.paneId })),
|
|
403
373
|
() => WHERE_IT_WAS,
|
|
404
374
|
),
|
|
405
375
|
}),
|
|
@@ -413,16 +383,16 @@ const letGo = (model: Model, layout: Layout, held: Held, putBack: string): Updat
|
|
|
413
383
|
const cancel = (model: Model, layout: Layout, held: Held): UpdateReturn =>
|
|
414
384
|
letGo(model, layout, { ...held, maybeTarget: Option.none() }, COPY.cancelled);
|
|
415
385
|
|
|
416
|
-
const lift = (model: Model, layout: Layout,
|
|
386
|
+
const lift = (model: Model, layout: Layout, paneId: string, by: By): UpdateReturn =>
|
|
417
387
|
Option.match(
|
|
418
388
|
Option.filter(
|
|
419
|
-
originOf(layout,
|
|
389
|
+
originOf(layout, paneId),
|
|
420
390
|
() => !isHolding(model) && Option.isNone(model.maybeResizing),
|
|
421
391
|
),
|
|
422
392
|
{
|
|
423
393
|
onNone: () => ({ model }),
|
|
424
394
|
onSome: (origin) => {
|
|
425
|
-
const held: Held = {
|
|
395
|
+
const held: Held = { paneId, by, maybeTarget: Option.some(origin) };
|
|
426
396
|
return {
|
|
427
397
|
model: withHeld(model, Option.some(held)),
|
|
428
398
|
commands:
|
|
@@ -464,7 +434,7 @@ const stepHeld = (model: Model, layout: Layout, held: Held, step: Step): UpdateR
|
|
|
464
434
|
{
|
|
465
435
|
onNone: () => ({ model }),
|
|
466
436
|
onSome: (target) => {
|
|
467
|
-
const next = steppedTarget(layout, held.
|
|
437
|
+
const next = steppedTarget(layout, held.paneId, target, step);
|
|
468
438
|
if (Equal.equals(next, target)) {
|
|
469
439
|
return { model };
|
|
470
440
|
}
|
|
@@ -474,7 +444,7 @@ const stepHeld = (model: Model, layout: Layout, held: Held, step: Step): UpdateR
|
|
|
474
444
|
announce(
|
|
475
445
|
fill(COPY.over, {
|
|
476
446
|
subject: heldWords(layout, held),
|
|
477
|
-
where: landingWords(layout, held.
|
|
447
|
+
where: landingWords(layout, held.paneId, next),
|
|
478
448
|
}),
|
|
479
449
|
),
|
|
480
450
|
...scrollToTarget(model, layout, next),
|
|
@@ -518,11 +488,11 @@ export const update = (model: Model, message: Message, context: UpdateContext):
|
|
|
518
488
|
const { layout } = context;
|
|
519
489
|
|
|
520
490
|
return Message.match<UpdateReturn>(message, {
|
|
521
|
-
PressedGrip: ({
|
|
491
|
+
PressedGrip: ({ paneId, pointerId, clientX, clientY }) =>
|
|
522
492
|
lift(
|
|
523
493
|
model,
|
|
524
494
|
layout,
|
|
525
|
-
|
|
495
|
+
paneId,
|
|
526
496
|
By.Pointer({
|
|
527
497
|
pointerId,
|
|
528
498
|
pressX: clientX,
|
|
@@ -532,7 +502,7 @@ export const update = (model: Model, message: Message, context: UpdateContext):
|
|
|
532
502
|
isCarrying: false,
|
|
533
503
|
}),
|
|
534
504
|
),
|
|
535
|
-
PressedGripKey: ({
|
|
505
|
+
PressedGripKey: ({ paneId }) => lift(model, layout, paneId, By.Keyboard()),
|
|
536
506
|
MovedPointer: (pointer) =>
|
|
537
507
|
whileHeldBy(model, "Pointer", (held) => movePointer(model, layout, held, pointer)),
|
|
538
508
|
ReleasedPointer: (pointer) =>
|
|
@@ -594,7 +564,6 @@ export const update = (model: Model, message: Message, context: UpdateContext):
|
|
|
594
564
|
CompletedAnnounceReel: () => ({ model }),
|
|
595
565
|
CompletedFocusPane: () => ({ model }),
|
|
596
566
|
CompletedRestoreFocus: () => ({ model }),
|
|
597
|
-
CompletedFocusGrip: () => ({ model }),
|
|
598
567
|
CompletedScrollIntoView: () => ({ model }),
|
|
599
568
|
CompletedSizeLiftedPane: () => ({ model }),
|
|
600
569
|
});
|
|
@@ -606,8 +575,8 @@ export const update = (model: Model, message: Message, context: UpdateContext):
|
|
|
606
575
|
* Brings the reel in line with a layout that changed: call it whenever the application changes
|
|
607
576
|
* the layout, by a request it applied, another client's or one of its own (a pane closed). The
|
|
608
577
|
* focused pane's place is read again; when it has gone, focus moves to the pane now nearest
|
|
609
|
-
* where it was, and the keyboard follows where it went with the pane. A drag whose pane
|
|
610
|
-
*
|
|
578
|
+
* where it was, and the keyboard follows where it went with the pane. A drag whose pane has
|
|
579
|
+
* gone is cancelled, and one whose target's column has gone aims where it started;
|
|
611
580
|
* a splitter whose column or pane has gone lets go.
|
|
612
581
|
*/
|
|
613
582
|
export const reconcile = (model: Model, context: UpdateContext): UpdateReturn => {
|
|
@@ -637,7 +606,7 @@ export const reconcile = (model: Model, context: UpdateContext): UpdateReturn =>
|
|
|
637
606
|
return Option.match(settled.maybeHeld, {
|
|
638
607
|
onNone: () => ({ model: settled, commands: restore }),
|
|
639
608
|
onSome: (held) => {
|
|
640
|
-
if (
|
|
609
|
+
if (Option.isNone(placeOf(layout, held.paneId))) {
|
|
641
610
|
return {
|
|
642
611
|
model: withHeld(settled, Option.none()),
|
|
643
612
|
commands: [...restore, announce(COPY.gone)],
|
|
@@ -645,7 +614,7 @@ export const reconcile = (model: Model, context: UpdateContext): UpdateReturn =>
|
|
|
645
614
|
}
|
|
646
615
|
const maybeTarget = Option.orElse(
|
|
647
616
|
Option.filter(held.maybeTarget, (target) => isTargetPresent(layout, target)),
|
|
648
|
-
() => (held.by._tag === "Keyboard" ? originOf(layout, held.
|
|
617
|
+
() => (held.by._tag === "Keyboard" ? originOf(layout, held.paneId) : Option.none()),
|
|
649
618
|
);
|
|
650
619
|
return {
|
|
651
620
|
model: withHeld(settled, Option.some({ ...held, maybeTarget })),
|
|
@@ -855,12 +824,8 @@ const LONGEST_FRAME_MILLIS = 50;
|
|
|
855
824
|
|
|
856
825
|
/** A column as the pointer reads it from the page: its edges, and the middles of its slots
|
|
857
826
|
* but the held pane's. */
|
|
858
|
-
const columnBoxOf = (element: Element,
|
|
827
|
+
const columnBoxOf = (element: Element, heldId: string): ColumnBox => {
|
|
859
828
|
const rect = element.getBoundingClientRect();
|
|
860
|
-
const heldId = Subject.match(subject, {
|
|
861
|
-
Pane: ({ paneId }) => Option.some(paneId),
|
|
862
|
-
Column: () => Option.none<string>(),
|
|
863
|
-
});
|
|
864
829
|
return {
|
|
865
830
|
id: element.getAttribute("data-reel-column") ?? "",
|
|
866
831
|
left: rect.left,
|
|
@@ -868,7 +833,7 @@ const columnBoxOf = (element: Element, subject: Subject): ColumnBox => {
|
|
|
868
833
|
slotMiddles: Array.map(
|
|
869
834
|
Array.filter(
|
|
870
835
|
Array.fromIterable(element.querySelectorAll("[data-reel-slot]")),
|
|
871
|
-
(slot) =>
|
|
836
|
+
(slot) => slot.getAttribute("data-reel-slot") !== heldId,
|
|
872
837
|
),
|
|
873
838
|
(slot) => {
|
|
874
839
|
const slotRect = slot.getBoundingClientRect();
|
|
@@ -878,11 +843,11 @@ const columnBoxOf = (element: Element, subject: Subject): ColumnBox => {
|
|
|
878
843
|
};
|
|
879
844
|
};
|
|
880
845
|
|
|
881
|
-
/** Where a pointer at (`clientX`, `clientY`) would drop `
|
|
882
|
-
* names, read from where its columns and their slots sit: none outside the reel. */
|
|
846
|
+
/** Where a pointer at (`clientX`, `clientY`) would drop the pane `paneId` in the reel
|
|
847
|
+
* `reelHook` names, read from where its columns and their slots sit: none outside the reel. */
|
|
883
848
|
const pointerTarget = (
|
|
884
849
|
reelHook: string,
|
|
885
|
-
|
|
850
|
+
paneId: string,
|
|
886
851
|
clientX: number,
|
|
887
852
|
clientY: number,
|
|
888
853
|
): Option.Option<Target> =>
|
|
@@ -898,9 +863,8 @@ const pointerTarget = (
|
|
|
898
863
|
}),
|
|
899
864
|
(view) =>
|
|
900
865
|
targetAtPoint(
|
|
901
|
-
subject,
|
|
902
866
|
Array.map(Array.fromIterable(view.querySelectorAll("[data-reel-column]")), (element) =>
|
|
903
|
-
columnBoxOf(element,
|
|
867
|
+
columnBoxOf(element, paneId),
|
|
904
868
|
),
|
|
905
869
|
clientX,
|
|
906
870
|
clientY,
|
|
@@ -909,7 +873,7 @@ const pointerTarget = (
|
|
|
909
873
|
|
|
910
874
|
const PointerHold = Schema.Struct({
|
|
911
875
|
reelHook: Schema.String,
|
|
912
|
-
|
|
876
|
+
paneId: Schema.String,
|
|
913
877
|
pointerId: Schema.Number,
|
|
914
878
|
});
|
|
915
879
|
type PointerHold = typeof PointerHold.Type;
|
|
@@ -917,18 +881,16 @@ type PointerHold = typeof PointerHold.Type;
|
|
|
917
881
|
const pointerAt = (hold: PointerHold, event: PointerEvent): PointerAt => ({
|
|
918
882
|
clientX: event.clientX,
|
|
919
883
|
clientY: event.clientY,
|
|
920
|
-
maybeTarget: pointerTarget(hold.reelHook, hold.
|
|
884
|
+
maybeTarget: pointerTarget(hold.reelHook, hold.paneId, event.clientX, event.clientY),
|
|
921
885
|
});
|
|
922
886
|
|
|
923
887
|
/** The pressing pointer's moves, release and cancel; another pointer's are not the reel's. */
|
|
924
888
|
const pointerMessages = (hold: PointerHold): Stream.Stream<Message> =>
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
),
|
|
931
|
-
);
|
|
889
|
+
dragMessages<Message>(hold.pointerId, {
|
|
890
|
+
moved: (event) => Message.MovedPointer(pointerAt(hold, event)),
|
|
891
|
+
released: (event) => Message.ReleasedPointer(pointerAt(hold, event)),
|
|
892
|
+
cancelled: () => Message.CancelledPointer(),
|
|
893
|
+
});
|
|
932
894
|
|
|
933
895
|
/** Which way a pointer at `at` scrolls a box from `low` to `high`: towards the edge it is near. */
|
|
934
896
|
const towards = (at: number, low: number, high: number): number => {
|
|
@@ -941,7 +903,7 @@ const towards = (at: number, low: number, high: number): number => {
|
|
|
941
903
|
}
|
|
942
904
|
};
|
|
943
905
|
|
|
944
|
-
/** While a pointer carries a pane
|
|
906
|
+
/** While a pointer carries a pane near the reel's edges, the reel scrolls that way,
|
|
945
907
|
* by the time each frame took, so a drop can reach a column or a place out of view. */
|
|
946
908
|
const edgeScroll = (hold: PointerHold): Stream.Stream<never> =>
|
|
947
909
|
Stream.callback<never>(() =>
|
|
@@ -1036,23 +998,21 @@ const splitterMessages = (hold: SplitterHold): Stream.Stream<Message> =>
|
|
|
1036
998
|
hold.splitter._tag === "Width" ? event.clientX - hold.pressX : event.clientY - hold.pressY;
|
|
1037
999
|
return scale > 0 ? moved / scale : 0;
|
|
1038
1000
|
};
|
|
1039
|
-
return
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
),
|
|
1045
|
-
);
|
|
1001
|
+
return dragMessages<Message>(hold.pointerId, {
|
|
1002
|
+
moved: (event) => Message.MovedSplitter({ share: shareOf(event) }),
|
|
1003
|
+
released: (event) => Message.ReleasedSplitter({ share: shareOf(event) }),
|
|
1004
|
+
cancelled: () => Message.CancelledSplitter(),
|
|
1005
|
+
});
|
|
1046
1006
|
});
|
|
1047
1007
|
|
|
1048
1008
|
const pointerHoldOf = (model: Model, isCarryingOnly: boolean): Option.Option<PointerHold> =>
|
|
1049
|
-
Option.flatMap(model.maybeHeld, ({
|
|
1009
|
+
Option.flatMap(model.maybeHeld, ({ paneId, by }) =>
|
|
1050
1010
|
By.match(by, {
|
|
1051
1011
|
Keyboard: () => Option.none(),
|
|
1052
1012
|
Pointer: ({ pointerId, isCarrying }) =>
|
|
1053
1013
|
isCarryingOnly && !isCarrying
|
|
1054
1014
|
? Option.none()
|
|
1055
|
-
: Option.some({ reelHook: model.id,
|
|
1015
|
+
: Option.some({ reelHook: model.id, paneId, pointerId }),
|
|
1056
1016
|
}),
|
|
1057
1017
|
);
|
|
1058
1018
|
|
|
@@ -1107,7 +1067,7 @@ const reelSubscriptions = Subscription.make<Model, Message>()((entry) => ({
|
|
|
1107
1067
|
),
|
|
1108
1068
|
}));
|
|
1109
1069
|
|
|
1110
|
-
/** A held pane
|
|
1070
|
+
/** A held pane follows the pointer (the reel scrolling near its edges), a dragged
|
|
1111
1071
|
* splitter resizes, and Escape puts either back, before a dialog or the page sees it, from
|
|
1112
1072
|
* listeners that run only while something is held. Lift them into the application's
|
|
1113
1073
|
* Subscriptions, once per reel, behind a key prefix of the parent's: `subscriptions("tiles")`
|
|
@@ -1159,7 +1119,7 @@ type ReelInfo = Readonly<{
|
|
|
1159
1119
|
toPaneView: (pane: Pane, render: PaneRender) => Html;
|
|
1160
1120
|
maybeFocusedId: Option.Option<string>;
|
|
1161
1121
|
maybeMark: Option.Option<Target>;
|
|
1162
|
-
|
|
1122
|
+
maybeCarriedId: Option.Option<string>;
|
|
1163
1123
|
}>;
|
|
1164
1124
|
|
|
1165
1125
|
const helpId = (model: Model): string => `${model.id}-grip-help`;
|
|
@@ -1174,9 +1134,6 @@ const HELD_STEPS: Readonly<Record<string, Step>> = {
|
|
|
1174
1134
|
ArrowRight: "Right",
|
|
1175
1135
|
};
|
|
1176
1136
|
|
|
1177
|
-
const isPlain = (modifiers: KeyboardModifiers): boolean =>
|
|
1178
|
-
!modifiers.shiftKey && !modifiers.ctrlKey && !modifiers.altKey && !modifiers.metaKey;
|
|
1179
|
-
|
|
1180
1137
|
const isDropKey = (key: string): boolean => key === " " || key === "Enter";
|
|
1181
1138
|
|
|
1182
1139
|
/** The anchor that pins a pane to its slot. Pane ids are the application's, so the name is
|
|
@@ -1190,31 +1147,16 @@ const byId = Order.mapInput(Order.String, (pane: Pane) => pane.id);
|
|
|
1190
1147
|
const markOf = (model: Model, layout: Layout): Option.Option<Target> =>
|
|
1191
1148
|
Option.flatMap(Option.filter(model.maybeHeld, isCarried), (held) =>
|
|
1192
1149
|
Option.filter(held.maybeTarget, (target) =>
|
|
1193
|
-
Option.isSome(requestOf(layout, held.
|
|
1150
|
+
Option.isSome(requestOf(layout, held.paneId, target)),
|
|
1194
1151
|
),
|
|
1195
1152
|
);
|
|
1196
1153
|
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1199
|
-
Option.match(Option.filter(model.maybeHeld, isCarried), {
|
|
1200
|
-
onNone: () => [],
|
|
1201
|
-
onSome: ({ subject }) =>
|
|
1202
|
-
Subject.match(subject, {
|
|
1203
|
-
Pane: ({ paneId }) => [paneId],
|
|
1204
|
-
Column: ({ columnId }) =>
|
|
1205
|
-
Option.match(columnOf(layout, columnId), {
|
|
1206
|
-
onNone: () => [],
|
|
1207
|
-
onSome: (column) => Array.map(column.panes, (pane) => pane.id),
|
|
1208
|
-
}),
|
|
1209
|
-
}),
|
|
1210
|
-
}),
|
|
1211
|
-
);
|
|
1154
|
+
const carriedIdOf = (model: Model): Option.Option<string> =>
|
|
1155
|
+
Option.map(Option.filter(model.maybeHeld, isCarried), ({ paneId }) => paneId);
|
|
1212
1156
|
|
|
1213
|
-
const gripView = (model: Model,
|
|
1157
|
+
const gripView = (model: Model, pane: Pane, h: HtmlBuilder<Message>): Html => {
|
|
1214
1158
|
const isIdleNow = !isHolding(model) && Option.isNone(model.maybeResizing);
|
|
1215
|
-
const maybeHeldHere = Option.filter(model.maybeHeld, (held) =>
|
|
1216
|
-
Equal.equals(held.subject, subject),
|
|
1217
|
-
);
|
|
1159
|
+
const maybeHeldHere = Option.filter(model.maybeHeld, (held) => held.paneId === pane.id);
|
|
1218
1160
|
const isHeld = Option.exists(maybeHeldHere, isCarried);
|
|
1219
1161
|
const isKeyboardHeld = Option.exists(maybeHeldHere, (held) => held.by._tag === "Keyboard");
|
|
1220
1162
|
|
|
@@ -1231,7 +1173,7 @@ const gripView = (model: Model, subject: Subject, label: string, h: HtmlBuilder<
|
|
|
1231
1173
|
);
|
|
1232
1174
|
} else {
|
|
1233
1175
|
return Option.liftPredicate(
|
|
1234
|
-
Message.PressedGripKey({
|
|
1176
|
+
Message.PressedGripKey({ paneId: pane.id }),
|
|
1235
1177
|
() => isIdleNow && isDropKey(key),
|
|
1236
1178
|
);
|
|
1237
1179
|
}
|
|
@@ -1240,7 +1182,7 @@ const gripView = (model: Model, subject: Subject, label: string, h: HtmlBuilder<
|
|
|
1240
1182
|
const onGripPress: Attribute<Message> = h.OnPointerDown(
|
|
1241
1183
|
(_pointerType, button, _screenX, _screenY, _time, clientX, clientY, pointerId) =>
|
|
1242
1184
|
Option.liftPredicate(
|
|
1243
|
-
Message.PressedGrip({
|
|
1185
|
+
Message.PressedGrip({ paneId: pane.id, pointerId, clientX, clientY }),
|
|
1244
1186
|
() => isIdleNow && button === LEFT_BUTTON,
|
|
1245
1187
|
),
|
|
1246
1188
|
);
|
|
@@ -1248,10 +1190,10 @@ const gripView = (model: Model, subject: Subject, label: string, h: HtmlBuilder<
|
|
|
1248
1190
|
return IconButton.view(
|
|
1249
1191
|
{
|
|
1250
1192
|
glyph: "grip",
|
|
1251
|
-
label,
|
|
1193
|
+
label: fill(COPY.paneGrip, { pane: pane.label }),
|
|
1252
1194
|
isQuiet: true,
|
|
1253
1195
|
attributes: [
|
|
1254
|
-
h.DataAttribute("reel-grip",
|
|
1196
|
+
h.DataAttribute("reel-grip", paneHook(model, pane.id)),
|
|
1255
1197
|
h.AriaRoleDescription("draggable"),
|
|
1256
1198
|
h.AriaDescribedBy(helpId(model)),
|
|
1257
1199
|
Button.pressedAttribute(isHeld, h),
|
|
@@ -1264,34 +1206,23 @@ const gripView = (model: Model, subject: Subject, label: string, h: HtmlBuilder<
|
|
|
1264
1206
|
);
|
|
1265
1207
|
};
|
|
1266
1208
|
|
|
1267
|
-
/** A splitter's handlers: a pointer drags it, and its arrows
|
|
1268
|
-
|
|
1269
|
-
const splitterAttributes = (
|
|
1209
|
+
/** A splitter's handlers: a pointer drags it, and its arrows step it by `SPLITTER_STEP`. */
|
|
1210
|
+
const reelSplitterAttributes = (
|
|
1270
1211
|
model: Model,
|
|
1271
1212
|
splitter: Splitter,
|
|
1272
1213
|
h: HtmlBuilder<Message>,
|
|
1273
|
-
): ReadonlyArray<Attribute<Message>> =>
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
(
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
Option.liftPredicate(
|
|
1286
|
-
Message.PressedSplitterKey({
|
|
1287
|
-
splitter,
|
|
1288
|
-
share: key === more ? SPLITTER_STEP : -SPLITTER_STEP,
|
|
1289
|
-
}),
|
|
1290
|
-
() => isPlain(modifiers) && (key === less || key === more),
|
|
1291
|
-
),
|
|
1292
|
-
),
|
|
1293
|
-
];
|
|
1294
|
-
};
|
|
1214
|
+
): ReadonlyArray<Attribute<Message>> =>
|
|
1215
|
+
splitterAttributes(
|
|
1216
|
+
{
|
|
1217
|
+
orientation: splitter._tag === "Width" ? "vertical" : "horizontal",
|
|
1218
|
+
isIdle: !isHolding(model) && Option.isNone(model.maybeResizing),
|
|
1219
|
+
onPress: (pointerId, clientX, clientY) =>
|
|
1220
|
+
Message.PressedSplitter({ splitter, pointerId, clientX, clientY }),
|
|
1221
|
+
onStep: (direction) =>
|
|
1222
|
+
Message.PressedSplitterKey({ splitter, share: direction * SPLITTER_STEP }),
|
|
1223
|
+
},
|
|
1224
|
+
h,
|
|
1225
|
+
);
|
|
1295
1226
|
|
|
1296
1227
|
const dropClass = (info: ReelInfo, column: Column, paneId: string): string =>
|
|
1297
1228
|
Option.match(
|
|
@@ -1299,7 +1230,10 @@ const dropClass = (info: ReelInfo, column: Column, paneId: string): string =>
|
|
|
1299
1230
|
if (mark._tag !== "Into" || mark.columnId !== column.id) {
|
|
1300
1231
|
return Option.none();
|
|
1301
1232
|
}
|
|
1302
|
-
const others = Array.filter(
|
|
1233
|
+
const others = Array.filter(
|
|
1234
|
+
column.panes,
|
|
1235
|
+
(pane) => !Option.contains(info.maybeCarriedId, pane.id),
|
|
1236
|
+
);
|
|
1303
1237
|
return Option.orElse(
|
|
1304
1238
|
Option.map(
|
|
1305
1239
|
Option.filter(Array.get(others, mark.index), (pane) => pane.id === paneId),
|
|
@@ -1341,14 +1275,13 @@ const splitView = (model: Model, above: Pane, share: number, h: HtmlBuilder<Mess
|
|
|
1341
1275
|
h.keyed("div")(`split:${above.id}`, [
|
|
1342
1276
|
h.Class("reel-split"),
|
|
1343
1277
|
h.Role("separator"),
|
|
1344
|
-
h.AriaOrientation("horizontal"),
|
|
1345
1278
|
h.AriaLabel(fill(COPY.heightLabel, { pane: above.label })),
|
|
1346
1279
|
h.AriaValuemin(0),
|
|
1347
1280
|
h.AriaValuemax(100),
|
|
1348
1281
|
h.AriaValuenow(Math.round(share * 100)),
|
|
1349
1282
|
h.Tabindex(0),
|
|
1350
1283
|
h.DataAttribute("reel-split", above.id),
|
|
1351
|
-
...
|
|
1284
|
+
...reelSplitterAttributes(model, Splitter.Split({ abovePaneId: above.id }), h),
|
|
1352
1285
|
]);
|
|
1353
1286
|
|
|
1354
1287
|
const columnView = (
|
|
@@ -1379,26 +1312,11 @@ const columnView = (
|
|
|
1379
1312
|
h.DataAttribute("reel-hook", columnHook(model, column.id)),
|
|
1380
1313
|
h.Style({ "--reel-width": `${column.width}` }),
|
|
1381
1314
|
],
|
|
1382
|
-
|
|
1383
|
-
h.div(
|
|
1384
|
-
[h.Class("reel-column-head")],
|
|
1385
|
-
[
|
|
1386
|
-
gripView(
|
|
1387
|
-
model,
|
|
1388
|
-
Subject.Column({ columnId: column.id }),
|
|
1389
|
-
fill(COPY.columnGrip, { column: index + 1 }),
|
|
1390
|
-
h,
|
|
1391
|
-
),
|
|
1392
|
-
h.span([h.Class("reel-width"), h.AriaHidden(true)], [widthMark(column.width)]),
|
|
1393
|
-
],
|
|
1394
|
-
),
|
|
1395
|
-
...stack,
|
|
1396
|
-
],
|
|
1315
|
+
stack,
|
|
1397
1316
|
),
|
|
1398
1317
|
h.keyed("div")(`resize:${column.id}`, [
|
|
1399
1318
|
h.Class(`reel-gap reel-resize${isGapMarked(info, maybeNextId) ? " drop-here" : ""}`),
|
|
1400
1319
|
h.Role("separator"),
|
|
1401
|
-
h.AriaOrientation("vertical"),
|
|
1402
1320
|
h.AriaLabel(fill(COPY.widthLabel, { column: index + 1 })),
|
|
1403
1321
|
h.AriaValuemin(0),
|
|
1404
1322
|
h.AriaValuemax(100),
|
|
@@ -1406,7 +1324,7 @@ const columnView = (
|
|
|
1406
1324
|
h.AriaValuetext(widthWords(column.width)),
|
|
1407
1325
|
h.Tabindex(0),
|
|
1408
1326
|
h.DataAttribute("reel-resize", column.id),
|
|
1409
|
-
...
|
|
1327
|
+
...reelSplitterAttributes(model, Splitter.Width({ columnId: column.id }), h),
|
|
1410
1328
|
]),
|
|
1411
1329
|
];
|
|
1412
1330
|
};
|
|
@@ -1426,7 +1344,7 @@ const paneLayerView = (
|
|
|
1426
1344
|
[h.Class("reel-panes")],
|
|
1427
1345
|
Array.map(panes, (pane) => {
|
|
1428
1346
|
const isFocused = Option.contains(info.maybeFocusedId, pane.id);
|
|
1429
|
-
const isHeld = info.
|
|
1347
|
+
const isHeld = Option.contains(info.maybeCarriedId, pane.id);
|
|
1430
1348
|
return h.keyed("div")(
|
|
1431
1349
|
pane.id,
|
|
1432
1350
|
[
|
|
@@ -1438,12 +1356,7 @@ const paneLayerView = (
|
|
|
1438
1356
|
],
|
|
1439
1357
|
[
|
|
1440
1358
|
info.toPaneView(pane, {
|
|
1441
|
-
grip: gripView(
|
|
1442
|
-
model,
|
|
1443
|
-
Subject.Pane({ paneId: pane.id }),
|
|
1444
|
-
fill(COPY.paneGrip, { pane: pane.label }),
|
|
1445
|
-
h,
|
|
1446
|
-
),
|
|
1359
|
+
grip: gripView(model, pane, h),
|
|
1447
1360
|
id: `${model.id}-${pane.id}`,
|
|
1448
1361
|
}),
|
|
1449
1362
|
],
|
|
@@ -1452,8 +1365,8 @@ const paneLayerView = (
|
|
|
1452
1365
|
);
|
|
1453
1366
|
};
|
|
1454
1367
|
|
|
1455
|
-
/** The copy a pointer carries: the held
|
|
1456
|
-
*
|
|
1368
|
+
/** The copy a pointer carries: the held pane's title bar, drawn by the reel from its label,
|
|
1369
|
+
* so whatever a pane holds (a terminal, a canvas) is never built twice. */
|
|
1457
1370
|
const liftedCopyView = (info: ReelInfo, h: HtmlBuilder<Message>): ReadonlyArray<Html> => {
|
|
1458
1371
|
const { model, layout } = info;
|
|
1459
1372
|
|
|
@@ -1471,12 +1384,7 @@ const liftedCopyView = (info: ReelInfo, h: HtmlBuilder<Message>): ReadonlyArray<
|
|
|
1471
1384
|
h.Inert(true),
|
|
1472
1385
|
h.OnMount(
|
|
1473
1386
|
SizeLiftedPane({
|
|
1474
|
-
sourceSelector:
|
|
1475
|
-
Pane: () =>
|
|
1476
|
-
`[data-reel-pane="${CSS.escape(subjectHook(model, held.subject))}"]`,
|
|
1477
|
-
Column: () =>
|
|
1478
|
-
`[data-reel-hook="${CSS.escape(subjectHook(model, held.subject))}"]`,
|
|
1479
|
-
}),
|
|
1387
|
+
sourceSelector: `[data-reel-pane="${CSS.escape(paneHook(model, held.paneId))}"]`,
|
|
1480
1388
|
pressX,
|
|
1481
1389
|
pressY,
|
|
1482
1390
|
}),
|
|
@@ -1484,34 +1392,19 @@ const liftedCopyView = (info: ReelInfo, h: HtmlBuilder<Message>): ReadonlyArray<
|
|
|
1484
1392
|
],
|
|
1485
1393
|
[
|
|
1486
1394
|
h.div(
|
|
1487
|
-
[h.Class("
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1395
|
+
[h.Class("panel window")],
|
|
1396
|
+
[
|
|
1397
|
+
h.div(
|
|
1398
|
+
[h.Class("panel-tb")],
|
|
1399
|
+
[
|
|
1400
|
+
h.span(
|
|
1401
|
+
[h.Class("panel-lead")],
|
|
1402
|
+
[h.span([h.Class("iconbtn quiet")], [Glyph.view({ name: "grip" }, h)])],
|
|
1403
|
+
),
|
|
1404
|
+
h.h2([], [heldWords(layout, held)]),
|
|
1405
|
+
],
|
|
1492
1406
|
),
|
|
1493
|
-
|
|
1494
|
-
h.div(
|
|
1495
|
-
[h.Class("panel window")],
|
|
1496
|
-
[
|
|
1497
|
-
h.div(
|
|
1498
|
-
[h.Class("panel-tb")],
|
|
1499
|
-
[
|
|
1500
|
-
h.span(
|
|
1501
|
-
[h.Class("panel-lead")],
|
|
1502
|
-
[
|
|
1503
|
-
h.span(
|
|
1504
|
-
[h.Class("iconbtn quiet")],
|
|
1505
|
-
[Glyph.view({ name: "grip" }, h)],
|
|
1506
|
-
),
|
|
1507
|
-
],
|
|
1508
|
-
),
|
|
1509
|
-
h.h2([], [pane.label]),
|
|
1510
|
-
],
|
|
1511
|
-
),
|
|
1512
|
-
],
|
|
1513
|
-
),
|
|
1514
|
-
),
|
|
1407
|
+
],
|
|
1515
1408
|
),
|
|
1516
1409
|
],
|
|
1517
1410
|
),
|
|
@@ -1532,15 +1425,14 @@ const previewed = (model: Model, layout: Layout): Layout =>
|
|
|
1532
1425
|
/**
|
|
1533
1426
|
* A row of columns wider than the view when it must be, each a stack of panes, scrolled
|
|
1534
1427
|
* sideways, and down where a column is taller than the view: `div.reel` holding the scroller
|
|
1535
|
-
* `div.reel-view`, whose `div.reel-track` lays out the columns (`div.reel-column`,
|
|
1536
|
-
*
|
|
1537
|
-
*
|
|
1538
|
-
*
|
|
1539
|
-
* each pane renders through `toPaneView`.
|
|
1428
|
+
* `div.reel-view`, whose `div.reel-track` lays out the columns (`div.reel-column`, a slot per
|
|
1429
|
+
* pane with a splitter between) with a splitter after each, and the panes themselves in
|
|
1430
|
+
* `div.reel-panes`, each pinned to its slot by anchor positioning. The reel takes its height
|
|
1431
|
+
* from whatever holds it. The layout is the parent's; each pane renders through `toPaneView`.
|
|
1540
1432
|
*
|
|
1541
|
-
* A pane's grip
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1433
|
+
* A pane's grip lifts it under a pointer or the keyboard: Space or Enter picks it up, the
|
|
1434
|
+
* arrows move it (left and right through the gaps and columns, up and down among a column's
|
|
1435
|
+
* panes), Space drops it, and Escape or Tab puts it back. A drop reports what it asks
|
|
1544
1436
|
* of the layout as the OutMessage. Lift `subscriptions(keyPrefix)` into the application's, or
|
|
1545
1437
|
* nothing follows the pointer, and call `reconcile` whenever the layout changes.
|
|
1546
1438
|
*/
|
|
@@ -1558,7 +1450,7 @@ export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h
|
|
|
1558
1450
|
toPaneView,
|
|
1559
1451
|
maybeFocusedId: focusedPaneId(model, layout),
|
|
1560
1452
|
maybeMark: markOf(model, layout),
|
|
1561
|
-
|
|
1453
|
+
maybeCarriedId: carriedIdOf(model),
|
|
1562
1454
|
};
|
|
1563
1455
|
|
|
1564
1456
|
return h.div(
|