@shenora/react 0.16.0 → 0.17.1
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 +4 -2
- package/dist/index.js +6 -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 +16 -0
- package/dist/types.js +16 -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/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @shenora/react
|
|
2
2
|
|
|
3
|
-
React client for [Shenora](https://github.com/JiarongGu/Shenora)
|
|
4
|
-
|
|
3
|
+
React client for [Shenora](https://github.com/JiarongGu/Shenora) hosts: the Windows shell (WebView2 or
|
|
4
|
+
Chromium), the Chromium shell, Android and iOS. The typed bridge between a React frontend and the Shenora host: correlated `invoke`
|
|
5
5
|
with timeouts and structured errors, the event hub host notifications stream into, typed module
|
|
6
6
|
services, React hooks, and a pluggable transport with a browser fallback so the UI can be
|
|
7
7
|
developed in a plain browser. Headless by design — no UI components, bring your own design
|
|
@@ -144,8 +144,9 @@ runs ahead of the feature code it is observing. Prefer `subscribe` when you know
|
|
|
144
144
|
catch-all wakes for every event on the bus.
|
|
145
145
|
|
|
146
146
|
Pure-UI development in a plain browser: pass a `fallback` to `configureBridge` (gated behind
|
|
147
|
-
`import.meta.env.DEV`) to answer requests with canned data.
|
|
148
|
-
|
|
147
|
+
`import.meta.env.DEV`) to answer requests with canned data. The kit's own hosts are found by
|
|
148
|
+
`createHostTransport()`; another host (a WebSocket, say) implements the small `ShenoraTransport` seam and
|
|
149
|
+
speaks the same envelopes.
|
|
149
150
|
For CDP-driven testing, `installDevInterceptor()` records IPC/event traffic into ring buffers
|
|
150
151
|
and exposes `window.__shenora.call()/waitEvent()`.
|
|
151
152
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, type IpcRequest, type IpcResponse, type IpcError, type IpcNotification, type IpcNotificationBatch, type EventMessage, ShellCapabilities, type ShellInfo, } from './types.js';
|
|
2
2
|
export { ShenoraError } from './errors.js';
|
|
3
|
-
export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, type ShenoraTransport, } from './transport.js';
|
|
3
|
+
export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, createChromiumTransport, type ShenoraTransport, } from './transport.js';
|
|
4
4
|
export { ShenoraEventBus, eventBus, type SubscribeOptions, } from './eventBus.js';
|
|
5
5
|
export { ShenoraBridge, getBridge, configureBridge, type ShenoraBridgeOptions, type InvokeOptions, type PostOptions, type PostFailure, } from './bridge.js';
|
|
6
6
|
export { createShenoraStore, type ShenoraStore, type ShenoraStoreOptions, type ShenoraStoreIo, type ShenoraStoreSnapshot, } from './store.js';
|
|
7
7
|
export { BaseModuleService } from './moduleService.js';
|
|
8
8
|
export { IpcRequestStates, IpcRequestEventTypes, IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, type IpcRequestState, type IpcLabel, type IpcProgress, type IpcRequestStatus, type RequestsState, type RequestsActions, type RequestsStoreOptions, } from './requests.js';
|
|
9
|
-
export { WindowCommands, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, } from './windowCommands.js';
|
|
9
|
+
export { WindowCommands, WindowEventTypes, useCaptionButtonState, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, type CaptionButtonState, type CaptionButtonColors, } from './windowCommands.js';
|
|
10
10
|
export { WindowOrientation, type WindowOrientationKind, } from './windowOrientation.js';
|
|
11
11
|
export { useDropZone, DROP_ZONE_MODULE, type DropZoneFileDrop, type UseDropZoneOptions, } from './useDropZone.js';
|
|
12
12
|
export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, type ShenoraQueryResult, } from './hooks.js';
|
|
@@ -21,3 +21,5 @@ export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
|
|
|
21
21
|
export type { SegmentBinderOptions, SegmentBinding } from './segmentBinder.js';
|
|
22
22
|
export type { SegmentEntry, SegmentManifest, MediaSourceKind, MediaSourceGlobals, FetchState, FetchPolicy, } from './segmentStream.js';
|
|
23
23
|
export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MEDIA_PLAYER_STATUS, MediaPlayerCommands, MediaConversionEvents, MediaConversionErrorCodes, type MediaPlayerReport, type MediaPlayerReportState, type UseMediaPlayerOptions, } from './mediaPlayer.js';
|
|
24
|
+
export { useMediaSurface, MediaSurfaceCommands, type UseMediaSurfaceOptions, } from './mediaSurface.js';
|
|
25
|
+
export { useMediaTransport, type MediaTransport, type MediaTransportStatus, type UseMediaTransportOptions, } from './mediaTransport.js';
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// components, no design-system dependency — apps bring their own.
|
|
5
5
|
export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, ShellCapabilities, } from './types.js';
|
|
6
6
|
export { ShenoraError } from './errors.js';
|
|
7
|
-
export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, } from './transport.js';
|
|
7
|
+
export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, createChromiumTransport, } from './transport.js';
|
|
8
8
|
export { ShenoraEventBus, eventBus, } from './eventBus.js';
|
|
9
9
|
export { ShenoraBridge, getBridge, configureBridge, } from './bridge.js';
|
|
10
10
|
export { createShenoraStore, } from './store.js';
|
|
@@ -13,7 +13,7 @@ export { IpcRequestStates,
|
|
|
13
13
|
// The event and route vocabulary + the default module name, so an app handling or cancelling a
|
|
14
14
|
// request without `useShenoraRequests` never types the wire literals by hand.
|
|
15
15
|
IpcRequestEventTypes, IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, } from './requests.js';
|
|
16
|
-
export { WindowCommands, useWindowMaximized, } from './windowCommands.js';
|
|
16
|
+
export { WindowCommands, WindowEventTypes, useCaptionButtonState, useWindowMaximized, } from './windowCommands.js';
|
|
17
17
|
export { WindowOrientation, } from './windowOrientation.js';
|
|
18
18
|
export { useDropZone, DROP_ZONE_MODULE, } from './useDropZone.js';
|
|
19
19
|
export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, } from './hooks.js';
|
|
@@ -35,3 +35,7 @@ export { parseManifest, pickMediaSource, nextSegment, segmentMimeType, codecsFro
|
|
|
35
35
|
export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
|
|
36
36
|
// The HOST-owned player (D58): .NET holds the lifecycle, the page's element is the display and the sound.
|
|
37
37
|
export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MEDIA_PLAYER_STATUS, MediaPlayerCommands, MediaConversionEvents, MediaConversionErrorCodes, } from './mediaPlayer.js';
|
|
38
|
+
// The SHELL-owned picture — the same player's second surface, for what the element cannot decode.
|
|
39
|
+
export { useMediaSurface, MediaSurfaceCommands, } from './mediaSurface.js';
|
|
40
|
+
// Driving and reading that same player, for when the shell owns the picture and the page owns the controls.
|
|
41
|
+
export { useMediaTransport, } from './mediaTransport.js';
|
package/dist/internal.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared internals for `@shenora/react`. NOT exported from the barrel — nothing here is public
|
|
3
3
|
* surface, and it must not become so by accident.
|
|
4
|
+
*
|
|
5
|
+
* ⚠ No React here: the bridge imports this module, and the bridge must load without React (a page importing
|
|
6
|
+
* `bridge.js` directly, a worker, a test). React-only internals live in `internalHooks.ts`.
|
|
4
7
|
*/
|
|
5
8
|
/** A debounced void callback with a `cancel` for effect teardown. */
|
|
6
9
|
export interface Debounced {
|
package/dist/internal.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared internals for `@shenora/react`. NOT exported from the barrel — nothing here is public
|
|
3
3
|
* surface, and it must not become so by accident.
|
|
4
|
+
*
|
|
5
|
+
* ⚠ No React here: the bridge imports this module, and the bridge must load without React (a page importing
|
|
6
|
+
* `bridge.js` directly, a worker, a test). React-only internals live in `internalHooks.ts`.
|
|
4
7
|
*/
|
|
5
8
|
/**
|
|
6
9
|
* Trailing-edge debounce: the callback runs `ms` after the LAST call. `cancel` must be called from a
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React-only internals for `@shenora/react`'s hooks, apart from `internal.ts`, which the React-free bridge
|
|
3
|
+
* imports. NOT exported from the barrel.
|
|
4
|
+
*/
|
|
5
|
+
import { type RefObject } from 'react';
|
|
6
|
+
/**
|
|
7
|
+
* A ref's CONTENT, as state. A ref is a stable object, so an effect keyed on it runs once, and a target that
|
|
8
|
+
* is not there on that run (rendered conditionally, or attached after the first commit) is never seen: the
|
|
9
|
+
* hook is silently dead for the component's whole life. A ref mutation triggers no render, so this effect has
|
|
10
|
+
* NO dependency array; setting an unchanged value is a React no-op, so it cannot loop.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useRefElement<T extends Element>(ref: RefObject<T | null>): T | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React-only internals for `@shenora/react`'s hooks, apart from `internal.ts`, which the React-free bridge
|
|
3
|
+
* imports. NOT exported from the barrel.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
/**
|
|
7
|
+
* A ref's CONTENT, as state. A ref is a stable object, so an effect keyed on it runs once, and a target that
|
|
8
|
+
* is not there on that run (rendered conditionally, or attached after the first commit) is never seen: the
|
|
9
|
+
* hook is silently dead for the component's whole life. A ref mutation triggers no render, so this effect has
|
|
10
|
+
* NO dependency array; setting an unchanged value is a React no-op, so it cannot loop.
|
|
11
|
+
*/
|
|
12
|
+
export function useRefElement(ref) {
|
|
13
|
+
const [element, setElement] = useState(null);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
setElement(ref.current ?? null);
|
|
16
|
+
});
|
|
17
|
+
return element;
|
|
18
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
import { type ShenoraBridge } from './bridge.js';
|
|
3
|
+
/**
|
|
4
|
+
* The routes that move the SHELL's picture. **A wire contract**: these strings are duplicated in C# as
|
|
5
|
+
* `MediaPlayerModule.SurfaceShowType` / `SurfaceHideType`, and the two halves agree by string or not at all.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MediaSurfaceCommands: {
|
|
8
|
+
/** `{ x, y, width, height, onTop? }` in CSS pixels. */
|
|
9
|
+
readonly show: "SURFACE_SHOW";
|
|
10
|
+
/** No payload. */
|
|
11
|
+
readonly hide: "SURFACE_HIDE";
|
|
12
|
+
};
|
|
13
|
+
/** Inputs for {@link useMediaSurface}. */
|
|
14
|
+
export interface UseMediaSurfaceOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Draw the picture ABOVE the page instead of behind it. Default `false`, which is what lets you paint
|
|
17
|
+
* captions and controls over it.
|
|
18
|
+
*/
|
|
19
|
+
onTop?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Stop measuring and hide the picture. Use it to turn the surface off without unmounting — flipping this
|
|
22
|
+
* to `false` sends one `hide`.
|
|
23
|
+
*/
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
/** Override the module. Must match the host's `MediaPlayerOptions.Access.Module`. */
|
|
26
|
+
module?: string;
|
|
27
|
+
/** Test seam. */
|
|
28
|
+
bridge?: ShenoraBridge;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Tell the shell where to draw the picture: this element's rectangle becomes a hole, and the host's own
|
|
32
|
+
* player fills it from underneath (D58's second surface).
|
|
33
|
+
*
|
|
34
|
+
* ```tsx
|
|
35
|
+
* const stage = useRef<HTMLDivElement>(null);
|
|
36
|
+
* useMediaSurface(stage);
|
|
37
|
+
* return <div ref={stage} style={{ background: 'transparent' }} />;
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* **Use this instead of {@link useMediaPlayer} when the webview cannot decode the file** — the shell's
|
|
41
|
+
* player opens what a `<video>` element refuses. Everywhere else the element is the better answer, and
|
|
42
|
+
* both are the same `IMediaPlayer` underneath.
|
|
43
|
+
*
|
|
44
|
+
* 🔴 **The element must be genuinely TRANSPARENT, and so must everything behind it.** The picture is drawn
|
|
45
|
+
* BELOW the webview, so any opaque ancestor — most often a `body` background — hides it completely. That
|
|
46
|
+
* failure looks exactly like a player that never started, so check the backgrounds before the player.
|
|
47
|
+
* ⚠ **A transparent `body` is necessary and not sufficient for a full-bleed stage**: your own content is
|
|
48
|
+
* still painting over the picture. Hide it for the duration — `docs/guides/media.md` has the two rules.
|
|
49
|
+
*
|
|
50
|
+
* ⚠ **Gate it on the `mediaSurface` capability**, by rendering this component only on a shell that has one:
|
|
51
|
+
* a host without a surface answers every post with `MEDIA_SURFACE_UNAVAILABLE`. Read the capability from a
|
|
52
|
+
* source that RE-RENDERS when the handshake lands — a synchronous cache read taken during the first render
|
|
53
|
+
* is `false` for the whole session on a page that mounts before the host answers.
|
|
54
|
+
*
|
|
55
|
+
* ⚠ **The rectangle is in CSS pixels and crosses to the shell unconverted** — do not scale it by
|
|
56
|
+
* `devicePixelRatio`.
|
|
57
|
+
*
|
|
58
|
+
* ⚠ **It follows scroll and resize, coalesced to one post per animation frame**, and posts nothing when
|
|
59
|
+
* the rectangle has not moved. A collapsed or unmounted element measures zero, which the host reads as
|
|
60
|
+
* "hide" rather than drawing a dot at the origin.
|
|
61
|
+
*/
|
|
62
|
+
export declare function useMediaSurface(ref: RefObject<HTMLElement | null>, options?: UseMediaSurfaceOptions): void;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { getBridge } from './bridge.js';
|
|
3
|
+
import { useRefElement } from './internalHooks.js';
|
|
4
|
+
import { MEDIA_PLAYER_MODULE } from './mediaPlayer.js';
|
|
5
|
+
/**
|
|
6
|
+
* The routes that move the SHELL's picture. **A wire contract**: these strings are duplicated in C# as
|
|
7
|
+
* `MediaPlayerModule.SurfaceShowType` / `SurfaceHideType`, and the two halves agree by string or not at all.
|
|
8
|
+
*/
|
|
9
|
+
export const MediaSurfaceCommands = {
|
|
10
|
+
/** `{ x, y, width, height, onTop? }` in CSS pixels. */
|
|
11
|
+
show: 'SURFACE_SHOW',
|
|
12
|
+
/** No payload. */
|
|
13
|
+
hide: 'SURFACE_HIDE',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Tell the shell where to draw the picture: this element's rectangle becomes a hole, and the host's own
|
|
17
|
+
* player fills it from underneath (D58's second surface).
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const stage = useRef<HTMLDivElement>(null);
|
|
21
|
+
* useMediaSurface(stage);
|
|
22
|
+
* return <div ref={stage} style={{ background: 'transparent' }} />;
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* **Use this instead of {@link useMediaPlayer} when the webview cannot decode the file** — the shell's
|
|
26
|
+
* player opens what a `<video>` element refuses. Everywhere else the element is the better answer, and
|
|
27
|
+
* both are the same `IMediaPlayer` underneath.
|
|
28
|
+
*
|
|
29
|
+
* 🔴 **The element must be genuinely TRANSPARENT, and so must everything behind it.** The picture is drawn
|
|
30
|
+
* BELOW the webview, so any opaque ancestor — most often a `body` background — hides it completely. That
|
|
31
|
+
* failure looks exactly like a player that never started, so check the backgrounds before the player.
|
|
32
|
+
* ⚠ **A transparent `body` is necessary and not sufficient for a full-bleed stage**: your own content is
|
|
33
|
+
* still painting over the picture. Hide it for the duration — `docs/guides/media.md` has the two rules.
|
|
34
|
+
*
|
|
35
|
+
* ⚠ **Gate it on the `mediaSurface` capability**, by rendering this component only on a shell that has one:
|
|
36
|
+
* a host without a surface answers every post with `MEDIA_SURFACE_UNAVAILABLE`. Read the capability from a
|
|
37
|
+
* source that RE-RENDERS when the handshake lands — a synchronous cache read taken during the first render
|
|
38
|
+
* is `false` for the whole session on a page that mounts before the host answers.
|
|
39
|
+
*
|
|
40
|
+
* ⚠ **The rectangle is in CSS pixels and crosses to the shell unconverted** — do not scale it by
|
|
41
|
+
* `devicePixelRatio`.
|
|
42
|
+
*
|
|
43
|
+
* ⚠ **It follows scroll and resize, coalesced to one post per animation frame**, and posts nothing when
|
|
44
|
+
* the rectangle has not moved. A collapsed or unmounted element measures zero, which the host reads as
|
|
45
|
+
* "hide" rather than drawing a dot at the origin.
|
|
46
|
+
*/
|
|
47
|
+
export function useMediaSurface(ref, options = {}) {
|
|
48
|
+
const { onTop = false, enabled = true, module = MEDIA_PLAYER_MODULE, bridge } = options;
|
|
49
|
+
// The ref's CONTENT: a stage rendered after the first commit (conditionally, say) would otherwise never bind.
|
|
50
|
+
const element = useRefElement(ref);
|
|
51
|
+
// Whether the shell is drawing for this hook now, so a stage that is not there yet posts no hide.
|
|
52
|
+
const shown = useRef(false);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
/* 🔴 NOTHING HERE MAY THROW AT THE PAGE, and the call sites are why.
|
|
55
|
+
*
|
|
56
|
+
* These run from a scroll handler, a ResizeObserver and an effect CLEANUP — three places where an
|
|
57
|
+
* exception is not a caught error but a broken render or a leaked observer. And the throw is reachable:
|
|
58
|
+
* a page rendering the same component in a browser has no host at all, which is exactly the fallback
|
|
59
|
+
* case the capability check is supposed to make safe.
|
|
60
|
+
*/
|
|
61
|
+
const post = (type, payload) => {
|
|
62
|
+
try {
|
|
63
|
+
(bridge ?? getBridge()).post(module, type, payload ? { payload } : {});
|
|
64
|
+
}
|
|
65
|
+
catch { /* a picture is never worth taking the page down for */ }
|
|
66
|
+
};
|
|
67
|
+
const hide = () => {
|
|
68
|
+
if (!shown.current)
|
|
69
|
+
return;
|
|
70
|
+
shown.current = false;
|
|
71
|
+
post(MediaSurfaceCommands.hide);
|
|
72
|
+
};
|
|
73
|
+
if (!element || !enabled) {
|
|
74
|
+
// Not a no-op once shown: turning the surface off has to reach the shell, or the picture stays where it was.
|
|
75
|
+
hide();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
let frame = 0;
|
|
79
|
+
let pending = false;
|
|
80
|
+
let last = '';
|
|
81
|
+
const measure = () => {
|
|
82
|
+
pending = false;
|
|
83
|
+
const box = element.getBoundingClientRect();
|
|
84
|
+
// Rounded before the comparison, not after: sub-pixel jitter during a scroll otherwise makes every
|
|
85
|
+
// frame look like a move and posts one message per frame forever.
|
|
86
|
+
const payload = {
|
|
87
|
+
x: Math.round(box.left),
|
|
88
|
+
y: Math.round(box.top),
|
|
89
|
+
width: Math.round(box.width),
|
|
90
|
+
height: Math.round(box.height),
|
|
91
|
+
onTop,
|
|
92
|
+
};
|
|
93
|
+
// ⚠ `onTop` is part of the dedupe key, or a surface that changes ONLY its z-order is never told.
|
|
94
|
+
const key = JSON.stringify(payload);
|
|
95
|
+
if (key === last)
|
|
96
|
+
return;
|
|
97
|
+
last = key;
|
|
98
|
+
shown.current = true;
|
|
99
|
+
post(MediaSurfaceCommands.show, payload);
|
|
100
|
+
};
|
|
101
|
+
// Coalesced to a frame: scroll fires far more often than the compositor draws, and every post is a
|
|
102
|
+
// trip across the bridge.
|
|
103
|
+
//
|
|
104
|
+
// ⚠ The guard is a FLAG, not the frame id. Clearing `frame` inside the callback only works while the
|
|
105
|
+
// callback is asynchronous — and the id is assigned AFTER it returns — so a synchronous frame leaves
|
|
106
|
+
// the id set for good and the surface never moves again.
|
|
107
|
+
const schedule = () => {
|
|
108
|
+
if (pending)
|
|
109
|
+
return;
|
|
110
|
+
pending = true;
|
|
111
|
+
frame = requestAnimationFrame(measure);
|
|
112
|
+
};
|
|
113
|
+
measure();
|
|
114
|
+
// `capture: true` is what makes a NESTED scroller count — a scroll inside a div does not bubble, and
|
|
115
|
+
// without it the picture stays behind whenever the stage is inside its own scroll area.
|
|
116
|
+
window.addEventListener('scroll', schedule, { capture: true, passive: true });
|
|
117
|
+
window.addEventListener('resize', schedule, { passive: true });
|
|
118
|
+
// Size and layout changes that no scroll or resize event reports: a sibling collapsing, a font
|
|
119
|
+
// loading, the element itself being animated.
|
|
120
|
+
const observer = new ResizeObserver(schedule);
|
|
121
|
+
observer.observe(element);
|
|
122
|
+
return () => {
|
|
123
|
+
if (frame !== 0)
|
|
124
|
+
cancelAnimationFrame(frame);
|
|
125
|
+
window.removeEventListener('scroll', schedule, { capture: true });
|
|
126
|
+
window.removeEventListener('resize', schedule);
|
|
127
|
+
observer.disconnect();
|
|
128
|
+
// 🔴 The picture is the SHELL's and outlives this component. Without this, unmounting the stage
|
|
129
|
+
// leaves a native rectangle painted over whatever the page navigates to next.
|
|
130
|
+
hide();
|
|
131
|
+
};
|
|
132
|
+
}, [element, onTop, enabled, module, bridge]);
|
|
133
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type ShenoraBridge } from './bridge.js';
|
|
2
|
+
import { type MediaPlayerReportState } from './mediaPlayer.js';
|
|
3
|
+
/**
|
|
4
|
+
* What the HOST's player is doing. The same vocabulary `MediaPlayerStatus` uses on the other side, with
|
|
5
|
+
* seconds where C# has `TimeSpan`.
|
|
6
|
+
*/
|
|
7
|
+
export interface MediaTransportStatus {
|
|
8
|
+
state: MediaPlayerReportState;
|
|
9
|
+
/** Seconds. */
|
|
10
|
+
position: number;
|
|
11
|
+
/** Seconds, or null for a live stream and — briefly — while opening. */
|
|
12
|
+
duration: number | null;
|
|
13
|
+
/** The rate ASKED FOR; a platform may clamp it. */
|
|
14
|
+
rate: number;
|
|
15
|
+
/** A short reason when `state` is `Failed`; never the platform's raw text. */
|
|
16
|
+
error?: string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Which player produced this reading — `AndroidMediaPlayer`, `IosMediaPlayer`, or an app's own.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 **Log it before forming a theory about playback.** With a pluggable player behind the shell's
|
|
21
|
+
* surface, "which decoder ran" is not answerable from anywhere else, and assuming it wrong is the
|
|
22
|
+
* cheapest way to debug the wrong code.
|
|
23
|
+
*
|
|
24
|
+
* ⚠ **A diagnostic, not a branch** — it names an implementation, so anything conditional on it is
|
|
25
|
+
* coupled to a class name. Branch on the shell's capabilities instead.
|
|
26
|
+
*/
|
|
27
|
+
engine?: string | null;
|
|
28
|
+
}
|
|
29
|
+
/** Inputs for {@link useMediaTransport}. */
|
|
30
|
+
export interface UseMediaTransportOptions {
|
|
31
|
+
/** How often to sample the position, in ms. Default 250 — the rate a scrubber redraws at. */
|
|
32
|
+
intervalMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Poll at all. Default `true`. Set it `false` while no player surface is on screen: a paused player's
|
|
35
|
+
* position does not move, and the host answers every ask.
|
|
36
|
+
*/
|
|
37
|
+
enabled?: boolean;
|
|
38
|
+
/** Override the module. Must match the host's `MediaPlayerOptions.Access.Module`. */
|
|
39
|
+
module?: string;
|
|
40
|
+
/** Test seam. */
|
|
41
|
+
bridge?: ShenoraBridge;
|
|
42
|
+
}
|
|
43
|
+
/** What {@link useMediaTransport} gives you. */
|
|
44
|
+
export interface MediaTransport {
|
|
45
|
+
/** The most recent trustworthy reading, or `null` before the first one lands. */
|
|
46
|
+
status: MediaTransportStatus | null;
|
|
47
|
+
/**
|
|
48
|
+
* The host has stopped answering: eight asks in a row failed. About 2 s at the default interval when the
|
|
49
|
+
* host refuses them, about 10 s when it has gone quiet (each ask waits up to a second).
|
|
50
|
+
*
|
|
51
|
+
* 🔴 **This exists because a dead poll has NO symptom of its own.** The callback simply stops running:
|
|
52
|
+
* the scrubber keeps its last value, the play button keeps whatever the last press set, and nothing
|
|
53
|
+
* anywhere says the transport is gone. Show it, or at least log it — the alternative is diagnosing it
|
|
54
|
+
* from an ABSENCE, which is the hardest evidence there is to notice.
|
|
55
|
+
*/
|
|
56
|
+
unanswered: boolean;
|
|
57
|
+
/** Point the host's player at a source and prepare it. Does not start playback. */
|
|
58
|
+
load(uri: string): Promise<void>;
|
|
59
|
+
play(): Promise<void>;
|
|
60
|
+
pause(): Promise<void>;
|
|
61
|
+
/** Move to an absolute position, in seconds. */
|
|
62
|
+
seek(position: number): Promise<void>;
|
|
63
|
+
/** Set the speed multiplier; 1 is normal. */
|
|
64
|
+
setRate(rate: number): Promise<void>;
|
|
65
|
+
/** Release the source and free the decoder. */
|
|
66
|
+
unload(): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Drive the HOST's player and read what it is doing — the companion to {@link useMediaSurface}, for when
|
|
70
|
+
* the shell owns the picture and the page owns the controls.
|
|
71
|
+
*
|
|
72
|
+
* ```tsx
|
|
73
|
+
* const { status, unanswered, play, pause, seek } = useMediaTransport();
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* **Use it when the shell is the player.** With the picture on the shell's surface the page's own element
|
|
77
|
+
* is not playing, so its `timeupdate` says nothing and the host is the only clock. Running both is two
|
|
78
|
+
* clocks that disagree.
|
|
79
|
+
*
|
|
80
|
+
* 🔴 **THE COMMANDS ARE HERE, AND NOT BY CONVENIENCE.** A status answer that was asked for BEFORE a
|
|
81
|
+
* command is stale in every field, not just in `state` — so this hook drops it, which it can only do if
|
|
82
|
+
* it knows when a command happened. Calling the routes directly instead re-opens exactly that hole:
|
|
83
|
+
* the poll returns the pre-command reading, the UI flips back to it, and the next poll flips it again.
|
|
84
|
+
*
|
|
85
|
+
* ⚠ **A command's own answer is applied at once.** The host's drive routes return the resulting status,
|
|
86
|
+
* so a press updates the UI without waiting up to `intervalMs` for the next sample.
|
|
87
|
+
*
|
|
88
|
+
* ⚠ **Failures are swallowed, deliberately.** A poll that cannot answer is not worth breaking playback
|
|
89
|
+
* over, and a refused command resolves quietly: it is the host's answer, not a reason to throw at the page.
|
|
90
|
+
* Watch {@link MediaTransport.unanswered} for the case that matters.
|
|
91
|
+
*/
|
|
92
|
+
export declare function useMediaTransport(options?: UseMediaTransportOptions): MediaTransport;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { getBridge } from './bridge.js';
|
|
3
|
+
import { MEDIA_PLAYER_MODULE, MEDIA_PLAYER_STATUS, MediaPlayerCommands, } from './mediaPlayer.js';
|
|
4
|
+
/**
|
|
5
|
+
* Consecutive failed asks before {@link MediaTransport.unanswered} goes true.
|
|
6
|
+
*
|
|
7
|
+
* ⚠ Counted in TICKS rather than elapsed ms so the threshold means the same thing however fast a caller
|
|
8
|
+
* samples: ONE dropped reply is ordinary, eight in a row is a transport that has gone.
|
|
9
|
+
*/
|
|
10
|
+
const UNANSWERED_AFTER_TICKS = 8;
|
|
11
|
+
/**
|
|
12
|
+
* How long one status ask waits: four intervals, and never under a second. Without its own bound an ask
|
|
13
|
+
* waits the bridge's default of 30 s, and eight of them made a host that went quiet take four minutes to
|
|
14
|
+
* notice.
|
|
15
|
+
*/
|
|
16
|
+
const askTimeoutMs = (intervalMs) => Math.max(1000, intervalMs * 4);
|
|
17
|
+
/**
|
|
18
|
+
* Drive the HOST's player and read what it is doing — the companion to {@link useMediaSurface}, for when
|
|
19
|
+
* the shell owns the picture and the page owns the controls.
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* const { status, unanswered, play, pause, seek } = useMediaTransport();
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* **Use it when the shell is the player.** With the picture on the shell's surface the page's own element
|
|
26
|
+
* is not playing, so its `timeupdate` says nothing and the host is the only clock. Running both is two
|
|
27
|
+
* clocks that disagree.
|
|
28
|
+
*
|
|
29
|
+
* 🔴 **THE COMMANDS ARE HERE, AND NOT BY CONVENIENCE.** A status answer that was asked for BEFORE a
|
|
30
|
+
* command is stale in every field, not just in `state` — so this hook drops it, which it can only do if
|
|
31
|
+
* it knows when a command happened. Calling the routes directly instead re-opens exactly that hole:
|
|
32
|
+
* the poll returns the pre-command reading, the UI flips back to it, and the next poll flips it again.
|
|
33
|
+
*
|
|
34
|
+
* ⚠ **A command's own answer is applied at once.** The host's drive routes return the resulting status,
|
|
35
|
+
* so a press updates the UI without waiting up to `intervalMs` for the next sample.
|
|
36
|
+
*
|
|
37
|
+
* ⚠ **Failures are swallowed, deliberately.** A poll that cannot answer is not worth breaking playback
|
|
38
|
+
* over, and a refused command resolves quietly: it is the host's answer, not a reason to throw at the page.
|
|
39
|
+
* Watch {@link MediaTransport.unanswered} for the case that matters.
|
|
40
|
+
*/
|
|
41
|
+
export function useMediaTransport(options = {}) {
|
|
42
|
+
const { intervalMs = 250, enabled = true, module = MEDIA_PLAYER_MODULE, bridge } = options;
|
|
43
|
+
const [status, setStatus] = useState(null);
|
|
44
|
+
const [unanswered, setUnanswered] = useState(false);
|
|
45
|
+
/**
|
|
46
|
+
* How many commands have been issued. A reading asked for while this was lower describes a player that
|
|
47
|
+
* has since been told to do something else.
|
|
48
|
+
*
|
|
49
|
+
* ⚠ A ref, not state: bumping it must not re-render, and every reader needs the value as of NOW rather
|
|
50
|
+
* than as of the render it closed over.
|
|
51
|
+
*/
|
|
52
|
+
const commands = useRef(0);
|
|
53
|
+
const live = useRef(true);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
live.current = true;
|
|
56
|
+
return () => { live.current = false; };
|
|
57
|
+
}, []);
|
|
58
|
+
const send = useCallback(async (type, payload) => {
|
|
59
|
+
commands.current += 1;
|
|
60
|
+
try {
|
|
61
|
+
const answer = await (bridge ?? getBridge())
|
|
62
|
+
.invoke(module, type, payload ? { payload } : {});
|
|
63
|
+
// The host answers a drive command with the status it produced — apply it rather than waiting for
|
|
64
|
+
// the next sample. ⚠ Only if this component is still mounted; a resolve after unmount is ordinary.
|
|
65
|
+
if (live.current && answer)
|
|
66
|
+
setStatus(normalise(answer));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* A refused command is the host's answer, not a reason to throw at the page. */
|
|
70
|
+
}
|
|
71
|
+
}, [module, bridge]);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!enabled)
|
|
74
|
+
return;
|
|
75
|
+
let stopped = false;
|
|
76
|
+
let timer;
|
|
77
|
+
let misses = 0;
|
|
78
|
+
const tick = async () => {
|
|
79
|
+
if (stopped)
|
|
80
|
+
return;
|
|
81
|
+
// Read BEFORE the ask goes out: everything after this point is a command the answer cannot have seen.
|
|
82
|
+
const asked = commands.current;
|
|
83
|
+
let answer = null;
|
|
84
|
+
try {
|
|
85
|
+
answer = await (bridge ?? getBridge())
|
|
86
|
+
.invoke(module, MEDIA_PLAYER_STATUS, { timeoutMs: askTimeoutMs(intervalMs) });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
answer = null;
|
|
90
|
+
}
|
|
91
|
+
if (stopped)
|
|
92
|
+
return;
|
|
93
|
+
if (answer) {
|
|
94
|
+
misses = 0;
|
|
95
|
+
setUnanswered(false);
|
|
96
|
+
// 🔴 DROPPED, not reported-with-a-caveat: a pre-command reading is stale in position as well as
|
|
97
|
+
// state, so there is nothing in it a caller could safely keep.
|
|
98
|
+
if (commands.current === asked)
|
|
99
|
+
setStatus(normalise(answer));
|
|
100
|
+
}
|
|
101
|
+
else if (++misses >= UNANSWERED_AFTER_TICKS) {
|
|
102
|
+
setUnanswered(true);
|
|
103
|
+
}
|
|
104
|
+
// ⚠ Scheduled from the ANSWER, never on a bare interval: a slow host stretches the gap instead of
|
|
105
|
+
// queueing asks behind each other.
|
|
106
|
+
timer = setTimeout(() => { void tick(); }, intervalMs);
|
|
107
|
+
};
|
|
108
|
+
void tick();
|
|
109
|
+
return () => { stopped = true; if (timer)
|
|
110
|
+
clearTimeout(timer); };
|
|
111
|
+
}, [enabled, intervalMs, module, bridge]);
|
|
112
|
+
const load = useCallback((uri) => send(MediaPlayerCommands.load, { uri }), [send]);
|
|
113
|
+
const play = useCallback(() => send(MediaPlayerCommands.play), [send]);
|
|
114
|
+
const pause = useCallback(() => send(MediaPlayerCommands.pause), [send]);
|
|
115
|
+
const seek = useCallback((position) => send(MediaPlayerCommands.seek, { position }), [send]);
|
|
116
|
+
const setRate = useCallback((rate) => send(MediaPlayerCommands.rate, { rate }), [send]);
|
|
117
|
+
const unload = useCallback(() => send(MediaPlayerCommands.unload), [send]);
|
|
118
|
+
return { status, unanswered, load, play, pause, seek, setRate, unload };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A host answer, with every field given a safe shape.
|
|
122
|
+
*
|
|
123
|
+
* ⚠ `duration` is null for a live stream and while opening, and a UI that reads a missing one as 0 puts
|
|
124
|
+
* the playhead at the end of something that has just started.
|
|
125
|
+
*/
|
|
126
|
+
function normalise(answer) {
|
|
127
|
+
return {
|
|
128
|
+
state: (answer.state ?? 'Empty'),
|
|
129
|
+
position: Number.isFinite(answer.position) ? Number(answer.position) : 0,
|
|
130
|
+
duration: Number.isFinite(answer.duration) ? Number(answer.duration) : null,
|
|
131
|
+
rate: Number.isFinite(answer.rate) ? Number(answer.rate) : 1,
|
|
132
|
+
error: answer.error ?? null,
|
|
133
|
+
engine: answer.engine ?? null,
|
|
134
|
+
};
|
|
135
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Chromium shell's marker. The shell writes it into every HTML document it serves, so its presence
|
|
3
|
+
* IS the host advertising itself (D36, D83), never a guess about the engine. The page posts to `ipc`, a
|
|
4
|
+
* same-origin route the shell answers; the shell pushes by calling `receive`, which the transport sets.
|
|
5
|
+
*/
|
|
6
|
+
export interface ChromiumHost {
|
|
7
|
+
ipc: string;
|
|
8
|
+
receive?: ChromiumReceive;
|
|
9
|
+
}
|
|
10
|
+
/** A named type, not inline: WireMirrorTests reads the interface's fields and would count a parameter. */
|
|
11
|
+
type ChromiumReceive = (message: unknown) => void;
|
|
12
|
+
/** The global the Chromium shell marks a document with. Mirrored by `ChromiumTransport.HostGlobal`. */
|
|
13
|
+
export declare const CHROMIUM_HOST_GLOBAL = "__shenora_chromium";
|
|
14
|
+
/** What the Chromium shell calls to push a message. Mirrored by `ChromiumTransport.ReceiveMember`. */
|
|
15
|
+
export declare const CHROMIUM_RECEIVE = "receive";
|
|
1
16
|
/**
|
|
2
17
|
* A message channel the bridge speaks over — the transport-pluggable seam (design D16): WebView2
|
|
3
18
|
* postMessage on desktop today; a WebSocket or a mobile shell's native channel speaks the same
|
|
@@ -10,8 +25,8 @@ export interface ShenoraTransport {
|
|
|
10
25
|
subscribe(listener: (message: string) => void): () => void;
|
|
11
26
|
}
|
|
12
27
|
/**
|
|
13
|
-
* True when running inside ANY Shenora host — a WebView2 desktop shell
|
|
14
|
-
* when a transport to the host exists. In a plain browser this is false and callers should fall back to
|
|
28
|
+
* True when running inside ANY Shenora host — a WebView2 desktop shell, the Chromium shell or a
|
|
29
|
+
* `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
|
|
15
30
|
* browser-only behavior. The question is "is there a host", never "is it WebView2".
|
|
16
31
|
*/
|
|
17
32
|
export declare function isShenoraAvailable(): boolean;
|
|
@@ -26,10 +41,17 @@ export declare function createWebView2Transport(): ShenoraTransport | null;
|
|
|
26
41
|
* `Shenora.Maui.MauiIpcBridge`.
|
|
27
42
|
*/
|
|
28
43
|
export declare function createHybridWebViewTransport(): ShenoraTransport | null;
|
|
44
|
+
/**
|
|
45
|
+
* The Chromium shell's transport, or null outside it. There is no code in the renderer: the page posts
|
|
46
|
+
* each envelope with `fetch` to the marker's same-origin `ipc` route, which the shell answers from its
|
|
47
|
+
* resource handler, and the shell pushes by calling the marker's `receive`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createChromiumTransport(): ShenoraTransport | null;
|
|
29
50
|
/**
|
|
30
51
|
* The transport for whichever Shenora host this page is running in, or null in a plain browser.
|
|
31
52
|
* This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
|
|
32
|
-
* desktop shell and the
|
|
33
|
-
* them;
|
|
53
|
+
* desktop shell, the MAUI shell and the Chromium shell without knowing which one it is. A page is only
|
|
54
|
+
* ever in one of them; the order decides only if several objects are somehow present.
|
|
34
55
|
*/
|
|
35
56
|
export declare function createHostTransport(): ShenoraTransport | null;
|
|
57
|
+
export {};
|
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
|
@@ -140,6 +140,22 @@ export declare const ShellCapabilities: {
|
|
|
140
140
|
* fullscreen first or leave rotation alone.
|
|
141
141
|
*/
|
|
142
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";
|
|
143
159
|
};
|
|
144
160
|
/** The response envelope the host returns for an {@link IpcRequest}. */
|
|
145
161
|
export interface IpcResponse<TData = unknown> {
|
package/dist/types.js
CHANGED
|
@@ -110,4 +110,20 @@ export const ShellCapabilities = {
|
|
|
110
110
|
* fullscreen first or leave rotation alone.
|
|
111
111
|
*/
|
|
112
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',
|
|
113
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
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shenora/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "React client for Shenora hosts on Windows, Android and iOS: correlated invoke/send/subscribe over the
|
|
3
|
+
"version": "0.17.1",
|
|
4
|
+
"description": "React client for Shenora hosts on Windows, macOS, Android and iOS: correlated invoke/send/subscribe over whichever host transport the page is in (WebView2 postMessage, the Chromium hosts' same-origin route, or MAUI HybridWebView), typed module services, host-backed stores, and hooks for request tracking and media playback. Drop zones and window commands are desktop-only, because the capabilities are. Ships a browser fallback for pure-UI development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jiarong Gu",
|
|
7
7
|
"repository": {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"keywords": [
|
|
14
14
|
"shenora",
|
|
15
15
|
"webview2",
|
|
16
|
+
"chromium",
|
|
17
|
+
"cef",
|
|
16
18
|
"desktop",
|
|
17
19
|
"android",
|
|
18
20
|
"ios",
|