@lincyaw/web 1.0.0-rc.0 → 1.0.0-rc.2
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 +33 -4
- package/package.json +20 -19
- package/src/app/base.css +117 -0
- package/src/app/block-playback.ts +81 -0
- package/src/app/block-report.ts +104 -0
- package/src/app/document-modules.ts +30 -0
- package/src/app/env.d.ts +12 -0
- package/src/app/main.ts +198 -0
- package/src/app/page-app.ts +166 -0
- package/src/app/router.ts +109 -0
- package/src/app/server.ts +32 -0
- package/src/app/site/home.tsx +117 -0
- package/src/app/site/layout.tsx +92 -0
- package/src/app/site/search.tsx +22 -0
- package/src/app/site.css +1450 -0
- package/src/client/document.css +172 -0
- package/src/client/host.ts +22 -94
- package/src/client/preview-frame.ts +152 -0
- package/src/client/runtime.ts +159 -0
- package/src/domain/app-config.ts +91 -0
- package/src/domain/config.ts +17 -9
- package/src/domain/dev-updates.ts +12 -0
- package/src/domain/index.ts +17 -7
- package/src/domain/landing.ts +83 -0
- package/src/domain/links.ts +23 -18
- package/src/domain/messages.ts +74 -63
- package/src/domain/pages.ts +23 -41
- package/src/domain/themes.ts +59 -0
- package/src/domain/workspace-home.ts +12 -0
- package/src/node/client-modules.ts +92 -0
- package/src/node/document-bundle.ts +227 -0
- package/src/node/landing-template.ts +34 -0
- package/src/node/landing.ts +82 -0
- package/src/node/offline.ts +34 -11
- package/src/node/publish.ts +83 -15
- package/src/node/renderer.ts +78 -117
- package/src/node/site-assets.ts +17 -0
- package/src/node/site-build.ts +304 -0
- package/src/node/site-files.ts +87 -0
- package/src/node/site-model.ts +337 -0
- package/src/node/site-page.ts +82 -0
- package/src/node/site-server.ts +822 -0
- package/src/node/slides.ts +135 -0
- package/src/node/viewer-app.ts +504 -0
- package/src/node/viewer-build.ts +38 -0
- package/custom-nav-controls.vue +0 -17
- package/src/client/env.d.ts +0 -1
- package/src/client/home.ts +0 -182
- package/src/client/index.ts +0 -4
- package/src/client/page.ts +0 -38
- package/src/client/preview.ts +0 -71
- package/src/client/style.css +0 -427
- package/src/client/theme.ts +0 -322
- package/src/node/addons.ts +0 -76
- package/src/node/site.ts +0 -349
- package/src/node/sites.ts +0 -531
- package/src/node/vite.ts +0 -166
- package/src/node/vitepress.ts +0 -72
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The typography of a document as the viewer shows it (`.pm-document`, the host of the page view and of the slides):
|
|
3
|
+
* the same on a built site, in a dev server and in the workbench.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
.pm-document :is(h1, h2, h3, h4, h5, h6) {
|
|
7
|
+
position: relative;
|
|
8
|
+
margin: 0;
|
|
9
|
+
padding: 0;
|
|
10
|
+
border: 0;
|
|
11
|
+
font-weight: 600;
|
|
12
|
+
outline: none;
|
|
13
|
+
overflow-wrap: break-word;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.pm-document h1 {
|
|
17
|
+
letter-spacing: -0.02em;
|
|
18
|
+
line-height: 2.5rem;
|
|
19
|
+
font-size: 1.75rem;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@media (min-width: 768px) {
|
|
23
|
+
.pm-document h1 {
|
|
24
|
+
font-size: 2rem;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.pm-document h2 {
|
|
29
|
+
margin: var(--pm-space-12) 0 var(--pm-space-4);
|
|
30
|
+
padding-top: var(--pm-space-6);
|
|
31
|
+
border-top: var(--pm-size-hairline) solid var(--pm-color-line);
|
|
32
|
+
letter-spacing: -0.02em;
|
|
33
|
+
line-height: 2rem;
|
|
34
|
+
font-size: 1.5rem;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.pm-document h3 {
|
|
38
|
+
margin: var(--pm-space-8) 0 0;
|
|
39
|
+
letter-spacing: -0.01em;
|
|
40
|
+
line-height: 1.75rem;
|
|
41
|
+
font-size: 1.25rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.pm-document h4 {
|
|
45
|
+
margin: var(--pm-space-6) 0 0;
|
|
46
|
+
letter-spacing: -0.01em;
|
|
47
|
+
line-height: 1.5rem;
|
|
48
|
+
font-size: 1.125rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.pm-document :is(h5, h6) {
|
|
52
|
+
line-height: 1.5rem;
|
|
53
|
+
font-size: 1rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.pm-document :is(p, summary) {
|
|
57
|
+
margin: var(--pm-space-4) 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.pm-document p {
|
|
61
|
+
overflow-wrap: break-word;
|
|
62
|
+
line-height: 1.75rem;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.pm-document blockquote {
|
|
66
|
+
margin: var(--pm-space-4) 0;
|
|
67
|
+
padding-left: var(--pm-space-4);
|
|
68
|
+
border-left: var(--pm-size-stroke) solid var(--pm-color-line);
|
|
69
|
+
color: var(--pm-color-muted);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.pm-document blockquote > p {
|
|
73
|
+
margin: 0;
|
|
74
|
+
font-size: 1rem;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.pm-document a {
|
|
78
|
+
color: var(--pm-color-accent);
|
|
79
|
+
font-weight: 500;
|
|
80
|
+
text-decoration: underline;
|
|
81
|
+
text-underline-offset: var(--pm-space-px);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.pm-document a:hover {
|
|
85
|
+
color: color-mix(in srgb, var(--pm-color-accent) 88%, var(--pm-color-ink));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.pm-document :is(strong, b) {
|
|
89
|
+
font-weight: 600;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.pm-document :is(ul, ol) {
|
|
93
|
+
margin: var(--pm-space-4) 0;
|
|
94
|
+
padding-left: 1.25rem;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.pm-document ul {
|
|
98
|
+
list-style: disc;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.pm-document ol {
|
|
102
|
+
list-style: decimal;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.pm-document li {
|
|
106
|
+
overflow-wrap: break-word;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.pm-document li + li {
|
|
110
|
+
margin-top: var(--pm-space-2);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.pm-document li > :is(ol, ul) {
|
|
114
|
+
margin: var(--pm-space-2) 0 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.pm-document table {
|
|
118
|
+
display: block;
|
|
119
|
+
margin: var(--pm-space-5) 0;
|
|
120
|
+
overflow-x: auto;
|
|
121
|
+
border-collapse: collapse;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.pm-document tr {
|
|
125
|
+
border-top: var(--pm-size-hairline) solid var(--pm-color-line);
|
|
126
|
+
background-color: var(--pm-color-bg);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.pm-document tr:nth-child(2n) {
|
|
130
|
+
background-color: var(--pm-color-sunk);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.pm-document :is(th, td) {
|
|
134
|
+
padding: var(--pm-space-2) var(--pm-space-4);
|
|
135
|
+
border: var(--pm-size-hairline) solid var(--pm-color-line);
|
|
136
|
+
font-size: var(--pm-text-md);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.pm-document th {
|
|
140
|
+
background-color: var(--pm-color-sunk);
|
|
141
|
+
color: var(--pm-color-muted);
|
|
142
|
+
font-weight: 600;
|
|
143
|
+
text-align: left;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.pm-document hr {
|
|
147
|
+
margin: var(--pm-space-4) 0;
|
|
148
|
+
border: 0;
|
|
149
|
+
border-top: var(--pm-size-hairline) solid var(--pm-color-line);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.pm-document :not(pre) > code {
|
|
153
|
+
padding: var(--pm-space-px) var(--pm-space-1);
|
|
154
|
+
border-radius: var(--pm-radius-chip);
|
|
155
|
+
background-color: var(--pm-color-sunk);
|
|
156
|
+
color: var(--pm-color-ink);
|
|
157
|
+
font-family: var(--pm-font-mono);
|
|
158
|
+
font-size: 0.875em;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.pm-document :is(h1, h2, h3) > code {
|
|
162
|
+
font-size: 0.9em;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.pm-document a > code {
|
|
166
|
+
color: inherit;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.pm-document :is(img, video) {
|
|
170
|
+
max-width: 100%;
|
|
171
|
+
height: auto;
|
|
172
|
+
}
|
package/src/client/host.ts
CHANGED
|
@@ -1,94 +1,22 @@
|
|
|
1
|
-
import { presmiumReport, presmiumService } from '@lincyaw/host'
|
|
2
|
-
import type { HostAdapter, Navigation, NavigationPosition, SharedStates, ViewRole } from '@lincyaw/host'
|
|
3
|
-
import { isRecord } from '@lincyaw/kernel'
|
|
4
|
-
import type { IRSection, PresentationMode } from '@lincyaw/kernel'
|
|
5
|
-
import {
|
|
6
|
-
import type {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export
|
|
11
|
-
lang:
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return {
|
|
24
|
-
get mode(): PresentationMode {
|
|
25
|
-
return overlay() ? 'slides' : 'document'
|
|
26
|
-
},
|
|
27
|
-
lang: options.lang,
|
|
28
|
-
useCursor() {
|
|
29
|
-
const section = useSectionContext()
|
|
30
|
-
return computed(() => {
|
|
31
|
-
if (section) return { slide: section.section.value.index, step: section.step.value }
|
|
32
|
-
const { position } = viewer
|
|
33
|
-
return { slide: viewer.section(position.value.section)?.index ?? 1, step: position.value.step }
|
|
34
|
-
})
|
|
35
|
-
},
|
|
36
|
-
useStep() {
|
|
37
|
-
const section = useSectionContext()
|
|
38
|
-
return computed(() => section?.step.value ?? FINAL_STEP)
|
|
39
|
-
},
|
|
40
|
-
useViewRole() {
|
|
41
|
-
const section = useSectionContext()
|
|
42
|
-
return section ? computed(() => roleOf(section.view.value)) : role
|
|
43
|
-
},
|
|
44
|
-
useStaticRender: () => staticRender,
|
|
45
|
-
useSectionConfig(pluginId) {
|
|
46
|
-
const context = useSectionContext()
|
|
47
|
-
return computed(() => {
|
|
48
|
-
const section = context?.section.value ?? viewer.section(viewer.position.value.section)
|
|
49
|
-
const global = viewer.doc.value.config.presmium[pluginId]
|
|
50
|
-
return { ...(isRecord(global) ? global : {}), ...section?.config?.[pluginId] }
|
|
51
|
-
})
|
|
52
|
-
},
|
|
53
|
-
useSharedState: options.shared.sharedState,
|
|
54
|
-
getService: presmiumService,
|
|
55
|
-
startReport: presmiumReport,
|
|
56
|
-
useDarkMode: () => useData().isDark,
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function createViewerNavigation(viewer: Viewer): Navigation {
|
|
61
|
-
const keys = new WeakMap<IRSection, StepKeys>()
|
|
62
|
-
const keysOf = (section: IRSection) => {
|
|
63
|
-
let known = keys.get(section)
|
|
64
|
-
if (!known) keys.set(section, known = stepKeys(section))
|
|
65
|
-
return known
|
|
66
|
-
}
|
|
67
|
-
return {
|
|
68
|
-
position: computed((): NavigationPosition & FollowAnchor => {
|
|
69
|
-
const { section: id, step } = viewer.position.value
|
|
70
|
-
const section = viewer.section(id)
|
|
71
|
-
const back = backSection(viewer.doc.value.sections, id)
|
|
72
|
-
return {
|
|
73
|
-
slide: section?.index ?? 1,
|
|
74
|
-
step,
|
|
75
|
-
view: viewer.view.value,
|
|
76
|
-
section: id,
|
|
77
|
-
...section ? { trail: stepTrail(keysOf(section), step) } : {},
|
|
78
|
-
...back ? { back } : {},
|
|
79
|
-
}
|
|
80
|
-
}),
|
|
81
|
-
go(position: NavigationPosition & FollowAnchor) {
|
|
82
|
-
const { slide, step, view, section: id } = position
|
|
83
|
-
const target = view === undefined ? undefined : getView(view)
|
|
84
|
-
if (target && !target.hidden) viewer.setView(target.id)
|
|
85
|
-
const place = id === undefined
|
|
86
|
-
? { section: viewer.doc.value.sections.find(s => s.index === slide)?.id, step }
|
|
87
|
-
: followedPlace(position, other => {
|
|
88
|
-
const section = viewer.section(other)
|
|
89
|
-
return section && keysOf(section)
|
|
90
|
-
})
|
|
91
|
-
if (place?.section !== undefined) viewer.show({ section: place.section, step: place.step })
|
|
92
|
-
},
|
|
93
|
-
}
|
|
94
|
-
}
|
|
1
|
+
import { createStore, presmiumReport, presmiumService, useStore } from '@lincyaw/host'
|
|
2
|
+
import type { HostAdapter, Navigation, NavigationPosition, SharedStates, Store, ViewRole } from '@lincyaw/host'
|
|
3
|
+
import { backSection, followedPlace, isRecord, stepKeys, stepTrail } from '@lincyaw/kernel'
|
|
4
|
+
import type { FollowAnchor, IRSection, PresentationMode, StepKeys } from '@lincyaw/kernel'
|
|
5
|
+
import { findView, useSectionContext } from '@lincyaw/viewer'
|
|
6
|
+
import type { Viewer } from '@lincyaw/viewer'
|
|
7
|
+
import { FINAL_STEP } from '@lincyaw/viewer/domain'
|
|
8
|
+
export interface WebHostAdapterOptions { lang: string, shared: SharedStates, printed?: boolean, isDark(): Store<boolean> }
|
|
9
|
+
function roleOf(view: string): ViewRole { return view === 'presenter' ? 'presenter' : findView(view)?.kind === 'overlay' ? 'audience' : 'reader' }
|
|
10
|
+
export function createWebHostAdapter(viewer: Viewer, options: WebHostAdapterOptions): HostAdapter { return {
|
|
11
|
+
get mode(): PresentationMode { return findView(viewer.view.getSnapshot())?.kind === 'overlay' ? 'slides' : 'document' }, lang: options.lang,
|
|
12
|
+
useDoc() { return useStore(viewer.doc) },
|
|
13
|
+
useCursor() { const section = useSectionContext(); const position = useStore(section ? undefined : viewer.position, viewer.position.getSnapshot()); return section ? { slide: section.section.index, step: section.step } : { slide: viewer.section(position.section)?.index ?? 1, step: position.step } },
|
|
14
|
+
useStep() { return useSectionContext()?.step ?? FINAL_STEP },
|
|
15
|
+
useViewRole() { const section = useSectionContext(); const view = useStore(viewer.view); return roleOf(section?.view ?? view) },
|
|
16
|
+
useStaticRender: () => options.printed === true,
|
|
17
|
+
useSectionConfig(pluginId) { const context = useSectionContext(); const doc = useStore(viewer.doc); const position = useStore(viewer.position); const section = context?.section ?? viewer.section(position.section); const global = doc.config.presmium[pluginId]; return { ...(isRecord(global) ? global : {}), ...section?.config?.[pluginId] } },
|
|
18
|
+
useSharedState(key, initial, scope) { const store = options.shared.sharedState(key, initial, scope); return [useStore(store), value => store.set(value)] },
|
|
19
|
+
sharedStateStore: options.shared.sharedState, getService: presmiumService, startReport: presmiumReport,
|
|
20
|
+
useDarkMode() { const store = options.isDark(); return [useStore(store), value => store.set(value)] },
|
|
21
|
+
} }
|
|
22
|
+
export function createViewerNavigation(viewer: Viewer): Navigation { const keys = new WeakMap<IRSection, StepKeys>(); const keysOf = (section: IRSection) => { let known = keys.get(section); if (!known) keys.set(section, known = stepKeys(section)); return known }; const read = (): NavigationPosition & FollowAnchor => { const { section: id, step, boundary } = viewer.position.getSnapshot(); const section = viewer.section(id); const back = backSection(viewer.doc.getSnapshot().sections, id); return { slide: section?.index ?? 1, step, ...boundary ? { boundary } : {}, view: viewer.view.getSnapshot(), section: id, ...(section ? { trail: stepTrail(keysOf(section), step) } : {}), ...(back ? { back } : {}) } }; const position = createStore(read()); const update = () => position.set(read()); viewer.position.subscribe(update); viewer.view.subscribe(update); viewer.doc.subscribe(update); return { position, go(input: NavigationPosition & FollowAnchor) { const { slide, step, view, boundary, section: id } = input; const target = view === undefined ? undefined : findView(view); if (target && !target.hidden) viewer.setView(target.id); const destination = id === undefined ? { section: viewer.doc.getSnapshot().sections.find(s => s.index === slide)?.id, step } : followedPlace(input, other => { const section = viewer.section(other); return section && keysOf(section) }); if (destination?.section !== undefined) viewer.show({ section: destination.section, step: destination.step, ...boundary ? { boundary } : {} }) } } }
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { componentErrors, componentErrorRevision, presmiumReport, presmiumService, PLAYBACK_CONTROL, PRODUCTION_PLAYBACK, createStore, observe, runtimeRevision } from '@lincyaw/host'
|
|
2
|
+
import type { NavigationPosition, PlaybackControl } from '@lincyaw/host'
|
|
3
|
+
import { connectPreviewFrame, createPreviewEvents, irBlocks, isPreviewActive, isPreviewEmbedded, PREVIEW_REVISION, stepKeys, stepTrail } from '@lincyaw/kernel'
|
|
4
|
+
import type { FollowAnchor, PreviewFrame, PreviewEventSubscription, PreviewEvents } from '@lincyaw/kernel'
|
|
5
|
+
import type { Viewer } from '@lincyaw/viewer'
|
|
6
|
+
import { findView, registryRevision } from '@lincyaw/viewer'
|
|
7
|
+
import type { Store } from '@lincyaw/host'
|
|
8
|
+
import { createViewerNavigation } from './host.ts'
|
|
9
|
+
|
|
10
|
+
export interface WebPreviewOptions {
|
|
11
|
+
origins: string[]
|
|
12
|
+
document: string
|
|
13
|
+
/** The form the page shows: its pages, or its slides. */
|
|
14
|
+
renderer?: 'web' | 'slides'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WebPreviewPage {
|
|
18
|
+
viewer: Viewer
|
|
19
|
+
dark: Store<boolean>
|
|
20
|
+
setActive(active: boolean): Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What a preview dev server's theme gives the document theme (`ThemeInput.preview`). */
|
|
24
|
+
export interface WebPreview {
|
|
25
|
+
/** False when the workbench paused the preview before the page loaded again. */
|
|
26
|
+
active: boolean
|
|
27
|
+
/** The page is in a workbench: it shows the document as a page, without the site's navigation. */
|
|
28
|
+
embedded: boolean
|
|
29
|
+
events?: PreviewEventSubscription
|
|
30
|
+
connect(page: WebPreviewPage): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function webPreview(options: WebPreviewOptions): WebPreview {
|
|
34
|
+
const events = createPreviewEvents(options.origins, options.document)
|
|
35
|
+
return { active: isPreviewActive(), embedded: isPreviewEmbedded(), ...events ? { events: events.subscribe } : {}, connect: page => connectWebPreviewFrame(page, options, events) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function connectWebPreviewFrame(page: WebPreviewPage, options: WebPreviewOptions, events?: PreviewEvents): void {
|
|
39
|
+
const { viewer, dark } = page
|
|
40
|
+
const navigation = createViewerNavigation(viewer)
|
|
41
|
+
const pendingGo = createStore<Parameters<typeof navigation.go>[0] | undefined>(undefined)
|
|
42
|
+
// Preview ready can precede async viewer-plugin registration and the lecture list. Keep the latest requested view
|
|
43
|
+
// until it is available instead of losing a first switch to video while this window is still starting.
|
|
44
|
+
const applyPending = () => {
|
|
45
|
+
const destination = pendingGo.getSnapshot()
|
|
46
|
+
if (!destination) return
|
|
47
|
+
const view = destination.view && findView(destination.view)
|
|
48
|
+
if (destination.view && (!view || view.hidden)) return
|
|
49
|
+
pendingGo.set(undefined)
|
|
50
|
+
navigation.go(destination)
|
|
51
|
+
}
|
|
52
|
+
registryRevision.subscribe(applyPending)
|
|
53
|
+
pendingGo.subscribe(applyPending)
|
|
54
|
+
const revision = () => {
|
|
55
|
+
const value = viewer.doc.getSnapshot().meta[PREVIEW_REVISION]
|
|
56
|
+
return typeof value === 'string' ? value : undefined
|
|
57
|
+
}
|
|
58
|
+
const blockStep = (section: string, block: string) => {
|
|
59
|
+
const found = viewer.section(section)
|
|
60
|
+
return found ? [...irBlocks(found.blocks)].find(b => b.id === block)?.at : undefined
|
|
61
|
+
}
|
|
62
|
+
const frame: PreviewFrame | undefined = connectPreviewFrame({
|
|
63
|
+
renderer: options.renderer ?? 'web',
|
|
64
|
+
events,
|
|
65
|
+
...viewer.options.confirmedRevision ? { confirmPresentation: (revision: string) => { viewer.options.confirmedRevision!.set(revision); frame?.moved() } } : {},
|
|
66
|
+
document: options.document,
|
|
67
|
+
origins: options.origins,
|
|
68
|
+
revision,
|
|
69
|
+
position() {
|
|
70
|
+
if (viewer.options.confirmedRevision && viewer.options.confirmedRevision.getSnapshot() !== revision()) return
|
|
71
|
+
const { section, step, trail, view } = navigation.position.getSnapshot() as NavigationPosition & FollowAnchor
|
|
72
|
+
return section === undefined ? undefined : { section, step, trail: trail ?? [], ...view ? { view } : {} }
|
|
73
|
+
},
|
|
74
|
+
go: ({ section, step, trail, view }) => { presmiumService(PLAYBACK_CONTROL)?.pause(); pendingGo.set({ slide: 0, step: step ?? 0, section, ...trail ? { trail } : {}, ...view ? { view } : {} }) },
|
|
75
|
+
reveal: ({ section, block }) => viewer.show({ section, step: (block && blockStep(section, block)) || 0 }),
|
|
76
|
+
setDark(value) {
|
|
77
|
+
dark.set(value)
|
|
78
|
+
},
|
|
79
|
+
setActive: page.setActive,
|
|
80
|
+
activityChanged: value => viewer.options.editingActive?.set(value),
|
|
81
|
+
pausePlayback: () => presmiumService(PLAYBACK_CONTROL)?.pause(),
|
|
82
|
+
async productionPlayback(command, signal) {
|
|
83
|
+
// The page's ready handshake can precede asynchronous browser-plugin setup.
|
|
84
|
+
if (!presmiumService(PRODUCTION_PLAYBACK) && !presmiumReport()) await new Promise<void>((resolve, reject) => {
|
|
85
|
+
const ready = () => {
|
|
86
|
+
if (!signal.aborted && !presmiumReport()) return
|
|
87
|
+
stop(); signal.removeEventListener('abort', ready)
|
|
88
|
+
signal.aborted ? reject(signal.reason ?? new DOMException('Playback cancelled', 'AbortError')) : resolve()
|
|
89
|
+
}
|
|
90
|
+
const stop = runtimeRevision.subscribe(ready)
|
|
91
|
+
signal.addEventListener('abort', ready, { once: true }); ready()
|
|
92
|
+
})
|
|
93
|
+
signal.throwIfAborted()
|
|
94
|
+
const service = presmiumService(PRODUCTION_PLAYBACK)
|
|
95
|
+
if (!service || service.version !== 1) throw new Error('production-playback-unavailable')
|
|
96
|
+
return service.control(command, signal)
|
|
97
|
+
},
|
|
98
|
+
subscribeProductionPlayback(listener) {
|
|
99
|
+
let current = presmiumService(PRODUCTION_PLAYBACK), release = () => {}
|
|
100
|
+
const attach = () => {
|
|
101
|
+
const next = presmiumService(PRODUCTION_PLAYBACK)
|
|
102
|
+
release(); current = next
|
|
103
|
+
release = current?.state.subscribe(() => listener(current?.state.getSnapshot())) ?? (() => {})
|
|
104
|
+
listener(current?.state.getSnapshot())
|
|
105
|
+
}
|
|
106
|
+
const stop = runtimeRevision.subscribe(attach)
|
|
107
|
+
attach()
|
|
108
|
+
return () => { stop(); release() }
|
|
109
|
+
},
|
|
110
|
+
setPlaybackGate(authorize) {
|
|
111
|
+
let current: PlaybackControl | undefined
|
|
112
|
+
let release = () => {}
|
|
113
|
+
const attach = () => {
|
|
114
|
+
const next = presmiumService(PLAYBACK_CONTROL)
|
|
115
|
+
if (next === current) return
|
|
116
|
+
release()
|
|
117
|
+
current = next
|
|
118
|
+
release = current?.setGate(authorize) ?? (() => {})
|
|
119
|
+
}
|
|
120
|
+
const stop = runtimeRevision.subscribe(attach)
|
|
121
|
+
attach()
|
|
122
|
+
return () => { stop(); release() }
|
|
123
|
+
},
|
|
124
|
+
stepOf({ section, block }) {
|
|
125
|
+
const step = blockStep(section, block)
|
|
126
|
+
const found = viewer.section(section)
|
|
127
|
+
return step === undefined || !found ? undefined : { step, trail: stepTrail(stepKeys(found), step) }
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
if (!frame) return
|
|
131
|
+
navigation.position.subscribe(() => frame.moved())
|
|
132
|
+
viewer.doc.subscribe(() => { presmiumService(PLAYBACK_CONTROL)?.pause(); requestAnimationFrame(() => frame.updated()) })
|
|
133
|
+
watchProblems(frame)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function watchProblems(frame: PreviewFrame): void {
|
|
137
|
+
let told = new Map<string, { level: 'error' | 'warning', plugin: string, message: string }>()
|
|
138
|
+
const report = () => {
|
|
139
|
+
const plugins = presmiumReport()?.plugins ?? [], errors = componentErrors()
|
|
140
|
+
const problems = [
|
|
141
|
+
...plugins.filter(p => p.status === 'failed' || p.status === 'disabled').map(p => ({ level: p.status === 'failed' ? 'error' as const : 'warning' as const, plugin: p.id, message: p.reason ?? p.status })),
|
|
142
|
+
...errors.map(e => ({ level: 'error' as const, plugin: e.component, message: e.message })),
|
|
143
|
+
]
|
|
144
|
+
const current = new Map(problems.map(problem => [JSON.stringify(problem), problem]))
|
|
145
|
+
for (const [key, problem] of told) if (!current.has(key)) frame.report({ ...problem, resolved: true })
|
|
146
|
+
for (const [key, problem] of current) if (!told.has(key)) frame.report(problem)
|
|
147
|
+
told = current
|
|
148
|
+
}
|
|
149
|
+
runtimeRevision.subscribe(report)
|
|
150
|
+
componentErrorRevision.subscribe(report)
|
|
151
|
+
report()
|
|
152
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { componentErrors, createServerClient, pluginsOf, presmiumReport, provideServer, registerPresmiumPlugin, reportPluginFailure, setHostActive, startHost } from '@lincyaw/host'
|
|
2
|
+
import type { ServerClient, SharedStates, SyncTransports } from '@lincyaw/host'
|
|
3
|
+
import { errorMessage } from '@lincyaw/kernel'
|
|
4
|
+
import type { IRDocument } from '@lincyaw/kernel'
|
|
5
|
+
import { PmComponentFallback, tryInstallViewerPlugin, withSlideGlobals } from '@lincyaw/viewer'
|
|
6
|
+
import type { Viewer } from '@lincyaw/viewer'
|
|
7
|
+
import { createElement as h, useEffect, useState } from 'react'
|
|
8
|
+
import type { ComponentType } from 'react'
|
|
9
|
+
import type { Store } from '@lincyaw/host'
|
|
10
|
+
import { flushSync } from 'react-dom'
|
|
11
|
+
import { createViewerNavigation } from './host.ts'
|
|
12
|
+
|
|
13
|
+
/** The document's runtime, apart from the shell that shows it (the site, the slides page, or the workbench's preview). */
|
|
14
|
+
|
|
15
|
+
export interface ViewerModule {
|
|
16
|
+
name: string
|
|
17
|
+
load: () => Promise<unknown>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What the runtime starts: the document's browser plugins and viewer plugins, and whether all of them restart safely. */
|
|
21
|
+
export interface RuntimeModules {
|
|
22
|
+
plugins: (() => Promise<unknown>)[]
|
|
23
|
+
viewers?: ViewerModule[]
|
|
24
|
+
restartable?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Registers the browser plugins of the document and starts them, unless the runtime was disabled while their modules loaded. */
|
|
28
|
+
export async function startRuntime(loaders: RuntimeModules['plugins'], doc: IRDocument, viewer: Viewer, transports: SyncTransports, server: ServerClient | undefined, enabled: () => boolean): Promise<{ started: boolean, restartable: boolean }> {
|
|
29
|
+
const config = doc.config.presmium
|
|
30
|
+
let failed = false
|
|
31
|
+
const modules = await Promise.all(loaders.map(load => load().catch((error) => {
|
|
32
|
+
failed = true
|
|
33
|
+
console.error('[presmium] a plugin module failed to load', error)
|
|
34
|
+
return {}
|
|
35
|
+
})))
|
|
36
|
+
const plugins = modules.flatMap(module => pluginsOf(module))
|
|
37
|
+
const restartable = !failed && plugins.every(plugin => plugin.restartable === true)
|
|
38
|
+
if (!enabled()) return { started: false, restartable }
|
|
39
|
+
plugins.forEach(registerPresmiumPlugin)
|
|
40
|
+
await startHost({ mode: 'document', config, navigation: createViewerNavigation(viewer), transports, server, environments: doc.config.execute?.environments })
|
|
41
|
+
return { started: true, restartable }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A component of the document, rendered once mounted (its module may touch the browser), with a placeholder until then. */
|
|
45
|
+
export function clientOnly(name: string, load: () => Promise<unknown>): ComponentType<any> {
|
|
46
|
+
let pending: Promise<ComponentType<any>> | undefined
|
|
47
|
+
return function ClientOnly(props: Record<string, unknown>) {
|
|
48
|
+
const [component, setComponent] = useState<ComponentType<any>>()
|
|
49
|
+
const [error, setError] = useState<unknown>()
|
|
50
|
+
useEffect(() => { let disposed = false; pending ??= load().then(module => withSlideGlobals((module as { default: ComponentType<any> }).default)); pending.then(value => { if (!disposed) setComponent(() => value) }, reason => { if (!disposed) setError(reason) }); return () => { disposed = true } }, [])
|
|
51
|
+
if (error) throw error
|
|
52
|
+
return component ? h(component, props) : h('div', { className: 'pm-client-only', 'data-component': name }, h(PmComponentFallback))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function loadViewerPlugins(modules: ViewerModule[]): Promise<boolean> {
|
|
57
|
+
let valid = true
|
|
58
|
+
await Promise.all(modules.map(async ({ name, load }) => {
|
|
59
|
+
const id = `${name} (viewer)`
|
|
60
|
+
try {
|
|
61
|
+
const reason = tryInstallViewerPlugin(((await load()) as { default?: unknown }).default, id)
|
|
62
|
+
if (reason) {
|
|
63
|
+
valid = false
|
|
64
|
+
reportPluginFailure(id, reason)
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
valid = false
|
|
68
|
+
console.error(`[presmium] ${id} failed to load`, error)
|
|
69
|
+
reportPluginFailure(id, errorMessage(error))
|
|
70
|
+
}
|
|
71
|
+
}))
|
|
72
|
+
return valid
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RuntimeLifecycleInput {
|
|
76
|
+
modules: RuntimeModules
|
|
77
|
+
/** The document the runtime started with: a pause cannot carry it over a change of configuration or language. */
|
|
78
|
+
doc: IRDocument
|
|
79
|
+
viewer: Viewer
|
|
80
|
+
shared: SharedStates
|
|
81
|
+
/** The document as last fetched, in its own language. */
|
|
82
|
+
original: Store<IRDocument>
|
|
83
|
+
/** Whether the page shows its content; a paused page unmounts it. */
|
|
84
|
+
active: Store<boolean>
|
|
85
|
+
/** The server client of the document, when the page has a server. */
|
|
86
|
+
server?: Parameters<typeof createServerClient>[0]
|
|
87
|
+
/** Fetches the current IR; `force` fails when it cannot. */
|
|
88
|
+
refresh(force?: boolean): Promise<void>
|
|
89
|
+
/** Called once the viewer plugins are loaded (their views may be asked for). */
|
|
90
|
+
onViewers?(ready: Promise<boolean>): void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface RuntimeLifecycle {
|
|
94
|
+
/** Whether the runtime was last asked to be active. */
|
|
95
|
+
isRequested(): boolean
|
|
96
|
+
setActive(active: boolean): Promise<void>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Starts and pauses the document's runtime, the browser plugins and the page, as a preview asks: a paused page unmounts and
|
|
101
|
+
* stops its plugins; on resuming it fetches the current IR and restarts them, or fails when they cannot restart safely.
|
|
102
|
+
*/
|
|
103
|
+
export function createRuntimeLifecycle({ modules, doc, viewer, shared, original, active, server: serverOptions, refresh, onViewers }: RuntimeLifecycleInput): RuntimeLifecycle {
|
|
104
|
+
let requested = active.getSnapshot()
|
|
105
|
+
let runtimeStarted = false
|
|
106
|
+
let restartable = false
|
|
107
|
+
let viewersLoaded = false
|
|
108
|
+
let viewersValid = false
|
|
109
|
+
let wasPaused = !requested
|
|
110
|
+
let queue = Promise.resolve()
|
|
111
|
+
const runtimeConfig = JSON.stringify(doc.config)
|
|
112
|
+
const pauseMedia = () => {
|
|
113
|
+
for (const media of document.querySelectorAll('audio, video')) (media as HTMLMediaElement).pause()
|
|
114
|
+
}
|
|
115
|
+
const setActive = (on: boolean): Promise<void> => {
|
|
116
|
+
requested = on
|
|
117
|
+
const change = queue.then(async () => {
|
|
118
|
+
if (!on) {
|
|
119
|
+
if (!restartable || componentErrors().length > 0 || presmiumReport()?.plugins.some(plugin => plugin.status !== 'started')) {
|
|
120
|
+
throw new Error('the document runtime cannot safely restart; reload the preview')
|
|
121
|
+
}
|
|
122
|
+
pauseMedia()
|
|
123
|
+
flushSync(() => active.set(false))
|
|
124
|
+
await Promise.resolve()
|
|
125
|
+
await setHostActive(false)
|
|
126
|
+
wasPaused = true
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
if (wasPaused) {
|
|
130
|
+
await refresh(true)
|
|
131
|
+
if (JSON.stringify(original.getSnapshot().config) !== runtimeConfig || original.getSnapshot().meta.lang !== doc.meta.lang) {
|
|
132
|
+
throw new Error('the document runtime configuration changed; reload the preview')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!runtimeStarted) {
|
|
136
|
+
if (!viewersLoaded) {
|
|
137
|
+
const ready = loadViewerPlugins(modules.viewers ?? [])
|
|
138
|
+
onViewers?.(ready)
|
|
139
|
+
viewersValid = await ready === true
|
|
140
|
+
viewersLoaded = true
|
|
141
|
+
}
|
|
142
|
+
const server = requested && serverOptions ? createServerClient(serverOptions) : undefined
|
|
143
|
+
if (requested) provideServer(server)
|
|
144
|
+
const runtime = await startRuntime(modules.plugins, viewer.doc.getSnapshot(), viewer, shared.transports, server, () => requested)
|
|
145
|
+
runtimeStarted = runtime.started
|
|
146
|
+
restartable = modules.restartable === true && viewersValid && runtime.restartable
|
|
147
|
+
} else await setHostActive(true)
|
|
148
|
+
wasPaused = false
|
|
149
|
+
// A queued pause checks the completed initial load before disposing anything.
|
|
150
|
+
if (requested) {
|
|
151
|
+
flushSync(() => active.set(true))
|
|
152
|
+
await Promise.resolve()
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
queue = change.catch(() => undefined)
|
|
156
|
+
return change
|
|
157
|
+
}
|
|
158
|
+
return { isRequested: () => requested, setActive }
|
|
159
|
+
}
|