sh3-core 0.19.3 → 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/api.d.ts +4 -0
- package/dist/api.js +3 -0
- package/dist/chrome/CompactChrome.svelte +34 -1
- package/dist/chrome/CompactChrome.svelte.test.js +4 -2
- 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/layout/compact/CompactRenderer.svelte +8 -2
- 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/floats.d.ts +27 -0
- package/dist/layout/floats.js +20 -0
- package/dist/layout/floats.test.js +34 -1
- package/dist/layout/inspection.js +25 -2
- package/dist/layout/inspection.svelte.test.js +49 -0
- 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/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/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { compactRootStore, resolveCompactBodyRoot, __resetCompactRootStoreForTest, } from './rootStore.svelte';
|
|
3
|
+
import { __resetLayoutStoreForTest, layoutStore, } from '../store.svelte';
|
|
4
|
+
function makeFloat(id, viewId = 'v') {
|
|
5
|
+
return {
|
|
6
|
+
id,
|
|
7
|
+
content: { type: 'slot', slotId: `slot-${id}`, viewId },
|
|
8
|
+
position: { x: 0, y: 0 },
|
|
9
|
+
size: { w: 200, h: 200 },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe('compactRootStore', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
__resetLayoutStoreForTest();
|
|
15
|
+
__resetCompactRootStoreForTest();
|
|
16
|
+
});
|
|
17
|
+
it('starts at { kind: "docked" }', () => {
|
|
18
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
19
|
+
});
|
|
20
|
+
it('setRoot accepts a float id present in the active tree', () => {
|
|
21
|
+
const f = makeFloat('f-1');
|
|
22
|
+
layoutStore.tree.floats.push(f);
|
|
23
|
+
compactRootStore.setRoot({ kind: 'float', floatId: 'f-1' });
|
|
24
|
+
expect(compactRootStore.current).toEqual({ kind: 'float', floatId: 'f-1' });
|
|
25
|
+
});
|
|
26
|
+
it('setRoot throws when the float id is not in the active tree', () => {
|
|
27
|
+
expect(() => compactRootStore.setRoot({ kind: 'float', floatId: 'missing' })).toThrow(/missing/);
|
|
28
|
+
});
|
|
29
|
+
it('reset returns to docked', () => {
|
|
30
|
+
layoutStore.tree.floats.push(makeFloat('f-2'));
|
|
31
|
+
compactRootStore.setRoot({ kind: 'float', floatId: 'f-2' });
|
|
32
|
+
compactRootStore.reset();
|
|
33
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
34
|
+
});
|
|
35
|
+
it('resolveCompactBodyRoot returns docked content when current is docked', () => {
|
|
36
|
+
expect(resolveCompactBodyRoot()).toEqual(layoutStore.tree.docked);
|
|
37
|
+
});
|
|
38
|
+
it('resolveCompactBodyRoot returns the float content when current points at it', () => {
|
|
39
|
+
const f = makeFloat('f-3');
|
|
40
|
+
layoutStore.tree.floats.push(f);
|
|
41
|
+
compactRootStore.setRoot({ kind: 'float', floatId: 'f-3' });
|
|
42
|
+
// Workspace-zone reactivity proxies the pushed object, so identity
|
|
43
|
+
// (`toBe`) does not hold; the structural content is what we care about.
|
|
44
|
+
expect(resolveCompactBodyRoot()).toEqual(f.content);
|
|
45
|
+
});
|
|
46
|
+
it('resolveCompactBodyRoot self-heals on stale id (returns docked + resets)', () => {
|
|
47
|
+
const f = makeFloat('f-4');
|
|
48
|
+
layoutStore.tree.floats.push(f);
|
|
49
|
+
compactRootStore.setRoot({ kind: 'float', floatId: 'f-4' });
|
|
50
|
+
layoutStore.tree.floats.length = 0;
|
|
51
|
+
expect(resolveCompactBodyRoot()).toEqual(layoutStore.tree.docked);
|
|
52
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
53
|
+
});
|
|
54
|
+
});
|
package/dist/layout/floats.d.ts
CHANGED
|
@@ -23,6 +23,33 @@ export declare function cascadePosition(existing: FloatEntry[], bounds: {
|
|
|
23
23
|
};
|
|
24
24
|
/** Stable, process-unique float id. Not cryptographic — just unique within a session. */
|
|
25
25
|
export declare function generateFloatId(): string;
|
|
26
|
+
/**
|
|
27
|
+
* Pull a float's rect into the supplied viewport bounds. Used at bind
|
|
28
|
+
* time so a float persisted from a larger viewport doesn't render past
|
|
29
|
+
* the overlay root — Firefox in particular grows the parent's painted
|
|
30
|
+
* area to fit an off-screen abspos child, which visibly bleeds the
|
|
31
|
+
* docked grid (footer ends up below the viewport).
|
|
32
|
+
*
|
|
33
|
+
* Size is shrunk to fit but never below `minSize`; if `minSize` itself
|
|
34
|
+
* exceeds bounds, position is pinned to (0,0) and size stays at min.
|
|
35
|
+
* Position is then clamped so the frame fits within bounds.
|
|
36
|
+
*/
|
|
37
|
+
export declare function clampFloatToViewport(rect: {
|
|
38
|
+
position: {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
};
|
|
42
|
+
size: Size;
|
|
43
|
+
}, minSize: Size, bounds: {
|
|
44
|
+
w: number;
|
|
45
|
+
h: number;
|
|
46
|
+
}): {
|
|
47
|
+
position: {
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
};
|
|
51
|
+
size: Size;
|
|
52
|
+
};
|
|
26
53
|
/**
|
|
27
54
|
* True if a LayoutNode subtree contains no leaf slot with a bound viewId.
|
|
28
55
|
* Used by the drag-commit auto-close invariant: when the last bound leaf
|
package/dist/layout/floats.js
CHANGED
|
@@ -55,6 +55,26 @@ export function generateFloatId() {
|
|
|
55
55
|
floatIdCounter += 1;
|
|
56
56
|
return `float-${Date.now().toString(36)}-${floatIdCounter.toString(36)}`;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Pull a float's rect into the supplied viewport bounds. Used at bind
|
|
60
|
+
* time so a float persisted from a larger viewport doesn't render past
|
|
61
|
+
* the overlay root — Firefox in particular grows the parent's painted
|
|
62
|
+
* area to fit an off-screen abspos child, which visibly bleeds the
|
|
63
|
+
* docked grid (footer ends up below the viewport).
|
|
64
|
+
*
|
|
65
|
+
* Size is shrunk to fit but never below `minSize`; if `minSize` itself
|
|
66
|
+
* exceeds bounds, position is pinned to (0,0) and size stays at min.
|
|
67
|
+
* Position is then clamped so the frame fits within bounds.
|
|
68
|
+
*/
|
|
69
|
+
export function clampFloatToViewport(rect, minSize, bounds) {
|
|
70
|
+
const w = Math.max(minSize.w, Math.min(rect.size.w, bounds.w));
|
|
71
|
+
const h = Math.max(minSize.h, Math.min(rect.size.h, bounds.h));
|
|
72
|
+
const maxX = Math.max(0, bounds.w - w);
|
|
73
|
+
const maxY = Math.max(0, bounds.h - h);
|
|
74
|
+
const x = Math.max(0, Math.min(rect.position.x, maxX));
|
|
75
|
+
const y = Math.max(0, Math.min(rect.position.y, maxY));
|
|
76
|
+
return { position: { x, y }, size: { w, h } };
|
|
77
|
+
}
|
|
58
78
|
/**
|
|
59
79
|
* True if a LayoutNode subtree contains no leaf slot with a bound viewId.
|
|
60
80
|
* Used by the drag-commit auto-close invariant: when the last bound leaf
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { computeMinSize, cascadePosition, isEmptyContent } from './floats';
|
|
2
|
+
import { computeMinSize, cascadePosition, isEmptyContent, clampFloatToViewport } from './floats';
|
|
3
3
|
const slot = (slotId, viewId = 'v') => ({
|
|
4
4
|
type: 'slot',
|
|
5
5
|
slotId,
|
|
@@ -72,6 +72,39 @@ describe('cascadePosition', () => {
|
|
|
72
72
|
expect(cascadePosition(existing, bounds)).toEqual({ x: 48, y: 48 });
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
|
+
describe('clampFloatToViewport', () => {
|
|
76
|
+
const min = { w: 120, h: 80 };
|
|
77
|
+
const bounds = { w: 1024, h: 768 };
|
|
78
|
+
it('returns the rect unchanged when fully inside bounds', () => {
|
|
79
|
+
const out = clampFloatToViewport({ position: { x: 100, y: 200 }, size: { w: 600, h: 400 } }, min, bounds);
|
|
80
|
+
expect(out).toEqual({ position: { x: 100, y: 200 }, size: { w: 600, h: 400 } });
|
|
81
|
+
});
|
|
82
|
+
it('pulls a float that extends past the right edge back inside', () => {
|
|
83
|
+
const out = clampFloatToViewport({ position: { x: 900, y: 50 }, size: { w: 600, h: 400 } }, min, bounds);
|
|
84
|
+
expect(out.position.x).toBe(bounds.w - 600);
|
|
85
|
+
expect(out.position.y).toBe(50);
|
|
86
|
+
expect(out.size).toEqual({ w: 600, h: 400 });
|
|
87
|
+
});
|
|
88
|
+
it('pulls a float that extends past the bottom edge back inside', () => {
|
|
89
|
+
const out = clampFloatToViewport({ position: { x: 50, y: 600 }, size: { w: 600, h: 400 } }, min, bounds);
|
|
90
|
+
expect(out.position.y).toBe(bounds.h - 400);
|
|
91
|
+
});
|
|
92
|
+
it('clamps negative position back to (0,0)', () => {
|
|
93
|
+
const out = clampFloatToViewport({ position: { x: -200, y: -50 }, size: { w: 600, h: 400 } }, min, bounds);
|
|
94
|
+
expect(out.position).toEqual({ x: 0, y: 0 });
|
|
95
|
+
});
|
|
96
|
+
it('shrinks size larger than bounds down to bounds (above min)', () => {
|
|
97
|
+
const out = clampFloatToViewport({ position: { x: 0, y: 0 }, size: { w: 4000, h: 3000 } }, min, bounds);
|
|
98
|
+
expect(out.size).toEqual({ w: bounds.w, h: bounds.h });
|
|
99
|
+
expect(out.position).toEqual({ x: 0, y: 0 });
|
|
100
|
+
});
|
|
101
|
+
it('never shrinks size below the supplied min, even when bounds < min', () => {
|
|
102
|
+
const tiny = { w: 80, h: 60 };
|
|
103
|
+
const out = clampFloatToViewport({ position: { x: 999, y: 999 }, size: { w: 600, h: 400 } }, min, tiny);
|
|
104
|
+
expect(out.size).toEqual(min);
|
|
105
|
+
expect(out.position).toEqual({ x: 0, y: 0 });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
75
108
|
describe('isEmptyContent', () => {
|
|
76
109
|
it('true for a slot with null viewId', () => {
|
|
77
110
|
expect(isEmptyContent({ type: 'slot', slotId: 's', viewId: null })).toBe(true);
|
|
@@ -16,6 +16,24 @@ import { activeLayout, getActiveRoot } from './store.svelte';
|
|
|
16
16
|
import { nodeAtPath, findTabBySlotId, removeTabBySlotId, cleanupTree, splitNodeAtPath, locateSlotIn, } from './ops';
|
|
17
17
|
import { getSlotHandle } from './slotHostPool.svelte';
|
|
18
18
|
import { floatManager } from '../overlays/float';
|
|
19
|
+
import { viewportStore } from '../viewport/store.svelte';
|
|
20
|
+
import { compactRootStore } from './compact/rootStore.svelte';
|
|
21
|
+
/**
|
|
22
|
+
* In compact mode the user sees one root at a time. When focus lands on
|
|
23
|
+
* a slot in a float, swap the compact body root to that float before
|
|
24
|
+
* returning. When focus lands in the docked tree, snap back to docked.
|
|
25
|
+
* Desktop is unaffected.
|
|
26
|
+
*/
|
|
27
|
+
function maybeSwapForCompact(located) {
|
|
28
|
+
if (viewportStore.current.class !== 'compact')
|
|
29
|
+
return;
|
|
30
|
+
if (located.kind === 'float') {
|
|
31
|
+
compactRootStore.setRoot({ kind: 'float', floatId: located.floatId });
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
compactRootStore.reset();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
19
37
|
/**
|
|
20
38
|
* Read-only snapshot of the currently-rendered layout tree. The return
|
|
21
39
|
* value is the live object — callers MUST NOT mutate it directly;
|
|
@@ -48,8 +66,10 @@ export function spliceIntoActiveLayout(entry) {
|
|
|
48
66
|
*/
|
|
49
67
|
export function focusTab(slotId) {
|
|
50
68
|
const tree = activeLayout();
|
|
51
|
-
if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId))
|
|
69
|
+
if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId)) {
|
|
70
|
+
maybeSwapForCompact({ kind: 'docked' });
|
|
52
71
|
return true;
|
|
72
|
+
}
|
|
53
73
|
return focusTabInFloats(tree, (entry) => entry.slotId === slotId);
|
|
54
74
|
}
|
|
55
75
|
/**
|
|
@@ -58,8 +78,10 @@ export function focusTab(slotId) {
|
|
|
58
78
|
*/
|
|
59
79
|
export function focusView(viewId) {
|
|
60
80
|
const tree = activeLayout();
|
|
61
|
-
if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId))
|
|
81
|
+
if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId)) {
|
|
82
|
+
maybeSwapForCompact({ kind: 'docked' });
|
|
62
83
|
return true;
|
|
84
|
+
}
|
|
63
85
|
return focusTabInFloats(tree, (entry) => entry.viewId === viewId);
|
|
64
86
|
}
|
|
65
87
|
/** Walk the tree looking for a tab entry that satisfies `pred`, activate it. */
|
|
@@ -85,6 +107,7 @@ function focusTabInFloats(tree, pred) {
|
|
|
85
107
|
for (const floatEntry of tree.floats) {
|
|
86
108
|
if (focusTabWhere(floatEntry.content, pred)) {
|
|
87
109
|
floatManager.focus(floatEntry.id);
|
|
110
|
+
maybeSwapForCompact({ kind: 'float', floatId: floatEntry.id });
|
|
88
111
|
return true;
|
|
89
112
|
}
|
|
90
113
|
}
|
|
@@ -112,3 +112,52 @@ describe('dockIntoActiveLayout — body-role preference', () => {
|
|
|
112
112
|
expect(right.tabs.map((t) => t.viewId)).toEqual(['view:r']);
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Compact body-root swap on focusView / focusTab
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
import { compactRootStore, __resetCompactRootStoreForTest, } from './compact/rootStore.svelte';
|
|
119
|
+
import { viewportStore } from '../viewport/store.svelte';
|
|
120
|
+
import { focusView } from './inspection';
|
|
121
|
+
import { floatManager, __resetFloatManagerForTest, bindFloatStore, } from '../overlays/float';
|
|
122
|
+
describe('focusView — compact body-root swap', () => {
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
__resetLayoutStoreForTest();
|
|
125
|
+
__resetCompactRootStoreForTest();
|
|
126
|
+
__resetFloatManagerForTest();
|
|
127
|
+
viewportStore.override(null);
|
|
128
|
+
bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
|
|
129
|
+
});
|
|
130
|
+
it('compact: focusView in a float swaps body root before activating', () => {
|
|
131
|
+
viewportStore.override('compact');
|
|
132
|
+
const id = floatManager.open('view:in-float', { title: 'In Float' });
|
|
133
|
+
// open() in compact already auto-switches; reset so we can see the swap.
|
|
134
|
+
compactRootStore.reset();
|
|
135
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
136
|
+
const ok = focusView('view:in-float');
|
|
137
|
+
expect(ok).toBe(true);
|
|
138
|
+
expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
|
|
139
|
+
viewportStore.override(null);
|
|
140
|
+
});
|
|
141
|
+
it('compact: focusView for a docked tab resets body root', () => {
|
|
142
|
+
viewportStore.override('compact');
|
|
143
|
+
layoutStore.tree.docked = {
|
|
144
|
+
type: 'tabs',
|
|
145
|
+
tabs: [{ slotId: 's-d', viewId: 'view:docked', label: 'Docked' }],
|
|
146
|
+
activeTab: 0,
|
|
147
|
+
};
|
|
148
|
+
floatManager.open('view:other');
|
|
149
|
+
expect(compactRootStore.current.kind).toBe('float');
|
|
150
|
+
const ok = focusView('view:docked');
|
|
151
|
+
expect(ok).toBe(true);
|
|
152
|
+
expect(compactRootStore.current).toEqual({ kind: 'docked' });
|
|
153
|
+
viewportStore.override(null);
|
|
154
|
+
});
|
|
155
|
+
it('desktop: focusView does not touch the compact body root', () => {
|
|
156
|
+
viewportStore.override('desktop');
|
|
157
|
+
floatManager.open('view:any');
|
|
158
|
+
const before = compactRootStore.current;
|
|
159
|
+
focusView('view:any');
|
|
160
|
+
expect(compactRootStore.current).toEqual(before);
|
|
161
|
+
viewportStore.override(null);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -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();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DocumentMeta } from '../../documents/types';
|
|
2
|
+
export type DocEntry = DocumentMeta & {
|
|
3
|
+
shardId: string;
|
|
4
|
+
};
|
|
5
|
+
export type OpenerValue = Pick<DocEntry, 'shardId' | 'path'> | null;
|
|
6
|
+
export type SaverValue = string | null;
|
|
7
|
+
export type FileItem = {
|
|
8
|
+
kind: 'folder';
|
|
9
|
+
name: string;
|
|
10
|
+
fullPath: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'file';
|
|
13
|
+
name: string;
|
|
14
|
+
doc: DocEntry;
|
|
15
|
+
};
|
|
16
|
+
export declare function buildTree(docs: DocEntry[], shardId: string | null, prefix: string): FileItem[];
|
|
17
|
+
export declare function formatSize(bytes: number): string;
|
|
18
|
+
export declare function formatDate(epochMs: number): string;
|
|
19
|
+
export declare function iconForFile(name: string): string;
|
|
20
|
+
export declare function breadcrumbSegments(shardId: string | null, prefix: string): {
|
|
21
|
+
label: string;
|
|
22
|
+
level: number;
|
|
23
|
+
targetShard: string | null;
|
|
24
|
+
targetPrefix: string;
|
|
25
|
+
}[];
|