@swiftbrowser/web 0.26.5 → 0.26.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/package.json +1 -1
- package/src/layout/engine.ts +43 -2
- package/src/layout/types.ts +6 -0
- package/src/layoutDom.ts +5 -0
- package/src/pointerEffects.ts +71 -11
- package/src/protocol.ts +15 -0
- package/src/renderer.ts +84 -3
- package/src/styles.css +28 -3
package/package.json
CHANGED
package/src/layout/engine.ts
CHANGED
|
@@ -475,6 +475,8 @@ const MAC_LIST_FOOTER_BOTTOM = 6;
|
|
|
475
475
|
// the bar and 10 above the rows. A screen not at the window's leading edge
|
|
476
476
|
// starts its toolbar 10 in.
|
|
477
477
|
const MAC_NAVSPLIT_SIDEBAR_WIDTH = 140;
|
|
478
|
+
/** The width of the strip astride the sidebar panel's trailing edge that drags the sidebar's width. */
|
|
479
|
+
const MAC_NAVSPLIT_DIVIDER_GRIP = 8;
|
|
478
480
|
const MAC_NAVSPLIT_SIDEBAR_INSET = 8;
|
|
479
481
|
const MAC_NAVSPLIT_TOGGLE_INSET = 6;
|
|
480
482
|
const MAC_SIDEBAR_SEARCH_ABOVE = 9;
|
|
@@ -1031,6 +1033,32 @@ function cellStyle(node: LayoutNode, key: string): unknown {
|
|
|
1031
1033
|
return undefined;
|
|
1032
1034
|
}
|
|
1033
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* A split view column's `.navigationSplitViewColumnWidth(min:ideal:max:)`:
|
|
1038
|
+
* the `splitColumnWidth` hint (`{ min?, ideal, max? }`) on the column's
|
|
1039
|
+
* content, read through its `styled` wrappers, its `navstack` and the root
|
|
1040
|
+
* screen of that stack (an app puts the modifier on the sidebar's list).
|
|
1041
|
+
*/
|
|
1042
|
+
function splitColumnWidthOf(column: LayoutNode): { min?: number; ideal: number; max?: number } | null {
|
|
1043
|
+
let current: LayoutNode | undefined = column;
|
|
1044
|
+
while (current) {
|
|
1045
|
+
const hint = cellStyle(current, 'splitColumnWidth');
|
|
1046
|
+
if (hint && typeof hint === 'object') {
|
|
1047
|
+
const spec = hint as Record<string, unknown>;
|
|
1048
|
+
const ideal = num(spec['ideal']);
|
|
1049
|
+
if (ideal === null) return null;
|
|
1050
|
+
const min = num(spec['min']);
|
|
1051
|
+
const max = num(spec['max']);
|
|
1052
|
+
return { ...(min !== null ? { min } : {}), ideal, ...(max !== null ? { max } : {}) };
|
|
1053
|
+
}
|
|
1054
|
+
const inner = unwrapStyled(current);
|
|
1055
|
+
if (inner.kind === 'navstack') current = inner.children[0] as LayoutNode | undefined;
|
|
1056
|
+
else if (inner.kind === 'navscreen') current = inner.children.find((child) => child.kind !== 'toolbar' && child.kind !== 'searchfield');
|
|
1057
|
+
else return null;
|
|
1058
|
+
}
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1034
1062
|
/** The default vertical padding above and below a list row's content. */
|
|
1035
1063
|
function listRowVPad(row: LayoutNode, sidebar: boolean, mac: boolean): number {
|
|
1036
1064
|
if (mac) return row.kind === 'button' ? MAC_LIST_BUTTON_ROW_VPAD : sidebar ? MAC_LIST_SIDEBAR_ROW_VPAD : MAC_LIST_ROW_VPAD;
|
|
@@ -5438,7 +5466,18 @@ export class LayoutPass {
|
|
|
5438
5466
|
const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
|
|
5439
5467
|
const usable = Math.max(0, size.width - leading - trailing);
|
|
5440
5468
|
const mac = this.env.platform === 'macos';
|
|
5441
|
-
const
|
|
5469
|
+
const [sidebar, detail, ...rest] = node.children;
|
|
5470
|
+
// The sidebar's width: the app's `.navigationSplitViewColumnWidth` (a
|
|
5471
|
+
// `splitColumnWidth` hint on the sidebar's content, its ideal width
|
|
5472
|
+
// between its min and max), else the platform's default; on a Mac the
|
|
5473
|
+
// user drags the column's edge (`env.sidebarWidths`) between the same
|
|
5474
|
+
// bounds. Never more than 40% of the width.
|
|
5475
|
+
const requested = sidebar ? splitColumnWidthOf(sidebar) : null;
|
|
5476
|
+
const fallback = mac ? MAC_NAVSPLIT_SIDEBAR_WIDTH : NAVSPLIT_SIDEBAR_WIDTH;
|
|
5477
|
+
const minWidth = requested?.min ?? Math.min(fallback, requested?.ideal ?? fallback);
|
|
5478
|
+
const maxWidth = Math.min(requested?.max ?? Infinity, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
|
|
5479
|
+
const dragged = mac ? this.env.sidebarWidths?.[node.id] : undefined;
|
|
5480
|
+
const sidebarWidth = Math.max(0, Math.min(maxWidth, Math.max(minWidth, dragged ?? requested?.ideal ?? fallback)));
|
|
5442
5481
|
const visible = node.props['sidebarVisible'] !== false;
|
|
5443
5482
|
// A Mac's sidebar is a panel inset from the window's edges, with no separator.
|
|
5444
5483
|
const panelInset = mac ? MAC_NAVSPLIT_SIDEBAR_INSET : 0;
|
|
@@ -5465,9 +5504,11 @@ export class LayoutPass {
|
|
|
5465
5504
|
};
|
|
5466
5505
|
// The Mac's sidebar panel (the page draws it), inset from the window's edges.
|
|
5467
5506
|
if (mac) chrome['panel'] = { x: sidebarX, y: y + panelInset, width: sidebarWidth, height: Math.max(0, size.height - 2 * panelInset) };
|
|
5507
|
+
// The Mac's column divider (the page's drag handle for the sidebar's
|
|
5508
|
+
// width), a strip astride the panel's trailing edge while it shows.
|
|
5509
|
+
if (mac && visible) chrome['divider'] = { x: separatorX - MAC_NAVSPLIT_DIVIDER_GRIP / 2, y, width: MAC_NAVSPLIT_DIVIDER_GRIP, height: size.height };
|
|
5468
5510
|
this.setChrome(node, chrome);
|
|
5469
5511
|
const toggleRoom = toggleSize + (mac ? MAC_BAR_GROUP_GAP : NAV_GROUP_SPACING);
|
|
5470
|
-
const [sidebar, detail, ...rest] = node.children;
|
|
5471
5512
|
if (sidebar) {
|
|
5472
5513
|
const proposal: Proposal = { width: sidebarWidth, height: size.height };
|
|
5473
5514
|
// On a Mac the toggle is at the sidebar's trailing end: its screens' items start where they would.
|
package/src/layout/types.ts
CHANGED
|
@@ -127,6 +127,12 @@ export interface LayoutEnvironment {
|
|
|
127
127
|
* shows its `keyboard` toolbars above the keyboard.
|
|
128
128
|
*/
|
|
129
129
|
focusedField?: number;
|
|
130
|
+
/**
|
|
131
|
+
* The sidebar widths the user dragged a `navsplit`'s column divider to (a
|
|
132
|
+
* Mac), by the `navsplit`'s element id. The engine keeps each between the
|
|
133
|
+
* column's min and max (its `splitColumnWidth` hint, else the defaults).
|
|
134
|
+
*/
|
|
135
|
+
sidebarWidths?: Record<number, number>;
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
export type TextAlignment = 'leading' | 'center' | 'trailing';
|
package/src/layoutDom.ts
CHANGED
|
@@ -1070,6 +1070,11 @@ function applyChrome(
|
|
|
1070
1070
|
// Phase 7: the sidebar toggle (the sidebar column draws the hairline).
|
|
1071
1071
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-toggle'), chrome?.['toggle'], box);
|
|
1072
1072
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-panel'), chrome?.['panel'], box);
|
|
1073
|
+
// The Mac's column divider, astride the panel's trailing edge; it
|
|
1074
|
+
// carries the sidebar's width for the drag that resizes it (renderer.ts).
|
|
1075
|
+
const divider = el.querySelector<HTMLElement>(':scope > .sb-navsplit-divider');
|
|
1076
|
+
placeChrome(divider, chrome?.['divider'], box, false);
|
|
1077
|
+
if (divider && chrome?.['panel']) divider.dataset['sbWidth'] = String(chrome['panel'].width);
|
|
1073
1078
|
setFlag(el, 'sbSidebarHidden', node.props['sidebarVisible'] === false ? '' : null);
|
|
1074
1079
|
return;
|
|
1075
1080
|
}
|
package/src/pointerEffects.ts
CHANGED
|
@@ -1,43 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What a page with a mouse adds to a phone's UI: `hover` events (`.onHover`),
|
|
3
|
-
*
|
|
4
|
-
* (`.
|
|
5
|
-
*
|
|
3
|
+
* `continuoushover` events (`.onContinuousHover`: where the mouse is as it
|
|
4
|
+
* moves), tooltips (`.help`), cursors (`.pointerStyle`), and SF Symbols'
|
|
5
|
+
* effects (`.symbolEffect`) drawn with CSS. The Swift shim draws an iPhone
|
|
6
|
+
* and sends none of these; producers in the page (@swiftbrowser/react) do.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import type { ElementId, Style, SymbolEffectStyle } from './protocol';
|
|
9
|
+
import type { ContinuousHoverEvent, ElementId, HoverEvent, Style, SymbolEffectStyle } from './protocol';
|
|
9
10
|
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Hover
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
|
|
14
15
|
const HOVER_ATTR = 'sbHover';
|
|
16
|
+
const CONTINUOUS_HOVER_ATTR = 'sbContinuousHover';
|
|
15
17
|
|
|
16
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Sends `hover` as a mouse enters and leaves the boxes marked `data-sb-hover`
|
|
20
|
+
* under `root`, and `continuoushover` as it moves over the boxes marked
|
|
21
|
+
* `data-sb-continuous-hover` (its value the space of the location: the box's
|
|
22
|
+
* own, or the root's), one per animation frame per box, then `ended` as it
|
|
23
|
+
* leaves each.
|
|
24
|
+
*/
|
|
17
25
|
export class HoverTracker {
|
|
18
26
|
private readonly hovered = new Set<HTMLElement>();
|
|
27
|
+
/** The boxes the mouse is over that follow it, with the location to send at the next frame. */
|
|
28
|
+
private readonly followed = new Map<HTMLElement, { x: number; y: number } | null>();
|
|
29
|
+
private frame: number | null = null;
|
|
19
30
|
|
|
20
31
|
constructor(
|
|
21
32
|
private readonly root: HTMLElement,
|
|
22
|
-
private readonly send: (
|
|
33
|
+
private readonly send: (event: HoverEvent | ContinuousHoverEvent) => void,
|
|
23
34
|
) {
|
|
24
35
|
root.addEventListener('pointerover', this.onOver);
|
|
25
36
|
root.addEventListener('pointerout', this.onOut);
|
|
37
|
+
root.addEventListener('pointermove', this.onMove);
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
dispose(): void {
|
|
29
41
|
this.root.removeEventListener('pointerover', this.onOver);
|
|
30
42
|
this.root.removeEventListener('pointerout', this.onOut);
|
|
43
|
+
this.root.removeEventListener('pointermove', this.onMove);
|
|
31
44
|
this.hovered.clear();
|
|
45
|
+
this.followed.clear();
|
|
46
|
+
if (this.frame !== null) cancelAnimationFrame(this.frame);
|
|
47
|
+
this.frame = null;
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
private readonly onOver = (event: PointerEvent): void => {
|
|
35
51
|
if (event.pointerType === 'touch') return;
|
|
36
52
|
for (let el = event.target as HTMLElement | null; el && el !== this.root; el = el.parentElement) {
|
|
37
|
-
if (el.dataset[HOVER_ATTR]
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
if (el.dataset[HOVER_ATTR] !== undefined && !this.hovered.has(el)) {
|
|
54
|
+
this.hovered.add(el);
|
|
55
|
+
this.report(el, true);
|
|
56
|
+
}
|
|
57
|
+
if (el.dataset[CONTINUOUS_HOVER_ATTR] !== undefined && !this.followed.has(el)) this.followed.set(el, this.locationIn(el, event));
|
|
58
|
+
}
|
|
59
|
+
if (this.followed.size) this.schedule();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
private readonly onMove = (event: PointerEvent): void => {
|
|
63
|
+
if (event.pointerType === 'touch' || !this.followed.size) return;
|
|
64
|
+
for (let el = event.target as HTMLElement | null; el && el !== this.root; el = el.parentElement) {
|
|
65
|
+
if (this.followed.has(el)) this.followed.set(el, this.locationIn(el, event));
|
|
40
66
|
}
|
|
67
|
+
this.schedule();
|
|
41
68
|
};
|
|
42
69
|
|
|
43
70
|
private readonly onOut = (event: PointerEvent): void => {
|
|
@@ -48,11 +75,42 @@ export class HoverTracker {
|
|
|
48
75
|
this.hovered.delete(el);
|
|
49
76
|
this.report(el, false);
|
|
50
77
|
}
|
|
78
|
+
for (const el of [...this.followed.keys()]) {
|
|
79
|
+
if (next && el.contains(next)) continue;
|
|
80
|
+
this.followed.delete(el);
|
|
81
|
+
const id = this.idOf(el);
|
|
82
|
+
if (id !== null) this.send({ type: 'continuoushover', id, phase: 'ended' });
|
|
83
|
+
}
|
|
51
84
|
};
|
|
52
85
|
|
|
53
|
-
|
|
86
|
+
/** Where the mouse is, in the box's own coordinates or the root's, as the box's style asks. */
|
|
87
|
+
private locationIn(el: HTMLElement, event: PointerEvent): { x: number; y: number } {
|
|
88
|
+
const rect = (el.dataset[CONTINUOUS_HOVER_ATTR] === 'global' ? this.root : el).getBoundingClientRect();
|
|
89
|
+
return { x: Math.round((event.clientX - rect.left) * 100) / 100, y: Math.round((event.clientY - rect.top) * 100) / 100 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The followed boxes' locations go out at the next frame, however many moves came in. */
|
|
93
|
+
private schedule(): void {
|
|
94
|
+
if (this.frame !== null) return;
|
|
95
|
+
this.frame = requestAnimationFrame(() => {
|
|
96
|
+
this.frame = null;
|
|
97
|
+
for (const [el, location] of this.followed) {
|
|
98
|
+
if (!location) continue;
|
|
99
|
+
this.followed.set(el, null);
|
|
100
|
+
const id = this.idOf(el);
|
|
101
|
+
if (id !== null) this.send({ type: 'continuoushover', id, phase: 'active', location });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private idOf(el: HTMLElement): ElementId | null {
|
|
54
107
|
const id = Number(el.dataset['sbId']);
|
|
55
|
-
|
|
108
|
+
return Number.isInteger(id) && el.isConnected ? id : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private report(el: HTMLElement, value: boolean): void {
|
|
112
|
+
const id = this.idOf(el);
|
|
113
|
+
if (id !== null) this.send({ type: 'hover', id, value });
|
|
56
114
|
}
|
|
57
115
|
}
|
|
58
116
|
|
|
@@ -87,6 +145,8 @@ const triggers = new WeakMap<HTMLElement, number>();
|
|
|
87
145
|
/** Apply the pointer and effect fields of a `styled` element's style (called on create and update). */
|
|
88
146
|
export function applyPointerAndEffects(el: HTMLElement, style: Style): void {
|
|
89
147
|
if (style.hover) el.dataset[HOVER_ATTR] = '';
|
|
148
|
+
if (style.continuousHover) el.dataset[CONTINUOUS_HOVER_ATTR] = style.continuousHover;
|
|
149
|
+
else delete el.dataset[CONTINUOUS_HOVER_ATTR];
|
|
90
150
|
if (style.help) {
|
|
91
151
|
el.title = style.help;
|
|
92
152
|
el.dataset['sbHelp'] = '';
|
package/src/protocol.ts
CHANGED
|
@@ -1593,6 +1593,7 @@ export type RendererEvent =
|
|
|
1593
1593
|
| LazyEvent
|
|
1594
1594
|
| MenuEvent
|
|
1595
1595
|
| HoverEvent
|
|
1596
|
+
| ContinuousHoverEvent
|
|
1596
1597
|
// Scroll geometry and animation completion (the block at the end of this file)
|
|
1597
1598
|
| ScrollGeometryEvent
|
|
1598
1599
|
| AnimationCompleteEvent
|
|
@@ -2694,6 +2695,13 @@ export interface SymbolEffectStyle {
|
|
|
2694
2695
|
export interface Style {
|
|
2695
2696
|
/** The page sends `hover` events as a mouse enters and leaves the box (`.onHover`). */
|
|
2696
2697
|
hover?: boolean;
|
|
2698
|
+
/**
|
|
2699
|
+
* `.onContinuousHover(coordinateSpace:)`: the page sends `continuoushover`
|
|
2700
|
+
* events as a mouse moves over the box, its location in the box's own
|
|
2701
|
+
* coordinates (`local`) or the root's, scrolling included (`global`, the
|
|
2702
|
+
* same a `reportsFrame` geometry event reports), and once as it leaves.
|
|
2703
|
+
*/
|
|
2704
|
+
continuousHover?: 'local' | 'global';
|
|
2697
2705
|
/** `.help(_:)`: a tooltip on the box. */
|
|
2698
2706
|
help?: string;
|
|
2699
2707
|
/** `.pointerStyle(_:)`: the CSS cursor over the box (`pointer`, `text`, `grab`…). */
|
|
@@ -2735,6 +2743,13 @@ export interface HoverEvent {
|
|
|
2735
2743
|
value: boolean;
|
|
2736
2744
|
}
|
|
2737
2745
|
|
|
2746
|
+
/**
|
|
2747
|
+
* A mouse (or pen) over a box whose style has `continuousHover`: `active`
|
|
2748
|
+
* with where it is, coalesced to one per animation frame while it moves,
|
|
2749
|
+
* then `ended` once as it leaves. Touch never hovers.
|
|
2750
|
+
*/
|
|
2751
|
+
export type ContinuousHoverEvent = { type: 'continuoushover'; id: ElementId; phase: 'active'; location: Point } | { type: 'continuoushover'; id: ElementId; phase: 'ended' };
|
|
2752
|
+
|
|
2738
2753
|
export interface ScrollToCommand {
|
|
2739
2754
|
anchor: UnitPoint | null;
|
|
2740
2755
|
/** Increases with every request (across readers); the page carries out each serial once. */
|
package/src/renderer.ts
CHANGED
|
@@ -422,7 +422,7 @@ export class Renderer {
|
|
|
422
422
|
* for size changes, and the measurements of the current layout pass.
|
|
423
423
|
*/
|
|
424
424
|
private readonly hostObserver: ResizeObserver | null = null;
|
|
425
|
-
/** `hover` events for boxes whose style asks for them (pointerEffects.ts). */
|
|
425
|
+
/** `hover` and `continuoushover` events for boxes whose style asks for them (pointerEffects.ts). */
|
|
426
426
|
private readonly hover: HoverTracker;
|
|
427
427
|
/** A frame check of the `reportsFrame` readers is queued for the next animation frame (after a scroll). */
|
|
428
428
|
private frameCheckQueued = false;
|
|
@@ -1015,6 +1015,64 @@ export class Renderer {
|
|
|
1015
1015
|
if (slider && this.root.contains(slider)) this.beginSliderDrag(slider, event);
|
|
1016
1016
|
};
|
|
1017
1017
|
|
|
1018
|
+
/**
|
|
1019
|
+
* The sidebar widths the user dragged each `navsplit`'s column divider to
|
|
1020
|
+
* (a Mac), by element id: the engine lays the split out with them, clamped
|
|
1021
|
+
* to the column's bounds (`LayoutEnvironment.sidebarWidths`).
|
|
1022
|
+
*/
|
|
1023
|
+
private readonly sidebarWidths: Record<number, number> = {};
|
|
1024
|
+
private sidebarDrag: SidebarDrag | null = null;
|
|
1025
|
+
|
|
1026
|
+
// A Mac split view's divider: the pointer down that starts resizing the sidebar.
|
|
1027
|
+
private readonly onDividerPointerDown = (event: PointerEvent): void => {
|
|
1028
|
+
if (event.button !== 0 || this.sidebarDrag) return;
|
|
1029
|
+
const target = event.target as HTMLElement | null;
|
|
1030
|
+
const divider = target?.closest<HTMLElement>('.sb-navsplit-divider');
|
|
1031
|
+
const split = divider?.parentElement;
|
|
1032
|
+
if (!divider || !split || !this.root.contains(divider)) return;
|
|
1033
|
+
const id = Number(split.dataset[ID_ATTR]);
|
|
1034
|
+
const width = Number(divider.dataset['sbWidth']);
|
|
1035
|
+
if (!Number.isInteger(id) || !Number.isFinite(width)) return;
|
|
1036
|
+
event.preventDefault();
|
|
1037
|
+
// The presenting tree may be scaled (behind a sheet): pointer distances are in screen px.
|
|
1038
|
+
const rect = split.getBoundingClientRect();
|
|
1039
|
+
const scale = split.offsetWidth > 0 ? rect.width / split.offsetWidth : 1;
|
|
1040
|
+
this.sidebarDrag = { id, pointerId: event.pointerId, startX: event.clientX, startWidth: width, scale, frame: null, pending: width };
|
|
1041
|
+
this.root.classList.add('sb-resizing-sidebar');
|
|
1042
|
+
window.addEventListener('pointermove', this.onDividerMove);
|
|
1043
|
+
window.addEventListener('pointerup', this.onDividerUp);
|
|
1044
|
+
window.addEventListener('pointercancel', this.onDividerUp);
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
private readonly onDividerMove = (event: PointerEvent): void => {
|
|
1048
|
+
const drag = this.sidebarDrag;
|
|
1049
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
1050
|
+
drag.pending = Math.round(drag.startWidth + (event.clientX - drag.startX) / drag.scale);
|
|
1051
|
+
if (drag.frame !== null) return;
|
|
1052
|
+
// One layout per frame, however many pointer moves arrive.
|
|
1053
|
+
drag.frame = window.requestAnimationFrame(() => {
|
|
1054
|
+
drag.frame = null;
|
|
1055
|
+
if (this.sidebarWidths[drag.id] === drag.pending) return;
|
|
1056
|
+
this.sidebarWidths[drag.id] = drag.pending;
|
|
1057
|
+
if (this.commits > 0) this.layout();
|
|
1058
|
+
});
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
private readonly onDividerUp = (event: PointerEvent): void => {
|
|
1062
|
+
const drag = this.sidebarDrag;
|
|
1063
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
1064
|
+
if (drag.frame !== null) window.cancelAnimationFrame(drag.frame);
|
|
1065
|
+
this.sidebarDrag = null;
|
|
1066
|
+
this.root.classList.remove('sb-resizing-sidebar');
|
|
1067
|
+
window.removeEventListener('pointermove', this.onDividerMove);
|
|
1068
|
+
window.removeEventListener('pointerup', this.onDividerUp);
|
|
1069
|
+
window.removeEventListener('pointercancel', this.onDividerUp);
|
|
1070
|
+
if (this.sidebarWidths[drag.id] !== drag.pending) {
|
|
1071
|
+
this.sidebarWidths[drag.id] = drag.pending;
|
|
1072
|
+
if (this.commits > 0) this.layout();
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1018
1076
|
constructor(root: HTMLElement, onEvent: EventCallback) {
|
|
1019
1077
|
this.root = root;
|
|
1020
1078
|
this.onEvent = onEvent;
|
|
@@ -1079,6 +1137,7 @@ export class Renderer {
|
|
|
1079
1137
|
});
|
|
1080
1138
|
root.addEventListener('change', this.onChange);
|
|
1081
1139
|
root.addEventListener('pointerdown', this.onSliderPointerDown);
|
|
1140
|
+
root.addEventListener('pointerdown', this.onDividerPointerDown);
|
|
1082
1141
|
if (typeof ResizeObserver !== 'undefined') {
|
|
1083
1142
|
this.resizeObserver = new ResizeObserver(() => {
|
|
1084
1143
|
const before = this.rootSize;
|
|
@@ -1112,7 +1171,7 @@ export class Renderer {
|
|
|
1112
1171
|
this.stopRichEditors = listenToRichEditors(root, editorId, (event) => this.onEvent(event));
|
|
1113
1172
|
// After `onInput` above, so a keystroke's `text` goes before its `selection`.
|
|
1114
1173
|
this.stopPlainEditors = listenToPlainEditors(root, editorId, (event) => this.onEvent(event));
|
|
1115
|
-
this.hover = new HoverTracker(root, (
|
|
1174
|
+
this.hover = new HoverTracker(root, (event) => this.onEvent(event));
|
|
1116
1175
|
root.addEventListener('scroll', this.queueFrameCheck, { capture: true, passive: true });
|
|
1117
1176
|
// Scroll transitions and pinned views follow the scroll in the frame it is drawn in.
|
|
1118
1177
|
root.addEventListener('scroll', drawScrollTransitions, { capture: true, passive: true });
|
|
@@ -1605,6 +1664,7 @@ export class Renderer {
|
|
|
1605
1664
|
...(this.contentHeight ? { contentHeight: true } : {}),
|
|
1606
1665
|
...(this.keyboard > 0 ? { keyboard: this.keyboard } : {}),
|
|
1607
1666
|
...(focused !== null ? { focusedField: focused } : {}),
|
|
1667
|
+
...(Object.keys(this.sidebarWidths).length > 0 ? { sidebarWidths: { ...this.sidebarWidths } } : {}),
|
|
1608
1668
|
};
|
|
1609
1669
|
}
|
|
1610
1670
|
|
|
@@ -2114,6 +2174,8 @@ export class Renderer {
|
|
|
2114
2174
|
this.root.removeEventListener('scroll', drawScrollTransitions, true);
|
|
2115
2175
|
this.root.removeEventListener('change', this.onChange);
|
|
2116
2176
|
this.root.removeEventListener('pointerdown', this.onSliderPointerDown);
|
|
2177
|
+
this.root.removeEventListener('pointerdown', this.onDividerPointerDown);
|
|
2178
|
+
if (this.sidebarDrag) this.onDividerUp({ pointerId: this.sidebarDrag.pointerId } as PointerEvent);
|
|
2117
2179
|
this.reset();
|
|
2118
2180
|
}
|
|
2119
2181
|
|
|
@@ -3375,7 +3437,11 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
3375
3437
|
// The Mac's floating sidebar panel, under the columns (the engine's `panel` rect).
|
|
3376
3438
|
const panel = div('sb-navsplit-panel');
|
|
3377
3439
|
panel.hidden = true;
|
|
3378
|
-
|
|
3440
|
+
// The Mac's column divider (the engine's `divider` rect): dragging it
|
|
3441
|
+
// resizes the sidebar, as SwiftUI's does.
|
|
3442
|
+
const divider = div('sb-navsplit-divider');
|
|
3443
|
+
divider.hidden = true;
|
|
3444
|
+
el.append(panel, slot, divider, toggle);
|
|
3379
3445
|
SLOTS.set(el, slot);
|
|
3380
3446
|
return el;
|
|
3381
3447
|
}
|
|
@@ -3858,6 +3924,21 @@ function animateAlertOut(container: HTMLElement, alert: HTMLElement): void {
|
|
|
3858
3924
|
afterAnimation(clone, ALERT_TRANSITION_MS, () => clone.remove());
|
|
3859
3925
|
}
|
|
3860
3926
|
|
|
3927
|
+
/** A drag of a Mac split view's column divider (`.sb-navsplit-divider`). */
|
|
3928
|
+
interface SidebarDrag {
|
|
3929
|
+
/** The `navsplit` element. */
|
|
3930
|
+
id: ElementId;
|
|
3931
|
+
pointerId: number;
|
|
3932
|
+
startX: number;
|
|
3933
|
+
/** The sidebar's width when the drag began (the divider's `data-sb-width`). */
|
|
3934
|
+
startWidth: number;
|
|
3935
|
+
/** Screen px per layout px of the split view (the presenting tree may be scaled). */
|
|
3936
|
+
scale: number;
|
|
3937
|
+
frame: number | null;
|
|
3938
|
+
/** The width the pointer asks for, laid out at the next animation frame. */
|
|
3939
|
+
pending: number;
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3861
3942
|
interface SliderDrag {
|
|
3862
3943
|
slider: HTMLElement;
|
|
3863
3944
|
track: HTMLElement;
|
package/src/styles.css
CHANGED
|
@@ -4499,9 +4499,11 @@ svg.sb-path {
|
|
|
4499
4499
|
height: auto;
|
|
4500
4500
|
}
|
|
4501
4501
|
|
|
4502
|
-
/* `.onHover` (pointerEffects.ts): the whole box
|
|
4503
|
-
view's frame, so a hover box is hit in its
|
|
4504
|
-
.
|
|
4502
|
+
/* `.onHover` and `.onContinuousHover` (pointerEffects.ts): the whole box
|
|
4503
|
+
hovers, as SwiftUI uses the view's frame, so a hover box is hit in its
|
|
4504
|
+
empty space too. */
|
|
4505
|
+
.sb-styled[data-sb-hover],
|
|
4506
|
+
.sb-styled[data-sb-continuous-hover] {
|
|
4505
4507
|
pointer-events: auto;
|
|
4506
4508
|
}
|
|
4507
4509
|
|
|
@@ -5602,6 +5604,29 @@ html[data-sb-platform="macos"] .sb-navsplit-panel:not([hidden]) {
|
|
|
5602
5604
|
display: block;
|
|
5603
5605
|
}
|
|
5604
5606
|
|
|
5607
|
+
/* The Mac's column divider: an invisible strip astride the sidebar panel's
|
|
5608
|
+
trailing edge (the engine's `divider` rect) that drags the sidebar's width,
|
|
5609
|
+
as SwiftUI's split view lets the user. Over the columns, under the toggle. */
|
|
5610
|
+
.sb-navsplit-divider {
|
|
5611
|
+
display: none;
|
|
5612
|
+
}
|
|
5613
|
+
|
|
5614
|
+
html[data-sb-platform="macos"] .sb-navsplit-divider:not([hidden]) {
|
|
5615
|
+
display: block;
|
|
5616
|
+
position: absolute;
|
|
5617
|
+
z-index: 1;
|
|
5618
|
+
cursor: col-resize;
|
|
5619
|
+
touch-action: none;
|
|
5620
|
+
}
|
|
5621
|
+
|
|
5622
|
+
/* While a divider is dragged the whole page keeps its cursor and selects no text. */
|
|
5623
|
+
.sb-resizing-sidebar,
|
|
5624
|
+
.sb-resizing-sidebar * {
|
|
5625
|
+
cursor: col-resize !important;
|
|
5626
|
+
user-select: none;
|
|
5627
|
+
-webkit-user-select: none;
|
|
5628
|
+
}
|
|
5629
|
+
|
|
5605
5630
|
html[data-sb-platform="macos"] .sb-navsplit-columns > [data-sb-id]:first-child,
|
|
5606
5631
|
html[data-sb-platform="macos"] .sb-navsplit-columns > [data-sb-ghost]:first-child {
|
|
5607
5632
|
box-shadow: none;
|