phonux 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.
- package/DESIGN.md +985 -0
- package/LICENSE +21 -0
- package/Panel.d.ts +76 -0
- package/Panel.js +45 -0
- package/PanelFrame.d.ts +154 -0
- package/PanelFrame.js +160 -0
- package/PanelRow.d.ts +46 -0
- package/PanelRow.js +98 -0
- package/PanelRowSlot.d.ts +74 -0
- package/PanelRowSlot.js +68 -0
- package/PhoneDetectPrompt.d.ts +18 -0
- package/PhoneDetectPrompt.js +52 -0
- package/README.md +86 -0
- package/Workspace.d.ts +65 -0
- package/Workspace.js +47 -0
- package/defaultTheme.d.ts +10 -0
- package/defaultTheme.js +26 -0
- package/directionalTransition.d.ts +33 -0
- package/directionalTransition.js +24 -0
- package/dragToClose.d.ts +60 -0
- package/dragToClose.js +151 -0
- package/fakeHost.d.ts +33 -0
- package/fakeHost.js +85 -0
- package/hostApi.d.ts +247 -0
- package/hostApi.js +54 -0
- package/index.d.ts +46 -0
- package/index.js +30 -0
- package/package.json +30 -0
- package/panelCapacity.d.ts +15 -0
- package/panelCapacity.js +19 -0
- package/panelRowEntry.d.ts +37 -0
- package/panelRowEntry.js +11 -0
- package/panelRowLayout.d.ts +57 -0
- package/panelRowLayout.js +52 -0
- package/panelRowOrder.d.ts +46 -0
- package/panelRowOrder.js +105 -0
- package/panelTiers.d.ts +24 -0
- package/panelTiers.js +29 -0
- package/panelWindow.d.ts +170 -0
- package/panelWindow.js +243 -0
- package/panels/usePanelClosing.d.ts +58 -0
- package/panels/usePanelClosing.js +140 -0
- package/panels/usePanelManager.d.ts +74 -0
- package/panels/usePanelManager.js +403 -0
- package/panels/useProvidePanels.d.ts +82 -0
- package/panels/useProvidePanels.js +142 -0
- package/panels/useRowScrollGesture.d.ts +2 -0
- package/panels/useRowScrollGesture.js +70 -0
- package/panels/useWorkspacePersistence.d.ts +14 -0
- package/panels/useWorkspacePersistence.js +74 -0
- package/phoneModels.d.ts +26 -0
- package/phoneModels.js +43 -0
- package/snapshots.d.ts +58 -0
- package/snapshots.js +22 -0
- package/viewRegistry.d.ts +23 -0
- package/viewRegistry.js +30 -0
- package/viewState.d.ts +32 -0
- package/viewState.js +135 -0
- package/windowOverlay.d.ts +87 -0
- package/windowOverlay.js +137 -0
- package/workspaceState.d.ts +63 -0
- package/workspaceState.js +95 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The row's scroll gesture: a wheel swipe or an arrow key moves the row one step, bringing a parked panel back from
|
|
3
|
+
* whichever side it sits on.
|
|
4
|
+
*
|
|
5
|
+
* Native `addEventListener`, not React's `onWheel`: React's wheel listener is passive, so the `preventDefault()` that
|
|
6
|
+
* keeps a horizontal swipe from also scrolling the page vertically would do nothing.
|
|
7
|
+
*
|
|
8
|
+
* `scrollRow` is read through a ref and never re-subscribed: it is a fresh closure every render (nothing here may be
|
|
9
|
+
* memoised), and tearing the listeners down would lose the momentum lock's timer on every re-render, including the
|
|
10
|
+
* one `scrollRow` itself causes.
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect, useRef } from 'react';
|
|
13
|
+
/** A trackpad swipe arrives as a burst of small wheel deltas with a momentum tail; Chromium cannot tell a momentum event from a fresh user one. Locking on the first event of a burst and unlocking only after this long of silence (not a shorter one -- gaps inside a real momentum stream can exceed 150 ms) keeps one swipe to one step. */
|
|
14
|
+
const GESTURE_LOCK_MS = 350;
|
|
15
|
+
export function useRowScrollGesture(overflowContainerRef, scrollRow) {
|
|
16
|
+
const scrollRowRef = useRef(scrollRow);
|
|
17
|
+
scrollRowRef.current = scrollRow;
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const container = overflowContainerRef.current;
|
|
20
|
+
if (!container)
|
|
21
|
+
return;
|
|
22
|
+
let locked = false;
|
|
23
|
+
let unlockTimer = null;
|
|
24
|
+
const armUnlock = () => {
|
|
25
|
+
if (unlockTimer)
|
|
26
|
+
clearTimeout(unlockTimer);
|
|
27
|
+
unlockTimer = setTimeout(() => {
|
|
28
|
+
locked = false;
|
|
29
|
+
}, GESTURE_LOCK_MS);
|
|
30
|
+
};
|
|
31
|
+
const onWheel = (e) => {
|
|
32
|
+
// A tilt wheel or two-finger swipe reports deltaX; Shift+wheel usually still reports deltaY, so treat
|
|
33
|
+
// Shift+deltaY as a horizontal ask too. Plain vertical wheel is left to overflowY:'auto'.
|
|
34
|
+
const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY) || (e.shiftKey && e.deltaY !== 0);
|
|
35
|
+
if (!horizontal)
|
|
36
|
+
return;
|
|
37
|
+
const amount = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
|
38
|
+
if (amount === 0)
|
|
39
|
+
return;
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
if (!locked) {
|
|
42
|
+
locked = true;
|
|
43
|
+
scrollRowRef.current(amount > 0 ? 'right' : 'left');
|
|
44
|
+
}
|
|
45
|
+
armUnlock();
|
|
46
|
+
};
|
|
47
|
+
const onKeyDown = (e) => {
|
|
48
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')
|
|
49
|
+
return;
|
|
50
|
+
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)
|
|
51
|
+
return;
|
|
52
|
+
const target = e.target;
|
|
53
|
+
const tag = target?.tagName;
|
|
54
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable)
|
|
55
|
+
return;
|
|
56
|
+
scrollRowRef.current(e.key === 'ArrowRight' ? 'right' : 'left');
|
|
57
|
+
};
|
|
58
|
+
container.addEventListener('wheel', onWheel, { passive: false });
|
|
59
|
+
window.addEventListener('keydown', onKeyDown);
|
|
60
|
+
return () => {
|
|
61
|
+
container.removeEventListener('wheel', onWheel);
|
|
62
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
63
|
+
if (unlockTimer)
|
|
64
|
+
clearTimeout(unlockTimer);
|
|
65
|
+
};
|
|
66
|
+
// `overflowContainerRef` is a stable ref object (a useRef in the host's row shell): this effect attaches once,
|
|
67
|
+
// at mount, and reads the latest `scrollRow` through `scrollRowRef` instead of re-subscribing to it.
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
}, [overflowContainerRef]);
|
|
70
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
2
|
+
import type { JsonValue } from '../hostApi.js';
|
|
3
|
+
import type { PanelWindow } from '../panelWindow.js';
|
|
4
|
+
import type { WorkspaceStorage } from '../workspaceState.js';
|
|
5
|
+
/** How long the panel list must stay unchanged before it is saved. A change inside that time restarts it. */
|
|
6
|
+
export declare const WORKSPACE_SAVE_QUIET_MS = 500;
|
|
7
|
+
export declare function useWorkspacePersistence(i: {
|
|
8
|
+
storage: WorkspaceStorage | undefined;
|
|
9
|
+
panels: PanelWindow[];
|
|
10
|
+
setPanels: Dispatch<SetStateAction<PanelWindow[]>>;
|
|
11
|
+
rootId: string;
|
|
12
|
+
freshId: () => string;
|
|
13
|
+
defaultHostData: (() => JsonValue) | undefined;
|
|
14
|
+
}): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps the panel list between launches through a host's `WorkspaceStorage`: one restore at mount, then a
|
|
3
|
+
* debounced save of every committed change. With no storage it does nothing: no state, no promise, no timer.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { computeRowOrder } from '../panelRowOrder.js';
|
|
7
|
+
import { hydrateWorkspaceState, isWorkspaceState, projectWorkspaceState } from '../workspaceState.js';
|
|
8
|
+
/** How long the panel list must stay unchanged before it is saved. A change inside that time restarts it. */
|
|
9
|
+
export const WORKSPACE_SAVE_QUIET_MS = 500;
|
|
10
|
+
export function useWorkspacePersistence(i) {
|
|
11
|
+
const { panels, setPanels } = i;
|
|
12
|
+
// Everything but the list itself is read once, at mount.
|
|
13
|
+
const { storage, rootId, freshId, defaultHostData } = useRef(i).current;
|
|
14
|
+
// State, not a ref: opening the gate must re-run the save effect, or a change committed while load() was
|
|
15
|
+
// pending (with nothing to restore) would never be saved.
|
|
16
|
+
const [settled, setSettled] = useState(false);
|
|
17
|
+
// The JSON last saved or restored; a projection equal to it is not saved again.
|
|
18
|
+
const lastSaved = useRef(null);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (storage === undefined)
|
|
21
|
+
return;
|
|
22
|
+
// StrictMode runs this effect twice; only the run that is not cancelled may apply its result.
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
// The executor runs at once, so a load() that throws reads as a rejection: no saved state.
|
|
25
|
+
new Promise((resolve) => resolve(storage.load())).then((loaded) => {
|
|
26
|
+
if (cancelled)
|
|
27
|
+
return;
|
|
28
|
+
let restored = [];
|
|
29
|
+
if (loaded !== null) {
|
|
30
|
+
if (isWorkspaceState(loaded)) {
|
|
31
|
+
// Drawn here, never inside an updater: React may run an updater twice.
|
|
32
|
+
restored = hydrateWorkspaceState(loaded, freshId, rootId).map((p) => {
|
|
33
|
+
const hostData = defaultHostData?.();
|
|
34
|
+
return hostData === undefined ? p : { ...p, hostData };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
console.warn('Ignored the saved workspace: it is not a version 1 workspace file.');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// The baseline is what the restored list projects to, not the file: saved ids differ from fresh ones,
|
|
42
|
+
// and comparing against the file would save at every launch.
|
|
43
|
+
lastSaved.current = JSON.stringify(projectWorkspaceState(restored, rootId));
|
|
44
|
+
// Appended after what is already there, so a panel created while load() was pending survives.
|
|
45
|
+
if (restored.length > 0)
|
|
46
|
+
setPanels((prev) => computeRowOrder([...prev, ...restored], rootId));
|
|
47
|
+
setSettled(true);
|
|
48
|
+
}, () => {
|
|
49
|
+
if (cancelled)
|
|
50
|
+
return;
|
|
51
|
+
lastSaved.current = JSON.stringify(projectWorkspaceState([], rootId));
|
|
52
|
+
setSettled(true);
|
|
53
|
+
});
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
};
|
|
57
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount only: the inputs are read once
|
|
58
|
+
}, []);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (storage === undefined || !settled)
|
|
61
|
+
return;
|
|
62
|
+
const state = projectWorkspaceState(panels, rootId);
|
|
63
|
+
const json = JSON.stringify(state);
|
|
64
|
+
if (json === lastSaved.current)
|
|
65
|
+
return;
|
|
66
|
+
// No flush on unmount or quit: a change in the last quiet period is lost, never a torn write.
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
lastSaved.current = json;
|
|
69
|
+
storage.save(state);
|
|
70
|
+
}, WORKSPACE_SAVE_QUIET_MS);
|
|
71
|
+
return () => clearTimeout(timer);
|
|
72
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- storage and rootId never change
|
|
73
|
+
}, [panels, settled]);
|
|
74
|
+
}
|
package/phoneModels.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A known phone model and its CSS-pixel frame size, as PHONE_MODELS lists them for the Settings dropdown and
|
|
3
|
+
* the USB-detect prompt.
|
|
4
|
+
* @public
|
|
5
|
+
*/
|
|
6
|
+
export interface PhoneModel {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Hand-copied from phonux-app's vendor/device-specs/viewport-sizes.json; keep
|
|
14
|
+
* the two in sync by hand (that directory's README.md says which entries are verbatim). No 'default' entry:
|
|
15
|
+
* that is DEFAULT_PHONE_SIZE (hostApi.tsx), so upgrading never resizes anyone who has not chosen a model.
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export declare const PHONE_MODELS: readonly PhoneModel[];
|
|
19
|
+
/** @public */
|
|
20
|
+
export declare function findPhoneModel(id: string): PhoneModel | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* A short, friendly noun phrase for a just-detected USB vendor id, for the connect prompt's copy. Never more
|
|
23
|
+
* specific than "iPhone" or "Android phone": a bare USB vendor/product id cannot tell the exact model.
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
export declare function vendorLabelForUsbVendorId(vendorId: number): string;
|
package/phoneModels.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A known phone model and its CSS-pixel frame size, as PHONE_MODELS lists them for the Settings dropdown and
|
|
3
|
+
* the USB-detect prompt.
|
|
4
|
+
* @public
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Hand-copied from phonux-app's vendor/device-specs/viewport-sizes.json; keep
|
|
8
|
+
* the two in sync by hand (that directory's README.md says which entries are verbatim). No 'default' entry:
|
|
9
|
+
* that is DEFAULT_PHONE_SIZE (hostApi.tsx), so upgrading never resizes anyone who has not chosen a model.
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
export const PHONE_MODELS = [
|
|
13
|
+
{ id: 'iphone8', label: 'iPhone 8 / SE (2nd/3rd gen) (375 × 667)', width: 375, height: 667 },
|
|
14
|
+
{ id: 'iphonex', label: 'iPhone X (375 × 812)', width: 375, height: 812 },
|
|
15
|
+
{ id: 'iphonexr', label: 'iPhone XR (414 × 896)', width: 414, height: 896 },
|
|
16
|
+
{ id: 'iphone11', label: 'iPhone 11 (414 × 896)', width: 414, height: 896 },
|
|
17
|
+
{ id: 'iphone1214', label: 'iPhone 12/13/14 (390 × 844)', width: 390, height: 844 },
|
|
18
|
+
{ id: 'iphonepromax', label: 'iPhone 14/15 Pro Max (430 × 932)', width: 430, height: 932 },
|
|
19
|
+
{ id: 'samsunggalaxys10', label: 'Samsung Galaxy S10 (360 × 740)', width: 360, height: 740 },
|
|
20
|
+
{ id: 'galaxys21', label: 'Samsung Galaxy S21/S22 (360 × 800)', width: 360, height: 800 },
|
|
21
|
+
{ id: 'galaxys23ultra', label: 'Samsung Galaxy S23 Ultra (384 × 824)', width: 384, height: 824 },
|
|
22
|
+
{ id: 'googlepixel3', label: 'Google Pixel 3 (393 × 786)', width: 393, height: 786 },
|
|
23
|
+
{ id: 'pixel67', label: 'Google Pixel 6/7 (412 × 915)', width: 412, height: 915 },
|
|
24
|
+
{ id: 'pixel8pro', label: 'Google Pixel 8 Pro (412 × 892)', width: 412, height: 892 },
|
|
25
|
+
{ id: 'oneplus11', label: 'OnePlus 11 (412 × 919)', width: 412, height: 919 },
|
|
26
|
+
];
|
|
27
|
+
/** @public */
|
|
28
|
+
export function findPhoneModel(id) {
|
|
29
|
+
return PHONE_MODELS.find((m) => m.id === id);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A short, friendly noun phrase for a just-detected USB vendor id, for the connect prompt's copy. Never more
|
|
33
|
+
* specific than "iPhone" or "Android phone": a bare USB vendor/product id cannot tell the exact model.
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
export function vendorLabelForUsbVendorId(vendorId) {
|
|
37
|
+
if (vendorId === 0x05ac)
|
|
38
|
+
return 'an iPhone';
|
|
39
|
+
const knownAndroidVendors = new Set([0x18d1, 0x04e8, 0x12d1, 0x2717, 0x2a70, 0x0bb4, 0x22b8, 0x1004, 0x0fce, 0x2ae5]);
|
|
40
|
+
if (knownAndroidVendors.has(vendorId))
|
|
41
|
+
return 'an Android phone';
|
|
42
|
+
return 'a phone';
|
|
43
|
+
}
|
package/snapshots.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A view's picture, captured only by the host at fixed moments (park/close, History opening), never by a view.
|
|
3
|
+
* Why the DOM-redraw engine lives in the host, not here: DESIGN.md, "Snapshots: a picture is data,
|
|
4
|
+
* capturing one is not".
|
|
5
|
+
*/
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
/** A panel's PanelSummary.id, or a fixed id ('settings' | 'history') for the non-panel columns. @public */
|
|
8
|
+
export type ViewId = string;
|
|
9
|
+
/** @public */
|
|
10
|
+
export interface ViewSnapshot {
|
|
11
|
+
readonly viewId: ViewId;
|
|
12
|
+
/** A data: or blob: URL for an <img>; opaque -- never parsed, never persisted verbatim. */
|
|
13
|
+
readonly src: string;
|
|
14
|
+
readonly width: number;
|
|
15
|
+
/** CSS px of the VIEW photographed, not of the resulting image's pixel dimensions. */
|
|
16
|
+
readonly height: number;
|
|
17
|
+
readonly extent: 'viewport' | 'full';
|
|
18
|
+
/** Epoch ms. */
|
|
19
|
+
readonly capturedAt: number;
|
|
20
|
+
/** Marks a picture outdated by a change the host knows of (phone size, theme, panel content); nothing sets it true yet. */
|
|
21
|
+
readonly stale: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Read-only, and STABLE across renders unlike the rest of Host: a field rebuilt per render would re-render every view on each snapshot, not just the one showing it. @public */
|
|
24
|
+
export interface SnapshotsAPI {
|
|
25
|
+
get(viewId: ViewId): ViewSnapshot | null;
|
|
26
|
+
/** For useSyncExternalStore. */
|
|
27
|
+
subscribe(viewId: ViewId, listener: () => void): () => void;
|
|
28
|
+
}
|
|
29
|
+
/** How a view reads a picture. Re-renders only when ITS viewId's picture changes: subscribe is keyed per id. @public */
|
|
30
|
+
export declare function useViewSnapshot(viewId: ViewId): ViewSnapshot | null;
|
|
31
|
+
/**
|
|
32
|
+
* How Panel hands its DOM node to the capture engine, in its own context and never on Host: every Host member lets a
|
|
33
|
+
* view act on itself or read shared state, but "hand me your DOM node" would expose its subtree to something else.
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
export interface CaptureRegistry {
|
|
37
|
+
/** Returns an unregister function. Registering the same viewId again (e.g. a re-mount, or a body-swap) replaces the previous entry. */
|
|
38
|
+
register(viewId: ViewId, el: HTMLElement, extent: 'viewport' | 'full'): () => void;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* What a host hands phonux to call at a park or close: (re)capture a view's picture, or drop it. phonux only says
|
|
42
|
+
* WHEN; what a capture is stays host code (see the DOM-redraw note above). @public
|
|
43
|
+
*/
|
|
44
|
+
export interface CaptureTrigger {
|
|
45
|
+
/** (Re)captures whatever is registered for this id; a no-op if nothing is. Never throws, even if the engine does. */
|
|
46
|
+
capture(viewId: ViewId): void;
|
|
47
|
+
/** Discard viewId's stored picture, e.g. once its undo window elapses without being restored. A no-op if nothing was stored. */
|
|
48
|
+
evict(viewId: ViewId): void;
|
|
49
|
+
}
|
|
50
|
+
/** The trigger a provider uses when its host supplies none: phonux never produces a real capture itself. @public */
|
|
51
|
+
export declare const noopCaptureTrigger: CaptureTrigger;
|
|
52
|
+
/** @public */
|
|
53
|
+
export declare const CaptureRegistryContext: React.Context<CaptureRegistry | null>;
|
|
54
|
+
/** Rendered only by a host that captures pictures, around HostProvider. Without it (a bare HostProvider, as in most tests) Panel registers nothing. @public */
|
|
55
|
+
export declare function CaptureRegistryProvider({ registry, children }: {
|
|
56
|
+
registry: CaptureRegistry;
|
|
57
|
+
children: React.ReactNode;
|
|
58
|
+
}): React.ReactElement;
|
package/snapshots.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A view's picture, captured only by the host at fixed moments (park/close, History opening), never by a view.
|
|
3
|
+
* Why the DOM-redraw engine lives in the host, not here: DESIGN.md, "Snapshots: a picture is data,
|
|
4
|
+
* capturing one is not".
|
|
5
|
+
*/
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
import { useHost } from './hostApi.js';
|
|
8
|
+
const NO_SNAPSHOT = null;
|
|
9
|
+
/** How a view reads a picture. Re-renders only when ITS viewId's picture changes: subscribe is keyed per id. @public */
|
|
10
|
+
export function useViewSnapshot(viewId) {
|
|
11
|
+
const { snapshots } = useHost();
|
|
12
|
+
// Host.snapshots is optional: a host without it shows no picture and never throws.
|
|
13
|
+
return React.useSyncExternalStore((onStoreChange) => (snapshots ? snapshots.subscribe(viewId, onStoreChange) : () => { }), () => (snapshots ? snapshots.get(viewId) : NO_SNAPSHOT), () => (snapshots ? snapshots.get(viewId) : NO_SNAPSHOT));
|
|
14
|
+
}
|
|
15
|
+
/** The trigger a provider uses when its host supplies none: phonux never produces a real capture itself. @public */
|
|
16
|
+
export const noopCaptureTrigger = { capture: () => { }, evict: () => { } };
|
|
17
|
+
/** @public */
|
|
18
|
+
export const CaptureRegistryContext = React.createContext(null);
|
|
19
|
+
/** Rendered only by a host that captures pictures, around HostProvider. Without it (a bare HostProvider, as in most tests) Panel registers nothing. @public */
|
|
20
|
+
export function CaptureRegistryProvider({ registry, children }) {
|
|
21
|
+
return React.createElement(CaptureRegistryContext.Provider, { value: registry }, children);
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Maps a panel's open-string `view` to the component that renders it. Why the fallback: DESIGN.md, "The panel-view registry". */
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import type { JsonValue, PanelSummary } from './hostApi.js';
|
|
4
|
+
/** A View's own props: what it reads about the panel it renders inside. @public */
|
|
5
|
+
export type ViewProps<P = JsonValue> = {
|
|
6
|
+
panel: PanelSummary<P>;
|
|
7
|
+
};
|
|
8
|
+
/** What any View -- built-in or third-party -- is rendered as. @public */
|
|
9
|
+
export type ViewComponent<P = JsonValue> = React.ComponentType<ViewProps<P>>;
|
|
10
|
+
/** @public @deprecated use ViewComponent */
|
|
11
|
+
export type PanelComponent<P = JsonValue> = ViewComponent<P>;
|
|
12
|
+
/**
|
|
13
|
+
* Registers (or replaces) `key`'s component; this module registers nothing itself. Declare `P` with a `type`,
|
|
14
|
+
* never an `interface`: an interface has no implicit index signature, so it never satisfies `JsonValue`.
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export declare function registerView<P extends JsonValue>(key: string, Component: ViewComponent<P>): void;
|
|
18
|
+
/** @public @deprecated use registerView */
|
|
19
|
+
export declare const registerPanelView: typeof registerView;
|
|
20
|
+
/** Never throws, never returns nothing: an unregistered key is a typo or a plugin missing THIS session, not a corrupt layout. @public */
|
|
21
|
+
export declare function resolveView(key: string): ViewComponent;
|
|
22
|
+
/** @public @deprecated use resolveView */
|
|
23
|
+
export declare const resolvePanelView: typeof resolveView;
|
package/viewRegistry.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
const registry = new Map();
|
|
3
|
+
/** One placeholder instance per unregistered key: AnimatePresence tracks a rendered element's own identity, so a fresh component type on every resolve would remount it on every render instead of once. */
|
|
4
|
+
const placeholders = new Map();
|
|
5
|
+
/**
|
|
6
|
+
* Registers (or replaces) `key`'s component; this module registers nothing itself. Declare `P` with a `type`,
|
|
7
|
+
* never an `interface`: an interface has no implicit index signature, so it never satisfies `JsonValue`.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export function registerView(key, Component) {
|
|
11
|
+
registry.set(key, Component);
|
|
12
|
+
}
|
|
13
|
+
/** @public @deprecated use registerView */
|
|
14
|
+
export const registerPanelView = registerView;
|
|
15
|
+
function placeholderFor(key) {
|
|
16
|
+
const cached = placeholders.get(key);
|
|
17
|
+
if (cached)
|
|
18
|
+
return cached;
|
|
19
|
+
function UnregisteredPanelPlaceholder({ panel }) {
|
|
20
|
+
return (_jsxs("div", { "data-unregistered-panel-view": key, children: [_jsx("span", { children: panel.title ?? 'Untitled panel' }), _jsx("span", { "data-unregistered-panel-view-key": "", children: key })] }));
|
|
21
|
+
}
|
|
22
|
+
placeholders.set(key, UnregisteredPanelPlaceholder);
|
|
23
|
+
return UnregisteredPanelPlaceholder;
|
|
24
|
+
}
|
|
25
|
+
/** Never throws, never returns nothing: an unregistered key is a typo or a plugin missing THIS session, not a corrupt layout. @public */
|
|
26
|
+
export function resolveView(key) {
|
|
27
|
+
return registry.get(key) ?? placeholderFor(key);
|
|
28
|
+
}
|
|
29
|
+
/** @public @deprecated use resolveView */
|
|
30
|
+
export const resolvePanelView = resolveView;
|
package/viewState.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useViewState: per-view state that survives the view's own panel being parked (unmounted) and revived.
|
|
3
|
+
* Why it is shaped this way: DESIGN.md, "`useViewState`: per-view state that outlives a park, not a restart".
|
|
4
|
+
*/
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import type { JsonValue } from './hostApi.js';
|
|
7
|
+
/** Thrown by useViewState() unless it renders under BOTH wrappers a host's panel row supplies. @public */
|
|
8
|
+
export declare class ViewStateMissingProviderError extends Error {
|
|
9
|
+
constructor(missing: 'host' | 'view id');
|
|
10
|
+
}
|
|
11
|
+
/** Mounted once by HostProvider, beside OverlayProvider. Not on the barrel: app code never needs its own store. @internal */
|
|
12
|
+
export declare function ViewStateProvider({ children }: {
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
}): React.ReactElement;
|
|
15
|
+
/**
|
|
16
|
+
* useViewState's type, spelled out so the public declaration names none of this file's private helpers.
|
|
17
|
+
* Callers see these call signatures, not useViewStateImpl's, so the JSON bound that counts is the one here.
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
export interface UseViewState {
|
|
21
|
+
<T>(key: [T] extends [JsonValue] ? string : never, initial: T): [T, (next: T | ((prev: T) => T)) => void];
|
|
22
|
+
<T extends JsonValue>(key: string, initial: T): [T, (next: T | ((prev: T) => T)) => void];
|
|
23
|
+
Provider: React.Provider<string | null>;
|
|
24
|
+
useRetainOnly: (panelIds: Iterable<string>) => void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The one hook a view uses for state that must survive its own panel parking and reviving: same
|
|
28
|
+
* `[value, setValue]` pair and same type inference as `React.useState`, but `initial` must be JSON-shaped.
|
|
29
|
+
* `Provider` and `useRetainOnly` are the host's side (PanelRow.tsx), never called by a view.
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
export declare const useViewState: UseViewState;
|
package/viewState.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useViewState: per-view state that survives the view's own panel being parked (unmounted) and revived.
|
|
3
|
+
* Why it is shaped this way: DESIGN.md, "`useViewState`: per-view state that outlives a park, not a restart".
|
|
4
|
+
*/
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
/** Ambient id of the panel currently rendering; read only by useViewState, set only by useViewState.Provider. */
|
|
7
|
+
const ViewIdContext = React.createContext(null);
|
|
8
|
+
/** Thrown by useViewState() unless it renders under BOTH wrappers a host's panel row supplies. @public */
|
|
9
|
+
export class ViewStateMissingProviderError extends Error {
|
|
10
|
+
constructor(missing) {
|
|
11
|
+
super(`useViewState() was called ${missing === 'host' ? 'outside a <HostProvider>, which holds the state' : 'with no ambient view id'}. ` +
|
|
12
|
+
'A view must render where its host mounts it, under both <HostProvider> and <useViewState.Provider value={panelId}>; ' +
|
|
13
|
+
'a test calling a view\'s hooks directly must wrap it in both: ' +
|
|
14
|
+
'<HostProvider host={createFakeHost()}><useViewState.Provider value="some-id">...</useViewState.Provider></HostProvider>.');
|
|
15
|
+
this.name = 'ViewStateMissingProviderError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Keyed by panel id first, so two views that pick the same key never share a cell. */
|
|
19
|
+
class ViewStateStore {
|
|
20
|
+
panels = new Map();
|
|
21
|
+
/** Released while a view was still mounted (mid exit animation): dropped once its last listener leaves. */
|
|
22
|
+
released = new Set();
|
|
23
|
+
cellFor(panelId, key) {
|
|
24
|
+
let byKey = this.panels.get(panelId);
|
|
25
|
+
if (!byKey) {
|
|
26
|
+
byKey = new Map();
|
|
27
|
+
this.panels.set(panelId, byKey);
|
|
28
|
+
}
|
|
29
|
+
let cell = byKey.get(key);
|
|
30
|
+
if (!cell) {
|
|
31
|
+
cell = { value: undefined, listeners: new Set() };
|
|
32
|
+
byKey.set(key, cell);
|
|
33
|
+
}
|
|
34
|
+
return cell;
|
|
35
|
+
}
|
|
36
|
+
get(panelId, key) {
|
|
37
|
+
return this.panels.get(panelId)?.get(key)?.value;
|
|
38
|
+
}
|
|
39
|
+
set(panelId, key, value) {
|
|
40
|
+
const cell = this.cellFor(panelId, key);
|
|
41
|
+
if (cell.value === value)
|
|
42
|
+
return;
|
|
43
|
+
cell.value = value;
|
|
44
|
+
for (const listener of [...cell.listeners])
|
|
45
|
+
listener();
|
|
46
|
+
}
|
|
47
|
+
subscribe(panelId, key, listener) {
|
|
48
|
+
const cell = this.cellFor(panelId, key);
|
|
49
|
+
cell.listeners.add(listener);
|
|
50
|
+
return () => {
|
|
51
|
+
cell.listeners.delete(listener);
|
|
52
|
+
this.dropIfReleased(panelId);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
hasPanelOutside(keep) {
|
|
56
|
+
for (const panelId of this.panels.keys())
|
|
57
|
+
if (!keep.has(panelId))
|
|
58
|
+
return true;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
/** Never notifies: a still-mounted view keeps showing its value until it unmounts. */
|
|
62
|
+
releaseAllExcept(keep) {
|
|
63
|
+
for (const panelId of this.released)
|
|
64
|
+
if (keep.has(panelId))
|
|
65
|
+
this.released.delete(panelId);
|
|
66
|
+
for (const panelId of [...this.panels.keys()]) {
|
|
67
|
+
if (keep.has(panelId))
|
|
68
|
+
continue;
|
|
69
|
+
this.released.add(panelId);
|
|
70
|
+
this.dropIfReleased(panelId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
dropIfReleased(panelId) {
|
|
74
|
+
if (!this.released.has(panelId))
|
|
75
|
+
return;
|
|
76
|
+
for (const cell of this.panels.get(panelId)?.values() ?? [])
|
|
77
|
+
if (cell.listeners.size > 0)
|
|
78
|
+
return;
|
|
79
|
+
this.panels.delete(panelId);
|
|
80
|
+
this.released.delete(panelId);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const StoreContext = React.createContext(null);
|
|
84
|
+
/** Mounted once by HostProvider, beside OverlayProvider. Not on the barrel: app code never needs its own store. @internal */
|
|
85
|
+
export function ViewStateProvider({ children }) {
|
|
86
|
+
const [store] = React.useState(() => new ViewStateStore()); // pure constructor: StrictMode's double init is harmless
|
|
87
|
+
return React.createElement(StoreContext.Provider, { value: store }, children);
|
|
88
|
+
}
|
|
89
|
+
function useViewStateImpl(key, initial) {
|
|
90
|
+
const panelId = React.useContext(ViewIdContext);
|
|
91
|
+
const store = React.useContext(StoreContext);
|
|
92
|
+
if (store === null)
|
|
93
|
+
throw new ViewStateMissingProviderError('host');
|
|
94
|
+
if (panelId === null)
|
|
95
|
+
throw new ViewStateMissingProviderError('view id');
|
|
96
|
+
// A ref, not `initial` itself: a fresh object each render must neither change the snapshot (useSyncExternalStore
|
|
97
|
+
// needs a stable one) nor the setter's identity (useState's setter never changes).
|
|
98
|
+
const firstInitial = React.useRef(initial);
|
|
99
|
+
const read = React.useCallback(() => {
|
|
100
|
+
const stored = store.get(panelId, key);
|
|
101
|
+
return stored === undefined ? firstInitial.current : stored; // not `??`: null is a real stored value
|
|
102
|
+
}, [store, panelId, key]);
|
|
103
|
+
const subscribe = React.useCallback((onChange) => store.subscribe(panelId, key, onChange), [store, panelId, key]);
|
|
104
|
+
const value = React.useSyncExternalStore(subscribe, read, read);
|
|
105
|
+
const setValue = React.useCallback((next) => {
|
|
106
|
+
// JsonValue never includes a function, so this check can never mistake a real value for an updater.
|
|
107
|
+
store.set(panelId, key, typeof next === 'function' ? next(read()) : next);
|
|
108
|
+
}, [store, panelId, key, read]);
|
|
109
|
+
return [value, setValue];
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* For the host's panel row, once per HostProvider: `panelIds` is every panel that can still be shown again
|
|
113
|
+
* (the row's own list plus `PanelsAPI.recentlyClosed`); every other panel's state is released.
|
|
114
|
+
*/
|
|
115
|
+
function useRetainOnly(panelIds) {
|
|
116
|
+
const store = React.useContext(StoreContext);
|
|
117
|
+
if (store === null)
|
|
118
|
+
throw new ViewStateMissingProviderError('host');
|
|
119
|
+
const keep = new Set(panelIds);
|
|
120
|
+
React.useEffect(() => {
|
|
121
|
+
if (!store.hasPanelOutside(keep))
|
|
122
|
+
return;
|
|
123
|
+
// A task later, and cancelled by any newer commit: undoClose can commit once with a restored id in
|
|
124
|
+
// neither list (recentlyClosed already null) before the commit that puts it back in the row.
|
|
125
|
+
const timer = setTimeout(() => store.releaseAllExcept(keep), 0);
|
|
126
|
+
return () => clearTimeout(timer);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The one hook a view uses for state that must survive its own panel parking and reviving: same
|
|
131
|
+
* `[value, setValue]` pair and same type inference as `React.useState`, but `initial` must be JSON-shaped.
|
|
132
|
+
* `Provider` and `useRetainOnly` are the host's side (PanelRow.tsx), never called by a view.
|
|
133
|
+
* @public
|
|
134
|
+
*/
|
|
135
|
+
export const useViewState = Object.assign(useViewStateImpl, { Provider: ViewIdContext.Provider, useRetainOnly });
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WindowOverlay: how a view shows something over the WINDOW (a dialog, a full-screen viewer, a toast) with no
|
|
3
|
+
* host wiring and no Portal. It registers its children with a store, and the host's two OverlaySlots render
|
|
4
|
+
* them. Usage, why content is hoisted, and the numbered contract the content must keep (the layer adds no
|
|
5
|
+
* wrapper, so it cannot enforce it): DESIGN.md, "`WindowOverlay`: window-level content with no Portal".
|
|
6
|
+
* Not to be confused with Panel's `overlay` prop, which decorates ONE view and is clipped to it.
|
|
7
|
+
*/
|
|
8
|
+
import * as React from 'react';
|
|
9
|
+
/**
|
|
10
|
+
* Where hoisted content renders (a PLACE the host chooses, never the view).
|
|
11
|
+
* 'dialog': the row's first children. Modal-ish, window-sized (scrims, dialogs, full-screen viewers).
|
|
12
|
+
* 'toast': the window root, after the scroll container. Transient notices (snackbars).
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export type OverlaySlotName = 'dialog' | 'toast';
|
|
16
|
+
/**
|
|
17
|
+
* The z-index each slot's content applies to ITS OWN root. WindowOverlay adds no wrapper element,
|
|
18
|
+
* so it cannot set it for you. dialog 2000 clears the host's 1400 drag strip (the Closed Pages viewer);
|
|
19
|
+
* toast 1400 is MUI's snackbar band (the undo toast).
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export declare const OVERLAY_Z: {
|
|
23
|
+
readonly dialog: 2000;
|
|
24
|
+
readonly toast: 1400;
|
|
25
|
+
};
|
|
26
|
+
/** @public */
|
|
27
|
+
export interface WindowOverlayProps {
|
|
28
|
+
/** Default 'dialog'. */
|
|
29
|
+
slot?: OverlaySlotName;
|
|
30
|
+
/**
|
|
31
|
+
* Default true. false = nothing registered, removed IMMEDIATELY. Content that must animate out
|
|
32
|
+
* (MUI Snackbar) stays registered and takes its own `open`.
|
|
33
|
+
*/
|
|
34
|
+
open?: boolean;
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
}
|
|
37
|
+
/** @public */
|
|
38
|
+
export interface Entry {
|
|
39
|
+
slot: OverlaySlotName;
|
|
40
|
+
node: React.ReactNode;
|
|
41
|
+
}
|
|
42
|
+
/** @public */
|
|
43
|
+
export interface Item {
|
|
44
|
+
id: string;
|
|
45
|
+
node: React.ReactNode;
|
|
46
|
+
}
|
|
47
|
+
/** External store, NOT React state: a registration re-renders neither the host nor any view. @public */
|
|
48
|
+
export declare class OverlayStore {
|
|
49
|
+
private entries;
|
|
50
|
+
private listeners;
|
|
51
|
+
private slots;
|
|
52
|
+
private warned;
|
|
53
|
+
private snap;
|
|
54
|
+
/** Count of listener notifications (test seam: pins the same-node short-circuit). */
|
|
55
|
+
notifications: number;
|
|
56
|
+
set(id: string, entry: Entry): void;
|
|
57
|
+
remove(id: string): void;
|
|
58
|
+
mountSlot(name: OverlaySlotName): () => void;
|
|
59
|
+
hasSlot(name: OverlaySlotName): boolean;
|
|
60
|
+
/** True once per slot name per store: the caller logs the "no slot" error only the first time. */
|
|
61
|
+
firstWarning(name: OverlaySlotName): boolean;
|
|
62
|
+
subscribe: (l: () => void) => (() => void);
|
|
63
|
+
read(slot: OverlaySlotName): readonly Item[];
|
|
64
|
+
/** Ids in registration order across both slots (test seam). */
|
|
65
|
+
ids(): string[];
|
|
66
|
+
private rebuild;
|
|
67
|
+
private emit;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Rendered by HostProvider, so a host adds nothing. The store is never part of the app-supplied Host bag
|
|
71
|
+
* (nothing about an overlay is app-specific). `store` is a TEST SEAM: a test passes its own to read `notifications`/`ids()`.
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
export declare function OverlayProvider({ children, store: injected }: {
|
|
75
|
+
children: React.ReactNode;
|
|
76
|
+
store?: OverlayStore;
|
|
77
|
+
}): React.JSX.Element;
|
|
78
|
+
/** Mounted by the HOST once per slot. Emits NO element: hoisted overlays become its parent's direct children. @public */
|
|
79
|
+
export declare const OverlaySlot: React.NamedExoticComponent<{
|
|
80
|
+
name: OverlaySlotName;
|
|
81
|
+
}>;
|
|
82
|
+
/**
|
|
83
|
+
* Render this anywhere in a view, at any depth. Its children appear in the matching OverlaySlot.
|
|
84
|
+
* With no provider, or under renderToStaticMarkup (no effects), the children render IN PLACE instead.
|
|
85
|
+
* @public
|
|
86
|
+
*/
|
|
87
|
+
export declare function WindowOverlay({ slot, open, children }: WindowOverlayProps): React.JSX.Element | null;
|