@shenora/react 0.1.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.
@@ -0,0 +1,183 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { getBridge } from './bridge.js';
3
+ import { eventBus as defaultEventBus } from './eventBus.js';
4
+ import { debounce, randomId } from './internal.js';
5
+ /** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneFacade`). */
6
+ export const DROP_ZONE_MODULE = 'DROP_ZONE';
7
+ const newZoneId = () => randomId('drop-zone-');
8
+ /**
9
+ * Sync a native drop-zone overlay to a page element, ported from the primary desktop sibling
10
+ * (its fix-history comments kept below): the host positions a transparent WinForms overlay
11
+ * over the element to capture REAL OS file paths — including drags started while the app is in
12
+ * the background. Bounds re-sync (debounced) on resize/scroll/intersection changes; the host
13
+ * converts the CSS rect to physical pixels per-monitor.
14
+ *
15
+ * How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
16
+ * drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
17
+ * window always shows overlays (background drag-drop); while the overlay is visible the host
18
+ * emits DRAG_ENTER/DRAG_LEAVE for CSS feedback.
19
+ */
20
+ export function useDropZone(options) {
21
+ const { targetRef, enabled = true } = options;
22
+ const zoneIdRef = useRef(options.zoneId ?? newZoneId());
23
+ const dropClassRef = useRef(options.dropClassName ?? 'shenora-drop-hover');
24
+ const onDropRef = useRef(options.onDrop);
25
+ onDropRef.current = options.onDrop;
26
+ const bridgeRef = useRef(options.bridge);
27
+ bridgeRef.current = options.bridge;
28
+ const bus = options.bus ?? defaultEventBus;
29
+ // Make the ref's CONTENT reactive (P5.5 H2). `targetRef` is a stable object, so effects keyed on it
30
+ // run exactly once — and if `targetRef.current` was null on that run (a conditionally-rendered
31
+ // target, or any order where the ref is attached after the first commit) the effect bailed out and
32
+ // NEVER re-ran: the zone was silently dead for the component's whole life, with no error anywhere.
33
+ // A ref mutation triggers no render, so this effect deliberately has NO dependency array — it
34
+ // observes `current` after every commit. `setElement` with an unchanged value is a no-op in React,
35
+ // so this cannot loop.
36
+ const [element, setElement] = useState(null);
37
+ useEffect(() => {
38
+ setElement(targetRef.current ?? null);
39
+ });
40
+ const isRegisteredRef = useRef(false);
41
+ // Whether a REGISTER has ever been SENT for this zone (even if not yet acked). The cleanup
42
+ // unregisters on THIS (not on the ack) so a fast unmount before REGISTER resolves still tears
43
+ // the overlay down.
44
+ const attemptedRef = useRef(false);
45
+ // A REGISTER is in flight — guards against sending a duplicate before the first resolves.
46
+ const registeringRef = useRef(false);
47
+ // Teardown epoch: the REGISTER ack must not apply after its zone was torn down — under
48
+ // StrictMode's mount-unmount-remount, a stale ack marked the DESTROYED zone "registered" and
49
+ // the overlay silently never existed again (found in review). Cleanup bumps the epoch; acks
50
+ // from an older epoch are ignored.
51
+ const epochRef = useRef(0);
52
+ const lastBoundsRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
53
+ const syncBoundsRef = useRef(() => { });
54
+ syncBoundsRef.current = () => {
55
+ const element = targetRef.current;
56
+ if (!element)
57
+ return;
58
+ const bridge = bridgeRef.current ?? getBridge();
59
+ const rect = element.getBoundingClientRect();
60
+ const bounds = {
61
+ x: Math.round(rect.left),
62
+ y: Math.round(rect.top),
63
+ width: Math.round(rect.width),
64
+ height: Math.round(rect.height),
65
+ };
66
+ const changed = !isRegisteredRef.current ||
67
+ bounds.x !== lastBoundsRef.current.x ||
68
+ bounds.y !== lastBoundsRef.current.y ||
69
+ bounds.width !== lastBoundsRef.current.width ||
70
+ bounds.height !== lastBoundsRef.current.height;
71
+ if (!isRegisteredRef.current) {
72
+ if (registeringRef.current)
73
+ return;
74
+ registeringRef.current = true;
75
+ attemptedRef.current = true;
76
+ lastBoundsRef.current = bounds;
77
+ const epoch = epochRef.current;
78
+ bridge
79
+ .invoke(DROP_ZONE_MODULE, 'REGISTER', { payload: { zoneId: zoneIdRef.current, ...bounds } })
80
+ .then(() => {
81
+ if (epochRef.current === epoch)
82
+ isRegisteredRef.current = true;
83
+ }, (error) => console.error('[shenora] drop-zone REGISTER failed:', error))
84
+ .finally(() => {
85
+ if (epochRef.current === epoch)
86
+ registeringRef.current = false;
87
+ });
88
+ }
89
+ else if (changed) {
90
+ lastBoundsRef.current = bounds;
91
+ bridge
92
+ .invoke(DROP_ZONE_MODULE, 'UPDATE', { payload: { zoneId: zoneIdRef.current, ...bounds } })
93
+ .catch((error) => console.error('[shenora] drop-zone UPDATE failed:', error));
94
+ }
95
+ };
96
+ // Track the element and keep the native overlay in sync.
97
+ useEffect(() => {
98
+ if (!enabled || !element)
99
+ return;
100
+ // The host's occlusion check finds the element through this attribute.
101
+ element.setAttribute('data-drop-zone-id', zoneIdRef.current);
102
+ const syncBounds = debounce(() => syncBoundsRef.current(), 100);
103
+ syncBoundsRef.current();
104
+ const sendShow = debounce(() => {
105
+ (bridgeRef.current ?? getBridge())
106
+ .invoke(DROP_ZONE_MODULE, 'SHOW', { payload: { zoneId: zoneIdRef.current } })
107
+ .catch((error) => console.error('[shenora] drop-zone SHOW failed:', error));
108
+ }, 100);
109
+ // The element's mouseleave (and the window losing focus) re-arm the overlay — native
110
+ // MouseLeave alone is unreliable through the WebView.
111
+ const onMouseLeave = () => sendShow();
112
+ const onWindowBlur = () => sendShow();
113
+ element.addEventListener('mouseleave', onMouseLeave);
114
+ window.addEventListener('blur', onWindowBlur);
115
+ // Guarded construction: test DOMs (jsdom) lack the observers; real WebView2 always has them.
116
+ const resizeObserver = typeof ResizeObserver !== 'undefined'
117
+ ? new ResizeObserver(() => syncBounds())
118
+ : undefined;
119
+ const intersectionObserver = typeof IntersectionObserver !== 'undefined'
120
+ ? new IntersectionObserver(() => syncBounds(), { threshold: [0, 0.1, 0.5, 0.9, 1.0] })
121
+ : undefined;
122
+ resizeObserver?.observe(element);
123
+ intersectionObserver?.observe(element);
124
+ window.addEventListener('scroll', syncBounds, true);
125
+ window.addEventListener('resize', syncBounds);
126
+ return () => {
127
+ sendShow.cancel();
128
+ syncBounds.cancel();
129
+ resizeObserver?.disconnect();
130
+ intersectionObserver?.disconnect();
131
+ window.removeEventListener('scroll', syncBounds, true);
132
+ window.removeEventListener('resize', syncBounds);
133
+ window.removeEventListener('blur', onWindowBlur);
134
+ element.removeEventListener('mouseleave', onMouseLeave);
135
+ element.removeAttribute('data-drop-zone-id');
136
+ // Unregister whenever this effect tears down — on unmount OR when `enabled` flips false —
137
+ // unconditionally (not gated on the REGISTER ack) so an in-flight REGISTER is also torn
138
+ // down. The host's UnregisterZone no-ops if the overlay isn't there yet, and the ordered
139
+ // IPC channel processes the earlier REGISTER before this UNREGISTER (create-then-destroy,
140
+ // no orphan).
141
+ if (attemptedRef.current) {
142
+ (bridgeRef.current ?? getBridge())
143
+ .invoke(DROP_ZONE_MODULE, 'UNREGISTER', { payload: { zoneId: zoneIdRef.current } })
144
+ .catch((error) => console.error('[shenora] drop-zone UNREGISTER failed:', error));
145
+ epochRef.current++; // invalidate any in-flight REGISTER's ack (see epochRef)
146
+ isRegisteredRef.current = false;
147
+ registeringRef.current = false; // a remount must re-send immediately
148
+ attemptedRef.current = false;
149
+ }
150
+ };
151
+ }, [enabled, element]);
152
+ // Drag-hover CSS feedback.
153
+ useEffect(() => {
154
+ if (!enabled || !element)
155
+ return;
156
+ const dropClass = dropClassRef.current;
157
+ const offEnter = bus.subscribe(DROP_ZONE_MODULE, 'DRAG_ENTER', (event) => {
158
+ if (event.payload?.zoneId === zoneIdRef.current)
159
+ element.classList.add(dropClass);
160
+ });
161
+ const offLeave = bus.subscribe(DROP_ZONE_MODULE, 'DRAG_LEAVE', (event) => {
162
+ if (event.payload?.zoneId === zoneIdRef.current)
163
+ element.classList.remove(dropClass);
164
+ });
165
+ return () => {
166
+ offEnter();
167
+ offLeave();
168
+ element.classList.remove(dropClass);
169
+ };
170
+ }, [enabled, element, bus]);
171
+ // File drops.
172
+ useEffect(() => {
173
+ if (!enabled)
174
+ return;
175
+ return bus.subscribe(DROP_ZONE_MODULE, 'FILE_DROP', (event) => {
176
+ const drop = event.payload;
177
+ if (!drop || drop.zoneId !== zoneIdRef.current)
178
+ return;
179
+ targetRef.current?.classList.remove(dropClassRef.current);
180
+ onDropRef.current(drop.files, drop);
181
+ });
182
+ }, [enabled, targetRef, bus]);
183
+ }
@@ -0,0 +1,80 @@
1
+ import type { ShenoraBridge } from './bridge.js';
2
+ import { BaseModuleService } from './moduleService.js';
3
+ /** The top resize edges — the only ones that exist: the frameless technique keeps the native
4
+ * side/bottom resize borders, so only the top (covered by the WebView) needs page-side help. */
5
+ export type WindowResizeEdge = 'top' | 'topLeft' | 'topRight';
6
+ /** Which system caption button a page-drawn region stands in for (mirrors the host's enum). */
7
+ export type CaptionButtonKind = 'minimize' | 'maximize' | 'close';
8
+ /**
9
+ * Where the page drew one caption button, in CSS px relative to the WebView2 — i.e. straight out of
10
+ * `getBoundingClientRect()`. The host converts to physical px using the control's DeviceDpi.
11
+ */
12
+ export interface CaptionButtonRect {
13
+ kind: CaptionButtonKind;
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ }
19
+ interface WindowRequests {
20
+ MINIMIZE: void;
21
+ TOGGLE_MAXIMIZE: void;
22
+ CLOSE: void;
23
+ IS_MAXIMIZED: void;
24
+ START_DRAG: void;
25
+ START_RESIZE: {
26
+ edge: WindowResizeEdge;
27
+ };
28
+ SET_THEME: {
29
+ dark: boolean;
30
+ };
31
+ SET_CAPTION_BUTTONS: {
32
+ buttons: CaptionButtonRect[];
33
+ };
34
+ }
35
+ /**
36
+ * Typed client for the host's `WINDOW` module (`WindowCommandFacade` in Shenora.WebView2) —
37
+ * drive the frameless window's chrome from the page: chrome buttons call
38
+ * `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
39
+ * then drags natively — snap and multi-monitor included), and a thin strip at the very top
40
+ * calls `startResize` on mousedown. `setTheme` resyncs the native chrome on a runtime
41
+ * light↔dark switch (without it the frame keeps the old theme's border; measured host-side).
42
+ */
43
+ export declare class WindowCommands extends BaseModuleService<WindowRequests> {
44
+ constructor(bridge?: ShenoraBridge);
45
+ minimize(): Promise<void>;
46
+ toggleMaximize(): Promise<void>;
47
+ close(): Promise<void>;
48
+ /** Authoritative maximize state (a frameless manual maximize never shows in the DOM). */
49
+ isMaximized(): Promise<boolean>;
50
+ /** Call from the header's `onMouseDown` — hands off to the OS move loop. */
51
+ startDrag(): Promise<void>;
52
+ /** Call from the top strip's `onMouseDown` — hands off to the OS size loop. */
53
+ startResize(edge?: WindowResizeEdge): Promise<void>;
54
+ /** Resync the native chrome to the app theme (host `WindowCommandOptions.ApplyTheme`). */
55
+ setTheme(dark: boolean): Promise<void>;
56
+ /**
57
+ * Tell the host where the page drew its caption buttons, so the OS can treat them as the real
58
+ * thing — chiefly so Windows 11 offers **Snap Layouts** on the maximize button, which a page-drawn
59
+ * button never gets otherwise.
60
+ *
61
+ * Two consequences worth knowing before calling this. The host takes over CLICKS in those rects
62
+ * (the OS stops delivering them to the page), so your `onClick` handlers stop firing there — the
63
+ * host performs minimize/maximize/close itself, through the same commands. And CSS `:hover` stops
64
+ * firing too, so subscribe to the host's caption-button state to render hot/pressed; it is also the
65
+ * only way to stay hot while the pointer is over the snap flyout, which is a different window.
66
+ *
67
+ * Re-send on every layout change (a resize, a theme that changes button size): the rectangles are a
68
+ * snapshot, and a stale one moves the hit-test off the button the user can see. Pass an empty array
69
+ * to hand every pixel back to the page.
70
+ */
71
+ setCaptionButtons(buttons: CaptionButtonRect[]): Promise<void>;
72
+ }
73
+ /**
74
+ * The max/restore-glyph resync pattern from the source app: the authoritative maximize state,
75
+ * re-queried on every window resize (a maximize/restore always resizes the window, and the DOM
76
+ * has no other signal for the manual work-area maximize). Failures (plain browser, no host)
77
+ * leave it false.
78
+ */
79
+ export declare function useWindowMaximized(commands?: WindowCommands): boolean;
80
+ export {};
@@ -0,0 +1,94 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { debounce } from './internal.js';
3
+ import { BaseModuleService } from './moduleService.js';
4
+ /**
5
+ * Typed client for the host's `WINDOW` module (`WindowCommandFacade` in Shenora.WebView2) —
6
+ * drive the frameless window's chrome from the page: chrome buttons call
7
+ * `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
8
+ * then drags natively — snap and multi-monitor included), and a thin strip at the very top
9
+ * calls `startResize` on mousedown. `setTheme` resyncs the native chrome on a runtime
10
+ * light↔dark switch (without it the frame keeps the old theme's border; measured host-side).
11
+ */
12
+ export class WindowCommands extends BaseModuleService {
13
+ constructor(bridge) {
14
+ super('WINDOW', bridge);
15
+ }
16
+ minimize() {
17
+ return this.send('MINIMIZE');
18
+ }
19
+ toggleMaximize() {
20
+ return this.send('TOGGLE_MAXIMIZE');
21
+ }
22
+ close() {
23
+ return this.send('CLOSE');
24
+ }
25
+ /** Authoritative maximize state (a frameless manual maximize never shows in the DOM). */
26
+ async isMaximized() {
27
+ const result = await this.send('IS_MAXIMIZED');
28
+ return result.maximized;
29
+ }
30
+ /** Call from the header's `onMouseDown` — hands off to the OS move loop. */
31
+ startDrag() {
32
+ return this.send('START_DRAG');
33
+ }
34
+ /** Call from the top strip's `onMouseDown` — hands off to the OS size loop. */
35
+ startResize(edge = 'top') {
36
+ return this.send('START_RESIZE', { payload: { edge } });
37
+ }
38
+ /** Resync the native chrome to the app theme (host `WindowCommandOptions.ApplyTheme`). */
39
+ setTheme(dark) {
40
+ return this.send('SET_THEME', { payload: { dark } });
41
+ }
42
+ /**
43
+ * Tell the host where the page drew its caption buttons, so the OS can treat them as the real
44
+ * thing — chiefly so Windows 11 offers **Snap Layouts** on the maximize button, which a page-drawn
45
+ * button never gets otherwise.
46
+ *
47
+ * Two consequences worth knowing before calling this. The host takes over CLICKS in those rects
48
+ * (the OS stops delivering them to the page), so your `onClick` handlers stop firing there — the
49
+ * host performs minimize/maximize/close itself, through the same commands. And CSS `:hover` stops
50
+ * firing too, so subscribe to the host's caption-button state to render hot/pressed; it is also the
51
+ * only way to stay hot while the pointer is over the snap flyout, which is a different window.
52
+ *
53
+ * Re-send on every layout change (a resize, a theme that changes button size): the rectangles are a
54
+ * snapshot, and a stale one moves the hit-test off the button the user can see. Pass an empty array
55
+ * to hand every pixel back to the page.
56
+ */
57
+ setCaptionButtons(buttons) {
58
+ return this.send('SET_CAPTION_BUTTONS', { payload: { buttons } });
59
+ }
60
+ }
61
+ /**
62
+ * The max/restore-glyph resync pattern from the source app: the authoritative maximize state,
63
+ * re-queried on every window resize (a maximize/restore always resizes the window, and the DOM
64
+ * has no other signal for the manual work-area maximize). Failures (plain browser, no host)
65
+ * leave it false.
66
+ */
67
+ export function useWindowMaximized(commands) {
68
+ const [maximized, setMaximized] = useState(false);
69
+ const defaultCommands = useRef(undefined);
70
+ useEffect(() => {
71
+ const target = commands ?? (defaultCommands.current ?? (defaultCommands.current = new WindowCommands()));
72
+ let stale = false;
73
+ const query = () => {
74
+ target.isMaximized().then((value) => {
75
+ if (!stale)
76
+ setMaximized(value);
77
+ }, () => { });
78
+ };
79
+ // DEBOUNCED (P5.5 H2). `resize` fires continuously while a window is dragged — roughly 180 events
80
+ // over a 3-second drag — and each one used to start a full IPC round-trip, every one of them
81
+ // arming a 30-second timeout timer. The state that matters only changes at the END of a resize
82
+ // (maximize/restore is a single step), so the trailing edge is not just cheaper, it is the correct
83
+ // semantics. 100 ms matches the drop-zone bounds sync.
84
+ const refresh = debounce(query, 100);
85
+ query(); // the initial read is immediate — nothing to coalesce yet
86
+ window.addEventListener('resize', refresh);
87
+ return () => {
88
+ stale = true;
89
+ refresh.cancel(); // a pending timer must not fire against an unmounted component
90
+ window.removeEventListener('resize', refresh);
91
+ };
92
+ }, [commands]);
93
+ return maximized;
94
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@shenora/react",
3
+ "version": "0.1.0",
4
+ "description": "React client for Shenora desktop hosts: correlated invoke/send/subscribe over the WebView2 bridge, typed module services, hooks, and a browser fallback for pure-UI development.",
5
+ "license": "MIT",
6
+ "author": "Jiarong Gu",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/JiarongGu/Shenora.git",
10
+ "directory": "src/Shenora.React"
11
+ },
12
+ "homepage": "https://github.com/JiarongGu/Shenora#readme",
13
+ "keywords": [
14
+ "shenora",
15
+ "webview2",
16
+ "desktop",
17
+ "ipc",
18
+ "react",
19
+ "winforms",
20
+ "dotnet"
21
+ ],
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "sideEffects": false,
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "build": "npm run clean && tsc -p tsconfig.build.json",
43
+ "prepublishOnly": "npm run build",
44
+ "//typecheck": "The ONLY thing that type-checks the tests — `build` excludes them and vitest transpiles without checking, so `@ts-expect-error` assertions (which pin the typed-service generic) are inert without this. Run by dev.mjs verify.",
45
+ "typecheck": "tsc -p tsconfig.json",
46
+ "test": "vitest run",
47
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
48
+ },
49
+ "peerDependencies": {
50
+ "react": ">=18"
51
+ },
52
+ "devDependencies": {
53
+ "@testing-library/react": "^16.3.2",
54
+ "@types/react": "^19.2.17",
55
+ "jsdom": "^29.1.1",
56
+ "react": "^19.2.8",
57
+ "react-dom": "^19.2.8",
58
+ "typescript": "^5.9.0",
59
+ "vitest": "^3.2.0"
60
+ }
61
+ }