@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/section.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { isChapterTitle, narrationAt } from '@lincyaw/kernel'
2
+ import type { IRBlock, IRSection } from '@lincyaw/kernel'
3
+ import type { InjectionKey, PropType, Ref, VNodeChild } from 'vue'
4
+ import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRef } from 'vue'
5
+ import { BlockView } from './block.ts'
6
+ import { SECTION_CONTEXT } from './context.ts'
7
+ import { FINAL_STEP, sectionDiagnostics, stepAtLine } from './domain/index.ts'
8
+ import type { Position } from './domain/index.ts'
9
+ import { messages } from './i18n.ts'
10
+ import { useViewer } from './viewer.ts'
11
+
12
+ export type SectionLayout = 'document' | 'scrolly'
13
+
14
+ export const STEP_LINE = 0.6
15
+
16
+ const READ_LINE = 0.25
17
+
18
+ interface Tracked {
19
+ el: HTMLElement
20
+ id(): string
21
+ layout(): SectionLayout
22
+ step: Ref<number>
23
+ }
24
+
25
+ export interface PageTracker {
26
+ add(section: Tracked): () => void
27
+ scrollTo(position: Position): boolean
28
+ hold(): () => void
29
+ dispose(): void
30
+ }
31
+
32
+ export const PAGE_TRACKER: InjectionKey<PageTracker> = Symbol('presmium.viewer.page')
33
+
34
+ function markers(el: HTMLElement): HTMLElement[] {
35
+ return [...el.querySelectorAll<HTMLElement>('.pm-step')]
36
+ }
37
+
38
+ export function createPageTracker(report: (section: string, step?: number) => void, active: () => boolean): PageTracker {
39
+ const tracked = new Set<Tracked>()
40
+ let frame = 0
41
+ let restoredY: number | undefined
42
+ let pinned: Position | undefined
43
+ const resized = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => schedule())
44
+ const scrolled = () => restoredY !== undefined && Math.abs(window.scrollY - restoredY) >= 2
45
+
46
+ const update = () => {
47
+ frame = 0
48
+ const steps = window.innerHeight * STEP_LINE
49
+ const line = window.innerHeight * READ_LINE
50
+ let reading: { section: Tracked, top: number } | undefined
51
+ let first: { section: Tracked, top: number } | undefined
52
+ for (const section of tracked) {
53
+ if (section.layout() === 'scrolly') {
54
+ section.step.value = stepAtLine(markers(section.el).map(m => ({ step: Number(m.dataset.step), top: m.getBoundingClientRect().top })), steps)
55
+ }
56
+ const top = section.el.getBoundingClientRect().top
57
+ if (top <= line && (!reading || top > reading.top)) reading = { section, top }
58
+ if (!first || top < first.top) first = { section, top }
59
+ }
60
+ if (restoredY !== undefined && !scrolled()) {
61
+ if (pinned) place(pinned)
62
+ return
63
+ }
64
+ restoredY = undefined
65
+ pinned = undefined
66
+ if (!active()) return
67
+ const current = (reading ?? first)?.section
68
+ if (current) report(current.id(), current.layout() === 'scrolly' ? current.step.value : undefined)
69
+ }
70
+ const schedule = () => {
71
+ frame ||= requestAnimationFrame(update)
72
+ }
73
+ const place = (position: Position): boolean => {
74
+ const section = [...tracked].find(s => s.id() === position.section)
75
+ if (!section) return false
76
+ const marker = section.layout() === 'scrolly'
77
+ ? markers(section.el).filter(m => Number(m.dataset.step) <= position.step).at(-1)
78
+ : undefined
79
+ const top = marker
80
+ ? window.scrollY + marker.getBoundingClientRect().top - window.innerHeight * STEP_LINE + 1
81
+ : window.scrollY + section.el.getBoundingClientRect().top - Number.parseFloat(getComputedStyle(section.el).scrollMarginTop || '0')
82
+ if (Math.abs(window.scrollY - Math.max(top, 0)) >= 1) window.scrollTo({ top, behavior: 'instant' })
83
+ restoredY = window.scrollY
84
+ return true
85
+ }
86
+ const dispose = () => {
87
+ cancelAnimationFrame(frame)
88
+ frame = 0
89
+ resized?.disconnect()
90
+ window.removeEventListener('scroll', schedule)
91
+ window.removeEventListener('resize', schedule)
92
+ }
93
+
94
+ return {
95
+ add(section) {
96
+ if (tracked.size === 0) {
97
+ window.addEventListener('scroll', schedule, { passive: true })
98
+ window.addEventListener('resize', schedule)
99
+ }
100
+ tracked.add(section)
101
+ resized?.observe(section.el)
102
+ schedule()
103
+ return () => {
104
+ tracked.delete(section)
105
+ resized?.unobserve(section.el)
106
+ if (tracked.size === 0) dispose()
107
+ }
108
+ },
109
+ hold() {
110
+ const line = window.innerHeight * STEP_LINE
111
+ let reading: { section: Tracked, top: number } | undefined
112
+ for (const section of tracked) {
113
+ const top = section.el.getBoundingClientRect().top
114
+ if (top <= line && (!reading || top > reading.top)) reading = { section, top }
115
+ }
116
+ if (!reading) return () => {}
117
+ const { el } = reading.section
118
+ const above = (candidates: HTMLElement[]) => candidates.filter(c => c.getBoundingClientRect().top <= line).at(-1)
119
+ const anchor = reading.section.layout() === 'scrolly'
120
+ ? above(markers(el)) ?? el
121
+ : [...el.querySelectorAll<HTMLElement>('.pm-block')].find(b => b.getBoundingClientRect().top <= line && b.getBoundingClientRect().bottom > line) ?? el
122
+ const top = anchor.getBoundingClientRect().top
123
+ return () => {
124
+ const moved = anchor.isConnected ? anchor.getBoundingClientRect().top - top : 0
125
+ if (Math.abs(moved) < 1) return
126
+ window.scrollBy({ top: moved, behavior: 'instant' })
127
+ restoredY = window.scrollY
128
+ schedule()
129
+ }
130
+ },
131
+ scrollTo(position) {
132
+ if (!place(position)) return false
133
+ pinned = position
134
+ schedule()
135
+ return true
136
+ },
137
+ dispose,
138
+ }
139
+ }
140
+
141
+ function blockViews(blocks: IRBlock[], section: IRSection, step: number, expanded: boolean): VNodeChild[] {
142
+ return blocks.map(block => h(BlockView, { key: block.id, block, section, step, expanded }))
143
+ }
144
+
145
+
146
+ export const SectionView = defineComponent({
147
+ name: 'PmSectionView',
148
+ props: {
149
+ section: { type: Object as PropType<IRSection>, required: true },
150
+ layout: { type: String as PropType<SectionLayout>, default: 'document' },
151
+ },
152
+ setup(props) {
153
+ const viewer = useViewer()
154
+ const tracker = inject(PAGE_TRACKER, undefined)
155
+ const el = shallowRef<HTMLElement>()
156
+ const scrolled = ref(0)
157
+ const step = computed(() => (props.layout === 'scrolly' ? scrolled.value : FINAL_STEP))
158
+ provide(SECTION_CONTEXT, { section: toRef(props, 'section'), step, view: viewer.pageView })
159
+
160
+ let untrack: (() => void) | undefined
161
+ onMounted(() => {
162
+ if (!el.value || !tracker) return
163
+ untrack = tracker.add({ el: el.value, id: () => props.section.id, layout: () => props.layout, step: scrolled })
164
+ })
165
+ onBeforeUnmount(() => untrack?.())
166
+
167
+ const heading = computed(() => (isChapterTitle(viewer.doc.value, props.section) ? 'h1' : 'h2'))
168
+
169
+ return () => {
170
+ const { section, layout } = props
171
+ const t = messages().section
172
+ const current = step.value
173
+ const problems = viewer.options.diagnostics ? sectionDiagnostics(section, viewer.doc.value.diagnostics) : []
174
+ const body = layout === 'scrolly' && section.blocks.some(b => b.role === 'visual')
175
+ ? [
176
+ h('div', { class: 'pm-stage pm-board' }, blockViews(section.blocks.filter(b => b.role === 'visual'), section, current, false)),
177
+ h('div', { class: 'pm-track' }, Array.from({ length: section.steps + 1 }, (_, k) => {
178
+ const narration = narrationAt(section, k)
179
+ return h('div', { 'key': k, 'class': ['pm-step', { 'pm-step--current': k === current, 'pm-step--bare': narration.length === 0 }], 'data-step': k }, blockViews(narration, section, current, false))
180
+ })),
181
+ ]
182
+ : [h('div', { class: 'pm-section__body' }, blockViews(section.blocks, section, current, true))]
183
+ return h('section', {
184
+ 'ref': el,
185
+ 'class': ['pm-section', `pm-section--${layout}`, { 'pm-section--error': problems.some(p => p.severity === 'error') }],
186
+ 'data-section': section.id,
187
+ 'data-index': section.index,
188
+ 'data-step': layout === 'scrolly' ? current : undefined,
189
+ }, [
190
+ h(heading.value, { id: section.id, class: 'pm-section__title', tabindex: -1 }, [
191
+ section.title,
192
+ viewer.options.sectionHeading?.(section, current) ?? null,
193
+ h('a', { 'class': 'header-anchor', 'href': `#${section.id}`, 'aria-label': t.anchor(section.title) }, '​'),
194
+ ]),
195
+ viewer.options.openSource
196
+ ? h('p', { class: 'pm-section__source' }, h('button', {
197
+ type: 'button',
198
+ title: t.sourceTitle(`${section.source.file}:${section.source.line}`),
199
+ onClick: () => viewer.options.openSource!(section.source),
200
+ }, t.openSource))
201
+ : null,
202
+ problems.length
203
+ ? h('ul', { class: 'pm-diagnostics', role: 'alert' }, problems.map((p, i) => h('li', { 'key': i, 'data-severity': p.severity }, `${p.severity}: ${p.message}`)))
204
+ : null,
205
+ ...body,
206
+ ])
207
+ }
208
+ },
209
+ })