@hoardodile/sdk-react 0.0.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/LICENSE +18 -0
- package/README.md +61 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +481 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
- package/src/context.tsx +28 -0
- package/src/define-api.ts +100 -0
- package/src/fixtures.tsx +25 -0
- package/src/i18n.test.tsx +147 -0
- package/src/i18n.ts +96 -0
- package/src/index.ts +23 -0
- package/src/query.ts +407 -0
- package/src/root.tsx +144 -0
- package/src/use-cache-writer.ts +61 -0
- package/src/use-extract-progress.test.tsx +118 -0
- package/src/use-extract-progress.ts +76 -0
package/src/query.ts
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnchorData,
|
|
3
|
+
Danmaku,
|
|
4
|
+
DanmakuListFilter,
|
|
5
|
+
DanmakuMode,
|
|
6
|
+
Message,
|
|
7
|
+
PluginSchema,
|
|
8
|
+
} from "@hoardodile/sdk-types"
|
|
9
|
+
import type {
|
|
10
|
+
Codec,
|
|
11
|
+
Host,
|
|
12
|
+
MutationState,
|
|
13
|
+
PluginFonts,
|
|
14
|
+
QueryState,
|
|
15
|
+
ReactivePluginAPI,
|
|
16
|
+
Theme,
|
|
17
|
+
} from "@hoardodile/sdk-web"
|
|
18
|
+
import {
|
|
19
|
+
extractFontsPayload,
|
|
20
|
+
extractPrefPayload,
|
|
21
|
+
extractThemePayload,
|
|
22
|
+
getPluginPrefStore,
|
|
23
|
+
invalidatePushKeys,
|
|
24
|
+
setPluginPref,
|
|
25
|
+
subscribeToPrefChanges,
|
|
26
|
+
} from "@hoardodile/sdk-web"
|
|
27
|
+
import { useEffect, useMemo, useState } from "react"
|
|
28
|
+
|
|
29
|
+
// ── Query state helpers ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function buildQuerySuccessState<T>(data: T): QueryState<T> {
|
|
32
|
+
return { data, isLoading: false, isError: false, error: null }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildQueryErrorState(err: unknown): QueryState<never> {
|
|
36
|
+
return {
|
|
37
|
+
data: undefined,
|
|
38
|
+
isLoading: false,
|
|
39
|
+
isError: true,
|
|
40
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildQueryLoadingState(): QueryState<never> {
|
|
45
|
+
return { data: undefined, isLoading: true, isError: false, error: null }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Base query hook ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
type PluginRequestKey = keyof import("@hoardodile/sdk-web").PluginRequests
|
|
51
|
+
type HostPushKey = keyof import("@hoardodile/sdk-web").HostPushes
|
|
52
|
+
|
|
53
|
+
type UseHostQueryOptions<K extends PluginRequestKey> = {
|
|
54
|
+
readonly method: K
|
|
55
|
+
readonly params: import("@hoardodile/sdk-web").RequestInput<K>
|
|
56
|
+
readonly invalidateKey: HostPushKey
|
|
57
|
+
readonly extraDeps?: readonly unknown[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function useHostQuery<K extends PluginRequestKey, T>(
|
|
61
|
+
host: Host,
|
|
62
|
+
options: UseHostQueryOptions<K>,
|
|
63
|
+
): QueryState<T> {
|
|
64
|
+
const { method, params, invalidateKey, extraDeps = [] } = options
|
|
65
|
+
const [state, setState] = useState<QueryState<T>>(buildQueryLoadingState)
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
let cancelled = false
|
|
69
|
+
setState(buildQueryLoadingState())
|
|
70
|
+
|
|
71
|
+
function fetchData() {
|
|
72
|
+
const args = params === undefined ? [] : [params]
|
|
73
|
+
host
|
|
74
|
+
.request(method, ...(args as never))
|
|
75
|
+
.then((result) => {
|
|
76
|
+
if (!cancelled) {
|
|
77
|
+
setState(buildQuerySuccessState(result as T))
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.catch((err: unknown) => {
|
|
81
|
+
if (!cancelled) {
|
|
82
|
+
setState(buildQueryErrorState(err))
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fetchData()
|
|
88
|
+
const unsub = host.subscribe(invalidateKey, fetchData as never)
|
|
89
|
+
return function cleanup() {
|
|
90
|
+
cancelled = true
|
|
91
|
+
unsub()
|
|
92
|
+
}
|
|
93
|
+
}, [host, method, invalidateKey, ...extraDeps])
|
|
94
|
+
|
|
95
|
+
return state
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── File queries ─────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function useFileList(host: Host, contextDeps: readonly unknown[]) {
|
|
101
|
+
return useHostQuery<"listFiles", readonly string[]>(host, {
|
|
102
|
+
method: "listFiles",
|
|
103
|
+
params: undefined,
|
|
104
|
+
invalidateKey: invalidatePushKeys.resource,
|
|
105
|
+
extraDeps: contextDeps,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
// ── Message queries ──────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function useMessageList(host: Host, contextDeps: readonly unknown[]) {
|
|
111
|
+
return useHostQuery<"listMessages", readonly Message[]>(host, {
|
|
112
|
+
method: "listMessages",
|
|
113
|
+
params: undefined,
|
|
114
|
+
invalidateKey: invalidatePushKeys.messages,
|
|
115
|
+
extraDeps: contextDeps,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Danmaku queries ───────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function useDanmakuList(
|
|
122
|
+
host: Host,
|
|
123
|
+
contextDeps: readonly unknown[],
|
|
124
|
+
filter?: DanmakuListFilter,
|
|
125
|
+
) {
|
|
126
|
+
return useHostQuery<"listDanmaku", readonly Danmaku[]>(host, {
|
|
127
|
+
method: "listDanmaku",
|
|
128
|
+
params: { filter },
|
|
129
|
+
invalidateKey: invalidatePushKeys.danmaku,
|
|
130
|
+
// The filter object is a fresh literal on every render; a stable
|
|
131
|
+
// serialization keeps the effect from refetching in a loop while
|
|
132
|
+
// still refetching when any filter value actually changes.
|
|
133
|
+
extraDeps: [
|
|
134
|
+
...contextDeps,
|
|
135
|
+
filter === undefined ? undefined : JSON.stringify(filter),
|
|
136
|
+
],
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Mutations ────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
function useHostMutation<
|
|
143
|
+
K extends PluginRequestKey,
|
|
144
|
+
TArgs extends import("@hoardodile/sdk-web").RequestInput<K>,
|
|
145
|
+
TResult extends import("@hoardodile/sdk-web").RequestOutput<K>,
|
|
146
|
+
>(host: Host, method: K): MutationState<TArgs, TResult> {
|
|
147
|
+
const [isPending, setIsPending] = useState(false)
|
|
148
|
+
|
|
149
|
+
async function mutate(args: TArgs): Promise<TResult> {
|
|
150
|
+
setIsPending(true)
|
|
151
|
+
try {
|
|
152
|
+
const requestArgs = args === undefined ? [] : [args]
|
|
153
|
+
return (await host.request(
|
|
154
|
+
method,
|
|
155
|
+
...(requestArgs as never),
|
|
156
|
+
)) as unknown as TResult
|
|
157
|
+
} finally {
|
|
158
|
+
setIsPending(false)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { mutate, isPending }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function useCreateMessage(
|
|
166
|
+
host: Host,
|
|
167
|
+
): MutationState<
|
|
168
|
+
{ readonly body: string; readonly anchor?: unknown },
|
|
169
|
+
Message
|
|
170
|
+
> {
|
|
171
|
+
const base = useHostMutation<
|
|
172
|
+
"createMessage",
|
|
173
|
+
{ readonly body: string; readonly anchor?: AnchorData },
|
|
174
|
+
Message
|
|
175
|
+
>(host, "createMessage")
|
|
176
|
+
// The hook input is the raw plugin location data; the wire anchor is
|
|
177
|
+
// the `{ data }` envelope (see sdk-web runtime).
|
|
178
|
+
return {
|
|
179
|
+
isPending: base.isPending,
|
|
180
|
+
async mutate(input) {
|
|
181
|
+
return base.mutate({
|
|
182
|
+
body: input.body,
|
|
183
|
+
anchor: input.anchor === undefined ? undefined : { data: input.anchor },
|
|
184
|
+
})
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function useCreateDanmaku(host: Host): MutationState<
|
|
190
|
+
{
|
|
191
|
+
readonly text: string
|
|
192
|
+
readonly anchor: unknown
|
|
193
|
+
readonly mode?: DanmakuMode
|
|
194
|
+
},
|
|
195
|
+
Danmaku
|
|
196
|
+
> {
|
|
197
|
+
const base = useHostMutation<
|
|
198
|
+
"createDanmaku",
|
|
199
|
+
{
|
|
200
|
+
readonly text: string
|
|
201
|
+
readonly anchor: AnchorData
|
|
202
|
+
readonly mode?: DanmakuMode
|
|
203
|
+
},
|
|
204
|
+
Danmaku
|
|
205
|
+
>(host, "createDanmaku")
|
|
206
|
+
return {
|
|
207
|
+
isPending: base.isPending,
|
|
208
|
+
async mutate(input) {
|
|
209
|
+
return base.mutate({
|
|
210
|
+
text: input.text,
|
|
211
|
+
anchor: { data: input.anchor },
|
|
212
|
+
mode: input.mode,
|
|
213
|
+
})
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Preferences hook ─────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/** Serialize a typed value to its stored string form (codec or String()). */
|
|
221
|
+
function encodePrefValue<T>(codec: Codec<T> | undefined, value: T): string {
|
|
222
|
+
return codec !== undefined ? codec.encode(value) : String(value)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Parse a stored string back to its typed form, falling back on malformed input. */
|
|
226
|
+
function decodePrefValue<T>(
|
|
227
|
+
codec: Codec<T> | undefined,
|
|
228
|
+
raw: string,
|
|
229
|
+
fallback: T,
|
|
230
|
+
): T {
|
|
231
|
+
if (codec === undefined) return raw as unknown as T
|
|
232
|
+
const decoded = codec.decode(raw)
|
|
233
|
+
return decoded !== undefined ? decoded : fallback
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function usePref<T>(
|
|
237
|
+
host: Host,
|
|
238
|
+
key: string,
|
|
239
|
+
defaultValue: T,
|
|
240
|
+
codec?: Codec<T>,
|
|
241
|
+
): readonly [T, (value: T) => void] {
|
|
242
|
+
const store = getPluginPrefStore()
|
|
243
|
+
const encodedDefault = useMemo(
|
|
244
|
+
function computeEncodedDefault() {
|
|
245
|
+
return encodePrefValue(codec, defaultValue)
|
|
246
|
+
},
|
|
247
|
+
[codec, defaultValue],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const [raw, setRawState] = useState(function getInitial() {
|
|
251
|
+
return store.get(key) ?? encodedDefault
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
useEffect(
|
|
255
|
+
function subscribeToStoreChanges() {
|
|
256
|
+
return subscribeToPrefChanges(key, function onChange() {
|
|
257
|
+
setRawState(getPluginPrefStore().get(key) ?? encodedDefault)
|
|
258
|
+
})
|
|
259
|
+
},
|
|
260
|
+
[key, encodedDefault],
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
useEffect(
|
|
264
|
+
function subscribeToHostPush() {
|
|
265
|
+
return host.subscribe("prefsChanged", function handlePrefPush(data) {
|
|
266
|
+
const payload = extractPrefPayload(data)
|
|
267
|
+
if (payload === undefined || payload.key !== key) return
|
|
268
|
+
if (payload.value !== undefined) {
|
|
269
|
+
setPluginPref(key, payload.value)
|
|
270
|
+
setRawState(payload.value)
|
|
271
|
+
} else {
|
|
272
|
+
setRawState(encodedDefault)
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
},
|
|
276
|
+
[host, key, encodedDefault],
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
const value = useMemo(
|
|
280
|
+
function decodeValue() {
|
|
281
|
+
return decodePrefValue(codec, raw, defaultValue)
|
|
282
|
+
},
|
|
283
|
+
[raw, codec, defaultValue],
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
function setValue(next: T): void {
|
|
287
|
+
const encoded = encodePrefValue(codec, next)
|
|
288
|
+
setPluginPref(key, encoded)
|
|
289
|
+
setRawState(encoded)
|
|
290
|
+
host.request("setPref", { key, value: encoded }).catch(() => {})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return [value, setValue] as const
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Host push hooks ──────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Subscribe to a host push and merge the extracted patch into state. The
|
|
300
|
+
* extractor returns `undefined` (or an empty patch) when the push carries
|
|
301
|
+
* no applicable change, so spurious pushes never re-render.
|
|
302
|
+
*/
|
|
303
|
+
function useHostPush<T>(
|
|
304
|
+
host: Host,
|
|
305
|
+
key: HostPushKey,
|
|
306
|
+
extract: (data: unknown) => Partial<T> | undefined,
|
|
307
|
+
initial: T,
|
|
308
|
+
): T {
|
|
309
|
+
const [value, setValue] = useState(initial)
|
|
310
|
+
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
const unsub = host.subscribe(key, function handlePush(data) {
|
|
313
|
+
const patch = extract(data)
|
|
314
|
+
if (patch !== undefined) {
|
|
315
|
+
setValue((prev) => ({ ...prev, ...patch }))
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
return function cleanup() {
|
|
319
|
+
unsub()
|
|
320
|
+
}
|
|
321
|
+
}, [host, key, extract])
|
|
322
|
+
|
|
323
|
+
return value
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Theme hook ───────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
function extractThemePatch(data: unknown): Partial<Theme> | undefined {
|
|
329
|
+
const { resolvedTheme, palette, iconStyle } = extractThemePayload(data)
|
|
330
|
+
if (
|
|
331
|
+
resolvedTheme === undefined &&
|
|
332
|
+
palette === undefined &&
|
|
333
|
+
iconStyle === undefined
|
|
334
|
+
) {
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
...(resolvedTheme !== undefined ? { resolvedTheme } : {}),
|
|
339
|
+
...(palette !== undefined ? { palette } : {}),
|
|
340
|
+
...(iconStyle !== undefined ? { iconStyle } : {}),
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function useTheme(
|
|
345
|
+
host: Host,
|
|
346
|
+
initialResolvedTheme: string,
|
|
347
|
+
initialPalette: string,
|
|
348
|
+
initialIconStyle: string,
|
|
349
|
+
): Theme {
|
|
350
|
+
return useHostPush(host, "themeChanged", extractThemePatch, {
|
|
351
|
+
resolvedTheme: initialResolvedTheme,
|
|
352
|
+
palette: initialPalette,
|
|
353
|
+
iconStyle: initialIconStyle,
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Font hook ────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
function useFont(host: Host, initialFonts: PluginFonts): PluginFonts {
|
|
360
|
+
return useHostPush(host, "fontsChanged", extractFontsPayload, initialFonts)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Public factory ───────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Builds the reactive half of the plugin API (`useFileList`,
|
|
367
|
+
* `useMessageList`, `useCreateMessage`, `useDanmakuList`,
|
|
368
|
+
* `useCreateDanmaku`, `usePref`, `useTheme`, `useFont`) on top of the
|
|
369
|
+
* imperative `WebPluginAPI` and the host bridge. Queries refetch
|
|
370
|
+
* automatically on the matching host invalidation push and when the
|
|
371
|
+
* iframe is rebound to another resource.
|
|
372
|
+
*
|
|
373
|
+
* Consumed by {@link createPluginRoot}; call it directly only when
|
|
374
|
+
* composing your own runtime. The returned hooks are bound to the
|
|
375
|
+
* `host` passed in — the one from `ensureHostBridge()`.
|
|
376
|
+
*/
|
|
377
|
+
export function createPluginQueryAPI<
|
|
378
|
+
TSchema extends PluginSchema = PluginSchema,
|
|
379
|
+
>(
|
|
380
|
+
host: Host,
|
|
381
|
+
ctx: {
|
|
382
|
+
readonly resolvedTheme: string
|
|
383
|
+
readonly palette: string
|
|
384
|
+
readonly iconStyle: string
|
|
385
|
+
readonly fonts: PluginFonts
|
|
386
|
+
readonly resId: string
|
|
387
|
+
},
|
|
388
|
+
): ReactivePluginAPI<TSchema> {
|
|
389
|
+
// Refetch when the iframe is rebound to another resource without a
|
|
390
|
+
// remount (createPluginRoot's `remountOnResourceChange: false`). With
|
|
391
|
+
// the default remount this dep is constant for the mount's lifetime.
|
|
392
|
+
const contextDeps = [ctx.resId]
|
|
393
|
+
return {
|
|
394
|
+
useFileList: () =>
|
|
395
|
+
useFileList(host, contextDeps) as QueryState<readonly TSchema["file"][]>,
|
|
396
|
+
useMessageList: () => useMessageList(host, contextDeps),
|
|
397
|
+
useCreateMessage: () => useCreateMessage(host),
|
|
398
|
+
useDanmakuList: (filter?: DanmakuListFilter) =>
|
|
399
|
+
useDanmakuList(host, contextDeps, filter),
|
|
400
|
+
useCreateDanmaku: () => useCreateDanmaku(host),
|
|
401
|
+
usePref: <T>(key: string, defaultValue: T, codec?: Codec<T>) =>
|
|
402
|
+
usePref(host, key, defaultValue, codec),
|
|
403
|
+
useTheme: () =>
|
|
404
|
+
useTheme(host, ctx.resolvedTheme, ctx.palette, ctx.iconStyle),
|
|
405
|
+
useFont: () => useFont(host, ctx.fonts),
|
|
406
|
+
}
|
|
407
|
+
}
|
package/src/root.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { PluginSchema } from "@hoardodile/sdk-types"
|
|
2
|
+
import {
|
|
3
|
+
applyFonts,
|
|
4
|
+
applyTheme,
|
|
5
|
+
createIframeHostAPI,
|
|
6
|
+
ensureHostBridge,
|
|
7
|
+
getVisibilitySnapshot,
|
|
8
|
+
mountPlugin,
|
|
9
|
+
subscribeToVisibility,
|
|
10
|
+
} from "@hoardodile/sdk-web"
|
|
11
|
+
import type { ComponentType, Provider, ReactNode } from "react"
|
|
12
|
+
import { createElement, useEffect, useSyncExternalStore } from "react"
|
|
13
|
+
import { flushSync } from "react-dom"
|
|
14
|
+
import { createRoot } from "react-dom/client"
|
|
15
|
+
import { usePluginAPI } from "./context.tsx"
|
|
16
|
+
import type { FullPluginAPI } from "./define-api.ts"
|
|
17
|
+
import { createPluginQueryAPI } from "./query.ts"
|
|
18
|
+
|
|
19
|
+
export type PluginRootConfig<TSchema extends PluginSchema = PluginSchema> = {
|
|
20
|
+
/** Root component rendered inside the plugin iframe. */
|
|
21
|
+
readonly render: ComponentType
|
|
22
|
+
/**
|
|
23
|
+
* Typed provider returned by {@link definePluginAPI}. This is the single
|
|
24
|
+
* source of truth for the plugin schema type.
|
|
25
|
+
*/
|
|
26
|
+
readonly provider: Provider<FullPluginAPI<TSchema> | null>
|
|
27
|
+
/**
|
|
28
|
+
* When `true` (default), the whole plugin tree remounts whenever the
|
|
29
|
+
* iframe is rebound to another resource — the safe choice: all
|
|
30
|
+
* per-resource state resets automatically.
|
|
31
|
+
*
|
|
32
|
+
* Set to `false` for fine-grained updates (e.g. cheap same-plugin
|
|
33
|
+
* navigation): the mounted tree stays alive and only re-renders with
|
|
34
|
+
* the new `api`. Queries refetch automatically, but every piece of
|
|
35
|
+
* per-resource state becomes the plugin's own responsibility — key
|
|
36
|
+
* subtrees and memos by `api.resource.id` and reset any hydration
|
|
37
|
+
* flags yourself.
|
|
38
|
+
*/
|
|
39
|
+
readonly remountOnResourceChange?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ThemeSync({ children }: { readonly children: ReactNode }) {
|
|
43
|
+
const api = usePluginAPI()
|
|
44
|
+
const { resolvedTheme, palette, iconStyle } = api.useTheme()
|
|
45
|
+
|
|
46
|
+
useEffect(
|
|
47
|
+
function applyOnChange() {
|
|
48
|
+
applyTheme(resolvedTheme, palette, iconStyle)
|
|
49
|
+
},
|
|
50
|
+
[resolvedTheme, palette, iconStyle],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return children
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function FontSync({ children }: { readonly children: ReactNode }) {
|
|
57
|
+
const api = usePluginAPI()
|
|
58
|
+
const { family, cssPaths } = api.useFont()
|
|
59
|
+
|
|
60
|
+
useEffect(
|
|
61
|
+
function applyOnChange() {
|
|
62
|
+
applyFonts(family, cssPaths)
|
|
63
|
+
},
|
|
64
|
+
[family, cssPaths],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return children
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Subscribe to the iframe visibility state from the host: `false` while
|
|
72
|
+
* the iframe is parked offscreen in the preview window. Do NOT gate
|
|
73
|
+
* rendering on it — parked slots are meant to pre-paint so a flip is a
|
|
74
|
+
* style swap, and an empty tree defeats that. Use visibility only to
|
|
75
|
+
* pause active behavior: media playback, autoplay, timers.
|
|
76
|
+
*/
|
|
77
|
+
export function useVisibility(): boolean {
|
|
78
|
+
return useSyncExternalStore(subscribeToVisibility, getVisibilitySnapshot)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One-call plugin bootstrap. Handles `mountPlugin`, `createRoot` caching,
|
|
83
|
+
* iframe host API, typed `PluginAPIProvider`, reactive theme application, and
|
|
84
|
+
* visibility subscription.
|
|
85
|
+
*
|
|
86
|
+
* The supplied component receives no props; it should call `usePluginAPI()`
|
|
87
|
+
* and `useVisibility()` internally as needed. By default the root remounts
|
|
88
|
+
* when the resource changes (see
|
|
89
|
+
* {@link PluginRootConfig.remountOnResourceChange}); even then, use
|
|
90
|
+
* `api.resource.id` as a key inside your component if you need finer
|
|
91
|
+
* control.
|
|
92
|
+
*/
|
|
93
|
+
export function createPluginRoot<TSchema extends PluginSchema = PluginSchema>(
|
|
94
|
+
config: PluginRootConfig<TSchema>,
|
|
95
|
+
): void {
|
|
96
|
+
let root: ReturnType<typeof createRoot> | undefined
|
|
97
|
+
|
|
98
|
+
mountPlugin(function onContext(ctx) {
|
|
99
|
+
flushSync(() => {
|
|
100
|
+
if (root === undefined) {
|
|
101
|
+
const el = document.getElementById("root")
|
|
102
|
+
if (el === null) {
|
|
103
|
+
console.error("[plugin] #root element not found — cannot mount")
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
root = createRoot(el)
|
|
107
|
+
}
|
|
108
|
+
const host = ensureHostBridge()
|
|
109
|
+
const baseApi = createIframeHostAPI<TSchema>(ctx)
|
|
110
|
+
const api: FullPluginAPI<TSchema> = {
|
|
111
|
+
...baseApi,
|
|
112
|
+
...createPluginQueryAPI(host, {
|
|
113
|
+
resolvedTheme: ctx.resolvedTheme,
|
|
114
|
+
palette: ctx.palette,
|
|
115
|
+
iconStyle: ctx.iconStyle,
|
|
116
|
+
fonts: ctx.fonts,
|
|
117
|
+
resId: ctx.resId,
|
|
118
|
+
}),
|
|
119
|
+
}
|
|
120
|
+
applyTheme(ctx.resolvedTheme, ctx.palette, ctx.iconStyle)
|
|
121
|
+
applyFonts(ctx.fonts.family, ctx.fonts.cssPaths)
|
|
122
|
+
root.render(
|
|
123
|
+
createElement(
|
|
124
|
+
config.provider,
|
|
125
|
+
{ value: api },
|
|
126
|
+
createElement(
|
|
127
|
+
ThemeSync,
|
|
128
|
+
null,
|
|
129
|
+
createElement(
|
|
130
|
+
FontSync,
|
|
131
|
+
null,
|
|
132
|
+
createElement(
|
|
133
|
+
config.render,
|
|
134
|
+
config.remountOnResourceChange === false
|
|
135
|
+
? {}
|
|
136
|
+
: { key: ctx.resId },
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react"
|
|
2
|
+
import { usePluginAPI } from "./context.tsx"
|
|
3
|
+
|
|
4
|
+
const DEFAULT_DEBOUNCE_MS = 500
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Persist a value to the per-resource plugin cache: debounced writes while
|
|
8
|
+
* the value changes, plus a flush on `pagehide` / `beforeunload` / unmount
|
|
9
|
+
* so no pending update is lost.
|
|
10
|
+
*
|
|
11
|
+
* Use this for reader positions, resume timestamps, and similar
|
|
12
|
+
* continuously-changing state. Pass `undefined` as the value (or
|
|
13
|
+
* `disabled: true`) to skip persistence while the real value is loading.
|
|
14
|
+
*/
|
|
15
|
+
export function useCacheWriter<T>(options: {
|
|
16
|
+
readonly key: string
|
|
17
|
+
readonly value: T | undefined
|
|
18
|
+
readonly encode: (value: T) => string
|
|
19
|
+
readonly disabled?: boolean
|
|
20
|
+
readonly debounceMs?: number
|
|
21
|
+
}): void {
|
|
22
|
+
const api = usePluginAPI()
|
|
23
|
+
const {
|
|
24
|
+
key,
|
|
25
|
+
value,
|
|
26
|
+
encode,
|
|
27
|
+
disabled = false,
|
|
28
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
29
|
+
} = options
|
|
30
|
+
const latestRef = useRef(value)
|
|
31
|
+
latestRef.current = value
|
|
32
|
+
const encodeRef = useRef(encode)
|
|
33
|
+
encodeRef.current = encode
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (disabled || value === undefined) return
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
const snap = latestRef.current
|
|
39
|
+
if (snap === undefined) return
|
|
40
|
+
api.setCache(key, encodeRef.current(snap))
|
|
41
|
+
}, debounceMs)
|
|
42
|
+
return () => clearTimeout(timer)
|
|
43
|
+
}, [api, key, value, disabled, debounceMs])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (disabled) return
|
|
47
|
+
function flush() {
|
|
48
|
+
const snap = latestRef.current
|
|
49
|
+
if (snap === undefined) return
|
|
50
|
+
api.setCache(key, encodeRef.current(snap))
|
|
51
|
+
}
|
|
52
|
+
window.addEventListener("pagehide", flush)
|
|
53
|
+
window.addEventListener("beforeunload", flush)
|
|
54
|
+
return () => {
|
|
55
|
+
window.removeEventListener("pagehide", flush)
|
|
56
|
+
window.removeEventListener("beforeunload", flush)
|
|
57
|
+
flush()
|
|
58
|
+
}
|
|
59
|
+
// api is structurally stable; flush reads the latest value via ref.
|
|
60
|
+
}, [api, key, disabled])
|
|
61
|
+
}
|