@monimesl/dot-webview 0.1.0-alpha.10
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 +3 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +249 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/README.md
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ColorScheme, DotEvent, DotEvent as DotEvent$1, Envelope, EventEmitter, HostLayerMessages, LogicLayerMessages, MessageChannel, PageLaunchOptions, PageState, PageState as PageState$1, RenderContext, SystemContext, SystemContext as SystemContext$1, Theme, Theme as Theme$1, Transport, Transport as Transport$1, UILayerMessages, ViewRender, ViewRender as ViewRender$1, hostTransport } from "@monimesl/dot-core";
|
|
2
|
+
//#region src/page.d.ts
|
|
3
|
+
type Dispatch = (instanceId: number, handler: string, event: DotEvent$1) => void;
|
|
4
|
+
export declare class PageController {
|
|
5
|
+
#private;
|
|
6
|
+
readonly pageId: string;
|
|
7
|
+
readonly pageInstanceId: number;
|
|
8
|
+
readonly pageOptions: PageLaunchOptions;
|
|
9
|
+
constructor(instanceId: number, id: string, options: PageLaunchOptions, viewRenderer: ViewRender$1, dispatch: Dispatch);
|
|
10
|
+
get viewRenderer(): ViewRender$1;
|
|
11
|
+
replaceView(view: ViewRender$1): void;
|
|
12
|
+
readonly getState: () => PageState$1 | null;
|
|
13
|
+
readonly subscribe: (listener: () => void) => (() => void);
|
|
14
|
+
on(handler: string, data?: Readonly<Record<string, unknown>>): (event: DotEvent$1) => void;
|
|
15
|
+
initializeState(state: PageState$1): void;
|
|
16
|
+
updateState(changes: PageState$1): void;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/store.d.ts
|
|
20
|
+
interface ReadonlyStore<T> {
|
|
21
|
+
readonly get: () => T;
|
|
22
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
23
|
+
}
|
|
24
|
+
export declare class Store<T> implements ReadonlyStore<T> {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(value: T);
|
|
27
|
+
readonly get: () => T;
|
|
28
|
+
readonly subscribe: (listener: () => void) => (() => void);
|
|
29
|
+
set(value: T): void;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/renderer.d.ts
|
|
33
|
+
interface DotContext {
|
|
34
|
+
readonly theme: ReadonlyStore<Theme$1>;
|
|
35
|
+
readonly systemContext: ReadonlyStore<SystemContext$1>;
|
|
36
|
+
}
|
|
37
|
+
interface Renderer {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
mount(container: Element, page: PageController, dotContext: DotContext): () => void;
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/webview.d.ts
|
|
43
|
+
type WebviewChannel = MessageChannel<UILayerMessages, LogicLayerMessages & HostLayerMessages>;
|
|
44
|
+
interface WebviewHooks {
|
|
45
|
+
pageOpen(page: PageController): void;
|
|
46
|
+
pageClose(page: PageController): void;
|
|
47
|
+
error(error: unknown, message: Envelope): void;
|
|
48
|
+
}
|
|
49
|
+
interface WebviewPlugin {
|
|
50
|
+
readonly name: string;
|
|
51
|
+
setup(webview: Webview): void;
|
|
52
|
+
}
|
|
53
|
+
interface WebviewOptions {
|
|
54
|
+
renderer: Renderer;
|
|
55
|
+
transport: Transport$1;
|
|
56
|
+
plugins?: readonly WebviewPlugin[];
|
|
57
|
+
views: Record<string, ViewRender$1>;
|
|
58
|
+
}
|
|
59
|
+
declare class Webview {
|
|
60
|
+
#private;
|
|
61
|
+
readonly pageRenderer: Renderer;
|
|
62
|
+
readonly channel: WebviewChannel;
|
|
63
|
+
readonly hooks: EventEmitter<WebviewHooks>;
|
|
64
|
+
readonly theme: Store<Theme$1>;
|
|
65
|
+
readonly systemContext: Store<SystemContext$1>;
|
|
66
|
+
constructor({ transport, renderer, views, plugins }: WebviewOptions);
|
|
67
|
+
preparePlugin(plugin: WebviewPlugin): this;
|
|
68
|
+
start(): () => void;
|
|
69
|
+
mount(container: Element): () => void;
|
|
70
|
+
replaceViews(views: Record<string, ViewRender$1>): void;
|
|
71
|
+
get stack(): readonly PageController[];
|
|
72
|
+
locatePage(instanceId: number): PageController | undefined;
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/index.d.ts
|
|
76
|
+
export interface StartOptions extends WebviewOptions {
|
|
77
|
+
container: Element;
|
|
78
|
+
}
|
|
79
|
+
export declare function startWebview({ container, ...options }: StartOptions): Webview;
|
|
80
|
+
export interface RunWebviewOptions {
|
|
81
|
+
renderer: () => Renderer;
|
|
82
|
+
views: Record<string, ViewRender$1>;
|
|
83
|
+
}
|
|
84
|
+
export declare function runWebview({ renderer, views }: RunWebviewOptions): Webview;
|
|
85
|
+
//#endregion
|
|
86
|
+
export { type ColorScheme, type DotContext, type DotEvent, type PageState, type ReadonlyStore, type RenderContext, type Renderer, type SystemContext, type Theme, type Transport, type ViewRender, type WebviewChannel, type WebviewHooks, type WebviewOptions, type WebviewPlugin, hostTransport };
|
|
87
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/page.ts","../src/store.ts","../src/renderer.ts","../src/webview.ts","../src/index.ts"],"mappings":";;KAIK,YAAY,oBAAoB,iBAAiB,OAAO;qBAEhD;;WACF;WAEA;WACA,aAAa;EAMpB,YAAA,oBACA,YACA,SAAS,mBACT,cAAc,cACd,UAAU;MASR,gBAAgB;EAKpB,YAAY,MAAM;WAST,gBAAQ;WAER,YAAS;EAElB,GAAG,iBAAiB,OAAO,SAAS,4BAA4B,OAAO;EAYvE,gBAAgB,OAAO;EAIvB,YAAY,SAAS;;;;UC/DN,cAAc;WACpB,WAAW;WACX,YAAY;;qBAGV,MAAM,cAAc,cAAc;;EAIjC,YAAA,OAAO;WAIV,WAAU;WAEV,YAAa;EAKtB,IAAI,OAAO;;;;UChBI;WACN,OAAO,cAAc;WACrB,eAAe,cAAc;;UAGvB;WACN;EACT,MAAM,WAAW,SAAS,MAAM,gBAAgB,YAAY;;;;KCMlD,iBAAiB,eAC3B,iBACA,qBAAqB;UAGN;EACf,SAAS,MAAM;EAEf,UAAU,MAAM;EAEhB,MAAM,gBAAgB,SAAS;;UAGhB;WACN;EAET,MAAM,SAAS;;UAGA;EACf,UAAU;EACV,WAAW;EACX,mBAAmB;EACnB,OAAO,eAAe;;cAWX;;WACF,cAAc;WACd,SAAS;WACT,OAAK,aAAA;WACL,OAAK,MAAA;WACL,eAAa,MAAA;EASV,cAAE,WAAW,UAAU,OAAO,WAAgB;EAgC1D,cAAc,QAAQ;EAKtB;EASA,MAAM,WAAW;EAOjB,aAAa,OAAO,eAAe;MAU/B,kBAAkB;EA+GtB,WAAW,qBAAqB;;;;iBCtNjB,qBAAqB;EACpC,WAAW;;wBAGG,eAAe,cAAc,WAAW,eAAe;iBAUtD;EAEf,gBAAgB;EAChB,OAAO,eAAe;;wBAKR,aAAa,UAAU,SAAS,oBAAoB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { DEFAULT_SYSTEM_CONTEXT, EventEmitter, MessageChannel, applyChanges, hostTransport, hostTransport as hostTransport$1 } from "@monimesl/dot-core";
|
|
2
|
+
//#region src/store.ts
|
|
3
|
+
var Store = class {
|
|
4
|
+
#value;
|
|
5
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
6
|
+
constructor(value) {
|
|
7
|
+
this.#value = value;
|
|
8
|
+
}
|
|
9
|
+
get = () => this.#value;
|
|
10
|
+
subscribe = (listener) => {
|
|
11
|
+
this.#listeners.add(listener);
|
|
12
|
+
return () => this.#listeners.delete(listener);
|
|
13
|
+
};
|
|
14
|
+
set(value) {
|
|
15
|
+
this.#value = value;
|
|
16
|
+
this.#listeners.forEach((listener) => listener());
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/page.ts
|
|
21
|
+
var PageController = class {
|
|
22
|
+
pageId;
|
|
23
|
+
#dispatch;
|
|
24
|
+
pageInstanceId;
|
|
25
|
+
pageOptions;
|
|
26
|
+
#viewRenderer;
|
|
27
|
+
#state = new Store(null);
|
|
28
|
+
#callbacks = /* @__PURE__ */ new Map();
|
|
29
|
+
constructor(instanceId, id, options, viewRenderer, dispatch) {
|
|
30
|
+
this.pageId = id;
|
|
31
|
+
this.pageOptions = options;
|
|
32
|
+
this.#dispatch = dispatch;
|
|
33
|
+
this.pageInstanceId = instanceId;
|
|
34
|
+
this.#viewRenderer = viewRenderer;
|
|
35
|
+
}
|
|
36
|
+
get viewRenderer() {
|
|
37
|
+
return this.#viewRenderer;
|
|
38
|
+
}
|
|
39
|
+
replaceView(view) {
|
|
40
|
+
this.#viewRenderer = view;
|
|
41
|
+
const state = this.#state.get();
|
|
42
|
+
if (state) this.#state.set({ ...state });
|
|
43
|
+
}
|
|
44
|
+
getState = this.#state.get;
|
|
45
|
+
subscribe = this.#state.subscribe;
|
|
46
|
+
on(handler, data) {
|
|
47
|
+
if (data) return (event) => this.#dispatch(this.pageInstanceId, handler, {
|
|
48
|
+
...event,
|
|
49
|
+
data
|
|
50
|
+
});
|
|
51
|
+
let callback = this.#callbacks.get(handler);
|
|
52
|
+
if (!callback) {
|
|
53
|
+
callback = (event) => this.#dispatch(this.pageInstanceId, handler, event);
|
|
54
|
+
this.#callbacks.set(handler, callback);
|
|
55
|
+
}
|
|
56
|
+
return callback;
|
|
57
|
+
}
|
|
58
|
+
initializeState(state) {
|
|
59
|
+
this.#state.set(state);
|
|
60
|
+
}
|
|
61
|
+
updateState(changes) {
|
|
62
|
+
const state = this.#state.get();
|
|
63
|
+
if (state) this.#state.set(applyChanges(state, changes));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/webview.ts
|
|
68
|
+
var Webview = class {
|
|
69
|
+
pageRenderer;
|
|
70
|
+
channel;
|
|
71
|
+
hooks = new EventEmitter();
|
|
72
|
+
theme = new Store({});
|
|
73
|
+
systemContext = new Store(DEFAULT_SYSTEM_CONTEXT);
|
|
74
|
+
#dotContext = {
|
|
75
|
+
theme: this.theme,
|
|
76
|
+
systemContext: this.systemContext
|
|
77
|
+
};
|
|
78
|
+
#viewByPageId;
|
|
79
|
+
#openPages = [];
|
|
80
|
+
#shownPage;
|
|
81
|
+
#pending = [];
|
|
82
|
+
#container = null;
|
|
83
|
+
constructor({ transport, renderer, views, plugins = [] }) {
|
|
84
|
+
this.pageRenderer = renderer;
|
|
85
|
+
this.#viewByPageId = views;
|
|
86
|
+
this.channel = new MessageChannel(transport);
|
|
87
|
+
this.channel.handle("setTheme", ({ theme }) => {
|
|
88
|
+
this.theme.set(theme);
|
|
89
|
+
});
|
|
90
|
+
this.channel.handle("initPageState", ({ instanceId, state }) => {
|
|
91
|
+
this.locatePage(instanceId)?.initializeState(state);
|
|
92
|
+
});
|
|
93
|
+
this.channel.handle("setPageState", ({ instanceId, patchState }) => {
|
|
94
|
+
this.locatePage(instanceId)?.updateState(patchState);
|
|
95
|
+
});
|
|
96
|
+
this.channel.handle("syncStack", ({ pages }) => {
|
|
97
|
+
this.#reconcile(pages);
|
|
98
|
+
});
|
|
99
|
+
this.channel.handle("syncSystemContext", (context) => {
|
|
100
|
+
this.systemContext.set(context);
|
|
101
|
+
this.#showTopPage();
|
|
102
|
+
});
|
|
103
|
+
this.channel.handle("error", ({ instanceId, message }) => {
|
|
104
|
+
if (instanceId === void 0) {
|
|
105
|
+
console.error(`[dot] service error: ${message}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const pageName = this.locatePage(instanceId)?.pageId ?? `#${instanceId}`;
|
|
109
|
+
console.error(`[dot] service error in page "${pageName}": ${message}`);
|
|
110
|
+
});
|
|
111
|
+
plugins.forEach((plugin) => this.preparePlugin(plugin));
|
|
112
|
+
}
|
|
113
|
+
preparePlugin(plugin) {
|
|
114
|
+
plugin.setup(this);
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
start() {
|
|
118
|
+
const stopListening = this.channel.listen((error, message) => {
|
|
119
|
+
this.hooks.emit("error", error, message);
|
|
120
|
+
console.error("[dot] webview error", error);
|
|
121
|
+
});
|
|
122
|
+
this.channel.send("connect", {});
|
|
123
|
+
return stopListening;
|
|
124
|
+
}
|
|
125
|
+
mount(container) {
|
|
126
|
+
this.#container = container;
|
|
127
|
+
this.#reconcile(this.#pending);
|
|
128
|
+
return () => this.#reconcile([]);
|
|
129
|
+
}
|
|
130
|
+
replaceViews(views) {
|
|
131
|
+
this.#viewByPageId = views;
|
|
132
|
+
for (const { page } of this.#openPages) {
|
|
133
|
+
const view = views[page.pageId];
|
|
134
|
+
if (view) page.replaceView(view);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
get stack() {
|
|
138
|
+
return this.#openPages.map(({ page }) => page);
|
|
139
|
+
}
|
|
140
|
+
#reconcile(pages) {
|
|
141
|
+
if (!this.#container) {
|
|
142
|
+
this.#pending = pages;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.#closePagesNotIn(pages);
|
|
146
|
+
if (this.#shownPage?.page.pageInstanceId !== pages.at(-1)?.instanceId) this.#showOnly(void 0);
|
|
147
|
+
this.#openPagesIn(pages);
|
|
148
|
+
const top = this.#top();
|
|
149
|
+
for (const open of this.#openPages) open.element.hidden = open !== top;
|
|
150
|
+
this.#showTopPage();
|
|
151
|
+
}
|
|
152
|
+
#showTopPage() {
|
|
153
|
+
if (this.systemContext.get().appState === "active") this.#showOnly(this.#top());
|
|
154
|
+
else this.#showOnly(void 0);
|
|
155
|
+
}
|
|
156
|
+
#showOnly(open) {
|
|
157
|
+
if (open === this.#shownPage) return;
|
|
158
|
+
if (this.#shownPage) this.channel.send("hide", { instanceId: this.#shownPage.page.pageInstanceId });
|
|
159
|
+
this.#shownPage = open;
|
|
160
|
+
if (open) this.channel.send("show", { instanceId: open.page.pageInstanceId });
|
|
161
|
+
}
|
|
162
|
+
#closePagesNotIn(pages) {
|
|
163
|
+
const instanceIdsToKeep = new Set(pages.map((entry) => entry.instanceId));
|
|
164
|
+
for (const open of [...this.#openPages].reverse()) if (!instanceIdsToKeep.has(open.page.pageInstanceId)) this.#close(open);
|
|
165
|
+
}
|
|
166
|
+
#openPagesIn(pages) {
|
|
167
|
+
const kept = new Map(this.#openPages.map((open) => [open.page.pageInstanceId, open]));
|
|
168
|
+
const opened = [];
|
|
169
|
+
this.#openPages = pages.map((entry) => {
|
|
170
|
+
let open = kept.get(entry.instanceId);
|
|
171
|
+
if (!open) {
|
|
172
|
+
open = this.#open(entry);
|
|
173
|
+
opened.push(open);
|
|
174
|
+
}
|
|
175
|
+
return open;
|
|
176
|
+
});
|
|
177
|
+
for (const { page } of opened) this.channel.send("load", {
|
|
178
|
+
instanceId: page.pageInstanceId,
|
|
179
|
+
page: page.pageId,
|
|
180
|
+
options: page.pageOptions
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
#open({ instanceId, page: id, options }) {
|
|
184
|
+
const view = this.#viewByPageId[id];
|
|
185
|
+
if (!view) throw new Error(`Unknown page "${id}"`);
|
|
186
|
+
const page = new PageController(instanceId, id, options, view, (instanceId, handler, event) => this.channel.send("event", {
|
|
187
|
+
instanceId,
|
|
188
|
+
handler,
|
|
189
|
+
event
|
|
190
|
+
}));
|
|
191
|
+
const container = this.#container;
|
|
192
|
+
const element = container.ownerDocument.createElement("div");
|
|
193
|
+
element.dataset.page = id;
|
|
194
|
+
element.hidden = true;
|
|
195
|
+
container.append(element);
|
|
196
|
+
const unmount = this.pageRenderer.mount(element, page, this.#dotContext);
|
|
197
|
+
this.hooks.emit("pageOpen", page);
|
|
198
|
+
return {
|
|
199
|
+
page,
|
|
200
|
+
element,
|
|
201
|
+
unmount
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
#close(open) {
|
|
205
|
+
if (this.#shownPage === open) this.#shownPage = void 0;
|
|
206
|
+
open.unmount();
|
|
207
|
+
open.element.remove();
|
|
208
|
+
this.#openPages = this.#openPages.filter((other) => other !== open);
|
|
209
|
+
this.channel.send("unload", { instanceId: open.page.pageInstanceId });
|
|
210
|
+
this.hooks.emit("pageClose", open.page);
|
|
211
|
+
}
|
|
212
|
+
#top() {
|
|
213
|
+
return this.#openPages.at(-1);
|
|
214
|
+
}
|
|
215
|
+
locatePage(instanceId) {
|
|
216
|
+
return this.#openPages.find(({ page }) => page.pageInstanceId === instanceId)?.page;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/index.ts
|
|
221
|
+
function startWebview({ container, ...options }) {
|
|
222
|
+
const webview = new Webview(options);
|
|
223
|
+
webview.start();
|
|
224
|
+
webview.mount(container);
|
|
225
|
+
return webview;
|
|
226
|
+
}
|
|
227
|
+
const RUNNING_WEBVIEW = Symbol.for("dot.webview");
|
|
228
|
+
function runWebview({ renderer, views }) {
|
|
229
|
+
const scope = globalThis;
|
|
230
|
+
const running = scope[RUNNING_WEBVIEW];
|
|
231
|
+
if (running) {
|
|
232
|
+
running.replaceViews(views);
|
|
233
|
+
return running;
|
|
234
|
+
}
|
|
235
|
+
const container = document.getElementById("root");
|
|
236
|
+
if (!container) throw new Error("The page has no element with id \"root\" to show the dot in");
|
|
237
|
+
const webview = startWebview({
|
|
238
|
+
container,
|
|
239
|
+
transport: hostTransport$1(),
|
|
240
|
+
renderer: renderer(),
|
|
241
|
+
views
|
|
242
|
+
});
|
|
243
|
+
scope[RUNNING_WEBVIEW] = webview;
|
|
244
|
+
return webview;
|
|
245
|
+
}
|
|
246
|
+
//#endregion
|
|
247
|
+
export { PageController, Store, hostTransport, runWebview, startWebview };
|
|
248
|
+
|
|
249
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["hostTransport"],"sources":["../src/store.ts","../src/page.ts","../src/webview.ts","../src/index.ts"],"sourcesContent":["export interface ReadonlyStore<T> {\n readonly get: () => T;\n readonly subscribe: (listener: () => void) => () => void;\n}\n\nexport class Store<T> implements ReadonlyStore<T> {\n #value: T;\n readonly #listeners = new Set<() => void>();\n\n constructor(value: T) {\n this.#value = value;\n }\n\n readonly get = (): T => this.#value;\n\n readonly subscribe = (listener: () => void): (() => void) => {\n this.#listeners.add(listener);\n return () => this.#listeners.delete(listener);\n };\n\n set(value: T): void {\n this.#value = value;\n this.#listeners.forEach((listener) => listener());\n }\n}\n","import { Store } from './store';\nimport type { DotEvent, PageLaunchOptions, PageState, ViewRender } from '@monimesl/dot-core';\nimport { applyChanges } from '@monimesl/dot-core';\n\ntype Dispatch = (instanceId: number, handler: string, event: DotEvent) => void;\n\nexport class PageController {\n readonly pageId: string;\n readonly #dispatch: Dispatch;\n readonly pageInstanceId: number;\n readonly pageOptions: PageLaunchOptions;\n #viewRenderer: ViewRender;\n readonly #state = new Store<PageState | null>(null);\n readonly #callbacks = new Map<string, (event: DotEvent) => void>();\n\n constructor(\n instanceId: number,\n id: string,\n options: PageLaunchOptions,\n viewRenderer: ViewRender,\n dispatch: Dispatch,\n ) {\n this.pageId = id;\n this.pageOptions = options;\n this.#dispatch = dispatch;\n this.pageInstanceId = instanceId;\n this.#viewRenderer = viewRenderer;\n }\n\n get viewRenderer(): ViewRender {\n return this.#viewRenderer;\n }\n\n // Redraws the page with a newer build's view on a hot reload, keeping its state.\n replaceView(view: ViewRender): void {\n this.#viewRenderer = view;\n const state = this.#state.get();\n if (state) {\n // A new object is what tells the renderer to draw again.\n this.#state.set({ ...state });\n }\n }\n\n readonly getState = this.#state.get;\n\n readonly subscribe = this.#state.subscribe;\n\n on(handler: string, data?: Readonly<Record<string, unknown>>): (event: DotEvent) => void {\n if (data) {\n return (event) => this.#dispatch(this.pageInstanceId, handler, { ...event, data });\n }\n let callback = this.#callbacks.get(handler);\n if (!callback) {\n callback = (event) => this.#dispatch(this.pageInstanceId, handler, event);\n this.#callbacks.set(handler, callback);\n }\n return callback;\n }\n\n initializeState(state: PageState): void {\n this.#state.set(state);\n }\n\n updateState(changes: PageState): void {\n const state = this.#state.get();\n if (state) {\n this.#state.set(applyChanges(state, changes));\n }\n }\n}\n","import type {\n Envelope,\n HostLayerMessages,\n LogicLayerMessages,\n StackEntry,\n SystemContext,\n Theme,\n Transport,\n UILayerMessages,\n ViewRender,\n} from '@monimesl/dot-core';\nimport { DEFAULT_SYSTEM_CONTEXT, EventEmitter, MessageChannel } from '@monimesl/dot-core';\n\nimport { Store } from './store';\nimport { PageController } from './page';\nimport type { DotContext, Renderer } from './renderer';\n\nexport type WebviewChannel = MessageChannel<\n UILayerMessages,\n LogicLayerMessages & HostLayerMessages\n>;\n\nexport interface WebviewHooks {\n pageOpen(page: PageController): void;\n\n pageClose(page: PageController): void;\n\n error(error: unknown, message: Envelope): void;\n}\n\nexport interface WebviewPlugin {\n readonly name: string;\n\n setup(webview: Webview): void;\n}\n\nexport interface WebviewOptions {\n renderer: Renderer;\n transport: Transport;\n plugins?: readonly WebviewPlugin[];\n views: Record<string, ViewRender>;\n}\n\ninterface OpenPage {\n readonly page: PageController;\n readonly element: HTMLElement;\n readonly unmount: () => void;\n}\n\n// Shows the stack the host announces. Each open page keeps its own element and only the top one is\n// visible, so going back is instant and keeps its UI.\nexport class Webview {\n readonly pageRenderer: Renderer;\n readonly channel: WebviewChannel;\n readonly hooks = new EventEmitter<WebviewHooks>();\n readonly theme = new Store<Theme>({});\n readonly systemContext = new Store<SystemContext>(DEFAULT_SYSTEM_CONTEXT);\n readonly #dotContext: DotContext = { theme: this.theme, systemContext: this.systemContext };\n #viewByPageId: Record<string, ViewRender>;\n #openPages: OpenPage[] = [];\n // The page the service last heard is showing. It's the top page while the app is active.\n #shownPage: OpenPage | undefined;\n #pending: readonly StackEntry[] = [];\n #container: Element | null = null;\n\n constructor({ transport, renderer, views, plugins = [] }: WebviewOptions) {\n this.pageRenderer = renderer;\n this.#viewByPageId = views;\n this.channel = new MessageChannel(transport);\n this.channel.handle('setTheme', ({ theme }) => {\n this.theme.set(theme);\n });\n this.channel.handle('initPageState', ({ instanceId, state }) => {\n this.locatePage(instanceId)?.initializeState(state);\n });\n this.channel.handle('setPageState', ({ instanceId, patchState }) => {\n this.locatePage(instanceId)?.updateState(patchState);\n });\n this.channel.handle('syncStack', ({ pages }) => {\n this.#reconcile(pages);\n });\n this.channel.handle('syncSystemContext', (context) => {\n this.systemContext.set(context);\n this.#showTopPage();\n });\n this.channel.handle('error', ({ instanceId, message }) => {\n if (instanceId === undefined) {\n console.error(`[dot] service error: ${message}`);\n return;\n }\n // The page may have closed before its error arrived, leaving only its instance id.\n const pageName = this.locatePage(instanceId)?.pageId ?? `#${instanceId}`;\n console.error(`[dot] service error in page \"${pageName}\": ${message}`);\n });\n plugins.forEach((plugin) => this.preparePlugin(plugin));\n }\n\n preparePlugin(plugin: WebviewPlugin): this {\n plugin.setup(this);\n return this;\n }\n\n start(): () => void {\n const stopListening = this.channel.listen((error, message) => {\n this.hooks.emit('error', error, message);\n console.error('[dot] webview error', error);\n });\n this.channel.send('connect', {});\n return stopListening;\n }\n\n mount(container: Element): () => void {\n this.#container = container;\n this.#reconcile(this.#pending);\n return () => this.#reconcile([]);\n }\n\n // Swaps in a newer build's views on a hot reload, and redraws the open pages.\n replaceViews(views: Record<string, ViewRender>): void {\n this.#viewByPageId = views;\n for (const { page } of this.#openPages) {\n const view = views[page.pageId];\n if (view) {\n page.replaceView(view);\n }\n }\n }\n\n get stack(): readonly PageController[] {\n return this.#openPages.map(({ page }) => page);\n }\n\n // Makes the open pages match the stack the host announced.\n #reconcile(pages: readonly StackEntry[]): void {\n if (!this.#container) {\n this.#pending = pages;\n return;\n }\n this.#closePagesNotIn(pages);\n // The page being covered hides before the new one loads, as on a device.\n if (this.#shownPage?.page.pageInstanceId !== pages.at(-1)?.instanceId) {\n this.#showOnly(undefined);\n }\n this.#openPagesIn(pages);\n const top = this.#top();\n for (const open of this.#openPages) {\n open.element.hidden = open !== top;\n }\n this.#showTopPage();\n }\n\n // The top page shows while the app is active, and hides while the person is away from Dash.\n // Its element stays visible either way, so the app switcher's snapshot shows it.\n #showTopPage(): void {\n if (this.systemContext.get().appState === 'active') {\n this.#showOnly(this.#top());\n } else {\n this.#showOnly(undefined);\n }\n }\n\n #showOnly(open: OpenPage | undefined): void {\n if (open === this.#shownPage) {\n return;\n }\n if (this.#shownPage) {\n this.channel.send('hide', { instanceId: this.#shownPage.page.pageInstanceId });\n }\n this.#shownPage = open;\n if (open) {\n this.channel.send('show', { instanceId: open.page.pageInstanceId });\n }\n }\n\n #closePagesNotIn(pages: readonly StackEntry[]): void {\n const instanceIdsToKeep = new Set(pages.map((entry) => entry.instanceId));\n // Close from the top of the stack down, the order a device closes pages in.\n for (const open of [...this.#openPages].reverse()) {\n if (!instanceIdsToKeep.has(open.page.pageInstanceId)) {\n this.#close(open);\n }\n }\n }\n\n #openPagesIn(pages: readonly StackEntry[]): void {\n const kept = new Map(this.#openPages.map((open) => [open.page.pageInstanceId, open]));\n const opened: OpenPage[] = [];\n this.#openPages = pages.map((entry) => {\n let open = kept.get(entry.instanceId);\n if (!open) {\n open = this.#open(entry);\n opened.push(open);\n }\n return open;\n });\n // Load only now that every page is in the stack, so the service's replies find their page.\n for (const { page } of opened) {\n this.channel.send('load', {\n instanceId: page.pageInstanceId,\n page: page.pageId,\n options: page.pageOptions,\n });\n }\n }\n\n #open({ instanceId, page: id, options }: StackEntry): OpenPage {\n const view = this.#viewByPageId[id];\n if (!view) {\n throw new Error(`Unknown page \"${id}\"`);\n }\n const page = new PageController(instanceId, id, options, view, (instanceId, handler, event) =>\n this.channel.send('event', { instanceId, handler, event }),\n );\n const container = this.#container!;\n const element = container.ownerDocument.createElement('div');\n element.dataset.page = id;\n element.hidden = true;\n container.append(element);\n const unmount = this.pageRenderer.mount(element, page, this.#dotContext);\n this.hooks.emit('pageOpen', page);\n return { page, element, unmount };\n }\n\n #close(open: OpenPage): void {\n // A closing page gets `unload`, not `hide`.\n if (this.#shownPage === open) {\n this.#shownPage = undefined;\n }\n open.unmount();\n open.element.remove();\n this.#openPages = this.#openPages.filter((other) => other !== open);\n this.channel.send('unload', { instanceId: open.page.pageInstanceId });\n this.hooks.emit('pageClose', open.page);\n }\n\n #top(): OpenPage | undefined {\n return this.#openPages.at(-1);\n }\n\n locatePage(instanceId: number): PageController | undefined {\n return this.#openPages.find(({ page }) => page.pageInstanceId === instanceId)?.page;\n }\n}\n","import type { ViewRender } from '@monimesl/dot-core';\nimport { hostTransport } from '@monimesl/dot-core';\n\nimport type { Renderer } from './renderer';\nimport type { WebviewOptions } from './webview';\nimport { Webview } from './webview';\n\nexport { PageController } from './page';\nexport { Store } from './store';\nexport type { ReadonlyStore } from './store';\n\nexport { hostTransport } from '@monimesl/dot-core';\nexport type { DotContext, Renderer } from './renderer';\nexport type { WebviewHooks, WebviewOptions, WebviewPlugin, WebviewChannel } from './webview';\nexport type {\n ColorScheme,\n DotEvent,\n PageState,\n RenderContext,\n SystemContext,\n Theme,\n Transport,\n ViewRender,\n} from '@monimesl/dot-core';\n\nexport interface StartOptions extends WebviewOptions {\n container: Element;\n}\n\nexport function startWebview({ container, ...options }: StartOptions): Webview {\n const webview = new Webview(options);\n webview.start();\n webview.mount(container);\n return webview;\n}\n\n// Lets the dot's next build, loaded into the same page, find the running webview.\nconst RUNNING_WEBVIEW = Symbol.for('dot.webview');\n\nexport interface RunWebviewOptions {\n // Called only when the webview starts.\n renderer: () => Renderer;\n views: Record<string, ViewRender>;\n}\n\n// Starts the webview in `#root`. On a hot reload, hands the new build's views to the one already\n// running.\nexport function runWebview({ renderer, views }: RunWebviewOptions): Webview {\n const scope = globalThis as unknown as Record<symbol, Webview | undefined>;\n const running = scope[RUNNING_WEBVIEW];\n if (running) {\n running.replaceViews(views);\n return running;\n }\n const container = document.getElementById('root');\n if (!container) {\n throw new Error('The page has no element with id \"root\" to show the dot in');\n }\n const webview = startWebview({\n container,\n transport: hostTransport(),\n renderer: renderer(),\n views,\n });\n scope[RUNNING_WEBVIEW] = webview;\n return webview;\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@monimesl/dot-webview",
|
|
3
|
+
"version": "0.1.0-alpha.10",
|
|
4
|
+
"description": "Dot view layer: keeps pages in sync with the logic and hands them to a renderer",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/monime-lab/dot.git",
|
|
9
|
+
"directory": "packages/webview"
|
|
10
|
+
},
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"@monimesl/dot-source": "./src/index.ts",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@monimesl/dot-core": "0.1.0-alpha.10"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"jsdom": "^30.1.1",
|
|
31
|
+
"publint": "^0.3.24",
|
|
32
|
+
"rimraf": "^6.1.3",
|
|
33
|
+
"tsdown": "^0.23.0",
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"vitest": "^5.0.1"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsdown",
|
|
42
|
+
"dev": "tsdown --watch",
|
|
43
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"clean": "rimraf dist *.tsbuildinfo"
|
|
46
|
+
}
|
|
47
|
+
}
|