@lincyaw/viewer 0.0.0-stage → 1.0.0-rc.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/src/viewer.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { joinWindows } from '@lincyaw/host'
2
+ import type { IRDocument, IRSection, SourcePos } from '@lincyaw/kernel'
3
+ import type { App, InjectionKey, Ref, ShallowRef, VNodeChild } from 'vue'
4
+ import { computed, inject, ref, shallowRef } from 'vue'
5
+ import { applyUpdate, clampPosition, footnoteSections, keepPosition, nextPosition, prevPosition } from './domain/index.ts'
6
+ import type { LiveUpdate, Position } from './domain/index.ts'
7
+ import { getView } from './registry.ts'
8
+
9
+ export interface ViewerOptions {
10
+ renderer?: string
11
+ sectionHref?(id: string): string
12
+ sectionHeading?(section: IRSection, step: number): VNodeChild
13
+ diagnostics?: boolean
14
+ navigate?(position: Position): void
15
+ openPresenter?(position: Position): void
16
+ openSource?(source: SourcePos): void
17
+ }
18
+
19
+ export interface Viewer {
20
+ readonly doc: Readonly<ShallowRef<IRDocument>>
21
+ readonly view: Readonly<Ref<string>>
22
+ readonly pageView: Readonly<Ref<string>>
23
+ readonly position: Readonly<ShallowRef<Position>>
24
+ readonly restoring: Ref<boolean>
25
+ readonly options: ViewerOptions & { renderer: string }
26
+ section(id: string): IRSection | undefined
27
+ footnoteHref(identifier: string): string
28
+ go(position: Position, remote?: boolean): void
29
+ show(position: Position): void
30
+ next(): boolean
31
+ prev(): boolean
32
+ setView(id: string): void
33
+ update(update: LiveUpdate): void
34
+ replace(doc: IRDocument): void
35
+ }
36
+
37
+ export const VIEWER: InjectionKey<Viewer> = Symbol('presmium.viewer')
38
+
39
+ export function footnoteId(identifier: string): string {
40
+ return `fn-${identifier}`
41
+ }
42
+
43
+ const CHANNEL = 'presmium.viewer.position'
44
+
45
+ export function createViewer(initial: IRDocument, options: ViewerOptions = {}): Viewer {
46
+ const doc = shallowRef(initial)
47
+ const view = ref('document')
48
+ const pageView = ref('document')
49
+ const position = shallowRef<Position>(clampPosition(initial, { section: initial.sections[0]?.id ?? '', step: 0 }))
50
+ const restoring = ref(false)
51
+ const presenting = () => getView(view.value)?.kind === 'overlay'
52
+ const footnotes = computed(() => footnoteSections(doc.value))
53
+ const windows = joinWindows(`${CHANNEL}:${initial.meta.title}`, (value) => {
54
+ if (presenting()) viewer.go(value as Position, true)
55
+ }, () => undefined)
56
+ const replace = (next: IRDocument) => {
57
+ const kept = keepPosition(doc.value, next, position.value)
58
+ doc.value = next
59
+ if (kept.section !== position.value.section || kept.step !== position.value.step) position.value = kept
60
+ }
61
+
62
+ const viewer: Viewer = {
63
+ doc,
64
+ view,
65
+ pageView,
66
+ position,
67
+ restoring,
68
+ options: { ...options, renderer: options.renderer ?? 'web' },
69
+ section: id => doc.value.sections.find(s => s.id === id),
70
+ footnoteHref(identifier) {
71
+ const anchor = `#${footnoteId(identifier)}`
72
+ const section = footnotes.value.get(identifier)
73
+ return section && options.sectionHref ? `${options.sectionHref(section).split('#')[0]}${anchor}` : anchor
74
+ },
75
+ go(target, remote = false) {
76
+ const next = clampPosition(doc.value, target)
77
+ if (next.section === position.value.section && next.step === position.value.step) return
78
+ position.value = next
79
+ if (!remote && presenting()) windows?.send(next)
80
+ },
81
+ show(target) {
82
+ viewer.go(target)
83
+ if (!presenting()) restoring.value = true
84
+ },
85
+ next() {
86
+ const next = nextPosition(doc.value, position.value)
87
+ if (next) viewer.go(next)
88
+ return Boolean(next)
89
+ },
90
+ prev() {
91
+ const prev = prevPosition(doc.value, position.value)
92
+ if (prev) viewer.go(prev)
93
+ return Boolean(prev)
94
+ },
95
+ setView(id) {
96
+ const target = getView(id)
97
+ if (!target || id === view.value) return
98
+ view.value = id
99
+ if (target.kind === 'page') pageView.value = id
100
+ restoring.value = true
101
+ },
102
+ update: update => replace(applyUpdate(doc.value, update)),
103
+ replace,
104
+ }
105
+ return viewer
106
+ }
107
+
108
+ export function provideViewer(app: App, viewer: Viewer): void {
109
+ app.provide(VIEWER, viewer)
110
+ }
111
+
112
+ export function useViewer(): Viewer {
113
+ const viewer = inject(VIEWER, undefined)
114
+ if (!viewer) throw new Error('[presmium] no viewer: call installViewer(app, viewer) first')
115
+ return viewer
116
+ }
package/src/views.ts ADDED
@@ -0,0 +1,101 @@
1
+ import type { IRSection } from '@lincyaw/kernel'
2
+ import type { PropType } from 'vue'
3
+ import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, provide, watch } from 'vue'
4
+ import { decoded } from './markdown.ts'
5
+ import { getView } from './registry.ts'
6
+ import { createPageTracker, PAGE_TRACKER, SectionView } from './section.ts'
7
+ import type { SectionLayout } from './section.ts'
8
+ import { useViewer } from './viewer.ts'
9
+
10
+ const sectionsProp = { sections: { type: Array as PropType<IRSection[]>, required: true } } as const
11
+
12
+ export const DocumentView = defineComponent({
13
+ name: 'PmDocumentView',
14
+ props: sectionsProp,
15
+ setup(props) {
16
+ const viewer = useViewer()
17
+ const layout = (section: IRSection): SectionLayout => (section.hints[viewer.options.renderer]?.layout === 'scrolly' ? 'scrolly' : 'document')
18
+ return () => h('div', { class: 'pm-view pm-view--document' }, props.sections.map(section => h(SectionView, { key: section.id, section, layout: layout(section) })))
19
+ },
20
+ })
21
+
22
+ export const ScrollyView = defineComponent({
23
+ name: 'PmScrollyView',
24
+ props: sectionsProp,
25
+ setup(props) {
26
+ return () => h('div', { class: 'pm-view pm-view--scrolly' }, props.sections.map(section => h(SectionView, { key: section.id, section, layout: 'scrolly' })))
27
+ },
28
+ })
29
+
30
+ const frame = () => new Promise(resolve => requestAnimationFrame(resolve))
31
+
32
+ export const ViewHost = defineComponent({
33
+ name: 'PmViewHost',
34
+ props: {
35
+ sections: { type: Array as PropType<string[]>, required: true },
36
+ },
37
+ setup(props) {
38
+ const viewer = useViewer()
39
+ const pageView = computed(() => getView(viewer.pageView.value) ?? getView('document'))
40
+ const isPage = () => getView(viewer.view.value)?.kind === 'page'
41
+ const tracker = createPageTracker((section, step) => {
42
+ const { position } = viewer
43
+ viewer.go({ section, step: step ?? (section === position.value.section ? position.value.step : 0) })
44
+ }, () => isPage() && !viewer.restoring.value)
45
+ provide(PAGE_TRACKER, tracker)
46
+ onBeforeUnmount(() => tracker.dispose())
47
+
48
+ const linked = () => {
49
+ const hash = decoded(location.hash.slice(1))
50
+ return props.sections.includes(hash) ? hash : undefined
51
+ }
52
+ const onHash = () => {
53
+ const section = linked()
54
+ if (section && isPage()) viewer.show({ section, step: 0 })
55
+ }
56
+ onMounted(() => {
57
+ window.addEventListener('hashchange', onHash)
58
+ if (viewer.restoring.value || props.sections.length === 0) return
59
+ const section = linked()
60
+ if (section) viewer.show({ section, step: 0 })
61
+ else if (!props.sections.includes(viewer.position.value.section)) viewer.go({ section: props.sections[0]!, step: 0 })
62
+ })
63
+ onBeforeUnmount(() => window.removeEventListener('hashchange', onHash))
64
+
65
+ watch(viewer.doc, async () => {
66
+ if (!isPage() || viewer.restoring.value) return
67
+ const restore = tracker.hold()
68
+ await nextTick()
69
+ restore()
70
+ await frame()
71
+ restore()
72
+ }, { flush: 'pre' })
73
+
74
+ watch([viewer.restoring, viewer.view], async () => {
75
+ if (!viewer.restoring.value || !isPage()) return
76
+ await nextTick()
77
+ await frame()
78
+ await frame()
79
+ const { position } = viewer
80
+ if (tracker.scrollTo(position.value)) viewer.restoring.value = false
81
+ else if (!props.sections.includes(position.value.section) && viewer.options.navigate) viewer.options.navigate(position.value)
82
+ else viewer.restoring.value = false
83
+ }, { immediate: true })
84
+
85
+ const sections = computed(() => props.sections.flatMap(id => viewer.section(id) ?? []))
86
+ return () => h('div', { 'class': 'pm-view-host', 'aria-busy': viewer.restoring.value ? 'true' : undefined }, [
87
+ pageView.value ? h(pageView.value.component, { sections: sections.value }) : null,
88
+ ])
89
+ },
90
+ })
91
+
92
+ export const OverlayHost = defineComponent({
93
+ name: 'PmOverlayHost',
94
+ setup() {
95
+ const viewer = useViewer()
96
+ return () => {
97
+ const view = getView(viewer.view.value)
98
+ return view?.kind === 'overlay' ? h(view.component, { key: view.id }) : null
99
+ }
100
+ },
101
+ })