@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/LICENSE +202 -0
- package/README.md +29 -2
- package/package.json +53 -3
- package/src/block.ts +122 -0
- package/src/cards.ts +130 -0
- package/src/clicks.ts +124 -0
- package/src/code.ts +235 -0
- package/src/context.ts +16 -0
- package/src/dark.ts +5 -0
- package/src/domain/clicks.ts +38 -0
- package/src/domain/follow.ts +32 -0
- package/src/domain/index.ts +21 -0
- package/src/domain/live.ts +43 -0
- package/src/domain/messages.ts +115 -0
- package/src/domain/origin.ts +93 -0
- package/src/domain/steps.ts +45 -0
- package/src/domain/text.ts +27 -0
- package/src/env.d.ts +3 -0
- package/src/globals.ts +66 -0
- package/src/highlight.ts +73 -0
- package/src/i18n.ts +7 -0
- package/src/index.ts +55 -0
- package/src/markdown.ts +255 -0
- package/src/operations.ts +28 -0
- package/src/outputs.ts +97 -0
- package/src/plugins.ts +34 -0
- package/src/present.ts +327 -0
- package/src/registry.ts +81 -0
- package/src/rich.ts +155 -0
- package/src/section.ts +209 -0
- package/src/style.css +833 -0
- package/src/viewer.ts +116 -0
- package/src/views.ts +101 -0
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
|
+
})
|