@shenora/react 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/index.d.ts +5 -2
- package/dist/index.js +7 -2
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +3 -0
- package/dist/internalHooks.d.ts +12 -0
- package/dist/internalHooks.js +18 -0
- package/dist/mediaSurface.d.ts +62 -0
- package/dist/mediaSurface.js +133 -0
- package/dist/mediaTransport.d.ts +92 -0
- package/dist/mediaTransport.js +135 -0
- package/dist/transport.d.ts +26 -4
- package/dist/transport.js +57 -6
- package/dist/types.d.ts +25 -0
- package/dist/types.js +25 -0
- package/dist/useDropZone.d.ts +10 -7
- package/dist/useDropZone.js +93 -25
- package/dist/windowCommands.d.ts +79 -5
- package/dist/windowCommands.js +48 -3
- package/dist/windowOrientation.d.ts +48 -0
- package/dist/windowOrientation.js +27 -0
- package/package.json +4 -2
package/dist/transport.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
/** The global the Chromium shell marks a document with. Mirrored by `ChromiumTransport.HostGlobal`. */
|
|
2
|
+
export const CHROMIUM_HOST_GLOBAL = '__shenora_chromium';
|
|
3
|
+
/** What the Chromium shell calls to push a message. Mirrored by `ChromiumTransport.ReceiveMember`. */
|
|
4
|
+
export const CHROMIUM_RECEIVE = 'receive';
|
|
1
5
|
const webViewWindow = () => typeof window === 'undefined' ? undefined : window;
|
|
2
6
|
/**
|
|
3
|
-
* True when running inside ANY Shenora host — a WebView2 desktop shell
|
|
4
|
-
* when a transport to the host exists. In a plain browser this is false and callers should fall back to
|
|
7
|
+
* True when running inside ANY Shenora host — a WebView2 desktop shell, the Chromium shell or a
|
|
8
|
+
* `ChromiumView`, or a MAUI `HybridWebView` — i.e. when a transport to the host exists. In a plain browser this is false and callers should fall back to
|
|
5
9
|
* browser-only behavior. The question is "is there a host", never "is it WebView2".
|
|
6
10
|
*/
|
|
7
11
|
export function isShenoraAvailable() {
|
|
8
12
|
const host = webViewWindow();
|
|
9
|
-
return !!host?.chrome?.webview || !!host?.HybridWebView;
|
|
13
|
+
return !!host?.chrome?.webview || !!host?.HybridWebView || !!chromiumHost(host);
|
|
10
14
|
}
|
|
11
15
|
/** The WebView2 postMessage transport, or null outside a WebView2 host. */
|
|
12
16
|
export function createWebView2Transport() {
|
|
@@ -55,12 +59,59 @@ export function createHybridWebViewTransport() {
|
|
|
55
59
|
},
|
|
56
60
|
};
|
|
57
61
|
}
|
|
62
|
+
/** The marker, when it is well-formed; anything else on that global is not ours. */
|
|
63
|
+
function chromiumHost(host) {
|
|
64
|
+
const marker = host?.[CHROMIUM_HOST_GLOBAL];
|
|
65
|
+
return marker && typeof marker === 'object' && typeof marker.ipc === 'string' ? marker : undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Everyone listening on one marker. The host calls ONE function, so it fans out here: a second
|
|
69
|
+
* transport (a bridge replaced by `configureBridge`, say) must not silence the first, which is how two
|
|
70
|
+
* WebView2 listeners behave too.
|
|
71
|
+
*/
|
|
72
|
+
const chromiumListeners = new WeakMap();
|
|
73
|
+
/**
|
|
74
|
+
* The Chromium shell's transport, or null outside it. There is no code in the renderer: the page posts
|
|
75
|
+
* each envelope with `fetch` to the marker's same-origin `ipc` route, which the shell answers from its
|
|
76
|
+
* resource handler, and the shell pushes by calling the marker's `receive`.
|
|
77
|
+
*/
|
|
78
|
+
export function createChromiumTransport() {
|
|
79
|
+
const marker = chromiumHost(webViewWindow());
|
|
80
|
+
if (!marker)
|
|
81
|
+
return null;
|
|
82
|
+
let listeners = chromiumListeners.get(marker);
|
|
83
|
+
if (!listeners) {
|
|
84
|
+
const set = new Set();
|
|
85
|
+
chromiumListeners.set(marker, set);
|
|
86
|
+
marker[CHROMIUM_RECEIVE] = (message) => {
|
|
87
|
+
// Narrow first: anything on the page can call this, and only strings are ours.
|
|
88
|
+
if (typeof message === 'string')
|
|
89
|
+
for (const listener of [...set])
|
|
90
|
+
listener(message);
|
|
91
|
+
};
|
|
92
|
+
listeners = set;
|
|
93
|
+
}
|
|
94
|
+
const own = listeners;
|
|
95
|
+
return {
|
|
96
|
+
// Each message is a request of its own. They reach the host in the order they were posted: measured,
|
|
97
|
+
// 1,500 of 1,500 one-way posts in order, though no spec promises it. A failed post is swallowed, since an
|
|
98
|
+
// unhandled rejection here would name nothing: an `invoke` then fails at the bridge's request timeout,
|
|
99
|
+
// which names the call, and a one-way `post` is lost.
|
|
100
|
+
post: (message) => {
|
|
101
|
+
void fetch(marker.ipc, { method: 'POST', body: message }).catch(() => undefined);
|
|
102
|
+
},
|
|
103
|
+
subscribe: (listener) => {
|
|
104
|
+
own.add(listener);
|
|
105
|
+
return () => { own.delete(listener); };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
58
109
|
/**
|
|
59
110
|
* The transport for whichever Shenora host this page is running in, or null in a plain browser.
|
|
60
111
|
* This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
|
|
61
|
-
* desktop shell and the
|
|
62
|
-
* them;
|
|
112
|
+
* desktop shell, the MAUI shell and the Chromium shell without knowing which one it is. A page is only
|
|
113
|
+
* ever in one of them; the order decides only if several objects are somehow present.
|
|
63
114
|
*/
|
|
64
115
|
export function createHostTransport() {
|
|
65
|
-
return createWebView2Transport() ?? createHybridWebViewTransport();
|
|
116
|
+
return createWebView2Transport() ?? createHybridWebViewTransport() ?? createChromiumTransport();
|
|
66
117
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -131,6 +131,31 @@ export declare const ShellCapabilities: {
|
|
|
131
131
|
* and allowed roots are the app's, and it says nothing about the URL SCHEME.
|
|
132
132
|
*/
|
|
133
133
|
readonly localFiles: "localFiles";
|
|
134
|
+
/**
|
|
135
|
+
* The shell can HOLD the window at an orientation — see `WindowOrientation`. Android only today; the
|
|
136
|
+
* desktop has nothing to hold, and iOS refuses rather than half-doing it.
|
|
137
|
+
*
|
|
138
|
+
* ⚠ Branch on it, because the page's own fallback is real but weaker: `screen.orientation.lock()` is
|
|
139
|
+
* honoured only while the document is FULLSCREEN, and not at all in WKWebView. Absent here means take
|
|
140
|
+
* fullscreen first or leave rotation alone.
|
|
141
|
+
*/
|
|
142
|
+
readonly windowOrientation: "windowOrientation";
|
|
143
|
+
/**
|
|
144
|
+
* The shell can draw the PICTURE itself, under a transparent region the page leaves — see
|
|
145
|
+
* `useMediaSurface`. The mobile shells; the desktop has none and does not need one.
|
|
146
|
+
*
|
|
147
|
+
* ⚠ Branch on it, but the fallback is a real player rather than a degraded one: absent, a `<video>`
|
|
148
|
+
* element is the picture, which is the right answer wherever the webview can decode the file. Present,
|
|
149
|
+
* the shell's own player opens what that element refuses.
|
|
150
|
+
*
|
|
151
|
+
* 🔴 It asserts TWO things: a surface exists AND a player is attached to draw into it. A host that
|
|
152
|
+
* advertises it on the strength of the surface alone gives you a hole with no decoder behind it — the
|
|
153
|
+
* controls render, nothing ever appears, and it is indistinguishable from a refused file.
|
|
154
|
+
*
|
|
155
|
+
* ⚠ It still says nothing about a GIVEN file — what the platform decodes is a per-stream question the
|
|
156
|
+
* host answers.
|
|
157
|
+
*/
|
|
158
|
+
readonly mediaSurface: "mediaSurface";
|
|
134
159
|
};
|
|
135
160
|
/** The response envelope the host returns for an {@link IpcRequest}. */
|
|
136
161
|
export interface IpcResponse<TData = unknown> {
|
package/dist/types.js
CHANGED
|
@@ -101,4 +101,29 @@ export const ShellCapabilities = {
|
|
|
101
101
|
* and allowed roots are the app's, and it says nothing about the URL SCHEME.
|
|
102
102
|
*/
|
|
103
103
|
localFiles: 'localFiles',
|
|
104
|
+
/**
|
|
105
|
+
* The shell can HOLD the window at an orientation — see `WindowOrientation`. Android only today; the
|
|
106
|
+
* desktop has nothing to hold, and iOS refuses rather than half-doing it.
|
|
107
|
+
*
|
|
108
|
+
* ⚠ Branch on it, because the page's own fallback is real but weaker: `screen.orientation.lock()` is
|
|
109
|
+
* honoured only while the document is FULLSCREEN, and not at all in WKWebView. Absent here means take
|
|
110
|
+
* fullscreen first or leave rotation alone.
|
|
111
|
+
*/
|
|
112
|
+
windowOrientation: 'windowOrientation',
|
|
113
|
+
/**
|
|
114
|
+
* The shell can draw the PICTURE itself, under a transparent region the page leaves — see
|
|
115
|
+
* `useMediaSurface`. The mobile shells; the desktop has none and does not need one.
|
|
116
|
+
*
|
|
117
|
+
* ⚠ Branch on it, but the fallback is a real player rather than a degraded one: absent, a `<video>`
|
|
118
|
+
* element is the picture, which is the right answer wherever the webview can decode the file. Present,
|
|
119
|
+
* the shell's own player opens what that element refuses.
|
|
120
|
+
*
|
|
121
|
+
* 🔴 It asserts TWO things: a surface exists AND a player is attached to draw into it. A host that
|
|
122
|
+
* advertises it on the strength of the surface alone gives you a hole with no decoder behind it — the
|
|
123
|
+
* controls render, nothing ever appears, and it is indistinguishable from a refused file.
|
|
124
|
+
*
|
|
125
|
+
* ⚠ It still says nothing about a GIVEN file — what the platform decodes is a per-stream question the
|
|
126
|
+
* host answers.
|
|
127
|
+
*/
|
|
128
|
+
mediaSurface: 'mediaSurface',
|
|
104
129
|
};
|
package/dist/useDropZone.d.ts
CHANGED
|
@@ -56,13 +56,16 @@ export interface UseDropZoneOptions {
|
|
|
56
56
|
* across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
|
|
57
57
|
* OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
|
|
58
58
|
*
|
|
59
|
-
*
|
|
60
|
-
* for drags started while the app is in the background. Bounds re-sync
|
|
61
|
-
* resize/scroll/intersection changes; the host converts the CSS rect to physical pixels
|
|
59
|
+
* **On the WebView2 shell** the host positions a transparent native overlay over the element to capture
|
|
60
|
+
* those paths, including for drags started while the app is in the background. Bounds re-sync
|
|
61
|
+
* (debounced) on resize/scroll/intersection changes; the host converts the CSS rect to physical pixels
|
|
62
|
+
* per-monitor. How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to
|
|
63
|
+
* catch a drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
|
|
64
|
+
* window always shows overlays (background drag-drop); while the overlay is visible the host emits
|
|
65
|
+
* DRAG_ENTER/DRAG_LEAVE for CSS feedback.
|
|
62
66
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* emits DRAG_ENTER/DRAG_LEAVE for CSS feedback.
|
|
67
|
+
* **On the Chromium shell there is no overlay.** The engine hands the host the drag's real paths as it
|
|
68
|
+
* enters, so the host answers REGISTER with `pageDrop`, this hook takes the page's own drag events on the
|
|
69
|
+
* element, and a drop there asks the host for the paths. Your code is the same on both.
|
|
67
70
|
*/
|
|
68
71
|
export declare function useDropZone(options: UseDropZoneOptions): void;
|
package/dist/useDropZone.js
CHANGED
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { getBridge } from './bridge.js';
|
|
3
3
|
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
4
4
|
import { debounce, randomId } from './internal.js';
|
|
5
|
+
import { useRefElement } from './internalHooks.js';
|
|
5
6
|
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
|
|
6
7
|
export const DROP_ZONE_MODULE = 'SHENORA.DROPZONE';
|
|
7
8
|
const newZoneId = () => randomId('drop-zone-');
|
|
@@ -12,14 +13,17 @@ const newZoneId = () => randomId('drop-zone-');
|
|
|
12
13
|
* across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
|
|
13
14
|
* OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
* for drags started while the app is in the background. Bounds re-sync
|
|
17
|
-
* resize/scroll/intersection changes; the host converts the CSS rect to physical pixels
|
|
16
|
+
* **On the WebView2 shell** the host positions a transparent native overlay over the element to capture
|
|
17
|
+
* those paths, including for drags started while the app is in the background. Bounds re-sync
|
|
18
|
+
* (debounced) on resize/scroll/intersection changes; the host converts the CSS rect to physical pixels
|
|
19
|
+
* per-monitor. How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to
|
|
20
|
+
* catch a drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
|
|
21
|
+
* window always shows overlays (background drag-drop); while the overlay is visible the host emits
|
|
22
|
+
* DRAG_ENTER/DRAG_LEAVE for CSS feedback.
|
|
18
23
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* emits DRAG_ENTER/DRAG_LEAVE for CSS feedback.
|
|
24
|
+
* **On the Chromium shell there is no overlay.** The engine hands the host the drag's real paths as it
|
|
25
|
+
* enters, so the host answers REGISTER with `pageDrop`, this hook takes the page's own drag events on the
|
|
26
|
+
* element, and a drop there asks the host for the paths. Your code is the same on both.
|
|
23
27
|
*/
|
|
24
28
|
export function useDropZone(options) {
|
|
25
29
|
const { targetRef, enabled = true } = options;
|
|
@@ -44,16 +48,8 @@ export function useDropZone(options) {
|
|
|
44
48
|
reportRef.current = (error, route) => onErrorRef.current
|
|
45
49
|
? onErrorRef.current(error, route)
|
|
46
50
|
: console.error(`[shenora] drop-zone ${route} failed:`, error);
|
|
47
|
-
// 🔴
|
|
48
|
-
|
|
49
|
-
// any order where the ref is attached after the first commit) the effect bails out and NEVER re-runs:
|
|
50
|
-
// the zone is silently dead for the component's whole life, with no error anywhere. A ref mutation
|
|
51
|
-
// triggers no render, so this effect has NO dependency array; `setElement` with an unchanged value is
|
|
52
|
-
// a React no-op, so it cannot loop.
|
|
53
|
-
const [element, setElement] = useState(null);
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
setElement(targetRef.current ?? null);
|
|
56
|
-
});
|
|
51
|
+
// 🔴 The ref's CONTENT, reactive: a target rendered after the first commit would otherwise never register.
|
|
52
|
+
const element = useRefElement(targetRef);
|
|
57
53
|
const isRegisteredRef = useRef(false);
|
|
58
54
|
// Whether a REGISTER has ever been SENT for this zone (even if not yet acked). The cleanup
|
|
59
55
|
// unregisters on THIS (not on the ack) so a fast unmount before REGISTER resolves still tears
|
|
@@ -66,6 +62,9 @@ export function useDropZone(options) {
|
|
|
66
62
|
// never exists again. Cleanup bumps the epoch; acks from an older epoch are ignored.
|
|
67
63
|
const epochRef = useRef(0);
|
|
68
64
|
const lastBoundsRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
|
|
65
|
+
// The host said the PAGE delivers drops (the Chromium shell). Read by the DOM listeners at event time,
|
|
66
|
+
// so they do nothing until REGISTER is answered, and nothing at all on the WebView2 shell.
|
|
67
|
+
const pageDropRef = useRef(false);
|
|
69
68
|
const syncBoundsRef = useRef(() => { });
|
|
70
69
|
syncBoundsRef.current = () => {
|
|
71
70
|
const element = targetRef.current;
|
|
@@ -93,9 +92,11 @@ export function useDropZone(options) {
|
|
|
93
92
|
const epoch = epochRef.current;
|
|
94
93
|
bridge
|
|
95
94
|
.invoke(DROP_ZONE_MODULE, 'REGISTER', { payload: { zoneId: zoneIdRef.current, ...bounds } })
|
|
96
|
-
.then(() => {
|
|
97
|
-
if (epochRef.current
|
|
98
|
-
|
|
95
|
+
.then((registration) => {
|
|
96
|
+
if (epochRef.current !== epoch)
|
|
97
|
+
return;
|
|
98
|
+
isRegisteredRef.current = true;
|
|
99
|
+
pageDropRef.current = registration?.pageDrop === true;
|
|
99
100
|
}, (error) => reportRef.current(error, 'REGISTER'))
|
|
100
101
|
.finally(() => {
|
|
101
102
|
if (epochRef.current === epoch)
|
|
@@ -118,6 +119,8 @@ export function useDropZone(options) {
|
|
|
118
119
|
const syncBounds = debounce(() => syncBoundsRef.current(), 100);
|
|
119
120
|
syncBoundsRef.current();
|
|
120
121
|
const sendShow = debounce(() => {
|
|
122
|
+
if (pageDropRef.current)
|
|
123
|
+
return; // no overlay to raise
|
|
121
124
|
(bridgeRef.current ?? getBridge())
|
|
122
125
|
.invoke(DROP_ZONE_MODULE, 'SHOW', { payload: { zoneId: zoneIdRef.current } })
|
|
123
126
|
.catch((error) => reportRef.current(error, 'SHOW'));
|
|
@@ -161,20 +164,85 @@ export function useDropZone(options) {
|
|
|
161
164
|
isRegisteredRef.current = false;
|
|
162
165
|
registeringRef.current = false; // a remount must re-send immediately
|
|
163
166
|
attemptedRef.current = false;
|
|
167
|
+
pageDropRef.current = false;
|
|
164
168
|
}
|
|
165
169
|
};
|
|
166
170
|
}, [enabled, element]);
|
|
167
|
-
//
|
|
171
|
+
// The Chromium shell's drops: the page's own drag events on the element, acted on only once the host
|
|
172
|
+
// answered REGISTER with `pageDrop`. Claiming `dragover` is what makes the element a drop target at all.
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!enabled || !element)
|
|
175
|
+
return;
|
|
176
|
+
const dropClass = dropClassRef.current;
|
|
177
|
+
const carriesFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
|
|
178
|
+
// dragenter/dragleave fire for every child the pointer crosses, so count them rather than trusting one.
|
|
179
|
+
let depth = 0;
|
|
180
|
+
const onDragEnter = (event) => {
|
|
181
|
+
if (!pageDropRef.current || !carriesFiles(event))
|
|
182
|
+
return;
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
depth++;
|
|
185
|
+
element.classList.add(dropClass);
|
|
186
|
+
};
|
|
187
|
+
const onDragOver = (event) => {
|
|
188
|
+
if (!pageDropRef.current || !carriesFiles(event))
|
|
189
|
+
return;
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
if (event.dataTransfer)
|
|
192
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
193
|
+
};
|
|
194
|
+
const onDragLeave = () => {
|
|
195
|
+
if (!pageDropRef.current)
|
|
196
|
+
return;
|
|
197
|
+
depth = Math.max(0, depth - 1);
|
|
198
|
+
if (depth === 0)
|
|
199
|
+
element.classList.remove(dropClass);
|
|
200
|
+
};
|
|
201
|
+
const onDrop = (event) => {
|
|
202
|
+
if (!pageDropRef.current || !carriesFiles(event))
|
|
203
|
+
return;
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
depth = 0;
|
|
206
|
+
element.classList.remove(dropClass);
|
|
207
|
+
const rect = element.getBoundingClientRect();
|
|
208
|
+
const position = {
|
|
209
|
+
x: Math.round((event.clientX - rect.left) * devicePixelRatio),
|
|
210
|
+
y: Math.round((event.clientY - rect.top) * devicePixelRatio),
|
|
211
|
+
};
|
|
212
|
+
const zoneId = zoneIdRef.current;
|
|
213
|
+
(bridgeRef.current ?? getBridge())
|
|
214
|
+
.invoke(DROP_ZONE_MODULE, 'DROP', { payload: { zoneId } })
|
|
215
|
+
.then((answer) => {
|
|
216
|
+
const files = answer?.files ?? [];
|
|
217
|
+
if (files.length > 0)
|
|
218
|
+
onDropRef.current(files, { zoneId, files, position });
|
|
219
|
+
}, (error) => reportRef.current(error, 'DROP'));
|
|
220
|
+
};
|
|
221
|
+
element.addEventListener('dragenter', onDragEnter);
|
|
222
|
+
element.addEventListener('dragover', onDragOver);
|
|
223
|
+
element.addEventListener('dragleave', onDragLeave);
|
|
224
|
+
element.addEventListener('drop', onDrop);
|
|
225
|
+
return () => {
|
|
226
|
+
element.removeEventListener('dragenter', onDragEnter);
|
|
227
|
+
element.removeEventListener('dragover', onDragOver);
|
|
228
|
+
element.removeEventListener('dragleave', onDragLeave);
|
|
229
|
+
element.removeEventListener('drop', onDrop);
|
|
230
|
+
element.classList.remove(dropClass);
|
|
231
|
+
};
|
|
232
|
+
}, [enabled, element]);
|
|
233
|
+
// Drag-hover CSS feedback, from the WebView2 shell's overlay. Where the page delivers drops itself, these bus events
|
|
234
|
+
// are some OTHER page's: an app with both engines has a WebView2 page announcing its drags to every page, and a zone
|
|
235
|
+
// of the same id there would otherwise light this one, or drop its files here.
|
|
168
236
|
useEffect(() => {
|
|
169
237
|
if (!enabled || !element)
|
|
170
238
|
return;
|
|
171
239
|
const dropClass = dropClassRef.current;
|
|
172
240
|
const offEnter = bus.subscribe(DROP_ZONE_MODULE, 'DRAG_ENTER', (event) => {
|
|
173
|
-
if (event.payload?.zoneId === zoneIdRef.current)
|
|
241
|
+
if (!pageDropRef.current && event.payload?.zoneId === zoneIdRef.current)
|
|
174
242
|
element.classList.add(dropClass);
|
|
175
243
|
});
|
|
176
244
|
const offLeave = bus.subscribe(DROP_ZONE_MODULE, 'DRAG_LEAVE', (event) => {
|
|
177
|
-
if (event.payload?.zoneId === zoneIdRef.current)
|
|
245
|
+
if (!pageDropRef.current && event.payload?.zoneId === zoneIdRef.current)
|
|
178
246
|
element.classList.remove(dropClass);
|
|
179
247
|
});
|
|
180
248
|
return () => {
|
|
@@ -189,8 +257,8 @@ export function useDropZone(options) {
|
|
|
189
257
|
return;
|
|
190
258
|
return bus.subscribe(DROP_ZONE_MODULE, 'FILE_DROP', (event) => {
|
|
191
259
|
const drop = event.payload;
|
|
192
|
-
if (!drop || drop.zoneId !== zoneIdRef.current)
|
|
193
|
-
return;
|
|
260
|
+
if (pageDropRef.current || !drop || drop.zoneId !== zoneIdRef.current)
|
|
261
|
+
return; // see the hover feedback above
|
|
194
262
|
targetRef.current?.classList.remove(dropClassRef.current);
|
|
195
263
|
onDropRef.current(drop.files, drop);
|
|
196
264
|
});
|
package/dist/windowCommands.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ShenoraBridge } from './bridge.js';
|
|
2
|
+
import type { ShenoraEventBus } from './eventBus.js';
|
|
2
3
|
import { BaseModuleService } from './moduleService.js';
|
|
3
4
|
/** The top resize edges — the only ones that exist: the frameless technique keeps the native
|
|
4
5
|
* side/bottom resize borders, so only the top (covered by the WebView) needs page-side help. */
|
|
@@ -6,8 +7,8 @@ export type WindowResizeEdge = 'top' | 'topLeft' | 'topRight';
|
|
|
6
7
|
/** Which system caption button a page-drawn region stands in for (mirrors the host's enum). */
|
|
7
8
|
export type CaptionButtonKind = 'minimize' | 'maximize' | 'close';
|
|
8
9
|
/**
|
|
9
|
-
* Where the page drew one caption button, in CSS px relative to the
|
|
10
|
-
* `getBoundingClientRect()`. The host converts to physical px
|
|
10
|
+
* Where the page drew one caption button, in CSS px relative to the page — i.e. straight out of
|
|
11
|
+
* `getBoundingClientRect()`. The host converts to physical px at the window's DPI.
|
|
11
12
|
*/
|
|
12
13
|
export interface CaptionButtonRect {
|
|
13
14
|
kind: CaptionButtonKind;
|
|
@@ -16,6 +17,42 @@ export interface CaptionButtonRect {
|
|
|
16
17
|
width: number;
|
|
17
18
|
height: number;
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* What the OS is doing to the page's caption buttons: the one the pointer is over, and the one being pressed.
|
|
22
|
+
* Absent means none.
|
|
23
|
+
*/
|
|
24
|
+
export interface CaptionButtonState {
|
|
25
|
+
hot?: CaptionButtonKind;
|
|
26
|
+
pressed?: CaptionButtonKind;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The colours of the caption buttons the window paints (`NativeCaptionButtons`), each a CSS hex colour: `#rgb`,
|
|
30
|
+
* `#rgba`, `#rrggbb` or `#rrggbbaa`. Mirrors the host's `CaptionButtonColors`.
|
|
31
|
+
*/
|
|
32
|
+
export interface CaptionButtonColors {
|
|
33
|
+
/** The title bar's own colour. The WebView2 shell paints it behind its buttons; the Chromium shell's idle buttons
|
|
34
|
+
* show the page through them, so it is unused there. */
|
|
35
|
+
surface: string;
|
|
36
|
+
/** A hovered minimize or maximize button's background. */
|
|
37
|
+
hover: string;
|
|
38
|
+
/** A pressed minimize or maximize button's background. */
|
|
39
|
+
pressed: string;
|
|
40
|
+
/** The glyphs. */
|
|
41
|
+
glyph: string;
|
|
42
|
+
/** A hovered close button's background, red by the platform's convention. */
|
|
43
|
+
closeHover: string;
|
|
44
|
+
/** A pressed close button's background. */
|
|
45
|
+
closePressed: string;
|
|
46
|
+
/** The close glyph while close is hovered or pressed. Absent: `glyph`. */
|
|
47
|
+
closeGlyphHot?: string;
|
|
48
|
+
/** The idle glyphs while the window is inactive. Absent: `glyph` at about a third of its opacity. */
|
|
49
|
+
inactiveGlyph?: string;
|
|
50
|
+
}
|
|
51
|
+
/** The host's `SHENORA.WINDOW` event names, pinned against it by `WireMirrorTests`. */
|
|
52
|
+
export declare const WindowEventTypes: {
|
|
53
|
+
/** A {@link CaptionButtonState}, sent to the window's own page. See {@link useCaptionButtonState}. */
|
|
54
|
+
readonly CaptionButtonState: "CAPTION_BUTTON_STATE";
|
|
55
|
+
};
|
|
19
56
|
interface WindowRequests {
|
|
20
57
|
MINIMIZE: void;
|
|
21
58
|
TOGGLE_MAXIMIZE: void;
|
|
@@ -25,12 +62,16 @@ interface WindowRequests {
|
|
|
25
62
|
START_RESIZE: {
|
|
26
63
|
edge: WindowResizeEdge;
|
|
27
64
|
};
|
|
65
|
+
SHOW_SYSTEM_MENU: void;
|
|
28
66
|
SET_THEME: {
|
|
29
67
|
dark: boolean;
|
|
30
68
|
};
|
|
31
69
|
SET_CAPTION_BUTTONS: {
|
|
32
70
|
buttons: CaptionButtonRect[];
|
|
33
71
|
};
|
|
72
|
+
SET_CAPTION_BUTTON_COLORS: {
|
|
73
|
+
colors?: CaptionButtonColors;
|
|
74
|
+
};
|
|
34
75
|
}
|
|
35
76
|
/**
|
|
36
77
|
* Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
|
|
@@ -51,6 +92,13 @@ export declare class WindowCommands extends BaseModuleService<WindowRequests> {
|
|
|
51
92
|
startDrag(): Promise<void>;
|
|
52
93
|
/** Call from the top strip's `onMouseDown` — hands off to the OS size loop. */
|
|
53
94
|
startResize(edge?: WindowResizeEdge): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Open the window's system menu at the pointer, as a right click on a real caption does. Call from the header's
|
|
97
|
+
* `onContextMenu`, and `preventDefault()` there so the browser's own menu does not open too. It resolves as the
|
|
98
|
+
* menu opens, not when it closes. A Chromium page's `-webkit-app-region: drag` area opens the menu on a right click
|
|
99
|
+
* without it.
|
|
100
|
+
*/
|
|
101
|
+
showSystemMenu(): Promise<void>;
|
|
54
102
|
/** Resync the native chrome to the app theme (host `WindowCommandOptions.ApplyTheme`). */
|
|
55
103
|
setTheme(dark: boolean): Promise<void>;
|
|
56
104
|
/**
|
|
@@ -58,15 +106,41 @@ export declare class WindowCommands extends BaseModuleService<WindowRequests> {
|
|
|
58
106
|
* chiefly so Windows 11 offers **Snap Layouts** on the maximize button.
|
|
59
107
|
*
|
|
60
108
|
* ⚠ The host then takes over CLICKS in those rects and performs minimize/maximize/close itself, so
|
|
61
|
-
* your `onClick` handlers stop firing there. CSS `:hover` stops firing too
|
|
62
|
-
*
|
|
63
|
-
*
|
|
109
|
+
* your `onClick` handlers stop firing there. CSS `:hover` stops firing too: render hot and pressed from
|
|
110
|
+
* {@link useCaptionButtonState}.
|
|
111
|
+
*
|
|
112
|
+
* ⚠ The Chromium shell forgets the rects when a new document loads, so send them from the page on
|
|
113
|
+
* every load as well as on every layout change.
|
|
114
|
+
*
|
|
115
|
+
* On either shell the window can paint the buttons itself instead (`NativeCaptionButtons` on
|
|
116
|
+
* `OptimizedFormOptions` or `ChromiumWindowOptions`): then the page reserves the rects and draws nothing
|
|
117
|
+
* there. The Chromium shell's painted buttons follow {@link WindowCommands.setTheme}.
|
|
64
118
|
*
|
|
65
119
|
* ⚠ Re-send on every layout change: the rectangles are a snapshot, and a stale one moves the
|
|
66
120
|
* hit-test off the button the user can see. Pass an empty array to hand the pixels back to the page.
|
|
67
121
|
*/
|
|
68
122
|
setCaptionButtons(buttons: CaptionButtonRect[]): Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Colour the caption buttons the window paints, to match the page's title bar; send them again when the page's
|
|
125
|
+
* theme changes. `null` goes back to the default: on the Chromium shell the colours of {@link WindowCommands.setTheme},
|
|
126
|
+
* on the WebView2 shell a fallback from the form's own colour (it replaces `OptimizedForm.CaptionButtonColors`).
|
|
127
|
+
* The row's height is the page's: the rects sent to {@link WindowCommands.setCaptionButtons}.
|
|
128
|
+
*
|
|
129
|
+
* ⚠ Rejects with `NO_ROUTE` where the window does not paint its buttons, and with `INVALID_PAYLOAD_VALUE` for a
|
|
130
|
+
* colour that is not CSS hex.
|
|
131
|
+
*/
|
|
132
|
+
setCaptionButtonColors(colors: CaptionButtonColors | null): Promise<void>;
|
|
69
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Which caption button to render hot or pressed, for buttons registered with
|
|
136
|
+
* {@link WindowCommands.setCaptionButtons}, where CSS `:hover` no longer fires. The Chromium shell sends it to
|
|
137
|
+
* the window's own page as the pointer moves. The WebView2 shell sends nothing by itself: with
|
|
138
|
+
* `NativeCaptionButtons` it paints the caption buttons itself, and an app drawing its own there can emit this
|
|
139
|
+
* event from `OptimizedForm.CaptionButtonStateChanged`.
|
|
140
|
+
*/
|
|
141
|
+
export declare function useCaptionButtonState(options?: {
|
|
142
|
+
bus?: ShenoraEventBus;
|
|
143
|
+
}): CaptionButtonState;
|
|
70
144
|
/**
|
|
71
145
|
* The authoritative maximize state, re-queried when a resize SETTLES — a maximize/restore always
|
|
72
146
|
* resizes the window, and the DOM has no other signal for the manual work-area maximize. Failures
|
package/dist/windowCommands.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useShenoraEvent } from './hooks.js';
|
|
2
3
|
import { debounce } from './internal.js';
|
|
3
4
|
import { BaseModuleService } from './moduleService.js';
|
|
5
|
+
/** The host's `SHENORA.WINDOW` event names, pinned against it by `WireMirrorTests`. */
|
|
6
|
+
export const WindowEventTypes = {
|
|
7
|
+
/** A {@link CaptionButtonState}, sent to the window's own page. See {@link useCaptionButtonState}. */
|
|
8
|
+
CaptionButtonState: 'CAPTION_BUTTON_STATE',
|
|
9
|
+
};
|
|
4
10
|
/**
|
|
5
11
|
* Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
|
|
6
12
|
* drive the frameless window's chrome from the page: chrome buttons call
|
|
@@ -35,6 +41,15 @@ export class WindowCommands extends BaseModuleService {
|
|
|
35
41
|
startResize(edge = 'top') {
|
|
36
42
|
return this.send('START_RESIZE', { payload: { edge } });
|
|
37
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Open the window's system menu at the pointer, as a right click on a real caption does. Call from the header's
|
|
46
|
+
* `onContextMenu`, and `preventDefault()` there so the browser's own menu does not open too. It resolves as the
|
|
47
|
+
* menu opens, not when it closes. A Chromium page's `-webkit-app-region: drag` area opens the menu on a right click
|
|
48
|
+
* without it.
|
|
49
|
+
*/
|
|
50
|
+
showSystemMenu() {
|
|
51
|
+
return this.send('SHOW_SYSTEM_MENU');
|
|
52
|
+
}
|
|
38
53
|
/** Resync the native chrome to the app theme (host `WindowCommandOptions.ApplyTheme`). */
|
|
39
54
|
setTheme(dark) {
|
|
40
55
|
return this.send('SET_THEME', { payload: { dark } });
|
|
@@ -44,9 +59,15 @@ export class WindowCommands extends BaseModuleService {
|
|
|
44
59
|
* chiefly so Windows 11 offers **Snap Layouts** on the maximize button.
|
|
45
60
|
*
|
|
46
61
|
* ⚠ The host then takes over CLICKS in those rects and performs minimize/maximize/close itself, so
|
|
47
|
-
* your `onClick` handlers stop firing there. CSS `:hover` stops firing too
|
|
48
|
-
*
|
|
49
|
-
*
|
|
62
|
+
* your `onClick` handlers stop firing there. CSS `:hover` stops firing too: render hot and pressed from
|
|
63
|
+
* {@link useCaptionButtonState}.
|
|
64
|
+
*
|
|
65
|
+
* ⚠ The Chromium shell forgets the rects when a new document loads, so send them from the page on
|
|
66
|
+
* every load as well as on every layout change.
|
|
67
|
+
*
|
|
68
|
+
* On either shell the window can paint the buttons itself instead (`NativeCaptionButtons` on
|
|
69
|
+
* `OptimizedFormOptions` or `ChromiumWindowOptions`): then the page reserves the rects and draws nothing
|
|
70
|
+
* there. The Chromium shell's painted buttons follow {@link WindowCommands.setTheme}.
|
|
50
71
|
*
|
|
51
72
|
* ⚠ Re-send on every layout change: the rectangles are a snapshot, and a stale one moves the
|
|
52
73
|
* hit-test off the button the user can see. Pass an empty array to hand the pixels back to the page.
|
|
@@ -54,6 +75,30 @@ export class WindowCommands extends BaseModuleService {
|
|
|
54
75
|
setCaptionButtons(buttons) {
|
|
55
76
|
return this.send('SET_CAPTION_BUTTONS', { payload: { buttons } });
|
|
56
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Colour the caption buttons the window paints, to match the page's title bar; send them again when the page's
|
|
80
|
+
* theme changes. `null` goes back to the default: on the Chromium shell the colours of {@link WindowCommands.setTheme},
|
|
81
|
+
* on the WebView2 shell a fallback from the form's own colour (it replaces `OptimizedForm.CaptionButtonColors`).
|
|
82
|
+
* The row's height is the page's: the rects sent to {@link WindowCommands.setCaptionButtons}.
|
|
83
|
+
*
|
|
84
|
+
* ⚠ Rejects with `NO_ROUTE` where the window does not paint its buttons, and with `INVALID_PAYLOAD_VALUE` for a
|
|
85
|
+
* colour that is not CSS hex.
|
|
86
|
+
*/
|
|
87
|
+
setCaptionButtonColors(colors) {
|
|
88
|
+
return this.send('SET_CAPTION_BUTTON_COLORS', { payload: colors ? { colors } : {} });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Which caption button to render hot or pressed, for buttons registered with
|
|
93
|
+
* {@link WindowCommands.setCaptionButtons}, where CSS `:hover` no longer fires. The Chromium shell sends it to
|
|
94
|
+
* the window's own page as the pointer moves. The WebView2 shell sends nothing by itself: with
|
|
95
|
+
* `NativeCaptionButtons` it paints the caption buttons itself, and an app drawing its own there can emit this
|
|
96
|
+
* event from `OptimizedForm.CaptionButtonStateChanged`.
|
|
97
|
+
*/
|
|
98
|
+
export function useCaptionButtonState(options = {}) {
|
|
99
|
+
const [state, setState] = useState({});
|
|
100
|
+
useShenoraEvent('SHENORA.WINDOW', WindowEventTypes.CaptionButtonState, (payload) => setState(payload ?? {}), { bus: options.bus });
|
|
101
|
+
return state;
|
|
57
102
|
}
|
|
58
103
|
/**
|
|
59
104
|
* The authoritative maximize state, re-queried when a resize SETTLES — a maximize/restore always
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hold the window at an orientation. Mirrors `Shenora.Modules.Platform.WindowOrientationModule`, pinned
|
|
3
|
+
* by `WireMirrorTests`.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 **Try `screen.orientation.lock()` first — and know why it usually will not do.** The web API is
|
|
6
|
+
* honoured only while the document is FULLSCREEN, so a page can hold an orientation only by taking over
|
|
7
|
+
* the display; and WKWebView does not implement it at all. This route has neither limitation, because the
|
|
8
|
+
* host asks the platform directly. The common shape — portrait everywhere EXCEPT a media viewer — is
|
|
9
|
+
* exactly the one the web API cannot express.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ **Branch on the `windowOrientation` capability**, which only a shell that can really hold an
|
|
12
|
+
* orientation advertises. Calling where it is absent is refused with `CAPABILITY_NOT_SUPPORTED` rather
|
|
13
|
+
* than silently ignored — but a page that checks first never shows a control that cannot work.
|
|
14
|
+
*
|
|
15
|
+
* ⚠ **There is no "what is it now".** The page already knows: `screen.orientation.type`, or a CSS media
|
|
16
|
+
* query that re-renders for it. An IPC answer would be the same fact, later.
|
|
17
|
+
*/
|
|
18
|
+
import type { ShenoraBridge } from './bridge.js';
|
|
19
|
+
import { BaseModuleService } from './moduleService.js';
|
|
20
|
+
/** The orientations a window can be held at — the host's `WindowOrientation` enum, serialized. */
|
|
21
|
+
export type WindowOrientationKind = 'portrait' | 'landscape';
|
|
22
|
+
interface WindowOrientationRequests {
|
|
23
|
+
LOCK: {
|
|
24
|
+
orientation: WindowOrientationKind;
|
|
25
|
+
};
|
|
26
|
+
UNLOCK: void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Typed client for the host's `SHENORA.ORIENTATION` module. Two calls and no state: the app decides
|
|
30
|
+
* WHEN, the kit never rotates anything by itself.
|
|
31
|
+
*
|
|
32
|
+
* ```tsx
|
|
33
|
+
* const orientation = new WindowOrientation();
|
|
34
|
+
* useEffect(() => {
|
|
35
|
+
* if (!capabilities.has('windowOrientation')) return;
|
|
36
|
+
* orientation.lock('landscape'); // entering the viewer
|
|
37
|
+
* return () => { void orientation.unlock(); }; // leaving it
|
|
38
|
+
* }, []);
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare class WindowOrientation extends BaseModuleService<WindowOrientationRequests> {
|
|
42
|
+
constructor(bridge?: ShenoraBridge);
|
|
43
|
+
/** Hold the window at `orientation` until {@link unlock}. Idempotent; a second lock replaces the first. */
|
|
44
|
+
lock(orientation: WindowOrientationKind): Promise<void>;
|
|
45
|
+
/** Let the platform choose again — whatever the device's own rotation setting says. Idempotent. */
|
|
46
|
+
unlock(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { BaseModuleService } from './moduleService.js';
|
|
2
|
+
/**
|
|
3
|
+
* Typed client for the host's `SHENORA.ORIENTATION` module. Two calls and no state: the app decides
|
|
4
|
+
* WHEN, the kit never rotates anything by itself.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* const orientation = new WindowOrientation();
|
|
8
|
+
* useEffect(() => {
|
|
9
|
+
* if (!capabilities.has('windowOrientation')) return;
|
|
10
|
+
* orientation.lock('landscape'); // entering the viewer
|
|
11
|
+
* return () => { void orientation.unlock(); }; // leaving it
|
|
12
|
+
* }, []);
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export class WindowOrientation extends BaseModuleService {
|
|
16
|
+
constructor(bridge) {
|
|
17
|
+
super('SHENORA.ORIENTATION', bridge);
|
|
18
|
+
}
|
|
19
|
+
/** Hold the window at `orientation` until {@link unlock}. Idempotent; a second lock replaces the first. */
|
|
20
|
+
lock(orientation) {
|
|
21
|
+
return this.send('LOCK', { payload: { orientation } });
|
|
22
|
+
}
|
|
23
|
+
/** Let the platform choose again — whatever the device's own rotation setting says. Idempotent. */
|
|
24
|
+
unlock() {
|
|
25
|
+
return this.send('UNLOCK');
|
|
26
|
+
}
|
|
27
|
+
}
|