@inkandswitch/patchwork-bootloader 0.4.2 → 0.4.4
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/CHANGELOG.md +60 -0
- package/dist/automerge-worker.js +560 -758
- package/dist/module-loader.d.ts +0 -5
- package/dist/module-loader.js +0 -6
- package/dist/service-worker.js +183 -192
- package/dist/setup.d.ts +2 -1
- package/dist/setup.js +350 -271
- package/dist/site.d.ts +17 -32
- package/dist/site.js +309 -340
- package/dist/types.d.ts +18 -1
- package/dist/vite/importmap-plugin.js +32 -6
- package/package.json +30 -11
- package/src/automerge-worker.ts +654 -820
- package/src/module-loader.ts +0 -6
- package/src/service-worker.ts +237 -225
- package/src/setup.ts +384 -281
- package/src/site.ts +404 -404
- package/src/types.ts +22 -1
- package/src/vite/importmap-plugin.ts +39 -6
package/dist/site.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Browser-app boot sequence for a Patchwork site.
|
|
3
3
|
*
|
|
4
|
-
* Layers on top of {@link setupServiceWorker}
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* configured root tool.
|
|
4
|
+
* Layers on top of {@link setupServiceWorker} to construct the Repo, wire up
|
|
5
|
+
* the automerge-worker port, load plugins via the ModuleWatcher, resolve the
|
|
6
|
+
* user's account document, and hand control to the configured root tool.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* only does SW registration and the automerge-worker handoff).
|
|
8
|
+
* Pulls in DOM- and plugin-layer dependencies, so it is for a browser site's
|
|
9
|
+
* `main.ts` only. Non-UI consumers should import the package default, which
|
|
10
|
+
* does SW registration and the automerge-worker handoff and nothing else.
|
|
13
11
|
*/
|
|
14
12
|
import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
|
|
15
13
|
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
@@ -20,141 +18,103 @@ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@autom
|
|
|
20
18
|
// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
|
|
21
19
|
import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
|
|
22
20
|
import { MemorySigner } from "@automerge/automerge-subduction/slim";
|
|
23
|
-
const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
|
|
24
|
-
const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
25
21
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
26
22
|
import { importAutomergePackageViaWorker } from "./module-loader.js";
|
|
27
23
|
import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
|
|
28
24
|
import { registerRepoProviderElement } from "@inkandswitch/patchwork-providers";
|
|
29
25
|
import { getRegistry, registerPlugins, resolveAccountHandle, unregisterPlugins, } from "@inkandswitch/patchwork-plugins";
|
|
30
26
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
31
|
-
import setupServiceWorker, {
|
|
27
|
+
import setupServiceWorker, { lifecycleLog } from "./setup.js";
|
|
32
28
|
import debug from "debug";
|
|
33
29
|
const log = debug("patchwork:bootloader:site");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
const siteName = typeof __SITE_NAME__ !== "undefined"
|
|
31
|
+
? __SITE_NAME__
|
|
32
|
+
: "patchwork.inkandswitch.com";
|
|
33
|
+
const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
34
|
+
// Started at module evaluation but not top-level awaited: awaiting here would
|
|
35
|
+
// hold up everything importing this module, so the loading animation couldn't
|
|
36
|
+
// appear until the biggest download of the boot had already finished.
|
|
37
|
+
const wasmFetches = Promise.all([
|
|
38
|
+
fetch("/automerge.wasm").then((r) => r.bytes()),
|
|
41
39
|
fetch("/subduction.wasm").then((r) => r.bytes()),
|
|
42
40
|
]);
|
|
43
|
-
|
|
44
|
-
* Boot a Patchwork browser site.
|
|
45
|
-
*
|
|
46
|
-
* Performs the full application-shell setup: service worker + port, Repo,
|
|
47
|
-
* plugin/module loading, account resolution, URL-hash routing, and dev-console
|
|
48
|
-
* globals (`window.repo`, `window.patchwork`, `window.uncache`). Returns the
|
|
49
|
-
* constructed Repo, ModuleWatcher and account handle for sites that want to
|
|
50
|
-
* do additional wiring after boot.
|
|
51
|
-
*/
|
|
41
|
+
wasmFetches.catch(() => { });
|
|
52
42
|
export async function bootPatchworkSite(config) {
|
|
53
|
-
const
|
|
43
|
+
const moduleSources = resolveDefaultModules(config);
|
|
54
44
|
showLoadingAnimation();
|
|
55
|
-
log(
|
|
45
|
+
log("booting", config);
|
|
56
46
|
installLifecycleLogging();
|
|
47
|
+
const [automergeWasm, subductionWasm] = await wasmFetches;
|
|
57
48
|
await initializeWasm(automergeWasm);
|
|
58
49
|
initSubductionSync(subductionWasm);
|
|
59
|
-
log("enabling workers");
|
|
60
50
|
const sw = await setupServiceWorker();
|
|
61
51
|
if (!sw)
|
|
62
52
|
throw new Error("Failed to set up service worker");
|
|
63
53
|
log("workers ready");
|
|
64
54
|
let hive;
|
|
65
55
|
let repo;
|
|
66
|
-
let
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
//
|
|
56
|
+
let signerIdentity;
|
|
57
|
+
// Called with a fresh port when the automerge worker dies and is recreated.
|
|
58
|
+
// Assigned once the repo exists.
|
|
59
|
+
let onWorkerPortRenewed;
|
|
60
|
+
// An embedding context may have provided a Repo before this entry ran. Reuse
|
|
61
|
+
// it and its keyhive so we share the same documents and sync context.
|
|
71
62
|
if (window.repo) {
|
|
72
63
|
log("using existing Repo from window");
|
|
73
64
|
repo = window.repo;
|
|
74
65
|
hive = window.hive;
|
|
75
66
|
}
|
|
76
67
|
else {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
68
|
+
const workerPort = await firstRepoPort(sw, (port) => {
|
|
69
|
+
if (onWorkerPortRenewed)
|
|
70
|
+
onWorkerPortRenewed(port);
|
|
71
|
+
else {
|
|
72
|
+
console.warn("automerge worker port renewed before the repo existed; dropping it");
|
|
73
|
+
}
|
|
82
74
|
});
|
|
83
|
-
|
|
84
|
-
await
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
101
|
-
repo: {
|
|
102
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
103
|
-
enableRemoteHeadsGossiping: true,
|
|
104
|
-
},
|
|
105
|
-
}));
|
|
106
|
-
log("keyhive setup complete");
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
log("creating repo");
|
|
110
|
-
// Pass an explicit signer (instead of the Repo's internal default) so we
|
|
111
|
-
// can expose tab signer identity on window.patchwork for dev inspection.
|
|
112
|
-
// The tab never connects via Subduction (no endpoints/adapters), so this
|
|
113
|
-
// id never goes on the wire.
|
|
114
|
-
const tabSigner = new MemorySigner();
|
|
115
|
-
repo = new Repo({
|
|
116
|
-
network: [new MessageChannelNetworkAdapter(workerPort)],
|
|
117
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
118
|
-
signer: tabSigner,
|
|
119
|
-
async sharePolicy(peerId) {
|
|
120
|
-
return peerId.includes("automerge-worker");
|
|
121
|
-
},
|
|
122
|
-
enableRemoteHeadsGossiping: true,
|
|
123
|
-
peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
|
|
124
|
-
});
|
|
125
|
-
tabSignerIdentity = {
|
|
126
|
-
peerId: tabSigner.peerId().toString(),
|
|
127
|
-
verifyingKey: tabSigner.verifyingKey().toHex(),
|
|
128
|
-
};
|
|
129
|
-
console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
|
|
130
|
-
log("repo created");
|
|
131
|
-
}
|
|
75
|
+
let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
|
|
76
|
+
({ repo, hive, signerIdentity } = await createRepo(config, workerAdapter));
|
|
77
|
+
// The worker was recreated with cold state: wire the repo onto the fresh
|
|
78
|
+
// port and drop the adapter stranded on the dead one.
|
|
79
|
+
const bootHive = hive;
|
|
80
|
+
onWorkerPortRenewed = (port) => {
|
|
81
|
+
const fresh = new MessageChannelNetworkAdapter(port);
|
|
82
|
+
// Mirror the boot wiring: a keyhive repo talks to the worker through a
|
|
83
|
+
// keyhive adapter wrapped around the message channel.
|
|
84
|
+
const registered = bootHive
|
|
85
|
+
? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
|
|
86
|
+
: fresh;
|
|
87
|
+
repo.networkSubsystem.addNetworkAdapter(registered);
|
|
88
|
+
removeAdapterFor(repo, workerAdapter, registered);
|
|
89
|
+
workerAdapter = fresh;
|
|
90
|
+
lifecycleLog("repo re-wired to the recreated automerge worker");
|
|
91
|
+
};
|
|
132
92
|
}
|
|
133
|
-
log("popping repo on window");
|
|
134
93
|
window.repo = repo;
|
|
135
|
-
|
|
94
|
+
window.Automerge = Automerge;
|
|
95
|
+
window.AutomergeRepo = AutomergeRepo;
|
|
96
|
+
window.getRepoChannel = sw.getRepoChannel;
|
|
97
|
+
if (hive)
|
|
98
|
+
window.hive = hive;
|
|
136
99
|
await repo.networkSubsystem.whenReady();
|
|
137
100
|
log("networkSubsystem ready");
|
|
138
|
-
|
|
139
|
-
hive.networkAdapter.syncKeyhive?.();
|
|
140
|
-
}
|
|
141
|
-
installDevConsoleGlobals(repo, hive, sw.getRepoChannel);
|
|
101
|
+
hive?.networkAdapter?.syncKeyhive?.();
|
|
142
102
|
registerRepoProviderElement(repo);
|
|
143
|
-
const
|
|
103
|
+
const rootElementId = config.rootElementId ?? "root";
|
|
104
|
+
const rootElement = document.getElementById(rootElementId);
|
|
144
105
|
if (!rootElement) {
|
|
145
|
-
throw new Error(`bootPatchworkSite: no element with id="${
|
|
106
|
+
throw new Error(`bootPatchworkSite: no element with id="${rootElementId}"`);
|
|
146
107
|
}
|
|
147
108
|
// `<repo-provider>` sits above the root and answers `repo:handle-descriptor`
|
|
148
|
-
// for any view outside a remapper
|
|
109
|
+
// for any view outside a remapper, resolving to the requested url unchanged.
|
|
149
110
|
const repoProvider = document.createElement("repo-provider");
|
|
150
111
|
rootElement.parentElement.insertBefore(repoProvider, rootElement);
|
|
151
112
|
repoProvider.appendChild(rootElement);
|
|
152
113
|
registerPatchworkViewElement({ hive, repo });
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
const moduleWatcher = new ModuleWatcher(repo, buildSystemSources(defaultModuleSources), onModuleLoaded, unregisterPlugins,
|
|
114
|
+
// Started with the site bundle alone so resolveAccountHandle has something to
|
|
115
|
+
// await on — the `account` datatype lives there. The user's own
|
|
116
|
+
// module-settings URL is added lazily once it appears on the account doc.
|
|
117
|
+
const moduleWatcher = new ModuleWatcher(repo, nameSources(moduleSources), onModuleLoaded, unregisterPlugins,
|
|
158
118
|
// Discover an Automerge package's plugin descriptors off the main thread;
|
|
159
119
|
// each plugin's load() re-imports the package (at heads) on this thread.
|
|
160
120
|
importAutomergePackageViaWorker);
|
|
@@ -162,25 +122,25 @@ export async function bootPatchworkSite(config) {
|
|
|
162
122
|
storageKey: config.accountStorageKey,
|
|
163
123
|
hive,
|
|
164
124
|
}));
|
|
165
|
-
// TODO: something we (Orion & pvh) changed in the types made this necessary
|
|
166
|
-
// fix this before merging to main!
|
|
167
125
|
window.accountDocHandle = accountDocHandle;
|
|
168
|
-
|
|
169
|
-
primeRootElement(rootElement, accountDocHandle);
|
|
170
|
-
logToolRegistryWhenLoaded(moduleWatcher);
|
|
126
|
+
window.uncache = uncache;
|
|
171
127
|
window.patchwork = {
|
|
172
128
|
repo,
|
|
173
129
|
packages: moduleWatcher,
|
|
174
130
|
plugins,
|
|
175
131
|
accountDocHandle,
|
|
176
|
-
...(
|
|
132
|
+
...(signerIdentity ? { signer: signerIdentity } : {}),
|
|
177
133
|
sw: {
|
|
178
134
|
connectClassicSync: sw.connectClassicSync,
|
|
179
135
|
subscribeToRepoChannel: sw.subscribeToRepoChannel,
|
|
180
136
|
subscribeSyncState: sw.subscribeSyncState,
|
|
181
137
|
},
|
|
182
138
|
};
|
|
183
|
-
|
|
139
|
+
wireModuleSettings(accountDocHandle, moduleWatcher);
|
|
140
|
+
primeRootElement(rootElement, accountDocHandle);
|
|
141
|
+
moduleWatcher.doneLoading.then(() => log("doneLoading, tools registered:", getRegistry("patchwork:tool")
|
|
142
|
+
.all()
|
|
143
|
+
.map((t) => t.id)), (err) => console.error("doneLoading rejected:", err));
|
|
184
144
|
installHashRouting({
|
|
185
145
|
rootElement,
|
|
186
146
|
repo,
|
|
@@ -189,110 +149,150 @@ export async function bootPatchworkSite(config) {
|
|
|
189
149
|
});
|
|
190
150
|
return { repo, moduleWatcher, accountDocHandle };
|
|
191
151
|
}
|
|
192
|
-
// ─── Internals ──────────────────────────────────────────────────────────
|
|
193
152
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
153
|
+
* Resolve with the first repo port the worker delivers, calling `onRenewed` for
|
|
154
|
+
* every later one.
|
|
155
|
+
*
|
|
156
|
+
* subscribeToRepoChannel is deliberately not awaited: it resolves only after
|
|
157
|
+
* the boot channel's port-ready handshake, which can take its full 30s timeout
|
|
158
|
+
* against a stranded worker connection. Boot blocks on the first *delivered*
|
|
159
|
+
* port instead — if the boot channel stalls, worker recovery hands the listener
|
|
160
|
+
* a good port long before that timeout.
|
|
196
161
|
*/
|
|
162
|
+
function firstRepoPort(sw, onRenewed) {
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
let seen = false;
|
|
165
|
+
void sw.subscribeToRepoChannel((port) => {
|
|
166
|
+
if (seen)
|
|
167
|
+
return onRenewed(port);
|
|
168
|
+
seen = true;
|
|
169
|
+
resolve(port);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async function createRepo(config, workerAdapter) {
|
|
174
|
+
if (config.keyhive) {
|
|
175
|
+
log("setting up keyhive");
|
|
176
|
+
initKeyhiveWasm();
|
|
177
|
+
const { hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
|
|
178
|
+
createRepo: (repoConfig) => new Repo(repoConfig),
|
|
179
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
180
|
+
peerIdSuffix: siteName + Math.random().toString(36).slice(2),
|
|
181
|
+
networkAdapter: workerAdapter,
|
|
182
|
+
automaticArchiveIngestion: true,
|
|
183
|
+
cachingMode: "periodic",
|
|
184
|
+
onlyShareWithHardcodedServerPeerId: false,
|
|
185
|
+
// ARK selects the relay via `syncServer`, defaulting to "subduction".
|
|
186
|
+
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
187
|
+
repo: {
|
|
188
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
189
|
+
enableRemoteHeadsGossiping: true,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
log("keyhive setup complete");
|
|
193
|
+
return { repo, hive };
|
|
194
|
+
}
|
|
195
|
+
// An explicit signer, rather than the Repo's internal default, so the tab's
|
|
196
|
+
// identity can be exposed on window.patchwork. The tab never connects via
|
|
197
|
+
// Subduction, so this id never goes on the wire.
|
|
198
|
+
const signer = new MemorySigner();
|
|
199
|
+
const repo = new Repo({
|
|
200
|
+
network: [workerAdapter],
|
|
201
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
202
|
+
signer,
|
|
203
|
+
async sharePolicy(peerId) {
|
|
204
|
+
return peerId.includes("automerge-worker");
|
|
205
|
+
},
|
|
206
|
+
enableRemoteHeadsGossiping: true,
|
|
207
|
+
peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
|
|
208
|
+
});
|
|
209
|
+
const signerIdentity = {
|
|
210
|
+
peerId: signer.peerId().toString(),
|
|
211
|
+
verifyingKey: signer.verifyingKey().toHex(),
|
|
212
|
+
};
|
|
213
|
+
log("repo created, tab subduction identity:", signerIdentity);
|
|
214
|
+
return { repo, signerIdentity };
|
|
215
|
+
}
|
|
216
|
+
/** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
|
|
217
|
+
function removeAdapterFor(repo, stale, keep) {
|
|
218
|
+
for (const adapter of [...repo.networkSubsystem.adapters]) {
|
|
219
|
+
if (adapter === keep)
|
|
220
|
+
continue;
|
|
221
|
+
// The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
|
|
222
|
+
const base = adapter.networkAdapter ?? adapter;
|
|
223
|
+
if (base !== stale)
|
|
224
|
+
continue;
|
|
225
|
+
try {
|
|
226
|
+
repo.networkSubsystem.removeNetworkAdapter(adapter);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
console.error("failed to remove stale worker network adapter", err);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
197
233
|
function isValidModuleSource(source) {
|
|
198
|
-
|
|
199
|
-
return true;
|
|
200
|
-
return (source.startsWith("/") ||
|
|
201
|
-
source.startsWith("http://") ||
|
|
202
|
-
source.startsWith("https://") ||
|
|
203
|
-
source.startsWith("./"));
|
|
234
|
+
return isValidAutomergeUrl(source) || /^(https?:\/\/|\.?\/)/.test(source);
|
|
204
235
|
}
|
|
205
236
|
/**
|
|
206
|
-
*
|
|
207
|
-
* `localStorage.systemPackageListURL` dev override
|
|
208
|
-
* built-in
|
|
237
|
+
* The site's default module-list sources, honouring the
|
|
238
|
+
* `localStorage.systemPackageListURL` dev override, which replaces the entire
|
|
239
|
+
* built-in bundle. `defaultToolsUrl` is the pre-rename key.
|
|
209
240
|
*/
|
|
210
241
|
function resolveDefaultModules(config) {
|
|
211
|
-
const
|
|
212
|
-
const
|
|
242
|
+
const configured = config.defaultModules ?? config.defaultModulesUrl ?? [];
|
|
243
|
+
const builtin = (Array.isArray(configured) ? configured : [configured]).filter(Boolean);
|
|
213
244
|
const storage = globalThis.localStorage;
|
|
214
|
-
// `defaultToolsUrl` is the pre-rename key, still honoured for existing browsers.
|
|
215
245
|
const override = storage?.getItem("systemPackageListURL") ??
|
|
216
246
|
storage?.getItem("defaultToolsUrl");
|
|
247
|
+
if (override && isValidModuleSource(override)) {
|
|
248
|
+
console.info(`using systemPackageListURL from localStorage: ${override}`);
|
|
249
|
+
return [override];
|
|
250
|
+
}
|
|
217
251
|
if (override) {
|
|
218
|
-
|
|
219
|
-
if (!builtinList.includes(override)) {
|
|
220
|
-
console.info(`using systemPackageListURL override from localStorage: ${override}`);
|
|
221
|
-
}
|
|
222
|
-
return [override];
|
|
223
|
-
}
|
|
224
|
-
console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}; using built-in default`);
|
|
252
|
+
console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}`);
|
|
225
253
|
}
|
|
226
|
-
if (
|
|
254
|
+
if (builtin.length === 0) {
|
|
227
255
|
throw new Error("bootPatchworkSite: no default module sources configured (set `defaultModules`)");
|
|
228
256
|
}
|
|
229
|
-
return
|
|
257
|
+
return builtin;
|
|
230
258
|
}
|
|
231
259
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* per-account settings doc, which has branch-override precedence).
|
|
260
|
+
* Name the sources for the ModuleWatcher. The first keeps the canonical
|
|
261
|
+
* `system` name; the rest get suffixed. None may be `user`, which is reserved
|
|
262
|
+
* for the per-account settings doc and has branch-override precedence.
|
|
236
263
|
*/
|
|
237
|
-
function
|
|
238
|
-
|
|
239
|
-
sources.forEach((source, index) => {
|
|
240
|
-
const name = index === 0 ? "system" : `system-${index}`;
|
|
241
|
-
map[name] = source;
|
|
242
|
-
});
|
|
243
|
-
return map;
|
|
264
|
+
function nameSources(sources) {
|
|
265
|
+
return Object.fromEntries(sources.map((source, i) => [i === 0 ? "system" : `system-${i}`, source]));
|
|
244
266
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
window.Automerge = Automerge;
|
|
248
|
-
window.AutomergeRepo = AutomergeRepo;
|
|
249
|
-
if (hive) {
|
|
250
|
-
window.hive = hive;
|
|
251
|
-
}
|
|
252
|
-
window.getRepoChannel = getRepoChannel;
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Log this tab's Page Lifecycle + connectivity transitions (visibility,
|
|
256
|
-
* freeze, bfcache, online/offline) so they line up against the SharedWorker's
|
|
257
|
-
* sync-socket reaps. [lifecycle]-tagged, on by default.
|
|
258
|
-
*/
|
|
267
|
+
/** Page Lifecycle and connectivity transitions, to line up against the
|
|
268
|
+
* SharedWorker's sync-socket reaps. */
|
|
259
269
|
function installLifecycleLogging() {
|
|
260
270
|
if (typeof document === "undefined")
|
|
261
271
|
return;
|
|
262
272
|
const opts = { capture: true };
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
document.addEventListener("visibilitychange", () => note(`visibilitychange → ${document.visibilityState}`), opts);
|
|
273
|
-
document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
|
|
274
|
-
document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
|
|
275
|
-
window.addEventListener("pageshow", e => note("pageshow", { persisted: e.persisted }), opts);
|
|
276
|
-
window.addEventListener("pagehide", e => note("pagehide", { persisted: e.persisted }), opts);
|
|
277
|
-
window.addEventListener("online", () => note("online"), opts);
|
|
278
|
-
window.addEventListener("offline", () => note("offline"), opts);
|
|
279
|
-
note(`lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`);
|
|
273
|
+
const persisted = (e) => e.persisted;
|
|
274
|
+
document.addEventListener("visibilitychange", () => lifecycleLog("visibilitychange → %s", document.visibilityState), opts);
|
|
275
|
+
document.addEventListener("freeze", () => lifecycleLog("freeze (tab suspended)"), opts);
|
|
276
|
+
document.addEventListener("resume", () => lifecycleLog("resume (tab unsuspended)"), opts);
|
|
277
|
+
window.addEventListener("pageshow", (e) => lifecycleLog("pageshow persisted=%s", persisted(e)), opts);
|
|
278
|
+
window.addEventListener("pagehide", (e) => lifecycleLog("pagehide persisted=%s", persisted(e)), opts);
|
|
279
|
+
window.addEventListener("online", () => lifecycleLog("online"), opts);
|
|
280
|
+
window.addEventListener("offline", () => lifecycleLog("offline"), opts);
|
|
281
|
+
lifecycleLog("logging installed (visibilityState=%s, hasFocus=%s)", document.visibilityState, document.hasFocus());
|
|
280
282
|
}
|
|
281
283
|
function onModuleLoaded(name, mod) {
|
|
282
|
-
if (Array.isArray(mod.plugins)) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
else {
|
|
287
|
-
console.warn(`module ${name.slice(0, 30)}... has no plugins array`, Object.keys(mod));
|
|
284
|
+
if (!Array.isArray(mod.plugins)) {
|
|
285
|
+
console.warn(`module ${name} has no plugins array`, Object.keys(mod));
|
|
286
|
+
return;
|
|
288
287
|
}
|
|
288
|
+
log(`registering ${mod.plugins.length} plugin(s) from ${name}`, mod.plugins.map((p) => `${p.type}:${p.id}`));
|
|
289
|
+
registerPlugins(mod.plugins, name);
|
|
289
290
|
}
|
|
290
291
|
/**
|
|
291
|
-
* The frame lazy-creates `moduleSettingsUrl` on first mount
|
|
292
|
-
* appear
|
|
293
|
-
* own tool bundle loads alongside the site default. Idempotent.
|
|
292
|
+
* The frame lazy-creates `moduleSettingsUrl` on first mount, so watch for it to
|
|
293
|
+
* appear and feed it to the ModuleWatcher.
|
|
294
294
|
*/
|
|
295
|
-
function
|
|
295
|
+
function wireModuleSettings(accountDocHandle, moduleWatcher) {
|
|
296
296
|
const wire = () => {
|
|
297
297
|
const url = accountDocHandle.doc()?.moduleSettingsUrl;
|
|
298
298
|
if (!url)
|
|
@@ -305,78 +305,53 @@ function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
|
|
|
305
305
|
accountDocHandle.on("change", wire);
|
|
306
306
|
}
|
|
307
307
|
}
|
|
308
|
-
/**
|
|
309
|
-
* Set initial `tool-id` / `doc-url` attributes on the root
|
|
310
|
-
* `<patchwork-view>` based on the URL hash (if it specifies a frame
|
|
311
|
-
* override) or the account doc's configured frame tool + the account doc
|
|
312
|
-
* itself.
|
|
313
|
-
*/
|
|
314
308
|
function primeRootElement(rootElement, accountDocHandle) {
|
|
315
309
|
rootElement.style.visibility = "hidden";
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
rootElement.setAttribute("doc-url", docUrl);
|
|
321
|
-
}
|
|
322
|
-
else {
|
|
323
|
-
rootElement.setAttribute("tool-id", accountDocHandle.doc().frameToolId);
|
|
324
|
-
rootElement.setAttribute("doc-url", accountDocHandle.url);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
function logToolRegistryWhenLoaded(moduleWatcher) {
|
|
328
|
-
moduleWatcher.doneLoading
|
|
329
|
-
.then(() => {
|
|
330
|
-
const toolReg = getRegistry("patchwork:tool");
|
|
331
|
-
const tools = toolReg.all();
|
|
332
|
-
log(`doneLoading: ${tools.length} tools registered:`, tools.map((t) => t.id));
|
|
333
|
-
})
|
|
334
|
-
.catch((err) => {
|
|
335
|
-
console.error("doneLoading rejected:", err);
|
|
336
|
-
});
|
|
310
|
+
const params = new URLSearchParams(location.hash.slice(1));
|
|
311
|
+
const frame = params.get("frame");
|
|
312
|
+
rootElement.setAttribute("tool-id", frame ?? accountDocHandle.doc().frameToolId);
|
|
313
|
+
rootElement.setAttribute("doc-url", (frame && docParamToUrl(params.get("doc"))) || accountDocHandle.url);
|
|
337
314
|
}
|
|
315
|
+
// ── Loading animation ───────────────────────────────────────────────────
|
|
338
316
|
const LOADING_STYLE_ID = "pw-bootloader-loading-styles";
|
|
339
317
|
const LOADING_ELEMENT_ID = "pw-bootloader-loading";
|
|
318
|
+
const LOADING_CSS = `
|
|
319
|
+
@keyframes pw-bootloader-pulse {
|
|
320
|
+
0%, 100% { opacity: 0.25; }
|
|
321
|
+
50% { opacity: 0.95; }
|
|
322
|
+
}
|
|
323
|
+
#${LOADING_ELEMENT_ID} {
|
|
324
|
+
position: fixed;
|
|
325
|
+
inset: 0;
|
|
326
|
+
z-index: 0;
|
|
327
|
+
pointer-events: none;
|
|
328
|
+
background-color: #fff;
|
|
329
|
+
background-image:
|
|
330
|
+
radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
|
|
331
|
+
radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
|
|
332
|
+
radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
|
|
333
|
+
animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
|
|
334
|
+
transition: opacity 0.6s ease-out;
|
|
335
|
+
}
|
|
336
|
+
@media (prefers-color-scheme: dark) {
|
|
337
|
+
#${LOADING_ELEMENT_ID} {
|
|
338
|
+
background-color: #000;
|
|
339
|
+
background-image:
|
|
340
|
+
radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
|
|
341
|
+
radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
|
|
342
|
+
radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
#${LOADING_ELEMENT_ID}.pw-bootloader-fading {
|
|
346
|
+
opacity: 0;
|
|
347
|
+
animation: none;
|
|
348
|
+
}
|
|
349
|
+
`;
|
|
340
350
|
function showLoadingAnimation() {
|
|
341
351
|
if (!document.getElementById(LOADING_STYLE_ID)) {
|
|
342
352
|
const style = document.createElement("style");
|
|
343
353
|
style.id = LOADING_STYLE_ID;
|
|
344
|
-
style.textContent =
|
|
345
|
-
@keyframes pw-bootloader-pulse {
|
|
346
|
-
0%, 100% { opacity: 0.25; }
|
|
347
|
-
50% { opacity: 0.95; }
|
|
348
|
-
}
|
|
349
|
-
#${LOADING_ELEMENT_ID} {
|
|
350
|
-
position: fixed;
|
|
351
|
-
inset: 0;
|
|
352
|
-
z-index: 0;
|
|
353
|
-
pointer-events: none;
|
|
354
|
-
background-color: #fff;
|
|
355
|
-
background-image:
|
|
356
|
-
radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
|
|
357
|
-
radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
|
|
358
|
-
radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
|
|
359
|
-
animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
|
|
360
|
-
transition: opacity 0.6s ease-out;
|
|
361
|
-
top: 0;
|
|
362
|
-
left: 0;
|
|
363
|
-
right: 0;
|
|
364
|
-
bottom: 0;
|
|
365
|
-
}
|
|
366
|
-
@media (prefers-color-scheme: dark) {
|
|
367
|
-
#${LOADING_ELEMENT_ID} {
|
|
368
|
-
background-color: #000;
|
|
369
|
-
background-image:
|
|
370
|
-
radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
|
|
371
|
-
radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
|
|
372
|
-
radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
#${LOADING_ELEMENT_ID}.pw-bootloader-fading {
|
|
376
|
-
opacity: 0;
|
|
377
|
-
animation: none;
|
|
378
|
-
}
|
|
379
|
-
`;
|
|
354
|
+
style.textContent = LOADING_CSS;
|
|
380
355
|
document.head.appendChild(style);
|
|
381
356
|
}
|
|
382
357
|
if (document.getElementById(LOADING_ELEMENT_ID))
|
|
@@ -396,40 +371,41 @@ async function uncache(match) {
|
|
|
396
371
|
for (const name of await caches.keys()) {
|
|
397
372
|
const cache = await caches.open(name);
|
|
398
373
|
for (const request of await cache.keys()) {
|
|
399
|
-
if (request.url.includes(match))
|
|
374
|
+
if (request.url.includes(match))
|
|
400
375
|
cache.delete(request);
|
|
401
|
-
}
|
|
402
376
|
}
|
|
403
377
|
}
|
|
404
378
|
}
|
|
405
|
-
//
|
|
406
|
-
//
|
|
379
|
+
// ── Hash routing ────────────────────────────────────────────────────────
|
|
380
|
+
// Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
|
|
381
|
+
// contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
|
|
382
|
+
// anchor on the `--` before the base58 document id rather than a strict slug
|
|
383
|
+
// charset.
|
|
384
|
+
const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
|
|
385
|
+
// The `doc=` value is an automerge URL, kept literal rather than
|
|
386
|
+
// percent-encoded so links stay readable.
|
|
407
387
|
const RAW_HASH_KEYS = new Set(["doc"]);
|
|
408
|
-
//
|
|
409
|
-
//
|
|
388
|
+
// A stable order means re-serializing the same logical params is
|
|
389
|
+
// byte-identical, avoiding spurious `hashchange` round-trips.
|
|
410
390
|
const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
|
|
411
391
|
function serializeHashParams(params) {
|
|
412
|
-
const
|
|
392
|
+
const keys = [...HASH_KEY_ORDER, ...params.keys()];
|
|
413
393
|
const parts = [];
|
|
414
|
-
const
|
|
394
|
+
const emitted = new Set();
|
|
395
|
+
for (const key of keys) {
|
|
415
396
|
if (emitted.has(key))
|
|
416
|
-
|
|
397
|
+
continue;
|
|
417
398
|
const value = params.get(key);
|
|
418
399
|
if (!value)
|
|
419
|
-
|
|
400
|
+
continue;
|
|
420
401
|
emitted.add(key);
|
|
421
402
|
parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
|
|
422
|
-
}
|
|
423
|
-
for (const key of HASH_KEY_ORDER)
|
|
424
|
-
emit(key);
|
|
425
|
-
for (const key of params.keys())
|
|
426
|
-
emit(key);
|
|
403
|
+
}
|
|
427
404
|
return parts.join("&");
|
|
428
405
|
}
|
|
429
406
|
/**
|
|
430
407
|
* Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
|
|
431
|
-
* (`automerge:<id>[#heads]`) or a bare document id for
|
|
432
|
-
* with older links.
|
|
408
|
+
* (`automerge:<id>[#heads]`) or a bare document id, for older links.
|
|
433
409
|
*/
|
|
434
410
|
function docParamToUrl(docParam) {
|
|
435
411
|
if (!docParam)
|
|
@@ -438,32 +414,67 @@ function docParamToUrl(docParam) {
|
|
|
438
414
|
return docParam;
|
|
439
415
|
}
|
|
440
416
|
const documentId = docParam.replace(/^automerge:/, "");
|
|
441
|
-
if (isValidDocumentId(documentId))
|
|
442
|
-
return
|
|
443
|
-
}
|
|
444
|
-
return undefined;
|
|
417
|
+
if (!isValidDocumentId(documentId))
|
|
418
|
+
return undefined;
|
|
419
|
+
return stringifyAutomergeUrl({ documentId: documentId });
|
|
445
420
|
}
|
|
446
|
-
function installHashRouting(
|
|
447
|
-
const
|
|
421
|
+
function installHashRouting({ rootElement, repo, accountDocHandle, titleSuffix, }) {
|
|
422
|
+
const handleHashChange = async () => {
|
|
423
|
+
const hash = window.location.hash.slice(1);
|
|
424
|
+
// Legacy big-patchwork link: normalize to `#doc=automerge:<docId>` and let
|
|
425
|
+
// routing re-run on the resulting hashchange.
|
|
426
|
+
const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
|
|
427
|
+
if (legacyDocId && isValidDocumentId(legacyDocId)) {
|
|
428
|
+
window.location.hash = serializeHashParams(new URLSearchParams({
|
|
429
|
+
doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
|
|
430
|
+
}));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
// Bare automerge URL: /#automerge:<documentId>
|
|
434
|
+
if (isValidAutomergeUrl(hash)) {
|
|
435
|
+
window.location.hash = "";
|
|
436
|
+
openDocument(rootElement, hash);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const params = new URLSearchParams(hash);
|
|
440
|
+
const docUrl = docParamToUrl(params.get("doc"));
|
|
441
|
+
const frame = params.get("frame");
|
|
442
|
+
if (frame) {
|
|
443
|
+
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
444
|
+
if (rootElement.getAttribute("tool-id") !== frame ||
|
|
445
|
+
rootElement.getAttribute("doc-url") !== frameDocUrl) {
|
|
446
|
+
rootElement.setAttribute("tool-id", frame);
|
|
447
|
+
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (docUrl) {
|
|
451
|
+
rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
|
|
452
|
+
detail: {
|
|
453
|
+
url: docUrl,
|
|
454
|
+
toolId: params.get("tool"),
|
|
455
|
+
title: params.get("title"),
|
|
456
|
+
type: params.get("type"),
|
|
457
|
+
},
|
|
458
|
+
}));
|
|
459
|
+
}
|
|
460
|
+
};
|
|
448
461
|
rootElement.addEventListener("patchwork:open-document", async (event) => {
|
|
449
|
-
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
450
462
|
const { url, toolId, type, title } = event.detail;
|
|
451
|
-
|
|
452
|
-
// the
|
|
463
|
+
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
464
|
+
// `doc` is the full automerge URL, so heads live inside it and the separate
|
|
465
|
+
// `heads=` param is gone.
|
|
453
466
|
params.delete("heads");
|
|
454
467
|
params.set("doc", url);
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
else
|
|
466
|
-
params.delete("type");
|
|
468
|
+
for (const [key, value] of [
|
|
469
|
+
["tool", toolId],
|
|
470
|
+
["title", title],
|
|
471
|
+
["type", type],
|
|
472
|
+
]) {
|
|
473
|
+
if (value)
|
|
474
|
+
params.set(key, value);
|
|
475
|
+
else
|
|
476
|
+
params.delete(key);
|
|
477
|
+
}
|
|
467
478
|
window.location.hash = serializeHashParams(params);
|
|
468
479
|
try {
|
|
469
480
|
const docHandle = await repo.find(url);
|
|
@@ -471,78 +482,36 @@ function installHashRouting(params) {
|
|
|
471
482
|
const docType = type || doc?.["@patchwork"]?.type;
|
|
472
483
|
if (!docType)
|
|
473
484
|
return;
|
|
474
|
-
const
|
|
475
|
-
const datatype = await registry.load(docType);
|
|
485
|
+
const datatype = await getRegistry("patchwork:datatype").load(docType);
|
|
476
486
|
if (!datatype)
|
|
477
487
|
return;
|
|
478
488
|
const docTitle = datatype.module.getTitle(doc);
|
|
479
|
-
if (docTitle)
|
|
489
|
+
if (docTitle)
|
|
480
490
|
document.title = `${docTitle} | ${titleSuffix}`;
|
|
481
|
-
}
|
|
482
491
|
}
|
|
483
492
|
catch (e) {
|
|
484
493
|
console.error("Failed to update document title", e);
|
|
485
494
|
}
|
|
486
495
|
});
|
|
487
|
-
let
|
|
496
|
+
let revealed = false;
|
|
488
497
|
const reveal = () => {
|
|
489
|
-
if (
|
|
498
|
+
if (revealed)
|
|
490
499
|
return;
|
|
491
|
-
|
|
500
|
+
revealed = true;
|
|
492
501
|
rootElement.style.visibility = "visible";
|
|
493
502
|
hideLoadingAnimation();
|
|
494
503
|
};
|
|
495
504
|
rootElement.addEventListener("patchwork:mounted", (event) => {
|
|
496
|
-
handleHashChange();
|
|
497
505
|
if (event.target !== rootElement)
|
|
498
506
|
return;
|
|
499
|
-
|
|
507
|
+
log("root element mounted");
|
|
508
|
+
void handleHashChange();
|
|
500
509
|
reveal();
|
|
501
|
-
//
|
|
502
|
-
// get a second chance to render.
|
|
510
|
+
// Deep-links from freshly-loaded tools get a second chance to render.
|
|
503
511
|
setTimeout(handleHashChange, 1000);
|
|
504
512
|
});
|
|
505
|
-
//
|
|
506
|
-
//
|
|
513
|
+
// If nothing ever mounts, reveal anyway so the user sees something rather
|
|
514
|
+
// than a blank page.
|
|
507
515
|
setTimeout(reveal, 12_000);
|
|
508
|
-
const handleHashChange = async () => {
|
|
509
|
-
const hash = window.location.hash.slice(1);
|
|
510
|
-
// Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
|
|
511
|
-
// `--` followed by a valid document id, normalize it to the canonical
|
|
512
|
-
// `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
|
|
513
|
-
const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
|
|
514
|
-
if (legacyDocId && isValidDocumentId(legacyDocId)) {
|
|
515
|
-
window.location.hash = serializeHashParams(new URLSearchParams({
|
|
516
|
-
doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
|
|
517
|
-
}));
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
// Bare automerge URL in hash: /#automerge:<documentId>
|
|
521
|
-
if (isValidAutomergeUrl(hash)) {
|
|
522
|
-
const url = hash;
|
|
523
|
-
window.location.hash = "";
|
|
524
|
-
openDocument(rootElement, url);
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
const params = new URLSearchParams(hash);
|
|
528
|
-
const docUrl = docParamToUrl(params.get("doc"));
|
|
529
|
-
const toolId = params.get("tool");
|
|
530
|
-
const title = params.get("title");
|
|
531
|
-
const type = params.get("type");
|
|
532
|
-
const frame = params.get("frame");
|
|
533
|
-
if (frame) {
|
|
534
|
-
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
535
|
-
if (rootElement.getAttribute("tool-id") !== frame ||
|
|
536
|
-
rootElement.getAttribute("doc-url") !== frameDocUrl) {
|
|
537
|
-
rootElement.setAttribute("tool-id", frame);
|
|
538
|
-
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
if (docUrl) {
|
|
542
|
-
rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
|
|
543
|
-
detail: { url: docUrl, toolId, title, type },
|
|
544
|
-
}));
|
|
545
|
-
}
|
|
546
|
-
};
|
|
547
516
|
window.addEventListener("hashchange", handleHashChange);
|
|
548
517
|
}
|