@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/present.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { FocusScope } from 'reka-ui'
|
|
2
|
+
import { PmApprovals } from '@lincyaw/host'
|
|
3
|
+
import { blockText, sectionNarration } from '@lincyaw/kernel'
|
|
4
|
+
import type { IRSection } from '@lincyaw/kernel'
|
|
5
|
+
import type { Ref } from 'vue'
|
|
6
|
+
import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, provide, ref, shallowRef, watch } from 'vue'
|
|
7
|
+
import { BlockView } from './block.ts'
|
|
8
|
+
import { SECTION_CONTEXT } from './context.ts'
|
|
9
|
+
import { nextPosition } from './domain/index.ts'
|
|
10
|
+
import { messages } from './i18n.ts'
|
|
11
|
+
import { listActions, listLayers } from './registry.ts'
|
|
12
|
+
import type { ViewerAction } from './registry.ts'
|
|
13
|
+
import { useViewer } from './viewer.ts'
|
|
14
|
+
import type { Viewer } from './viewer.ts'
|
|
15
|
+
|
|
16
|
+
const overview = ref(false)
|
|
17
|
+
|
|
18
|
+
export const BUILTIN_ACTIONS: readonly ViewerAction[] = [
|
|
19
|
+
{ id: 'next', keys: ['ArrowRight', 'ArrowDown', 'PageDown', ' '], run: (viewer) => { viewer.next() } },
|
|
20
|
+
{ id: 'prev', keys: ['ArrowLeft', 'ArrowUp', 'PageUp', 'Backspace'], run: (viewer) => { viewer.prev() } },
|
|
21
|
+
{ id: 'first', keys: ['Home'], run: viewer => viewer.go({ section: viewer.doc.value.sections[0]?.id ?? '', step: 0 }) },
|
|
22
|
+
{ id: 'last', keys: ['End'], run: viewer => viewer.go({ section: viewer.doc.value.sections.at(-1)?.id ?? '', step: Number.POSITIVE_INFINITY }) },
|
|
23
|
+
{ id: 'exit', keys: ['Escape'], views: ['present'], run: viewer => viewer.setView(viewer.pageView.value) },
|
|
24
|
+
{
|
|
25
|
+
id: 'presenter',
|
|
26
|
+
keys: ['p'],
|
|
27
|
+
views: ['present'],
|
|
28
|
+
run: viewer => viewer.options.openPresenter ? viewer.options.openPresenter(viewer.position.value) : false,
|
|
29
|
+
},
|
|
30
|
+
{ id: 'overview', keys: ['o'], views: ['present'], run: () => { overview.value = !overview.value } },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
function typing(target: EventTarget | null): boolean {
|
|
34
|
+
return Boolean((target as HTMLElement | null)?.closest?.('input, textarea, select, [contenteditable], [role="dialog"], [role="menu"], [role="tablist"], [role="slider"]'))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function useActions(viewer: Viewer, view: string): void {
|
|
38
|
+
const onKey = (event: KeyboardEvent) => {
|
|
39
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || typing(event.target)) return
|
|
40
|
+
for (const action of listActions(view)) {
|
|
41
|
+
if (!action.keys.includes(event.key) || action.run(viewer) === false) continue
|
|
42
|
+
event.preventDefault()
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
onMounted(() => {
|
|
47
|
+
window.addEventListener('keydown', onKey)
|
|
48
|
+
document.documentElement.classList.add('pm-presenting')
|
|
49
|
+
})
|
|
50
|
+
onBeforeUnmount(() => {
|
|
51
|
+
window.removeEventListener('keydown', onKey)
|
|
52
|
+
document.documentElement.classList.remove('pm-presenting')
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The page stays mounted to preserve its scroll position; only the overlay is interactive.
|
|
57
|
+
function useModal(root: Readonly<Ref<HTMLElement | undefined>>): void {
|
|
58
|
+
const inert: Element[] = []
|
|
59
|
+
onMounted(() => {
|
|
60
|
+
const el = root.value?.closest('[data-pm-overlay-scope]')
|
|
61
|
+
if (!el) return
|
|
62
|
+
for (let node: Element = el; node.parentElement && node !== document.body; node = node.parentElement) {
|
|
63
|
+
for (const sibling of node.parentElement.children) {
|
|
64
|
+
if (sibling === node || sibling.hasAttribute('inert') || ['SCRIPT', 'STYLE', 'LINK'].includes(sibling.tagName)) continue
|
|
65
|
+
sibling.setAttribute('inert', '')
|
|
66
|
+
inert.push(sibling)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
onBeforeUnmount(() => inert.splice(0).forEach(el => el.removeAttribute('inert')))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function focusScope(root: Readonly<Ref<HTMLElement | undefined>>) {
|
|
74
|
+
return { 'data-pm-overlay-scope': '', style: { display: 'contents' }, loop: true, trapped: true, onMountAutoFocus: (event: Event) => {
|
|
75
|
+
event.preventDefault()
|
|
76
|
+
root.value?.focus({ preventScroll: true })
|
|
77
|
+
} }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Fitted {
|
|
81
|
+
width?: number
|
|
82
|
+
scale: number
|
|
83
|
+
height: number
|
|
84
|
+
shift: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function useFit(frame: Readonly<Ref<HTMLElement | undefined>>, content: Readonly<Ref<HTMLElement | undefined>>) {
|
|
88
|
+
const fitted = ref<Fitted>()
|
|
89
|
+
const fit = () => {
|
|
90
|
+
const [outer, inner] = [frame.value, content.value]
|
|
91
|
+
if (!outer || !inner) return
|
|
92
|
+
const style = getComputedStyle(outer)
|
|
93
|
+
const available = {
|
|
94
|
+
height: outer.clientHeight - Number.parseFloat(style.paddingTop) - Number.parseFloat(style.paddingBottom) - 1,
|
|
95
|
+
width: outer.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight),
|
|
96
|
+
}
|
|
97
|
+
if (available.height <= 0 || available.width <= 0) return
|
|
98
|
+
inner.style.width = ''
|
|
99
|
+
const wide = Math.max(0, ...[...inner.querySelectorAll<HTMLElement>('pre, table, .pm-block, .pm-mermaid, .pm-math--display')].map(el => el.scrollWidth - el.clientWidth))
|
|
100
|
+
const width = available.width + wide
|
|
101
|
+
if (wide > 0) inner.style.width = `${width}px`
|
|
102
|
+
const natural = inner.offsetHeight
|
|
103
|
+
const scale = Math.min(1, available.width / width, available.height / natural)
|
|
104
|
+
const next = scale < 1 ? { width: wide > 0 ? width : undefined, scale, height: natural * scale, shift: (available.width - width * scale) / 2 } : undefined
|
|
105
|
+
if (JSON.stringify(next) !== JSON.stringify(fitted.value)) fitted.value = next
|
|
106
|
+
else if (next?.width) inner.style.width = `${next.width}px`
|
|
107
|
+
}
|
|
108
|
+
let observer: ResizeObserver | undefined
|
|
109
|
+
onMounted(() => {
|
|
110
|
+
observer = new ResizeObserver(fit)
|
|
111
|
+
if (frame.value) observer.observe(frame.value)
|
|
112
|
+
})
|
|
113
|
+
watch(content, (el, old) => {
|
|
114
|
+
if (old) observer?.unobserve(old)
|
|
115
|
+
if (el) observer?.observe(el)
|
|
116
|
+
}, { flush: 'post' })
|
|
117
|
+
onBeforeUnmount(() => observer?.disconnect())
|
|
118
|
+
return fitted
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function useOverlayBar(bar: Readonly<Ref<HTMLElement | undefined>>): void {
|
|
122
|
+
const root = () => document.documentElement.style
|
|
123
|
+
let observer: ResizeObserver | undefined
|
|
124
|
+
onMounted(() => {
|
|
125
|
+
const el = bar.value
|
|
126
|
+
if (!el) return
|
|
127
|
+
const update = () => root().setProperty('--pm-overlay-bar', `${el.offsetHeight}px`)
|
|
128
|
+
update()
|
|
129
|
+
observer = new ResizeObserver(update)
|
|
130
|
+
observer.observe(el)
|
|
131
|
+
})
|
|
132
|
+
onBeforeUnmount(() => {
|
|
133
|
+
observer?.disconnect()
|
|
134
|
+
root().removeProperty('--pm-overlay-bar')
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function useCurrent(viewer: Viewer, view: string) {
|
|
139
|
+
const section = computed(() => viewer.section(viewer.position.value.section))
|
|
140
|
+
const step = computed(() => viewer.position.value.step)
|
|
141
|
+
provide(SECTION_CONTEXT, { section: section as Readonly<Ref<IRSection>>, step, view: computed(() => view) })
|
|
142
|
+
return { section, step }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function visuals(section: IRSection, step: number) {
|
|
146
|
+
return section.blocks.filter(b => b.role === 'visual').map(block => h(BlockView, { key: `${section.id}/${block.id}`, block, section, step }))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function button(label: string, onClick: () => void) {
|
|
150
|
+
return h('button', { type: 'button', class: 'pm-present__button', onClick }, label)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function layers(view: string) {
|
|
154
|
+
return listLayers(view).map(layer => h(layer.component, { key: layer.id, view }))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function moveInOverview(root: HTMLElement | undefined, key: string): boolean {
|
|
158
|
+
const items = [...root?.querySelectorAll<HTMLElement>('.pm-overview__item') ?? []]
|
|
159
|
+
if (!items.length) return false
|
|
160
|
+
const at = Math.max(items.indexOf(document.activeElement as HTMLElement), 0)
|
|
161
|
+
const columns = items.filter(item => item.offsetTop === items[0]!.offsetTop).length
|
|
162
|
+
const moves: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns, Home: -at, End: items.length }
|
|
163
|
+
if (!(key in moves)) return false
|
|
164
|
+
items[Math.min(Math.max(at + moves[key]!, 0), items.length - 1)]!.focus()
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const PresentView = defineComponent({
|
|
169
|
+
name: 'PmPresentView',
|
|
170
|
+
setup() {
|
|
171
|
+
const viewer = useViewer()
|
|
172
|
+
const { section, step } = useCurrent(viewer, 'present')
|
|
173
|
+
const root = shallowRef<HTMLElement>()
|
|
174
|
+
const frame = shallowRef<HTMLElement>()
|
|
175
|
+
const content = shallowRef<HTMLElement>()
|
|
176
|
+
const bar = shallowRef<HTMLElement>()
|
|
177
|
+
const fitted = useFit(frame, content)
|
|
178
|
+
useOverlayBar(bar)
|
|
179
|
+
overview.value = false
|
|
180
|
+
const closeOverview = () => {
|
|
181
|
+
overview.value = false
|
|
182
|
+
root.value?.focus()
|
|
183
|
+
}
|
|
184
|
+
useActions(viewer, 'present')
|
|
185
|
+
const onOverviewKey = (event: KeyboardEvent) => {
|
|
186
|
+
if (!overview.value || event.key === 'Tab' || event.altKey || event.ctrlKey || event.metaKey) return
|
|
187
|
+
event.stopImmediatePropagation()
|
|
188
|
+
if (event.key === 'Escape' || event.key === 'o') closeOverview()
|
|
189
|
+
else if (!moveInOverview(root.value, event.key)) return
|
|
190
|
+
event.preventDefault()
|
|
191
|
+
}
|
|
192
|
+
useModal(root)
|
|
193
|
+
onMounted(() => {
|
|
194
|
+
window.addEventListener('keydown', onOverviewKey, { capture: true })
|
|
195
|
+
})
|
|
196
|
+
onBeforeUnmount(() => {
|
|
197
|
+
window.removeEventListener('keydown', onOverviewKey, { capture: true })
|
|
198
|
+
overview.value = false
|
|
199
|
+
})
|
|
200
|
+
watch(overview, async (open) => {
|
|
201
|
+
if (!open) return
|
|
202
|
+
await nextTick()
|
|
203
|
+
root.value?.querySelector<HTMLElement>('.pm-overview__item[aria-current="true"]')?.focus()
|
|
204
|
+
})
|
|
205
|
+
const jump = (id: string) => {
|
|
206
|
+
viewer.go({ section: id, step: 0 })
|
|
207
|
+
closeOverview()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return () => {
|
|
211
|
+
const current = section.value
|
|
212
|
+
if (!current) return null
|
|
213
|
+
const t = messages()
|
|
214
|
+
const { sections } = viewer.doc.value
|
|
215
|
+
return h(FocusScope, focusScope(root), () => [h('div', {
|
|
216
|
+
'ref': root,
|
|
217
|
+
'class': 'pm-present',
|
|
218
|
+
'role': 'region',
|
|
219
|
+
'aria-label': t.present.region,
|
|
220
|
+
'tabindex': -1,
|
|
221
|
+
'data-section': current.id,
|
|
222
|
+
'data-step': step.value,
|
|
223
|
+
}, [
|
|
224
|
+
h('div', { ref: frame, class: 'pm-present__slide' }, [
|
|
225
|
+
h('div', { class: 'pm-present__fit', style: fitted.value ? { height: `${fitted.value.height}px`, overflow: 'clip' } : undefined }, h('div', {
|
|
226
|
+
key: current.id,
|
|
227
|
+
ref: content,
|
|
228
|
+
class: 'pm-present__content',
|
|
229
|
+
style: fitted.value
|
|
230
|
+
? { width: fitted.value.width ? `${fitted.value.width}px` : undefined, transform: `translateX(${fitted.value.shift}px) scale(${fitted.value.scale})` }
|
|
231
|
+
: undefined,
|
|
232
|
+
}, [
|
|
233
|
+
h('h1', { class: 'pm-present__title' }, current.title),
|
|
234
|
+
...visuals(current, step.value),
|
|
235
|
+
])),
|
|
236
|
+
overview.value
|
|
237
|
+
? h('div', { 'class': 'pm-overview', 'role': 'dialog', 'aria-label': t.present.overview }, h('ol', { class: 'pm-overview__grid' }, sections.map(s => h('li', { key: s.id }, h('button', {
|
|
238
|
+
'type': 'button',
|
|
239
|
+
'class': 'pm-overview__item',
|
|
240
|
+
'data-section': s.id,
|
|
241
|
+
'aria-current': s.id === current.id ? 'true' : undefined,
|
|
242
|
+
'onClick': () => jump(s.id),
|
|
243
|
+
}, [h('span', { class: 'pm-overview__index' }, String(s.index)), h('span', { class: 'pm-overview__title' }, s.title)])))))
|
|
244
|
+
: null,
|
|
245
|
+
]),
|
|
246
|
+
h('div', { ref: bar, class: 'pm-present__bar' }, [
|
|
247
|
+
h('span', { class: 'pm-present__counter' }, t.present.counter(current.index, sections.length, step.value, current.steps)),
|
|
248
|
+
button(t.present.prev, () => viewer.prev()),
|
|
249
|
+
button(t.present.next, () => viewer.next()),
|
|
250
|
+
button(t.present.overview, () => overview.value = !overview.value),
|
|
251
|
+
viewer.options.openPresenter ? button(t.present.presenter, () => viewer.options.openPresenter!(viewer.position.value)) : null,
|
|
252
|
+
button(t.present.fullscreen, () => void root.value?.requestFullscreen?.().catch(() => {})),
|
|
253
|
+
button(t.present.exit, () => viewer.setView(viewer.pageView.value)),
|
|
254
|
+
h(PmApprovals, { placement: 'above' }),
|
|
255
|
+
]),
|
|
256
|
+
]), ...layers('present')])
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
function clock(ms: number): string {
|
|
262
|
+
const s = Math.floor(ms / 1000)
|
|
263
|
+
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const PresenterView = defineComponent({
|
|
267
|
+
name: 'PmPresenterView',
|
|
268
|
+
setup() {
|
|
269
|
+
const viewer = useViewer()
|
|
270
|
+
const { section, step } = useCurrent(viewer, 'presenter')
|
|
271
|
+
const root = shallowRef<HTMLElement>()
|
|
272
|
+
const bar = shallowRef<HTMLElement>()
|
|
273
|
+
useActions(viewer, 'presenter')
|
|
274
|
+
useOverlayBar(bar)
|
|
275
|
+
useModal(root)
|
|
276
|
+
const started = ref(Date.now())
|
|
277
|
+
const now = ref(Date.now())
|
|
278
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
279
|
+
onMounted(() => {
|
|
280
|
+
timer = setInterval(() => now.value = Date.now(), 1000)
|
|
281
|
+
})
|
|
282
|
+
onBeforeUnmount(() => clearInterval(timer))
|
|
283
|
+
|
|
284
|
+
const upcoming = computed(() => {
|
|
285
|
+
const t = messages()
|
|
286
|
+
const next = nextPosition(viewer.doc.value, viewer.position.value)
|
|
287
|
+
if (!next) return t.presenter.end
|
|
288
|
+
const target = viewer.section(next.section)!
|
|
289
|
+
if (next.section !== section.value?.id) return t.presenter.nextSection(target.title)
|
|
290
|
+
return sectionNarration(target).filter(block => block.at === next.step).map(blockText).join('\n') || t.presenter.nextStep(next.step)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
return () => {
|
|
294
|
+
const current = section.value
|
|
295
|
+
if (!current) return null
|
|
296
|
+
const t = messages()
|
|
297
|
+
const notes = sectionNarration(current)
|
|
298
|
+
return h(FocusScope, focusScope(root), () => [h('div', { 'ref': root, 'tabindex': -1, 'class': 'pm-presenter', 'data-section': current.id, 'data-step': step.value }, [
|
|
299
|
+
h('div', { ref: bar, class: 'pm-presenter__bar' }, [
|
|
300
|
+
h('strong', { class: 'pm-presenter__title' }, `${current.index}. ${current.title}`),
|
|
301
|
+
current.steps > 0 ? h('span', { class: 'pm-presenter__counter' }, t.presenter.step(step.value, current.steps)) : null,
|
|
302
|
+
h('span', { 'class': 'pm-presenter__timer', 'aria-label': t.presenter.timer }, clock(now.value - started.value)),
|
|
303
|
+
button(t.presenter.restart, () => started.value = now.value = Date.now()),
|
|
304
|
+
button(t.present.prev, () => viewer.prev()),
|
|
305
|
+
button(t.present.next, () => viewer.next()),
|
|
306
|
+
h(PmApprovals),
|
|
307
|
+
]),
|
|
308
|
+
h('div', { class: 'pm-presenter__main' }, [
|
|
309
|
+
h('div', { key: current.id, class: 'pm-presenter__slide' }, [h('h1', { class: 'pm-present__title' }, current.title), ...visuals(current, step.value)]),
|
|
310
|
+
h('div', { class: 'pm-presenter__side' }, [
|
|
311
|
+
h('p', { class: 'pm-presenter__label' }, t.presenter.notes),
|
|
312
|
+
h('div', { class: 'pm-presenter__notes' }, notes.length
|
|
313
|
+
? notes.map(block => h('div', {
|
|
314
|
+
'key': block.id,
|
|
315
|
+
'class': ['pm-presenter__note', { 'pm-current': block.at === step.value }],
|
|
316
|
+
'data-step': block.at,
|
|
317
|
+
'aria-current': block.at === step.value ? 'step' : undefined,
|
|
318
|
+
}, [h(BlockView, { block, section: current, step: step.value, expanded: true })]))
|
|
319
|
+
: [h('p', { class: 'pm-presenter__empty' }, t.presenter.noNotes)]),
|
|
320
|
+
h('p', { class: 'pm-presenter__label' }, t.presenter.upcoming),
|
|
321
|
+
h('p', { class: 'pm-presenter__next' }, upcoming.value),
|
|
322
|
+
]),
|
|
323
|
+
]),
|
|
324
|
+
]), ...layers('presenter')])
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
})
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
import { markRaw, shallowReactive } from 'vue'
|
|
3
|
+
import type { Viewer } from './viewer.ts'
|
|
4
|
+
|
|
5
|
+
export interface ViewDefinition {
|
|
6
|
+
id: string
|
|
7
|
+
label: string
|
|
8
|
+
kind: 'page' | 'overlay'
|
|
9
|
+
component: Component
|
|
10
|
+
hidden?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type NodeRenderer = Component
|
|
14
|
+
|
|
15
|
+
export interface ViewerAction {
|
|
16
|
+
id: string
|
|
17
|
+
keys: readonly string[]
|
|
18
|
+
run(viewer: Viewer): boolean | void
|
|
19
|
+
views?: readonly string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ViewerLayer {
|
|
23
|
+
id: string
|
|
24
|
+
component: Component
|
|
25
|
+
views?: readonly string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const OVERLAY_VIEWS: readonly string[] = ['present', 'presenter']
|
|
29
|
+
|
|
30
|
+
const views = shallowReactive(new Map<string, ViewDefinition>())
|
|
31
|
+
const nodeRenderers = shallowReactive(new Map<string, NodeRenderer>())
|
|
32
|
+
const components = shallowReactive(new Map<string, Component>())
|
|
33
|
+
const actions = shallowReactive(new Map<string, ViewerAction>())
|
|
34
|
+
const layers = shallowReactive(new Map<string, ViewerLayer>())
|
|
35
|
+
|
|
36
|
+
export function registerView(view: ViewDefinition): void {
|
|
37
|
+
const definition = Object.defineProperties({}, Object.getOwnPropertyDescriptors(view)) as ViewDefinition
|
|
38
|
+
definition.component = markRaw(view.component)
|
|
39
|
+
views.set(view.id, definition)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function registerNodeRenderer(key: string, renderer: NodeRenderer): void {
|
|
43
|
+
nodeRenderers.set(key, markRaw(renderer))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerComponent(name: string, component: Component): void {
|
|
47
|
+
components.set(name, markRaw(component))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function registerAction(action: ViewerAction): void {
|
|
51
|
+
actions.delete(action.id)
|
|
52
|
+
actions.set(action.id, action)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function registerLayer(layer: ViewerLayer): void {
|
|
56
|
+
layers.set(layer.id, { ...layer, component: markRaw(layer.component) })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getView(id: string): ViewDefinition | undefined {
|
|
60
|
+
return views.get(id)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function listViews(): ViewDefinition[] {
|
|
64
|
+
return [...views.values()]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getNodeRenderer(key: string): NodeRenderer | undefined {
|
|
68
|
+
return nodeRenderers.get(key)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getComponent(name: string): Component | undefined {
|
|
72
|
+
return components.get(name)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function listActions(view: string): ViewerAction[] {
|
|
76
|
+
return [...actions.values()].reverse().filter(action => (action.views ?? OVERLAY_VIEWS).includes(view))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function listLayers(view: string): ViewerLayer[] {
|
|
80
|
+
return [...layers.values()].filter(layer => (layer.views ?? OVERLAY_VIEWS).includes(view))
|
|
81
|
+
}
|
package/src/rich.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { AUTHOR_MARKS_CLASS, errorMessage, escapeHtml } from '@lincyaw/kernel'
|
|
2
|
+
import 'katex/dist/katex.min.css'
|
|
3
|
+
import { computed, defineComponent, h, onMounted, ref, shallowRef, watch } from 'vue'
|
|
4
|
+
import { useSectionContext } from './context.ts'
|
|
5
|
+
import { useDark } from './dark.ts'
|
|
6
|
+
|
|
7
|
+
const never = <T>() => new Promise<T>(() => {})
|
|
8
|
+
|
|
9
|
+
type Katex = typeof import('katex').default
|
|
10
|
+
|
|
11
|
+
let katex: Promise<Katex> | undefined
|
|
12
|
+
|
|
13
|
+
function loadKatex(): Promise<Katex> {
|
|
14
|
+
if (import.meta.env.SSR) return never()
|
|
15
|
+
katex ??= import('katex').then(m => m.default)
|
|
16
|
+
return katex
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const PmMath = defineComponent({
|
|
20
|
+
name: 'PmMath',
|
|
21
|
+
props: {
|
|
22
|
+
tex: { type: String, required: true },
|
|
23
|
+
display: Boolean,
|
|
24
|
+
html: { type: String, default: undefined },
|
|
25
|
+
},
|
|
26
|
+
setup(props) {
|
|
27
|
+
const rendered = ref<string>()
|
|
28
|
+
const render = async () => {
|
|
29
|
+
if (props.html) return
|
|
30
|
+
const { renderToString } = await loadKatex()
|
|
31
|
+
rendered.value = renderToString(props.tex, { displayMode: props.display, throwOnError: false })
|
|
32
|
+
}
|
|
33
|
+
onMounted(render)
|
|
34
|
+
watch(() => [props.tex, props.display, props.html], render)
|
|
35
|
+
return () => {
|
|
36
|
+
const html = props.html ?? rendered.value
|
|
37
|
+
return h(props.display ? 'div' : 'span', {
|
|
38
|
+
'class': ['pm-math', props.display ? 'pm-math--display' : 'pm-math--inline'],
|
|
39
|
+
'data-rendered': html ? '' : undefined,
|
|
40
|
+
'innerHTML': html ?? escapeHtml(props.tex),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
type Mermaid = typeof import('mermaid').default
|
|
47
|
+
|
|
48
|
+
let mermaid: Promise<Mermaid> | undefined
|
|
49
|
+
let diagrams = 0
|
|
50
|
+
|
|
51
|
+
function loadMermaid(): Promise<Mermaid> {
|
|
52
|
+
if (import.meta.env.SSR) return never()
|
|
53
|
+
mermaid ??= import('mermaid').then(m => m.default)
|
|
54
|
+
return mermaid
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tokenTheme(dark: boolean): Record<string, string | boolean> | undefined {
|
|
58
|
+
const style = getComputedStyle(document.documentElement)
|
|
59
|
+
const token = (name: string) => style.getPropertyValue(`--pm-color-${name}`).trim()
|
|
60
|
+
const [ink, muted, surface, sunk, line, accent, accentSoft, warn, warnSoft] = ['ink', 'muted', 'surface', 'sunk', 'line', 'accent', 'accent-soft', 'warn', 'warn-soft'].map(token)
|
|
61
|
+
if (!ink || !surface || !accent) return undefined
|
|
62
|
+
return {
|
|
63
|
+
darkMode: dark,
|
|
64
|
+
background: surface,
|
|
65
|
+
fontFamily: style.getPropertyValue('--pm-font-sans').trim() || 'sans-serif',
|
|
66
|
+
textColor: ink,
|
|
67
|
+
lineColor: muted!,
|
|
68
|
+
primaryColor: accentSoft!,
|
|
69
|
+
primaryTextColor: ink,
|
|
70
|
+
primaryBorderColor: accent,
|
|
71
|
+
secondaryColor: sunk!,
|
|
72
|
+
secondaryTextColor: ink,
|
|
73
|
+
secondaryBorderColor: line!,
|
|
74
|
+
tertiaryColor: surface,
|
|
75
|
+
tertiaryTextColor: ink,
|
|
76
|
+
tertiaryBorderColor: line!,
|
|
77
|
+
edgeLabelBackground: surface,
|
|
78
|
+
clusterBkg: sunk!,
|
|
79
|
+
clusterBorder: line!,
|
|
80
|
+
titleColor: ink,
|
|
81
|
+
noteBkgColor: warnSoft!,
|
|
82
|
+
noteTextColor: ink,
|
|
83
|
+
noteBorderColor: warn!,
|
|
84
|
+
actorBkg: accentSoft!,
|
|
85
|
+
actorBorder: accent,
|
|
86
|
+
actorTextColor: ink,
|
|
87
|
+
signalColor: ink,
|
|
88
|
+
signalTextColor: ink,
|
|
89
|
+
labelBoxBkgColor: sunk!,
|
|
90
|
+
labelTextColor: ink,
|
|
91
|
+
loopTextColor: ink,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const PmMermaid = defineComponent({
|
|
96
|
+
name: 'PmMermaid',
|
|
97
|
+
props: { code: { type: String, required: true } },
|
|
98
|
+
setup(props) {
|
|
99
|
+
const drawn = ref<{ svg: string, theme: string }>()
|
|
100
|
+
const failed = ref<string>()
|
|
101
|
+
const dark = useDark()
|
|
102
|
+
let latest = 0
|
|
103
|
+
const render = async () => {
|
|
104
|
+
const id = ++latest
|
|
105
|
+
try {
|
|
106
|
+
const library = await loadMermaid()
|
|
107
|
+
const variables = tokenTheme(dark.value)
|
|
108
|
+
const appearance = dark.value ? 'dark' : 'default'
|
|
109
|
+
const theme = variables ? 'base' : appearance
|
|
110
|
+
library.initialize({ startOnLoad: false, securityLevel: 'strict', theme, ...variables ? { themeVariables: variables } : {}, flowchart: { useMaxWidth: false } })
|
|
111
|
+
const { svg } = await library.render(`pm-mermaid-${++diagrams}`, props.code)
|
|
112
|
+
if (id !== latest) return
|
|
113
|
+
drawn.value = { svg, theme: appearance }
|
|
114
|
+
failed.value = undefined
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (id === latest) failed.value = errorMessage(error)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
onMounted(render)
|
|
120
|
+
watch(() => [props.code, dark.value], render)
|
|
121
|
+
return () => h('div', {
|
|
122
|
+
'class': ['pm-mermaid', { 'pm-mermaid--error': failed.value }],
|
|
123
|
+
'data-rendered': drawn.value ? '' : undefined,
|
|
124
|
+
'data-theme': drawn.value?.theme,
|
|
125
|
+
'title': failed.value,
|
|
126
|
+
'innerHTML': drawn.value?.svg ?? `<pre>${escapeHtml(props.code)}</pre>`,
|
|
127
|
+
})
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const authorMarks = shallowRef(false)
|
|
132
|
+
let observed = false
|
|
133
|
+
|
|
134
|
+
function useAuthorMarks() {
|
|
135
|
+
if (!observed && typeof document !== 'undefined') {
|
|
136
|
+
observed = true
|
|
137
|
+
const root = document.documentElement
|
|
138
|
+
authorMarks.value = root.classList.contains(AUTHOR_MARKS_CLASS)
|
|
139
|
+
new MutationObserver(() => {
|
|
140
|
+
authorMarks.value = root.classList.contains(AUTHOR_MARKS_CLASS)
|
|
141
|
+
}).observe(root, { attributes: true, attributeFilter: ['class'] })
|
|
142
|
+
}
|
|
143
|
+
return authorMarks
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const PmRoleMark = defineComponent({
|
|
147
|
+
name: 'PmRoleMark',
|
|
148
|
+
props: { mark: { type: String, required: true } },
|
|
149
|
+
setup(props) {
|
|
150
|
+
const section = useSectionContext()
|
|
151
|
+
const marks = useAuthorMarks()
|
|
152
|
+
const shown = computed(() => section?.view.value === 'presenter' || marks.value)
|
|
153
|
+
return () => (shown.value ? h('span', { class: 'pm-role-mark' }, props.mark) : null)
|
|
154
|
+
},
|
|
155
|
+
})
|