sh3-core 0.19.3 → 0.19.6
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 +5 -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/documents/picker-api.d.ts +31 -0
- package/dist/documents/picker-api.js +1 -0
- package/dist/documents/picker-api.test.d.ts +1 -0
- package/dist/documents/picker-api.test.js +132 -0
- package/dist/documents/picker-primitive.d.ts +7 -0
- package/dist/documents/picker-primitive.js +58 -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 +339 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +12 -0
- package/dist/shards/activate.svelte.js +23 -14
- package/dist/shards/types.d.ts +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { sh3 } from '../sh3Runtime.svelte';
|
|
3
|
+
import { createDocumentPicker } from './picker-primitive';
|
|
4
|
+
vi.mock('../sh3Runtime.svelte', () => ({
|
|
5
|
+
sh3: {
|
|
6
|
+
popup: {
|
|
7
|
+
show: vi.fn(),
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
const mockShow = sh3.popup.show;
|
|
12
|
+
function mockPopup() {
|
|
13
|
+
let capturedCommit = null;
|
|
14
|
+
let capturedCancel = null;
|
|
15
|
+
const handle = {
|
|
16
|
+
close: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
mockShow.mockImplementation((_Content, _opts, props) => {
|
|
19
|
+
capturedCommit = props.onCommit;
|
|
20
|
+
capturedCancel = props.onCancel;
|
|
21
|
+
return handle;
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
commit: (v) => {
|
|
25
|
+
capturedCommit === null || capturedCommit === void 0 ? void 0 : capturedCommit(v);
|
|
26
|
+
},
|
|
27
|
+
cancel: () => {
|
|
28
|
+
capturedCancel === null || capturedCancel === void 0 ? void 0 : capturedCancel();
|
|
29
|
+
},
|
|
30
|
+
dismiss: () => {
|
|
31
|
+
handle.close();
|
|
32
|
+
},
|
|
33
|
+
handle,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
describe('createDocumentPicker', () => {
|
|
40
|
+
const sampleDoc = { shardId: 'my-shard', path: 'readme.md' };
|
|
41
|
+
describe('open()', () => {
|
|
42
|
+
it('resolves with OpenerValue when user commits', async () => {
|
|
43
|
+
const listFn = async () => [{ shardId: 'my-shard', path: 'readme.md', size: 100, lastModified: 0 }];
|
|
44
|
+
const picker = createDocumentPicker(listFn);
|
|
45
|
+
const popup = mockPopup();
|
|
46
|
+
const promise = picker.open();
|
|
47
|
+
await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
|
|
48
|
+
popup.commit(sampleDoc);
|
|
49
|
+
const result = await promise;
|
|
50
|
+
expect(result).toEqual({ shardId: 'my-shard', path: 'readme.md' });
|
|
51
|
+
});
|
|
52
|
+
it('resolves with null when user cancels', async () => {
|
|
53
|
+
const listFn = async () => [];
|
|
54
|
+
const picker = createDocumentPicker(listFn);
|
|
55
|
+
const popup = mockPopup();
|
|
56
|
+
const promise = picker.open();
|
|
57
|
+
await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
|
|
58
|
+
popup.cancel();
|
|
59
|
+
const result = await promise;
|
|
60
|
+
expect(result).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
it('resolves with null when popup is dismissed externally', async () => {
|
|
63
|
+
const listFn = async () => [];
|
|
64
|
+
const picker = createDocumentPicker(listFn);
|
|
65
|
+
const popup = mockPopup();
|
|
66
|
+
const promise = picker.open();
|
|
67
|
+
await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
|
|
68
|
+
popup.dismiss();
|
|
69
|
+
const result = await promise;
|
|
70
|
+
expect(result).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
it('rejects when listFn fails', async () => {
|
|
73
|
+
const listFn = async () => { throw new Error('network error'); };
|
|
74
|
+
const picker = createDocumentPicker(listFn);
|
|
75
|
+
const promise = picker.open();
|
|
76
|
+
await expect(promise).rejects.toThrow('network error');
|
|
77
|
+
expect(mockShow).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
it('uses anchor element position when provided', async () => {
|
|
80
|
+
const listFn = async () => [];
|
|
81
|
+
const picker = createDocumentPicker(listFn);
|
|
82
|
+
mockPopup();
|
|
83
|
+
const el = document.createElement('div');
|
|
84
|
+
picker.open({ anchor: el });
|
|
85
|
+
await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
|
|
86
|
+
const call = mockShow.mock.calls[0];
|
|
87
|
+
expect(call[1].anchor).toEqual({ x: 0, y: 0 });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('save()', () => {
|
|
91
|
+
it('resolves with SaverValue string when user commits a filename', async () => {
|
|
92
|
+
const listFn = async () => [];
|
|
93
|
+
const picker = createDocumentPicker(listFn);
|
|
94
|
+
const popup = mockPopup();
|
|
95
|
+
const promise = picker.save();
|
|
96
|
+
await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
|
|
97
|
+
popup.commit('my-shard/report.txt');
|
|
98
|
+
const result = await promise;
|
|
99
|
+
expect(result).toBe('my-shard/report.txt');
|
|
100
|
+
});
|
|
101
|
+
it('resolves with null when user cancels', async () => {
|
|
102
|
+
const listFn = async () => [];
|
|
103
|
+
const picker = createDocumentPicker(listFn);
|
|
104
|
+
const popup = mockPopup();
|
|
105
|
+
const promise = picker.save();
|
|
106
|
+
await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
|
|
107
|
+
popup.cancel();
|
|
108
|
+
const result = await promise;
|
|
109
|
+
expect(result).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
it('passes suggestedName as prop', async () => {
|
|
112
|
+
const listFn = async () => [];
|
|
113
|
+
const picker = createDocumentPicker(listFn);
|
|
114
|
+
mockPopup();
|
|
115
|
+
picker.save({ suggestedName: 'draft.txt' });
|
|
116
|
+
await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
|
|
117
|
+
const call = mockShow.mock.calls[0];
|
|
118
|
+
expect(call[2].suggestedName).toBe('draft.txt');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
it('defaults anchor to viewport center when not provided', async () => {
|
|
122
|
+
const listFn = async () => [];
|
|
123
|
+
const picker = createDocumentPicker(listFn);
|
|
124
|
+
mockPopup();
|
|
125
|
+
const w = window.innerWidth;
|
|
126
|
+
const h = window.innerHeight;
|
|
127
|
+
picker.open();
|
|
128
|
+
await vi.waitFor(() => expect(mockShow).toHaveBeenCalledOnce());
|
|
129
|
+
const call = mockShow.mock.calls[0];
|
|
130
|
+
expect(call[1].anchor).toEqual({ x: w / 2, y: h / 2 });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DocumentPickerApi, DocListFn } from './picker-api';
|
|
2
|
+
/**
|
|
3
|
+
* Create a document picker API bound to a document listing function.
|
|
4
|
+
* The listFn is derived from the shard's document zone + browse permission
|
|
5
|
+
* and baked in at construction time so callers don't pass their own scope.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createDocumentPicker(listFn: DocListFn): DocumentPickerApi;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { sh3 } from '../sh3Runtime.svelte';
|
|
2
|
+
import DocumentBrowser from '../primitives/widgets/_DocumentBrowser.svelte';
|
|
3
|
+
/**
|
|
4
|
+
* Create a document picker API bound to a document listing function.
|
|
5
|
+
* The listFn is derived from the shard's document zone + browse permission
|
|
6
|
+
* and baked in at construction time so callers don't pass their own scope.
|
|
7
|
+
*/
|
|
8
|
+
export function createDocumentPicker(listFn) {
|
|
9
|
+
function anchorOrDefault(anchor) {
|
|
10
|
+
if (anchor) {
|
|
11
|
+
const rect = anchor.getBoundingClientRect();
|
|
12
|
+
return { x: rect.left + rect.width / 2, y: rect.top };
|
|
13
|
+
}
|
|
14
|
+
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
|
15
|
+
}
|
|
16
|
+
async function open(opts) {
|
|
17
|
+
const docs = await listFn();
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const handle = sh3.popup.show(DocumentBrowser, { anchor: anchorOrDefault(opts === null || opts === void 0 ? void 0 : opts.anchor) }, {
|
|
20
|
+
mode: 'open',
|
|
21
|
+
docs,
|
|
22
|
+
onCommit: (value) => {
|
|
23
|
+
resolve(value);
|
|
24
|
+
},
|
|
25
|
+
onCancel: () => {
|
|
26
|
+
resolve(null);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const origClose = handle.close;
|
|
30
|
+
handle.close = () => {
|
|
31
|
+
origClose();
|
|
32
|
+
resolve(null);
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async function save(opts) {
|
|
37
|
+
const docs = await listFn();
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const handle = sh3.popup.show(DocumentBrowser, { anchor: anchorOrDefault(opts === null || opts === void 0 ? void 0 : opts.anchor) }, {
|
|
40
|
+
mode: 'save',
|
|
41
|
+
docs,
|
|
42
|
+
suggestedName: opts === null || opts === void 0 ? void 0 : opts.suggestedName,
|
|
43
|
+
onCommit: (value) => {
|
|
44
|
+
resolve(value);
|
|
45
|
+
},
|
|
46
|
+
onCancel: () => {
|
|
47
|
+
resolve(null);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const origClose = handle.close;
|
|
51
|
+
handle.close = () => {
|
|
52
|
+
origClose();
|
|
53
|
+
resolve(null);
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return { open, save };
|
|
58
|
+
}
|
|
@@ -15,14 +15,20 @@
|
|
|
15
15
|
* tag them at authoring time. View-default fall-through ships when the
|
|
16
16
|
* registry exposes a pre-mount lookup (deferred from this PR).
|
|
17
17
|
*/
|
|
18
|
-
import { layoutStore } from '../store.svelte';
|
|
19
18
|
import { drawerStore } from './drawerStore.svelte';
|
|
20
19
|
import { derive } from './derive';
|
|
20
|
+
import { resolveCompactBodyRoot } from './rootStore.svelte';
|
|
21
21
|
import LayoutRenderer from '../LayoutRenderer.svelte';
|
|
22
22
|
import DrawerSurface from '../../overlays/DrawerSurface.svelte';
|
|
23
23
|
import type { DrawerAnchor } from './types';
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// Compact body shows whatever compactRootStore currently points at —
|
|
26
|
+
// the docked tree by default, or one float when the user navigates
|
|
27
|
+
// there via the FloatsSheet. Drawer derivation runs on the same root,
|
|
28
|
+
// so a float carrying role-tagged sidebar slots gets the same drawer
|
|
29
|
+
// chrome the docked tree gets.
|
|
30
|
+
const bodyRoot = $derived(resolveCompactBodyRoot());
|
|
31
|
+
const rendering = $derived(derive(bodyRoot));
|
|
26
32
|
|
|
27
33
|
const anchors: DrawerAnchor[] = ['left', 'right', 'top'];
|
|
28
34
|
</script>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LayoutNode } from '../types';
|
|
2
|
+
export type CompactRoot = {
|
|
3
|
+
kind: 'docked';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'float';
|
|
6
|
+
floatId: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const compactRootStore: {
|
|
9
|
+
readonly current: CompactRoot;
|
|
10
|
+
setRoot(r: CompactRoot): void;
|
|
11
|
+
reset(): void;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the LayoutNode the compact body should render. Returns
|
|
15
|
+
* `layoutStore.root` when current is docked OR when the referenced float
|
|
16
|
+
* has been removed (self-heal: also resets `current` to docked).
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveCompactBodyRoot(): LayoutNode;
|
|
19
|
+
/** Test-only reset. Not exported from src/index.ts. */
|
|
20
|
+
export declare function __resetCompactRootStoreForTest(): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* compactRootStore — selects which LayoutNode the compact body shows.
|
|
3
|
+
*
|
|
4
|
+
* In compact mode the user sees exactly one root at a time: either the
|
|
5
|
+
* docked tree (the "active layout"), or the content of one float. This
|
|
6
|
+
* module is the source of truth for that selection. Desktop never reads it.
|
|
7
|
+
*
|
|
8
|
+
* Reset triggers (called from existing modules):
|
|
9
|
+
* - bindFloatStore (active tree changed)
|
|
10
|
+
* - closeFloat(id) when current.floatId === id
|
|
11
|
+
* - resolveCompactBodyRoot self-heal when the referenced float is gone
|
|
12
|
+
*
|
|
13
|
+
* Set triggers:
|
|
14
|
+
* - floatManager.open in compact + non-dismissable
|
|
15
|
+
* - focusView / focusTab in compact when the target lives in a float
|
|
16
|
+
* - FloatsSheet row tap
|
|
17
|
+
*/
|
|
18
|
+
import { layoutStore } from '../store.svelte';
|
|
19
|
+
let current = $state({ kind: 'docked' });
|
|
20
|
+
export const compactRootStore = {
|
|
21
|
+
get current() {
|
|
22
|
+
return current;
|
|
23
|
+
},
|
|
24
|
+
setRoot(r) {
|
|
25
|
+
if (r.kind === 'float') {
|
|
26
|
+
const exists = layoutStore.tree.floats.some((f) => f.id === r.floatId);
|
|
27
|
+
if (!exists) {
|
|
28
|
+
throw new Error(`compactRootStore.setRoot: float id "${r.floatId}" is not in the active tree`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
current = r;
|
|
32
|
+
},
|
|
33
|
+
reset() {
|
|
34
|
+
current = { kind: 'docked' };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the LayoutNode the compact body should render. Returns
|
|
39
|
+
* `layoutStore.root` when current is docked OR when the referenced float
|
|
40
|
+
* has been removed (self-heal: also resets `current` to docked).
|
|
41
|
+
*/
|
|
42
|
+
export function resolveCompactBodyRoot() {
|
|
43
|
+
// Snapshot locally — narrowing on a module-level $state binding is lost
|
|
44
|
+
// across function calls because the narrowed-out branch could in theory
|
|
45
|
+
// reassign before the next read.
|
|
46
|
+
const cur = current;
|
|
47
|
+
if (cur.kind === 'docked')
|
|
48
|
+
return layoutStore.root;
|
|
49
|
+
const entry = layoutStore.tree.floats.find((f) => f.id === cur.floatId);
|
|
50
|
+
if (!entry) {
|
|
51
|
+
current = { kind: 'docked' };
|
|
52
|
+
return layoutStore.root;
|
|
53
|
+
}
|
|
54
|
+
return entry.content;
|
|
55
|
+
}
|
|
56
|
+
/** Test-only reset. Not exported from src/index.ts. */
|
|
57
|
+
export function __resetCompactRootStoreForTest() {
|
|
58
|
+
current = { kind: 'docked' };
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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;
|