@shenora/react 0.10.0 → 0.12.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 +152 -165
- package/dist/bridge.d.ts +29 -46
- package/dist/bridge.js +47 -71
- package/dist/clipboard.d.ts +89 -0
- package/dist/clipboard.js +119 -0
- package/dist/devInterceptor.d.ts +11 -8
- package/dist/devInterceptor.js +16 -10
- package/dist/errors.d.ts +5 -6
- package/dist/errors.js +6 -7
- package/dist/eventBus.d.ts +21 -26
- package/dist/eventBus.js +38 -40
- package/dist/fileDialogs.d.ts +9 -12
- package/dist/fileDialogs.js +12 -16
- package/dist/hooks.d.ts +19 -20
- package/dist/hooks.js +26 -29
- package/dist/index.d.ts +8 -2
- package/dist/index.js +14 -10
- package/dist/internal.d.ts +2 -8
- package/dist/internal.js +2 -8
- package/dist/media.d.ts +14 -22
- package/dist/media.js +14 -22
- package/dist/mediaPlayer.d.ts +103 -0
- package/dist/mediaPlayer.js +202 -0
- package/dist/moduleService.d.ts +17 -21
- package/dist/moduleService.js +17 -21
- package/dist/requests.d.ts +145 -0
- package/dist/requests.js +113 -0
- package/dist/segmentBinder.d.ts +74 -0
- package/dist/segmentBinder.js +239 -0
- package/dist/segmentStream.d.ts +125 -0
- package/dist/segmentStream.js +239 -0
- package/dist/store.d.ts +18 -26
- package/dist/store.js +69 -36
- package/dist/transport.d.ts +9 -18
- package/dist/transport.js +9 -18
- package/dist/types.d.ts +33 -42
- package/dist/types.js +29 -25
- package/dist/useDropZone.d.ts +23 -22
- package/dist/useDropZone.js +41 -37
- package/dist/windowCommands.d.ts +15 -19
- package/dist/windowCommands.js +18 -25
- package/package.json +10 -3
- package/dist/operations.d.ts +0 -256
- package/dist/operations.js +0 -191
package/dist/transport.js
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
const webViewWindow = () => typeof window === 'undefined' ? undefined : window;
|
|
2
2
|
/**
|
|
3
|
-
* True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* It used to test WebView2 alone, which answered FALSE on the MAUI shell: an app would have
|
|
8
|
-
* concluded it was in a plain browser tab while a perfectly good host sat on the other side of the
|
|
9
|
-
* channel. Widened when the second shell arrived — the question this function is asked is "is there
|
|
10
|
-
* a host", never "is it WebView2".
|
|
3
|
+
* True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI `HybridWebView` — i.e.
|
|
4
|
+
* when a transport to the host exists. In a plain browser this is false and callers should fall back to
|
|
5
|
+
* browser-only behavior. The question is "is there a host", never "is it WebView2".
|
|
11
6
|
*/
|
|
12
7
|
export function isShenoraAvailable() {
|
|
13
8
|
const host = webViewWindow();
|
|
@@ -34,11 +29,10 @@ export function createWebView2Transport() {
|
|
|
34
29
|
/**
|
|
35
30
|
* The MAUI `HybridWebView` transport, or null outside a MAUI host.
|
|
36
31
|
*
|
|
37
|
-
* Asymmetric by the platform's design
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* changed, which is the whole point of the transport seam (D16).
|
|
32
|
+
* Asymmetric by the platform's design: sending goes through `window.HybridWebView.SendRawMessage`,
|
|
33
|
+
* while receiving is a `HybridWebViewMessageReceived` CustomEvent dispatched on `window`. Both
|
|
34
|
+
* directions carry the same JSON envelopes the desktop shell speaks; the host side is
|
|
35
|
+
* `Shenora.Maui.MauiIpcBridge`.
|
|
42
36
|
*/
|
|
43
37
|
export function createHybridWebViewTransport() {
|
|
44
38
|
const host = webViewWindow();
|
|
@@ -64,11 +58,8 @@ export function createHybridWebViewTransport() {
|
|
|
64
58
|
/**
|
|
65
59
|
* The transport for whichever Shenora host this page is running in, or null in a plain browser.
|
|
66
60
|
* This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
|
|
67
|
-
* desktop shell and the MAUI shell without knowing which one it is.
|
|
68
|
-
*
|
|
69
|
-
* WebView2 is probed FIRST for no deeper reason than that it is the older shell and a page can only
|
|
70
|
-
* ever be in one of them; if both objects were somehow present, preferring the one the desktop host
|
|
71
|
-
* injects keeps existing behaviour byte-identical.
|
|
61
|
+
* desktop shell and the MAUI shell without knowing which one it is. A page is only ever in one of
|
|
62
|
+
* them; WebView2 wins if both objects are somehow present.
|
|
72
63
|
*/
|
|
73
64
|
export function createHostTransport() {
|
|
74
65
|
return createWebView2Transport() ?? createHybridWebViewTransport();
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Shenora IPC wire contract — the TS mirror of the `Shenora.Ipc` C# envelopes (names are
|
|
2
|
+
* The Shenora IPC wire contract — the TS mirror of the `Shenora.Core.Ipc` C# envelopes (names are
|
|
3
3
|
* pinned on both sides; the host serializes camelCase). Transport-neutral: the same envelopes
|
|
4
4
|
* travel over WebView2 postMessage, a WebSocket, or a mobile shell's channel.
|
|
5
5
|
*/
|
|
@@ -21,33 +21,34 @@ export declare const HANDSHAKE_TYPE = "READY";
|
|
|
21
21
|
*/
|
|
22
22
|
export declare const IpcErrorCodes: {
|
|
23
23
|
readonly unknownError: "UNKNOWN_ERROR";
|
|
24
|
-
readonly noHandler: "NO_HANDLER";
|
|
25
24
|
/**
|
|
26
|
-
*
|
|
25
|
+
* **No MODULE claimed the request** — nothing host-side answers that name, i.e. the module was never
|
|
26
|
+
* registered. Parameters: `module`, `type`.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
* ⚠ Distinct from {@link noRoute}, and opposite fixes: wire the module up, versus correct a route name.
|
|
29
|
+
*/
|
|
30
|
+
readonly noHandler: "NO_HANDLER";
|
|
31
|
+
/**
|
|
32
|
+
* **The module answered but has no route of that type** — so it IS registered, and this is a
|
|
33
|
+
* route-name problem. Parameters: `module`, `type`.
|
|
31
34
|
*/
|
|
35
|
+
readonly noRoute: "NO_ROUTE";
|
|
36
|
+
/** A scope-routed module was called without a `scope`. Parameters: `module`. */
|
|
32
37
|
readonly scopeRequired: "SCOPE_REQUIRED";
|
|
33
38
|
readonly missingPayloadValue: "MISSING_PAYLOAD_VALUE";
|
|
34
39
|
readonly invalidPayloadValue: "INVALID_PAYLOAD_VALUE";
|
|
35
40
|
/**
|
|
36
41
|
* The operation was cancelled — a NORMAL outcome, not a fault. Treat it as "show nothing": it is the
|
|
37
|
-
* one failure a UI should stay silent about.
|
|
42
|
+
* one failure a UI should stay silent about.
|
|
38
43
|
*/
|
|
39
44
|
readonly operationCancelled: "OPERATION_CANCELLED";
|
|
40
45
|
/**
|
|
41
46
|
* The shell has NO EXPRESSION of what was asked for — not a fault, and not something a retry fixes.
|
|
42
|
-
* Parameters: `capability` (a {@link ShellCapabilities} value).
|
|
47
|
+
* Parameters: `capability` (a {@link ShellCapabilities} value). Hide the control rather than showing
|
|
48
|
+
* an error: the capability is absent by design on that platform, not broken.
|
|
43
49
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* instance) rather than broken.
|
|
47
|
-
*
|
|
48
|
-
* ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities`
|
|
49
|
-
* precisely so one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the
|
|
50
|
-
* intended path. This is the honest answer when a page asks anyway.
|
|
50
|
+
* ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities` so
|
|
51
|
+
* one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the intended path.
|
|
51
52
|
*/
|
|
52
53
|
readonly capabilityNotSupported: "CAPABILITY_NOT_SUPPORTED";
|
|
53
54
|
/** Client-only: the request timed out waiting for a response. */
|
|
@@ -57,8 +58,7 @@ export declare const IpcErrorCodes: {
|
|
|
57
58
|
};
|
|
58
59
|
/**
|
|
59
60
|
* The codes that exist ONLY on the client — they never arrive from the host, but reject through the same
|
|
60
|
-
* structured shape
|
|
61
|
-
* exclude them by intent rather than by a hard-coded list on the other side.
|
|
61
|
+
* structured shape. Named here so the cross-language mirror check excludes them by intent.
|
|
62
62
|
*/
|
|
63
63
|
export declare const ClientOnlyIpcErrorCodes: readonly string[];
|
|
64
64
|
/** The request envelope a client sends to the host. */
|
|
@@ -85,20 +85,8 @@ export interface IpcError {
|
|
|
85
85
|
}
|
|
86
86
|
/**
|
|
87
87
|
* What the host is and what it can do — the handshake's response data (mirror of the host's
|
|
88
|
-
* `ShellInfo`).
|
|
89
|
-
*
|
|
90
|
-
* This is what lets ONE page ship to every shell. Render on the data rather than sniffing the
|
|
91
|
-
* platform:
|
|
92
|
-
*
|
|
93
|
-
* ```tsx
|
|
94
|
-
* const shell = useShellInfo();
|
|
95
|
-
* return <>{shell?.capabilities.includes(ShellCapabilities.windowChrome) && <TitleBar />}</>;
|
|
96
|
-
* ```
|
|
97
|
-
*
|
|
98
|
-
* A desktop shell that draws its own chrome advertises `windowChrome` and `dropZones`; a mobile one
|
|
99
|
-
* has neither, and the same bundle renders correctly on both. Undefined means no host said
|
|
100
|
-
* anything — a plain browser tab, or a host predating this — so treat absent as "assume nothing",
|
|
101
|
-
* never as "assume desktop".
|
|
88
|
+
* `ShellInfo`), and what lets ONE page ship to every shell. Read it with `useShellInfo`, whose docs
|
|
89
|
+
* carry the rule for an absent one.
|
|
102
90
|
*/
|
|
103
91
|
export interface ShellInfo {
|
|
104
92
|
/** Short host identifier, for diagnostics (`"winforms"`, `"maui"`). Never branch on this — branch on the capabilities. */
|
|
@@ -119,16 +107,20 @@ export declare const ShellCapabilities: {
|
|
|
119
107
|
readonly secondaryWindows: "secondaryWindows";
|
|
120
108
|
readonly tray: "tray";
|
|
121
109
|
/**
|
|
122
|
-
* The host can
|
|
123
|
-
*
|
|
110
|
+
* The host can put a FILE LIST on the clipboard, so the user can paste into Explorer, Finder or a
|
|
111
|
+
* file manager. No web API expresses this, so it is the one part of the clipboard worth branching on.
|
|
124
112
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
|
|
113
|
+
* ⚠ It says nothing about the rest: text and bytes work everywhere, and the gesture-driven half is
|
|
114
|
+
* `navigator.clipboard`'s job.
|
|
115
|
+
*/
|
|
116
|
+
readonly clipboardFiles: "clipboardFiles";
|
|
117
|
+
/**
|
|
118
|
+
* The host can serve LOCAL FILES to this page — media, images, documents, exports — through its
|
|
119
|
+
* resource interceptor. Pair it with `mediaUrl`.
|
|
128
120
|
*
|
|
129
|
-
* ⚠
|
|
130
|
-
*
|
|
131
|
-
*
|
|
121
|
+
* ⚠ A page cannot reach a local file itself on any shell, so branch on this and fall back rather than
|
|
122
|
+
* rendering a player that can never load. It says the host CAN serve, not what: routes, payload shape
|
|
123
|
+
* and allowed roots are the app's, and it says nothing about the URL SCHEME.
|
|
132
124
|
*/
|
|
133
125
|
readonly localFiles: "localFiles";
|
|
134
126
|
};
|
|
@@ -159,9 +151,8 @@ export interface IpcNotificationBatch {
|
|
|
159
151
|
timestamp: string;
|
|
160
152
|
}
|
|
161
153
|
/**
|
|
162
|
-
* A client-side event on the event bus — an unbundled {@link IpcNotification}
|
|
163
|
-
*
|
|
164
|
-
* cross the wire).
|
|
154
|
+
* A client-side event on the event bus — an unbundled {@link IpcNotification}, or a locally emitted
|
|
155
|
+
* event. The host-side `EventMessage` additionally carries id/timestamp, which don't cross the wire.
|
|
165
156
|
*/
|
|
166
157
|
export interface EventMessage<TPayload = unknown> {
|
|
167
158
|
module: string;
|
package/dist/types.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Shenora IPC wire contract — the TS mirror of the `Shenora.Ipc` C# envelopes (names are
|
|
2
|
+
* The Shenora IPC wire contract — the TS mirror of the `Shenora.Core.Ipc` C# envelopes (names are
|
|
3
3
|
* pinned on both sides; the host serializes camelCase). Transport-neutral: the same envelopes
|
|
4
4
|
* travel over WebView2 postMessage, a WebSocket, or a mobile shell's channel.
|
|
5
5
|
*/
|
|
@@ -21,33 +21,34 @@ export const HANDSHAKE_TYPE = 'READY';
|
|
|
21
21
|
*/
|
|
22
22
|
export const IpcErrorCodes = {
|
|
23
23
|
unknownError: 'UNKNOWN_ERROR',
|
|
24
|
-
noHandler: 'NO_HANDLER',
|
|
25
24
|
/**
|
|
26
|
-
*
|
|
25
|
+
* **No MODULE claimed the request** — nothing host-side answers that name, i.e. the module was never
|
|
26
|
+
* registered. Parameters: `module`, `type`.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
* ⚠ Distinct from {@link noRoute}, and opposite fixes: wire the module up, versus correct a route name.
|
|
29
|
+
*/
|
|
30
|
+
noHandler: 'NO_HANDLER',
|
|
31
|
+
/**
|
|
32
|
+
* **The module answered but has no route of that type** — so it IS registered, and this is a
|
|
33
|
+
* route-name problem. Parameters: `module`, `type`.
|
|
31
34
|
*/
|
|
35
|
+
noRoute: 'NO_ROUTE',
|
|
36
|
+
/** A scope-routed module was called without a `scope`. Parameters: `module`. */
|
|
32
37
|
scopeRequired: 'SCOPE_REQUIRED',
|
|
33
38
|
missingPayloadValue: 'MISSING_PAYLOAD_VALUE',
|
|
34
39
|
invalidPayloadValue: 'INVALID_PAYLOAD_VALUE',
|
|
35
40
|
/**
|
|
36
41
|
* The operation was cancelled — a NORMAL outcome, not a fault. Treat it as "show nothing": it is the
|
|
37
|
-
* one failure a UI should stay silent about.
|
|
42
|
+
* one failure a UI should stay silent about.
|
|
38
43
|
*/
|
|
39
44
|
operationCancelled: 'OPERATION_CANCELLED',
|
|
40
45
|
/**
|
|
41
46
|
* The shell has NO EXPRESSION of what was asked for — not a fault, and not something a retry fixes.
|
|
42
|
-
* Parameters: `capability` (a {@link ShellCapabilities} value).
|
|
47
|
+
* Parameters: `capability` (a {@link ShellCapabilities} value). Hide the control rather than showing
|
|
48
|
+
* an error: the capability is absent by design on that platform, not broken.
|
|
43
49
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* instance) rather than broken.
|
|
47
|
-
*
|
|
48
|
-
* ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities`
|
|
49
|
-
* precisely so one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the
|
|
50
|
-
* intended path. This is the honest answer when a page asks anyway.
|
|
50
|
+
* ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities` so
|
|
51
|
+
* one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the intended path.
|
|
51
52
|
*/
|
|
52
53
|
capabilityNotSupported: 'CAPABILITY_NOT_SUPPORTED',
|
|
53
54
|
/** Client-only: the request timed out waiting for a response. */
|
|
@@ -57,8 +58,7 @@ export const IpcErrorCodes = {
|
|
|
57
58
|
};
|
|
58
59
|
/**
|
|
59
60
|
* The codes that exist ONLY on the client — they never arrive from the host, but reject through the same
|
|
60
|
-
* structured shape
|
|
61
|
-
* exclude them by intent rather than by a hard-coded list on the other side.
|
|
61
|
+
* structured shape. Named here so the cross-language mirror check excludes them by intent.
|
|
62
62
|
*/
|
|
63
63
|
export const ClientOnlyIpcErrorCodes = [
|
|
64
64
|
IpcErrorCodes.timeout,
|
|
@@ -77,16 +77,20 @@ export const ShellCapabilities = {
|
|
|
77
77
|
secondaryWindows: 'secondaryWindows',
|
|
78
78
|
tray: 'tray',
|
|
79
79
|
/**
|
|
80
|
-
* The host can
|
|
81
|
-
*
|
|
80
|
+
* The host can put a FILE LIST on the clipboard, so the user can paste into Explorer, Finder or a
|
|
81
|
+
* file manager. No web API expresses this, so it is the one part of the clipboard worth branching on.
|
|
82
82
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
|
|
83
|
+
* ⚠ It says nothing about the rest: text and bytes work everywhere, and the gesture-driven half is
|
|
84
|
+
* `navigator.clipboard`'s job.
|
|
85
|
+
*/
|
|
86
|
+
clipboardFiles: 'clipboardFiles',
|
|
87
|
+
/**
|
|
88
|
+
* The host can serve LOCAL FILES to this page — media, images, documents, exports — through its
|
|
89
|
+
* resource interceptor. Pair it with `mediaUrl`.
|
|
86
90
|
*
|
|
87
|
-
* ⚠
|
|
88
|
-
*
|
|
89
|
-
*
|
|
91
|
+
* ⚠ A page cannot reach a local file itself on any shell, so branch on this and fall back rather than
|
|
92
|
+
* rendering a player that can never load. It says the host CAN serve, not what: routes, payload shape
|
|
93
|
+
* and allowed roots are the app's, and it says nothing about the URL SCHEME.
|
|
90
94
|
*/
|
|
91
95
|
localFiles: 'localFiles',
|
|
92
96
|
};
|
package/dist/useDropZone.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type RefObject } from 'react';
|
|
2
2
|
import { type ShenoraBridge } from './bridge.js';
|
|
3
3
|
import { type ShenoraEventBus } from './eventBus.js';
|
|
4
|
-
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`
|
|
5
|
-
export declare const DROP_ZONE_MODULE = "
|
|
4
|
+
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
|
|
5
|
+
export declare const DROP_ZONE_MODULE = "SHENORA.DROPZONE";
|
|
6
6
|
/** A native file drop delivered to a zone. */
|
|
7
7
|
export interface DropZoneFileDrop {
|
|
8
8
|
zoneId: string;
|
|
@@ -18,9 +18,7 @@ export interface DropZoneFileDrop {
|
|
|
18
18
|
* Inputs for {@link useDropZone}.
|
|
19
19
|
*
|
|
20
20
|
* No ordering constraint against `notifyReady()`: the host clears zones when a new DOCUMENT starts
|
|
21
|
-
* loading, not on the handshake, so this hook's `REGISTER` cannot be wiped by a reset
|
|
22
|
-
* after it. (It could, until the reset moved off the handshake — React runs CHILD effects before
|
|
23
|
-
* PARENT effects, which made losing the registration the default outcome rather than bad luck.)
|
|
21
|
+
* loading, not on the handshake, so this hook's `REGISTER` cannot be wiped by a later reset.
|
|
24
22
|
*/
|
|
25
23
|
export interface UseDropZoneOptions {
|
|
26
24
|
/** The element the native overlay tracks. */
|
|
@@ -29,35 +27,38 @@ export interface UseDropZoneOptions {
|
|
|
29
27
|
onDrop: (files: string[], drop: DropZoneFileDrop) => void;
|
|
30
28
|
/** False = zone torn down (same as unmount). Default true. */
|
|
31
29
|
enabled?: boolean;
|
|
32
|
-
/** Stable zone id; default: generated per mount.
|
|
30
|
+
/** Stable zone id; default: generated per mount. ⚠ Read on the FIRST render only — changing it later
|
|
31
|
+
* does not re-register the zone, which is what "stable" means here. */
|
|
33
32
|
zoneId?: string;
|
|
34
33
|
/**
|
|
35
|
-
* Class toggled on the element while a file drag hovers the zone. UNSTYLED —
|
|
36
|
-
*
|
|
34
|
+
* Class toggled on the element while a file drag hovers the zone. UNSTYLED — the library ships no
|
|
35
|
+
* CSS; style it in the app. Default `"shenora-drop-hover"`.
|
|
36
|
+
* ⚠ Read on the FIRST render only, like {@link UseDropZoneOptions.zoneId}: a mid-hover change would
|
|
37
|
+
* add one class and remove another, leaving the first stuck on the element. Switch it by remounting.
|
|
37
38
|
*/
|
|
38
39
|
dropClassName?: string;
|
|
39
40
|
/** The bridge to speak over. Default: the shared default bridge. */
|
|
40
41
|
bridge?: ShenoraBridge;
|
|
41
42
|
/** The event bus host notifications arrive on. Default: the shared bus. */
|
|
42
43
|
bus?: ShenoraEventBus;
|
|
44
|
+
/**
|
|
45
|
+
* Where a failed REGISTER / UPDATE / SHOW / UNREGISTER is reported. Default: `console.error`.
|
|
46
|
+
*
|
|
47
|
+
* ⚠ Worth routing somewhere real, because a failure here is INVISIBLE in the UI: the page renders
|
|
48
|
+
* exactly as it should and files simply do not drop.
|
|
49
|
+
*/
|
|
50
|
+
onError?: (error: unknown, route: string) => void;
|
|
43
51
|
}
|
|
44
52
|
/**
|
|
45
53
|
* **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
|
|
46
|
-
* it is not an alternative here, it is the thing this exists to replace.**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* the renderer and then pushed across the IPC boundary: a full copy of every dropped file, EAGERLY,
|
|
51
|
-
* at drop time, before the app knows whether it wants any of them. Drop 200 files to filter by
|
|
52
|
-
* extension and you pay for all 200; drop a multi-GB asset and you pay that, to reach a file the
|
|
53
|
-
* host could have opened off the same disk. This hook gives you `string[]` paths instead — open
|
|
54
|
-
* lazily, stream, hash incrementally, move or link without copying, watch for changes.
|
|
54
|
+
* it is not an alternative here, it is the thing this exists to replace.** A page-side `onDrop` gets a
|
|
55
|
+
* `File` whose only accessor is its CONTENT, so every dropped file is copied into the renderer and
|
|
56
|
+
* across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
|
|
57
|
+
* OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
|
|
55
58
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* the background. Bounds re-sync (debounced) on resize/scroll/intersection changes; the host
|
|
60
|
-
* converts the CSS rect to physical pixels per-monitor.
|
|
59
|
+
* The host positions a transparent native overlay over the element to capture those paths, including
|
|
60
|
+
* for drags started while the app is in the background. Bounds re-sync (debounced) on
|
|
61
|
+
* resize/scroll/intersection changes; the host converts the CSS rect to physical pixels per-monitor.
|
|
61
62
|
*
|
|
62
63
|
* How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
|
|
63
64
|
* drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
|
package/dist/useDropZone.js
CHANGED
|
@@ -2,26 +2,19 @@ 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
|
-
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`
|
|
6
|
-
export const DROP_ZONE_MODULE = '
|
|
5
|
+
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
|
|
6
|
+
export const DROP_ZONE_MODULE = 'SHENORA.DROPZONE';
|
|
7
7
|
const newZoneId = () => randomId('drop-zone-');
|
|
8
8
|
/**
|
|
9
9
|
* **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
|
|
10
|
-
* it is not an alternative here, it is the thing this exists to replace.**
|
|
10
|
+
* it is not an alternative here, it is the thing this exists to replace.** A page-side `onDrop` gets a
|
|
11
|
+
* `File` whose only accessor is its CONTENT, so every dropped file is copied into the renderer and
|
|
12
|
+
* across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
|
|
13
|
+
* OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* at drop time, before the app knows whether it wants any of them. Drop 200 files to filter by
|
|
16
|
-
* extension and you pay for all 200; drop a multi-GB asset and you pay that, to reach a file the
|
|
17
|
-
* host could have opened off the same disk. This hook gives you `string[]` paths instead — open
|
|
18
|
-
* lazily, stream, hash incrementally, move or link without copying, watch for changes.
|
|
19
|
-
*
|
|
20
|
-
* Sync a native drop-zone overlay to a page element, ported from the primary desktop sibling
|
|
21
|
-
* (its fix-history comments kept below): the host positions a transparent WinForms overlay
|
|
22
|
-
* over the element to capture REAL OS file paths — including drags started while the app is in
|
|
23
|
-
* the background. Bounds re-sync (debounced) on resize/scroll/intersection changes; the host
|
|
24
|
-
* converts the CSS rect to physical pixels per-monitor.
|
|
15
|
+
* The host positions a transparent native overlay over the element to capture those paths, including
|
|
16
|
+
* for drags started while the app is in the background. Bounds re-sync (debounced) on
|
|
17
|
+
* resize/scroll/intersection changes; the host converts the CSS rect to physical pixels per-monitor.
|
|
25
18
|
*
|
|
26
19
|
* How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
|
|
27
20
|
* drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
|
|
@@ -30,20 +23,33 @@ const newZoneId = () => randomId('drop-zone-');
|
|
|
30
23
|
*/
|
|
31
24
|
export function useDropZone(options) {
|
|
32
25
|
const { targetRef, enabled = true } = options;
|
|
33
|
-
|
|
26
|
+
// ⚠ LAZY, because `useRef(newZoneId())` evaluates its argument on EVERY render and keeps only the
|
|
27
|
+
// first. The empty string is a safe sentinel: a generated id is never empty, and a caller passing
|
|
28
|
+
// `zoneId: ''` short-circuits the `??` and so still never reaches the generator.
|
|
29
|
+
const zoneIdRef = useRef('');
|
|
30
|
+
if (zoneIdRef.current === '')
|
|
31
|
+
zoneIdRef.current = options.zoneId ?? newZoneId();
|
|
32
|
+
// Read ONCE, unlike `onDrop`/`bridge` below which track the latest value — see the option's docs.
|
|
34
33
|
const dropClassRef = useRef(options.dropClassName ?? 'shenora-drop-hover');
|
|
35
34
|
const onDropRef = useRef(options.onDrop);
|
|
36
35
|
onDropRef.current = options.onDrop;
|
|
37
36
|
const bridgeRef = useRef(options.bridge);
|
|
38
37
|
bridgeRef.current = options.bridge;
|
|
39
38
|
const bus = options.bus ?? defaultEventBus;
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
// Tracks the latest handler (like `onDrop`), so a cleanup that runs long after mount still reports
|
|
40
|
+
// through the sink the app has NOW. Logs only when the app supplied none.
|
|
41
|
+
const onErrorRef = useRef(options.onError);
|
|
42
|
+
onErrorRef.current = options.onError;
|
|
43
|
+
const reportRef = useRef(() => { });
|
|
44
|
+
reportRef.current = (error, route) => onErrorRef.current
|
|
45
|
+
? onErrorRef.current(error, route)
|
|
46
|
+
: console.error(`[shenora] drop-zone ${route} failed:`, error);
|
|
47
|
+
// 🔴 Make the ref's CONTENT reactive. `targetRef` is a stable object, so an effect keyed on it runs
|
|
48
|
+
// exactly once — and if `targetRef.current` is null on that run (a conditionally-rendered target, or
|
|
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.
|
|
47
53
|
const [element, setElement] = useState(null);
|
|
48
54
|
useEffect(() => {
|
|
49
55
|
setElement(targetRef.current ?? null);
|
|
@@ -55,10 +61,9 @@ export function useDropZone(options) {
|
|
|
55
61
|
const attemptedRef = useRef(false);
|
|
56
62
|
// A REGISTER is in flight — guards against sending a duplicate before the first resolves.
|
|
57
63
|
const registeringRef = useRef(false);
|
|
58
|
-
// Teardown epoch:
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// from an older epoch are ignored.
|
|
64
|
+
// Teardown epoch: a REGISTER ack must not apply after its zone was torn down. Under StrictMode's
|
|
65
|
+
// mount-unmount-remount a stale ack marks the DESTROYED zone "registered" and the overlay silently
|
|
66
|
+
// never exists again. Cleanup bumps the epoch; acks from an older epoch are ignored.
|
|
62
67
|
const epochRef = useRef(0);
|
|
63
68
|
const lastBoundsRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
|
|
64
69
|
const syncBoundsRef = useRef(() => { });
|
|
@@ -91,7 +96,7 @@ export function useDropZone(options) {
|
|
|
91
96
|
.then(() => {
|
|
92
97
|
if (epochRef.current === epoch)
|
|
93
98
|
isRegisteredRef.current = true;
|
|
94
|
-
}, (error) =>
|
|
99
|
+
}, (error) => reportRef.current(error, 'REGISTER'))
|
|
95
100
|
.finally(() => {
|
|
96
101
|
if (epochRef.current === epoch)
|
|
97
102
|
registeringRef.current = false;
|
|
@@ -101,7 +106,7 @@ export function useDropZone(options) {
|
|
|
101
106
|
lastBoundsRef.current = bounds;
|
|
102
107
|
bridge
|
|
103
108
|
.invoke(DROP_ZONE_MODULE, 'UPDATE', { payload: { zoneId: zoneIdRef.current, ...bounds } })
|
|
104
|
-
.catch((error) =>
|
|
109
|
+
.catch((error) => reportRef.current(error, 'UPDATE'));
|
|
105
110
|
}
|
|
106
111
|
};
|
|
107
112
|
// Track the element and keep the native overlay in sync.
|
|
@@ -115,7 +120,7 @@ export function useDropZone(options) {
|
|
|
115
120
|
const sendShow = debounce(() => {
|
|
116
121
|
(bridgeRef.current ?? getBridge())
|
|
117
122
|
.invoke(DROP_ZONE_MODULE, 'SHOW', { payload: { zoneId: zoneIdRef.current } })
|
|
118
|
-
.catch((error) =>
|
|
123
|
+
.catch((error) => reportRef.current(error, 'SHOW'));
|
|
119
124
|
}, 100);
|
|
120
125
|
// The element's mouseleave (and the window losing focus) re-arm the overlay — native
|
|
121
126
|
// MouseLeave alone is unreliable through the WebView.
|
|
@@ -144,15 +149,14 @@ export function useDropZone(options) {
|
|
|
144
149
|
window.removeEventListener('blur', onWindowBlur);
|
|
145
150
|
element.removeEventListener('mouseleave', onMouseLeave);
|
|
146
151
|
element.removeAttribute('data-drop-zone-id');
|
|
147
|
-
// Unregister whenever this effect tears down — on unmount OR when `enabled` flips false —
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// no orphan).
|
|
152
|
+
// Unregister whenever this effect tears down — on unmount OR when `enabled` flips false — and
|
|
153
|
+
// never gated on the REGISTER ack, so an in-flight REGISTER is torn down too. The host's
|
|
154
|
+
// UnregisterZone no-ops if the overlay isn't there yet, and the ordered IPC channel processes
|
|
155
|
+
// the earlier REGISTER first, so there is no orphan.
|
|
152
156
|
if (attemptedRef.current) {
|
|
153
157
|
(bridgeRef.current ?? getBridge())
|
|
154
158
|
.invoke(DROP_ZONE_MODULE, 'UNREGISTER', { payload: { zoneId: zoneIdRef.current } })
|
|
155
|
-
.catch((error) =>
|
|
159
|
+
.catch((error) => reportRef.current(error, 'UNREGISTER'));
|
|
156
160
|
epochRef.current++; // invalidate any in-flight REGISTER's ack (see epochRef)
|
|
157
161
|
isRegisteredRef.current = false;
|
|
158
162
|
registeringRef.current = false; // a remount must re-send immediately
|
package/dist/windowCommands.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ interface WindowRequests {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Typed client for the host's `WINDOW` module (`
|
|
36
|
+
* Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
|
|
37
37
|
* drive the frameless window's chrome from the page: chrome buttons call
|
|
38
38
|
* `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
|
|
39
39
|
* then drags natively — snap and multi-monitor included), and a thin strip at the very top
|
|
@@ -54,31 +54,27 @@ export declare class WindowCommands extends BaseModuleService<WindowRequests> {
|
|
|
54
54
|
/** Resync the native chrome to the app theme (host `WindowCommandOptions.ApplyTheme`). */
|
|
55
55
|
setTheme(dark: boolean): Promise<void>;
|
|
56
56
|
/**
|
|
57
|
-
* Tell the host where the page drew its caption buttons, so the OS
|
|
58
|
-
*
|
|
59
|
-
* button never gets otherwise.
|
|
57
|
+
* Tell the host where the page drew its caption buttons, so the OS treats them as the real thing —
|
|
58
|
+
* chiefly so Windows 11 offers **Snap Layouts** on the maximize button.
|
|
60
59
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* only way to stay hot while the pointer is over the snap flyout, which is a different window.
|
|
60
|
+
* ⚠ 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 — subscribe to the host's
|
|
62
|
+
* caption-button state to render hot/pressed, which is also the only way to stay hot while the
|
|
63
|
+
* pointer is over the snap flyout, a different window.
|
|
66
64
|
*
|
|
67
|
-
* Re-send on every layout change
|
|
68
|
-
*
|
|
69
|
-
* to hand every pixel back to the page.
|
|
65
|
+
* ⚠ Re-send on every layout change: the rectangles are a snapshot, and a stale one moves the
|
|
66
|
+
* hit-test off the button the user can see. Pass an empty array to hand the pixels back to the page.
|
|
70
67
|
*/
|
|
71
68
|
setCaptionButtons(buttons: CaptionButtonRect[]): Promise<void>;
|
|
72
69
|
}
|
|
73
70
|
/**
|
|
74
|
-
* The
|
|
75
|
-
*
|
|
76
|
-
*
|
|
71
|
+
* The authoritative maximize state, re-queried when a resize SETTLES — a maximize/restore always
|
|
72
|
+
* resizes the window, and the DOM has no other signal for the manual work-area maximize. Failures
|
|
73
|
+
* (plain browser, no host) leave it false.
|
|
77
74
|
*
|
|
78
|
-
* Read once immediately, then on the TRAILING edge of a 100 ms debounce
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* not build on intermediate values during a drag; there are none.
|
|
75
|
+
* ⚠ Read once immediately, then only on the TRAILING edge of a 100 ms debounce, so there are no
|
|
76
|
+
* intermediate values during a drag to build on. Maximize/restore is a single step; only the end
|
|
77
|
+
* state exists.
|
|
82
78
|
*/
|
|
83
79
|
export declare function useWindowMaximized(commands?: WindowCommands): boolean;
|
|
84
80
|
export {};
|