sh3-core 0.19.1 → 0.19.5
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/dist/Sh3.svelte +3 -1
- package/dist/actions/menuBarModel.js +8 -0
- package/dist/actions/menuBarModel.test.js +61 -0
- package/dist/api.d.ts +4 -0
- package/dist/api.js +3 -0
- package/dist/app/admin/ApiKeysView.svelte +6 -5
- package/dist/app/store/PermissionConfirmModal.svelte +23 -0
- package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
- package/dist/app/store/StoreView.svelte +6 -1
- package/dist/chrome/CompactChrome.svelte +34 -1
- package/dist/chrome/CompactChrome.svelte.test.js +11 -6
- package/dist/chrome/FloatsSheet.svelte +236 -0
- package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
- package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
- package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
- package/dist/env/client.d.ts +5 -4
- package/dist/env/client.js +11 -17
- package/dist/env/serverUrl.d.ts +2 -0
- package/dist/env/serverUrl.js +8 -0
- package/dist/gestures/index.d.ts +17 -0
- package/dist/gestures/index.js +27 -0
- package/dist/keys/client.js +6 -7
- package/dist/keys/revocation-bus.svelte.js +11 -1
- package/dist/layout/compact/CarouselTabs.svelte +150 -14
- package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
- package/dist/layout/compact/CompactRenderer.svelte +9 -3
- package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
- package/dist/layout/compact/derive.js +7 -16
- package/dist/layout/compact/derive.test.js +30 -9
- package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
- package/dist/layout/compact/rootStore.svelte.js +59 -0
- package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
- package/dist/layout/compact/rootStore.svelte.test.js +54 -0
- package/dist/layout/drag.svelte.js +16 -3
- package/dist/layout/floats.d.ts +27 -0
- package/dist/layout/floats.js +20 -0
- package/dist/layout/floats.test.js +34 -1
- package/dist/layout/inspection.d.ts +20 -9
- package/dist/layout/inspection.js +91 -13
- package/dist/layout/inspection.svelte.test.d.ts +1 -0
- package/dist/layout/inspection.svelte.test.js +163 -0
- package/dist/layout/store.schemaVersion.test.js +2 -2
- package/dist/layout/types.d.ts +11 -8
- package/dist/layout/types.js +1 -1
- package/dist/layout/types.test.js +2 -2
- package/dist/overlays/FloatFrame.svelte +93 -22
- package/dist/overlays/FloatLayer.svelte +12 -1
- package/dist/overlays/float.d.ts +7 -0
- package/dist/overlays/float.js +76 -6
- package/dist/overlays/float.test.js +170 -0
- package/dist/primitives/ResizableSplitter.svelte +42 -8
- package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
- package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
- package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
- package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
- package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
- package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +337 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +11 -0
- package/dist/registry/checkFetch.d.ts +6 -0
- package/dist/registry/checkFetch.js +23 -0
- package/dist/sh3/views/KeysAndPeers.svelte +4 -3
- package/dist/shards/activate-runtime.test.js +99 -1
- package/dist/shards/activate.svelte.js +12 -3
- package/dist/shards/registry.d.ts +8 -1
- package/dist/shards/registry.js +13 -2
- package/dist/shards/registry.test.js +25 -4
- package/dist/shards/types.d.ts +14 -1
- package/dist/shell-shard/ScrollbackView.svelte +145 -67
- package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
- package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
- package/dist/shell-shard/dispatch-gating.test.js +38 -2
- package/dist/shell-shard/dispatch.js +9 -1
- package/dist/shell-shard/registry-resolve.test.js +50 -0
- package/dist/shell-shard/registry.d.ts +2 -1
- package/dist/shell-shard/registry.js +12 -2
- package/dist/shell-shard/verbs/help.js +5 -4
- package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
- package/dist/verbs/types.d.ts +10 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import { makeSelectionApi } from '../actions/selection.svelte';
|
|
36
36
|
import { spawnSatellite } from '../sh3Api/window';
|
|
37
37
|
import { walkShardsForContent } from '../satellite/walkShards';
|
|
38
|
+
import { logGesture } from '../gestures';
|
|
38
39
|
|
|
39
40
|
const isTauri =
|
|
40
41
|
typeof (globalThis as Record<string, unknown>).__TAURI_INTERNALS__ !== 'undefined';
|
|
@@ -64,8 +65,23 @@
|
|
|
64
65
|
|
|
65
66
|
let dragging = $state(false);
|
|
66
67
|
let dragOffset = { x: 0, y: 0 };
|
|
68
|
+
/**
|
|
69
|
+
* Pointer id of the active header drag (null when idle). All header-pointer
|
|
70
|
+
* handlers filter on this so a second finger / palm / stylus ghost touch
|
|
71
|
+
* landing on the same element cannot interfere with — or worse,
|
|
72
|
+
* `pointercancel` away — the primary drag.
|
|
73
|
+
*
|
|
74
|
+
* Previously this component used `setPointerCapture` on the header. On
|
|
75
|
+
* Android, transferring implicit capture from the original pointerdown
|
|
76
|
+
* target (a child span) to the header element fires `pointercancel` on
|
|
77
|
+
* the original target, which bubbled up to the `onpointercancel` handler
|
|
78
|
+
* here and ended the drag instantly. Switching to pointer-id tracking +
|
|
79
|
+
* implicit capture is the same fix the carousel landed (commit 638d75a).
|
|
80
|
+
*/
|
|
81
|
+
let dragPointerId: number | null = null;
|
|
67
82
|
let resizing = $state(false);
|
|
68
83
|
let resizeStart = { pointer: { x: 0, y: 0 }, size: { w: 0, h: 0 }, min: { w: 0, h: 0 } };
|
|
84
|
+
let resizePointerId: number | null = null;
|
|
69
85
|
let frameEl: HTMLDivElement | undefined = $state();
|
|
70
86
|
|
|
71
87
|
const isMaximized = $derived(floatManager.isMaximized(entry.id));
|
|
@@ -99,20 +115,40 @@
|
|
|
99
115
|
};
|
|
100
116
|
});
|
|
101
117
|
|
|
118
|
+
// Remove any in-flight document-level pointer listeners if the float is
|
|
119
|
+
// closed mid-drag (e.g. the close button is hit, or the framework removes
|
|
120
|
+
// the float for another reason). Without this, dangling listeners on
|
|
121
|
+
// document would keep firing on the next pointermove and leak the
|
|
122
|
+
// closure references to the destroyed component.
|
|
123
|
+
$effect(() => {
|
|
124
|
+
return () => {
|
|
125
|
+
if (dragging) endHeaderDrag();
|
|
126
|
+
if (resizing) endGripResize();
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
|
|
102
130
|
function onHeaderPointerDown(e: PointerEvent): void {
|
|
103
131
|
if (e.button !== 0) return;
|
|
104
132
|
if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
|
|
105
133
|
if ((e.target as HTMLElement).closest('.sh3-float-maximize')) return;
|
|
106
134
|
if ((e.target as HTMLElement).closest('.sh3-float-popout')) return;
|
|
107
|
-
|
|
108
|
-
target.setPointerCapture?.(e.pointerId);
|
|
135
|
+
if (dragging) return; // already mid-drag; second finger is ignored
|
|
109
136
|
dragging = true;
|
|
137
|
+
dragPointerId = e.pointerId;
|
|
110
138
|
dragOffset = { x: e.clientX - entry.position.x, y: e.clientY - entry.position.y };
|
|
111
139
|
floatManager.focus(entry.id);
|
|
140
|
+
// Listen on document so the drag survives the pointer leaving the
|
|
141
|
+
// header. Mouse pointer capture is NOT implicit (touch is) — without
|
|
142
|
+
// these listeners, a fast mouse move that crosses the header edge
|
|
143
|
+
// would never deliver another pointermove and the drag would stall.
|
|
144
|
+
// The carousel uses the same pattern.
|
|
145
|
+
document.addEventListener('pointermove', onHeaderPointerMove);
|
|
146
|
+
document.addEventListener('pointerup', onHeaderPointerUp);
|
|
147
|
+
document.addEventListener('pointercancel', onHeaderPointerCancel);
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
function onHeaderPointerMove(e: PointerEvent): void {
|
|
115
|
-
if (!dragging) return;
|
|
151
|
+
if (!dragging || e.pointerId !== dragPointerId) return;
|
|
116
152
|
// Implicit un-maximize on first drag movement (no-op if not maximized).
|
|
117
153
|
// We unmaximize on move rather than pointerdown so a casual click —
|
|
118
154
|
// which precedes a dblclick — does not destroy the saved prev rect
|
|
@@ -122,13 +158,30 @@
|
|
|
122
158
|
entry.position.y = e.clientY - dragOffset.y;
|
|
123
159
|
}
|
|
124
160
|
|
|
125
|
-
function
|
|
126
|
-
if (!dragging) return;
|
|
161
|
+
function endHeaderDrag(): void {
|
|
127
162
|
dragging = false;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
163
|
+
dragPointerId = null;
|
|
164
|
+
document.removeEventListener('pointermove', onHeaderPointerMove);
|
|
165
|
+
document.removeEventListener('pointerup', onHeaderPointerUp);
|
|
166
|
+
document.removeEventListener('pointercancel', onHeaderPointerCancel);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function onHeaderPointerUp(e: PointerEvent): void {
|
|
170
|
+
if (!dragging || e.pointerId !== dragPointerId) return;
|
|
171
|
+
endHeaderDrag();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function onHeaderPointerCancel(e: PointerEvent): void {
|
|
175
|
+
// Only abort if the cancel is for OUR pointer. Without this filter, any
|
|
176
|
+
// ghost cancellation on the header (palm contact, a stylus touching
|
|
177
|
+
// while a finger drags, descendant losing implicit capture) would tear
|
|
178
|
+
// down the active drag — the touch-only auto-release symptom.
|
|
179
|
+
if (e.pointerId !== dragPointerId) {
|
|
180
|
+
logGesture('floatframe:cancel-other-id', e, { dragPointerId });
|
|
181
|
+
return;
|
|
131
182
|
}
|
|
183
|
+
logGesture('floatframe:cancel-our-id', e, { dragPointerId });
|
|
184
|
+
endHeaderDrag();
|
|
132
185
|
}
|
|
133
186
|
|
|
134
187
|
function onHeaderDblClick(e: MouseEvent): void {
|
|
@@ -141,19 +194,22 @@
|
|
|
141
194
|
function onGripPointerDown(e: PointerEvent): void {
|
|
142
195
|
if (e.button !== 0) return;
|
|
143
196
|
e.stopPropagation();
|
|
144
|
-
|
|
145
|
-
target.setPointerCapture?.(e.pointerId);
|
|
197
|
+
if (resizing) return;
|
|
146
198
|
resizing = true;
|
|
199
|
+
resizePointerId = e.pointerId;
|
|
147
200
|
resizeStart = {
|
|
148
201
|
pointer: { x: e.clientX, y: e.clientY },
|
|
149
202
|
size: { w: entry.size.w, h: entry.size.h },
|
|
150
203
|
min: computeMinSize(entry.content),
|
|
151
204
|
};
|
|
152
205
|
floatManager.focus(entry.id);
|
|
206
|
+
document.addEventListener('pointermove', onGripPointerMove);
|
|
207
|
+
document.addEventListener('pointerup', onGripPointerUp);
|
|
208
|
+
document.addEventListener('pointercancel', onGripPointerCancel);
|
|
153
209
|
}
|
|
154
210
|
|
|
155
211
|
function onGripPointerMove(e: PointerEvent): void {
|
|
156
|
-
if (!resizing) return;
|
|
212
|
+
if (!resizing || e.pointerId !== resizePointerId) return;
|
|
157
213
|
floatManager.unmaximize(entry.id);
|
|
158
214
|
const dx = e.clientX - resizeStart.pointer.x;
|
|
159
215
|
const dy = e.clientY - resizeStart.pointer.y;
|
|
@@ -161,13 +217,26 @@
|
|
|
161
217
|
entry.size.h = Math.max(resizeStart.min.h, resizeStart.size.h + dy);
|
|
162
218
|
}
|
|
163
219
|
|
|
164
|
-
function
|
|
165
|
-
if (!resizing) return;
|
|
220
|
+
function endGripResize(): void {
|
|
166
221
|
resizing = false;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
222
|
+
resizePointerId = null;
|
|
223
|
+
document.removeEventListener('pointermove', onGripPointerMove);
|
|
224
|
+
document.removeEventListener('pointerup', onGripPointerUp);
|
|
225
|
+
document.removeEventListener('pointercancel', onGripPointerCancel);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function onGripPointerUp(e: PointerEvent): void {
|
|
229
|
+
if (!resizing || e.pointerId !== resizePointerId) return;
|
|
230
|
+
endGripResize();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function onGripPointerCancel(e: PointerEvent): void {
|
|
234
|
+
if (e.pointerId !== resizePointerId) {
|
|
235
|
+
logGesture('floatframe-grip:cancel-other-id', e, { resizePointerId });
|
|
236
|
+
return;
|
|
170
237
|
}
|
|
238
|
+
logGesture('floatframe-grip:cancel-our-id', e, { resizePointerId });
|
|
239
|
+
endGripResize();
|
|
171
240
|
}
|
|
172
241
|
|
|
173
242
|
function onFrameClick(): void {
|
|
@@ -226,9 +295,6 @@
|
|
|
226
295
|
data-sh3-scope="element:float-header"
|
|
227
296
|
data-float-id={entry.id}
|
|
228
297
|
onpointerdown={onHeaderPointerDown}
|
|
229
|
-
onpointermove={onHeaderPointerMove}
|
|
230
|
-
onpointerup={onHeaderPointerUp}
|
|
231
|
-
onpointercancel={onHeaderPointerUp}
|
|
232
298
|
ondblclick={onHeaderDblClick}
|
|
233
299
|
oncontextmenu={openHeaderContextMenu}
|
|
234
300
|
>
|
|
@@ -261,9 +327,6 @@
|
|
|
261
327
|
role="presentation"
|
|
262
328
|
aria-hidden="true"
|
|
263
329
|
onpointerdown={onGripPointerDown}
|
|
264
|
-
onpointermove={onGripPointerMove}
|
|
265
|
-
onpointerup={onGripPointerUp}
|
|
266
|
-
onpointercancel={onGripPointerUp}
|
|
267
330
|
></div>
|
|
268
331
|
</div>
|
|
269
332
|
|
|
@@ -291,6 +354,11 @@
|
|
|
291
354
|
border-top-left-radius: var(--sh3-radius);
|
|
292
355
|
border-top-right-radius: var(--sh3-radius);
|
|
293
356
|
flex-shrink: 0;
|
|
357
|
+
/* Suppress browser-claimed touch gestures (pan / pinch) on the drag
|
|
358
|
+
handle. Without this, Android/iOS fire `pointercancel` as soon as the
|
|
359
|
+
finger moves past the system's scroll-claim threshold, killing the
|
|
360
|
+
header drag mid-pan. */
|
|
361
|
+
touch-action: none;
|
|
294
362
|
}
|
|
295
363
|
.sh3-float-title {
|
|
296
364
|
font-size: 12px;
|
|
@@ -335,6 +403,9 @@
|
|
|
335
403
|
width: 16px;
|
|
336
404
|
height: 16px;
|
|
337
405
|
cursor: nwse-resize;
|
|
406
|
+
/* Same rationale as the header: opt out of browser-claimed touch
|
|
407
|
+
gestures so the resize drag survives past the scroll-claim threshold. */
|
|
408
|
+
touch-action: none;
|
|
338
409
|
/* Subtle visual hint without being obtrusive — two diagonal lines made
|
|
339
410
|
from a CSS gradient stripe. */
|
|
340
411
|
background:
|
|
@@ -7,14 +7,25 @@
|
|
|
7
7
|
-->
|
|
8
8
|
<script lang="ts">
|
|
9
9
|
import { layoutStore } from '../layout/store.svelte';
|
|
10
|
+
import { viewportStore } from '../viewport/store.svelte';
|
|
10
11
|
import FloatFrame from './FloatFrame.svelte';
|
|
11
12
|
|
|
12
13
|
const floats = $derived(layoutStore.floats);
|
|
14
|
+
// In compact mode, non-dismissable floats are body-or-menu only — the
|
|
15
|
+
// compact-floats-menu design demotes them to the FloatsSheet so the
|
|
16
|
+
// user sees one root at a time. Dismissable pickers (anchored
|
|
17
|
+
// popovers) keep floating because they're transient.
|
|
18
|
+
const compact = $derived(viewportStore.current.class === 'compact');
|
|
19
|
+
function shouldRender(entry: { dismissable?: boolean }): boolean {
|
|
20
|
+
return !compact || entry.dismissable === true;
|
|
21
|
+
}
|
|
13
22
|
</script>
|
|
14
23
|
|
|
15
24
|
<div class="sh3-float-layer">
|
|
16
25
|
{#each floats as entry, i (entry.id)}
|
|
17
|
-
|
|
26
|
+
{#if shouldRender(entry)}
|
|
27
|
+
<FloatFrame bind:entry={floats[i]} />
|
|
28
|
+
{/if}
|
|
18
29
|
{/each}
|
|
19
30
|
</div>
|
|
20
31
|
|
package/dist/overlays/float.d.ts
CHANGED
|
@@ -74,6 +74,13 @@ export interface FloatManager {
|
|
|
74
74
|
* Bind the manager to the active LayoutTree's `floats` array. Called
|
|
75
75
|
* from Sh3.svelte during boot. `getBounds` returns the current
|
|
76
76
|
* tree-allocated area for cascade-position wraparound.
|
|
77
|
+
*
|
|
78
|
+
* Persisted floats observed for the first time are pulled into the
|
|
79
|
+
* supplied viewport — without this, a float whose position/size came
|
|
80
|
+
* from a larger window renders past the overlay root, which Firefox
|
|
81
|
+
* paints by growing the parent and visibly shifts the docked grid.
|
|
82
|
+
* Ids already in `clampedIds` (re-bound on viewport-class swap, etc.)
|
|
83
|
+
* are left alone.
|
|
77
84
|
*/
|
|
78
85
|
export declare function bindFloatStore(floats: FloatEntry[], getBounds: () => {
|
|
79
86
|
w: number;
|
package/dist/overlays/float.js
CHANGED
|
@@ -26,21 +26,58 @@
|
|
|
26
26
|
* in-memory fallback array is used — this is both the test environment
|
|
27
27
|
* and the pre-boot state.
|
|
28
28
|
*/
|
|
29
|
-
import { computeMinSize, cascadePosition, generateFloatId } from '../layout/floats';
|
|
29
|
+
import { computeMinSize, cascadePosition, generateFloatId, clampFloatToViewport, } from '../layout/floats';
|
|
30
30
|
import { findEnclosingOverlayHost } from './parentHost';
|
|
31
|
+
import { compactRootStore } from '../layout/compact/rootStore.svelte';
|
|
32
|
+
import { viewportStore } from '../viewport/store.svelte';
|
|
31
33
|
import { setMaximizedReactive, readMaximizedReactive, __resetMaximizedReactiveForTest, } from './floatMaximized.svelte';
|
|
32
34
|
// ----- storage binding ---------------------------------------------------
|
|
33
35
|
let fallbackFloats = [];
|
|
34
36
|
let boundFloats = null;
|
|
35
37
|
let getTreeBounds = () => ({ w: 1600, h: 900 });
|
|
38
|
+
/**
|
|
39
|
+
* Float ids that have already been pulled into the viewport — either at
|
|
40
|
+
* open() time, or on the bind that first observed them as persisted.
|
|
41
|
+
* Subsequent binds (viewport-class swap, preset switch back-and-forth)
|
|
42
|
+
* skip these so a user-positioned float isn't snapped on every re-bind.
|
|
43
|
+
*
|
|
44
|
+
* Ids are time+counter unique per session, so this set never confuses a
|
|
45
|
+
* recycled id; it grows for the lifetime of the session.
|
|
46
|
+
*/
|
|
47
|
+
const clampedIds = new Set();
|
|
48
|
+
function clampFloatRect(entry, bounds) {
|
|
49
|
+
const minSize = computeMinSize(entry.content);
|
|
50
|
+
const clamped = clampFloatToViewport({ position: entry.position, size: entry.size }, minSize, bounds);
|
|
51
|
+
entry.position.x = clamped.position.x;
|
|
52
|
+
entry.position.y = clamped.position.y;
|
|
53
|
+
entry.size.w = clamped.size.w;
|
|
54
|
+
entry.size.h = clamped.size.h;
|
|
55
|
+
}
|
|
36
56
|
/**
|
|
37
57
|
* Bind the manager to the active LayoutTree's `floats` array. Called
|
|
38
58
|
* from Sh3.svelte during boot. `getBounds` returns the current
|
|
39
59
|
* tree-allocated area for cascade-position wraparound.
|
|
60
|
+
*
|
|
61
|
+
* Persisted floats observed for the first time are pulled into the
|
|
62
|
+
* supplied viewport — without this, a float whose position/size came
|
|
63
|
+
* from a larger window renders past the overlay root, which Firefox
|
|
64
|
+
* paints by growing the parent and visibly shifts the docked grid.
|
|
65
|
+
* Ids already in `clampedIds` (re-bound on viewport-class swap, etc.)
|
|
66
|
+
* are left alone.
|
|
40
67
|
*/
|
|
41
68
|
export function bindFloatStore(floats, getBounds) {
|
|
42
69
|
boundFloats = floats;
|
|
43
70
|
getTreeBounds = getBounds;
|
|
71
|
+
const bounds = getBounds();
|
|
72
|
+
for (const entry of floats) {
|
|
73
|
+
if (clampedIds.has(entry.id))
|
|
74
|
+
continue;
|
|
75
|
+
clampFloatRect(entry, bounds);
|
|
76
|
+
clampedIds.add(entry.id);
|
|
77
|
+
}
|
|
78
|
+
// Active tree changed (app/preset switch) — drop any compact body
|
|
79
|
+
// selection so the user lands on the new docked tree.
|
|
80
|
+
compactRootStore.reset();
|
|
44
81
|
}
|
|
45
82
|
export function unbindFloatStore() {
|
|
46
83
|
boundFloats = null;
|
|
@@ -53,6 +90,7 @@ export function __resetFloatManagerForTest() {
|
|
|
53
90
|
getTreeBounds = () => ({ w: 1600, h: 900 });
|
|
54
91
|
parentHosts.clear();
|
|
55
92
|
maximizedRects.clear();
|
|
93
|
+
clampedIds.clear();
|
|
56
94
|
__resetMaximizedReactiveForTest();
|
|
57
95
|
}
|
|
58
96
|
function activeStore() {
|
|
@@ -80,8 +118,16 @@ function mintFloatSlotId(viewId) {
|
|
|
80
118
|
}
|
|
81
119
|
// ----- API ---------------------------------------------------------------
|
|
82
120
|
const DEFAULT_SIZE = { w: 600, h: 400 };
|
|
83
|
-
|
|
84
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The default opening size: prefer DEFAULT_SIZE, but cap each axis at
|
|
123
|
+
* the current viewport so phones don't get a 600×400 float on a 360×800
|
|
124
|
+
* screen. Never shrunk below the content's computed minimum.
|
|
125
|
+
*/
|
|
126
|
+
function defaultOpenSize(min, bounds) {
|
|
127
|
+
return {
|
|
128
|
+
w: Math.max(min.w, Math.min(DEFAULT_SIZE.w, bounds.w)),
|
|
129
|
+
h: Math.max(min.h, Math.min(DEFAULT_SIZE.h, bounds.h)),
|
|
130
|
+
};
|
|
85
131
|
}
|
|
86
132
|
function openFloat(viewId, options = {}) {
|
|
87
133
|
var _a, _b, _c;
|
|
@@ -118,8 +164,9 @@ function openFloat(viewId, options = {}) {
|
|
|
118
164
|
};
|
|
119
165
|
}
|
|
120
166
|
const computedMin = computeMinSize(content);
|
|
121
|
-
const
|
|
122
|
-
const
|
|
167
|
+
const bounds = getTreeBounds();
|
|
168
|
+
const size = (_b = options.size) !== null && _b !== void 0 ? _b : defaultOpenSize(computedMin, bounds);
|
|
169
|
+
const position = (_c = options.position) !== null && _c !== void 0 ? _c : cascadePosition(store, bounds);
|
|
123
170
|
const entry = {
|
|
124
171
|
id,
|
|
125
172
|
content,
|
|
@@ -134,14 +181,26 @@ function openFloat(viewId, options = {}) {
|
|
|
134
181
|
if (host)
|
|
135
182
|
parentHosts.set(id, host);
|
|
136
183
|
}
|
|
184
|
+
// Pull the chosen rect into the current viewport (handles user-supplied
|
|
185
|
+
// off-screen positions and oversized user-supplied sizes).
|
|
186
|
+
clampFloatRect(entry, bounds);
|
|
187
|
+
// Mark already-clamped so the next bind doesn't snap it again.
|
|
188
|
+
clampedIds.add(id);
|
|
137
189
|
store.push(entry);
|
|
190
|
+
// Compact mode demotes non-dismissable floats from overlays to body-or-menu;
|
|
191
|
+
// a fresh open auto-switches the body to the new float so the user actually
|
|
192
|
+
// sees what they just opened. Pickers stay floating per the design.
|
|
193
|
+
if (viewportStore.current.class === 'compact' && !options.dismissable) {
|
|
194
|
+
compactRootStore.setRoot({ kind: 'float', floatId: id });
|
|
195
|
+
}
|
|
138
196
|
return id;
|
|
139
197
|
}
|
|
140
198
|
function openFloatWithContent(options) {
|
|
141
199
|
var _a;
|
|
142
200
|
const store = activeStore();
|
|
143
201
|
const id = generateFloatId();
|
|
144
|
-
const
|
|
202
|
+
const bounds = getTreeBounds();
|
|
203
|
+
const position = (_a = options.position) !== null && _a !== void 0 ? _a : cascadePosition(store, bounds);
|
|
145
204
|
const entry = {
|
|
146
205
|
id,
|
|
147
206
|
content: options.content,
|
|
@@ -149,7 +208,12 @@ function openFloatWithContent(options) {
|
|
|
149
208
|
size: options.size,
|
|
150
209
|
title: options.title,
|
|
151
210
|
};
|
|
211
|
+
clampFloatRect(entry, bounds);
|
|
212
|
+
clampedIds.add(id);
|
|
152
213
|
store.push(entry);
|
|
214
|
+
if (viewportStore.current.class === 'compact') {
|
|
215
|
+
compactRootStore.setRoot({ kind: 'float', floatId: id });
|
|
216
|
+
}
|
|
153
217
|
return id;
|
|
154
218
|
}
|
|
155
219
|
function closeFloat(floatId) {
|
|
@@ -160,6 +224,12 @@ function closeFloat(floatId) {
|
|
|
160
224
|
store.splice(idx, 1);
|
|
161
225
|
parentHosts.delete(floatId);
|
|
162
226
|
maximizedRects.delete(floatId);
|
|
227
|
+
// If this float was the compact body, snap back to docked.
|
|
228
|
+
const cur = compactRootStore.current;
|
|
229
|
+
if (cur.kind === 'float' && cur.floatId === floatId) {
|
|
230
|
+
compactRootStore.reset();
|
|
231
|
+
}
|
|
232
|
+
clampedIds.delete(floatId);
|
|
163
233
|
setMaximizedReactive(floatId, false);
|
|
164
234
|
}
|
|
165
235
|
function listFloats() {
|
|
@@ -80,6 +80,176 @@ describe('floatManager', () => {
|
|
|
80
80
|
expect(f.content.type).toBe('tabs');
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
|
+
describe('bindFloatStore — clamp persisted floats to viewport', () => {
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
__resetFloatManagerForTest();
|
|
86
|
+
});
|
|
87
|
+
it('pulls an off-screen persisted float back into the bound viewport', () => {
|
|
88
|
+
// Persisted state from a previous, larger viewport: x is past the
|
|
89
|
+
// current right edge, y is past the current bottom.
|
|
90
|
+
const floats = [
|
|
91
|
+
{
|
|
92
|
+
id: 'persisted-1',
|
|
93
|
+
content: {
|
|
94
|
+
type: 'tabs',
|
|
95
|
+
tabs: [{ slotId: 'float:v:1', viewId: 'v', label: 'V' }],
|
|
96
|
+
activeTab: 0,
|
|
97
|
+
},
|
|
98
|
+
position: { x: 2400, y: 1800 },
|
|
99
|
+
size: { w: 600, h: 400 },
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
|
|
103
|
+
expect(floats[0].position.x).toBe(1024 - 600);
|
|
104
|
+
expect(floats[0].position.y).toBe(768 - 400);
|
|
105
|
+
expect(floats[0].size).toEqual({ w: 600, h: 400 });
|
|
106
|
+
});
|
|
107
|
+
it('shrinks an oversized persisted float to fit the bound viewport', () => {
|
|
108
|
+
const floats = [
|
|
109
|
+
{
|
|
110
|
+
id: 'persisted-2',
|
|
111
|
+
content: {
|
|
112
|
+
type: 'tabs',
|
|
113
|
+
tabs: [{ slotId: 'float:v:2', viewId: 'v', label: 'V' }],
|
|
114
|
+
activeTab: 0,
|
|
115
|
+
},
|
|
116
|
+
position: { x: 0, y: 0 },
|
|
117
|
+
size: { w: 4000, h: 3000 },
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
|
|
121
|
+
expect(floats[0].size).toEqual({ w: 1024, h: 768 });
|
|
122
|
+
});
|
|
123
|
+
it('leaves a float that already fits unchanged', () => {
|
|
124
|
+
const floats = [
|
|
125
|
+
{
|
|
126
|
+
id: 'persisted-3',
|
|
127
|
+
content: {
|
|
128
|
+
type: 'tabs',
|
|
129
|
+
tabs: [{ slotId: 'float:v:3', viewId: 'v', label: 'V' }],
|
|
130
|
+
activeTab: 0,
|
|
131
|
+
},
|
|
132
|
+
position: { x: 100, y: 200 },
|
|
133
|
+
size: { w: 600, h: 400 },
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
|
|
137
|
+
expect(floats[0].position).toEqual({ x: 100, y: 200 });
|
|
138
|
+
expect(floats[0].size).toEqual({ w: 600, h: 400 });
|
|
139
|
+
});
|
|
140
|
+
it('does not re-clamp a float on subsequent binds (e.g. viewport-class swap)', () => {
|
|
141
|
+
// Simulate a user-positioned float that's outside a smaller viewport.
|
|
142
|
+
// First bind clamps it; the user then moves it back to (700, 200);
|
|
143
|
+
// a second bind (compact ↔ desktop) must not snap it again.
|
|
144
|
+
const floats = [
|
|
145
|
+
{
|
|
146
|
+
id: 'persisted-4',
|
|
147
|
+
content: {
|
|
148
|
+
type: 'tabs',
|
|
149
|
+
tabs: [{ slotId: 'float:v:4', viewId: 'v', label: 'V' }],
|
|
150
|
+
activeTab: 0,
|
|
151
|
+
},
|
|
152
|
+
position: { x: 2400, y: 0 },
|
|
153
|
+
size: { w: 600, h: 400 },
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
|
|
157
|
+
// User drags it back to an out-of-bounds spot (allowed mid-session).
|
|
158
|
+
floats[0].position.x = 2400;
|
|
159
|
+
floats[0].position.y = 1800;
|
|
160
|
+
bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
|
|
161
|
+
expect(floats[0].position).toEqual({ x: 2400, y: 1800 });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
describe('floatManager.open — default size respects viewport', () => {
|
|
165
|
+
beforeEach(() => {
|
|
166
|
+
__resetFloatManagerForTest();
|
|
167
|
+
});
|
|
168
|
+
it('caps the default size at viewport bounds (phone-sized window)', () => {
|
|
169
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
|
|
170
|
+
const id = floatManager.open('test:view', { title: 'Phone' });
|
|
171
|
+
const f = floatManager.list().find((e) => e.id === id);
|
|
172
|
+
expect(f.size.w).toBeLessThanOrEqual(360);
|
|
173
|
+
expect(f.size.h).toBeLessThanOrEqual(740);
|
|
174
|
+
// And the float is fully on-screen.
|
|
175
|
+
expect(f.position.x + f.size.w).toBeLessThanOrEqual(360);
|
|
176
|
+
expect(f.position.y + f.size.h).toBeLessThanOrEqual(740);
|
|
177
|
+
});
|
|
178
|
+
it('uses DEFAULT_SIZE when the viewport is large enough', () => {
|
|
179
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 1280, h: 800 }));
|
|
180
|
+
const id = floatManager.open('test:view', { title: 'Desktop' });
|
|
181
|
+
const f = floatManager.list().find((e) => e.id === id);
|
|
182
|
+
expect(f.size).toEqual({ w: 600, h: 400 });
|
|
183
|
+
});
|
|
184
|
+
it('clamps a user-supplied oversized size to viewport on open', () => {
|
|
185
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
|
|
186
|
+
const id = floatManager.open('test:view', {
|
|
187
|
+
title: 'Phone',
|
|
188
|
+
size: { w: 2000, h: 2000 },
|
|
189
|
+
});
|
|
190
|
+
const f = floatManager.list().find((e) => e.id === id);
|
|
191
|
+
expect(f.size).toEqual({ w: 360, h: 740 });
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Compact body-root integration: floatManager auto-switches in compact mode,
|
|
196
|
+
// resets on close-of-current and on bind.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
import { compactRootStore, __resetCompactRootStoreForTest, } from '../layout/compact/rootStore.svelte';
|
|
199
|
+
import { viewportStore } from '../viewport/store.svelte';
|
|
200
|
+
describe('floatManager — compact body root integration', () => {
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
__resetFloatManagerForTest();
|
|
203
|
+
__resetCompactRootStoreForTest();
|
|
204
|
+
viewportStore.override(null);
|
|
205
|
+
});
|
|
206
|
+
afterEach(() => {
|
|
207
|
+
viewportStore.override(null);
|
|
208
|
+
});
|
|
209
|
+
it('open() in compact mode switches the body to the new (non-dismissable) float', () => {
|
|
210
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
|
|
211
|
+
viewportStore.override('compact');
|
|
212
|
+
const id = floatManager.open('test:view', { title: 'Notes' });
|
|
213
|
+
expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
|
|
214
|
+
});
|
|
215
|
+
it('open() in compact mode does NOT switch the body for dismissable pickers', () => {
|
|
216
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
|
|
217
|
+
viewportStore.override('compact');
|
|
218
|
+
floatManager.open('picker:view', { dismissable: true });
|
|
219
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
220
|
+
});
|
|
221
|
+
it('open() on desktop does not touch the compact body root', () => {
|
|
222
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 1280, h: 800 }));
|
|
223
|
+
viewportStore.override('desktop');
|
|
224
|
+
floatManager.open('test:view');
|
|
225
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
226
|
+
});
|
|
227
|
+
it('close(id) resets when the closed float is the current body', () => {
|
|
228
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
|
|
229
|
+
viewportStore.override('compact');
|
|
230
|
+
const id = floatManager.open('test:view');
|
|
231
|
+
floatManager.close(id);
|
|
232
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
233
|
+
});
|
|
234
|
+
it('close(id) leaves the body root alone when closing a different float', () => {
|
|
235
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
|
|
236
|
+
viewportStore.override('compact');
|
|
237
|
+
floatManager.open('view:a');
|
|
238
|
+
const b = floatManager.open('view:b');
|
|
239
|
+
expect(compactRootStore.current).toEqual({ kind: 'float', floatId: b });
|
|
240
|
+
floatManager.close(layoutStore.floats[0].id);
|
|
241
|
+
expect(compactRootStore.current).toEqual({ kind: 'float', floatId: b });
|
|
242
|
+
});
|
|
243
|
+
it('bindFloatStore resets the body root (covers app/preset switch)', () => {
|
|
244
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
|
|
245
|
+
viewportStore.override('compact');
|
|
246
|
+
const id = floatManager.open('test:view');
|
|
247
|
+
expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
|
|
248
|
+
// Re-bind (e.g. preset switch).
|
|
249
|
+
bindFloatStore([], () => ({ w: 360, h: 740 }));
|
|
250
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
251
|
+
});
|
|
252
|
+
});
|
|
83
253
|
describe('floatManager — anchor-aware parent host', () => {
|
|
84
254
|
beforeEach(() => {
|
|
85
255
|
__resetFloatManagerForTest();
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import type { Snippet } from 'svelte';
|
|
21
21
|
import type { SizeMode, SplitDirection } from '../layout/types';
|
|
22
22
|
import { claim, revoke } from '../gestures/pointerClaim';
|
|
23
|
-
import { ancestorCount } from '../gestures';
|
|
23
|
+
import { ancestorCount, logGesture } from '../gestures';
|
|
24
24
|
|
|
25
25
|
const MIN_PX = 40;
|
|
26
26
|
const COLLAPSED_PX = 28;
|
|
@@ -128,7 +128,16 @@
|
|
|
128
128
|
activeDragPointerId = e.pointerId;
|
|
129
129
|
|
|
130
130
|
e.preventDefault();
|
|
131
|
-
(
|
|
131
|
+
// Document-level listeners (not element-level attributes) so the drag
|
|
132
|
+
// survives the pointer leaving the handle. Mouse pointer capture is
|
|
133
|
+
// not implicit; without document listeners, a fast mouse move past
|
|
134
|
+
// the handle edge stops delivering pointermove. setPointerCapture
|
|
135
|
+
// would work for mouse but reintroduces the Android pointercancel
|
|
136
|
+
// bug (see CarouselTabs commit 638d75a). document listeners avoid
|
|
137
|
+
// both pitfalls — same pattern the carousel uses.
|
|
138
|
+
document.addEventListener('pointermove', moveDrag);
|
|
139
|
+
document.addEventListener('pointerup', endDrag);
|
|
140
|
+
document.addEventListener('pointercancel', cancelDrag);
|
|
132
141
|
|
|
133
142
|
const rect = container.getBoundingClientRect();
|
|
134
143
|
const containerPx = direction === 'horizontal' ? rect.width : rect.height;
|
|
@@ -154,6 +163,7 @@
|
|
|
154
163
|
|
|
155
164
|
function moveDrag(e: PointerEvent) {
|
|
156
165
|
if (!drag) return;
|
|
166
|
+
if (e.pointerId !== activeDragPointerId) return;
|
|
157
167
|
const client = direction === 'horizontal' ? e.clientX : e.clientY;
|
|
158
168
|
const deltaPx = client - drag.startClient;
|
|
159
169
|
|
|
@@ -203,15 +213,42 @@
|
|
|
203
213
|
}
|
|
204
214
|
}
|
|
205
215
|
|
|
206
|
-
function
|
|
207
|
-
if (!drag) return;
|
|
208
|
-
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
|
216
|
+
function teardownDrag(): void {
|
|
209
217
|
if (activeDragPointerId !== null) {
|
|
210
218
|
revoke(activeDragPointerId, 'sh3:splitter');
|
|
211
219
|
activeDragPointerId = null;
|
|
212
220
|
}
|
|
213
221
|
drag = null;
|
|
222
|
+
document.removeEventListener('pointermove', moveDrag);
|
|
223
|
+
document.removeEventListener('pointerup', endDrag);
|
|
224
|
+
document.removeEventListener('pointercancel', cancelDrag);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function endDrag(e: PointerEvent) {
|
|
228
|
+
if (!drag) return;
|
|
229
|
+
if (e.pointerId !== activeDragPointerId) return;
|
|
230
|
+
teardownDrag();
|
|
214
231
|
}
|
|
232
|
+
|
|
233
|
+
function cancelDrag(e: PointerEvent) {
|
|
234
|
+
// Filter so a ghost pointercancel for an unrelated pointer (palm, stylus,
|
|
235
|
+
// second finger on multi-touch) doesn't abort the active resize.
|
|
236
|
+
if (!drag) return;
|
|
237
|
+
if (e.pointerId !== activeDragPointerId) {
|
|
238
|
+
logGesture('splitter:cancel-other-id', e, { activeDragPointerId });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
logGesture('splitter:cancel-our-id', e, { activeDragPointerId });
|
|
242
|
+
teardownDrag();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// If the splitter unmounts mid-drag, remove the document listeners and
|
|
246
|
+
// release the claim so the next component doesn't see a leaked pointer.
|
|
247
|
+
$effect(() => {
|
|
248
|
+
return () => {
|
|
249
|
+
if (drag) teardownDrag();
|
|
250
|
+
};
|
|
251
|
+
});
|
|
215
252
|
</script>
|
|
216
253
|
|
|
217
254
|
<div
|
|
@@ -281,9 +318,6 @@
|
|
|
281
318
|
class:frozen={isHandleFrozen(i)}
|
|
282
319
|
data-testid="splitter-handle-{i}"
|
|
283
320
|
onpointerdown={(e) => beginDrag(e, i)}
|
|
284
|
-
onpointermove={moveDrag}
|
|
285
|
-
onpointerup={endDrag}
|
|
286
|
-
onpointercancel={endDrag}
|
|
287
321
|
ondblclick={() => {
|
|
288
322
|
if (isHandleFrozen(i)) return;
|
|
289
323
|
if (!canCollapse(i) && !isCollapsed(i)) return;
|