@lincyaw/viewer 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 +6 -4
- package/package.json +20 -8
- package/src/client/block.ts +70 -0
- package/src/client/code/code-block.ts +54 -0
- package/src/client/code/code-editor.ts +73 -0
- package/src/client/code/code-view.ts +22 -0
- package/src/{highlight.ts → client/code/highlight.ts} +2 -2
- package/src/{outputs.ts → client/code/outputs.ts} +23 -36
- package/src/client/content-marks.ts +15 -0
- package/src/client/element.ts +27 -0
- package/src/client/index.ts +40 -0
- package/src/client/layout.ts +22 -0
- package/src/{markdown.ts → client/markdown/handlers.ts} +22 -116
- package/src/client/markdown/hast.ts +20 -0
- package/src/client/markdown/layout-handlers.ts +57 -0
- package/src/client/markdown/math.ts +10 -0
- package/src/client/markdown/mermaid.ts +75 -0
- package/src/client/markdown/render.ts +79 -0
- package/src/client/markdown/role-mark.ts +9 -0
- package/src/client/markdown/tables.ts +49 -0
- package/src/client/markdown/tabs.ts +52 -0
- package/src/client/markdown/tree-cache.ts +77 -0
- package/src/client/operations.ts +9 -0
- package/src/client/registry.ts +21 -0
- package/src/client/section-context.ts +5 -0
- package/src/client/section.ts +30 -0
- package/src/client/slidev-compat/click-directives.ts +16 -0
- package/src/client/slidev-compat/slide-globals.ts +3 -0
- package/src/{style.css → client/style.css} +306 -81
- package/src/client/viewer-messages.ts +7 -0
- package/src/{plugins.ts → client/viewer-plugins.ts} +3 -2
- package/src/client/viewer-state.ts +47 -0
- package/src/client/views/actions.ts +52 -0
- package/src/client/views/controls.css +44 -0
- package/src/client/views/document.ts +12 -0
- package/src/client/views/outlets.ts +37 -0
- package/src/client/views/overlay.ts +16 -0
- package/src/client/views/present.ts +132 -0
- package/src/client/views/presenter.ts +76 -0
- package/src/client/views/scrolly.ts +4 -0
- package/src/client/views/slide.css +8 -0
- package/src/client/views/slide.ts +57 -0
- package/src/domain/clicks.ts +1 -1
- package/src/domain/code-width.ts +11 -0
- package/src/domain/document-content.ts +6 -0
- package/src/domain/{text.ts → footnotes.ts} +0 -12
- package/src/domain/index.ts +5 -4
- package/src/domain/live.ts +11 -10
- package/src/domain/messages.ts +54 -30
- package/src/domain/origin.ts +17 -32
- package/src/domain/outputs.ts +27 -0
- package/src/domain/steps.ts +2 -1
- package/src/block.ts +0 -122
- package/src/cards.ts +0 -130
- package/src/clicks.ts +0 -124
- package/src/code.ts +0 -235
- package/src/context.ts +0 -16
- package/src/dark.ts +0 -5
- package/src/domain/follow.ts +0 -32
- package/src/globals.ts +0 -66
- package/src/i18n.ts +0 -7
- package/src/index.ts +0 -55
- package/src/operations.ts +0 -28
- package/src/present.ts +0 -327
- package/src/registry.ts +0 -81
- package/src/rich.ts +0 -155
- package/src/section.ts +0 -209
- package/src/viewer.ts +0 -116
- package/src/views.ts +0 -101
- /package/src/{env.d.ts → client/env.d.ts} +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { IRDocument, MdastNode } from '@lincyaw/kernel'
|
|
2
|
+
import type { Root } from 'hast'
|
|
3
|
+
import type { Viewer } from '../viewer-state.ts'
|
|
4
|
+
import { footnoteSections } from '../../domain/index.ts'
|
|
5
|
+
import { markdownToHast } from './handlers.ts'
|
|
6
|
+
import type { MarkdownLinks } from './handlers.ts'
|
|
7
|
+
|
|
8
|
+
interface CachedTree {
|
|
9
|
+
node: MdastNode
|
|
10
|
+
key: string
|
|
11
|
+
tree: Root
|
|
12
|
+
bytes: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ViewerTrees {
|
|
16
|
+
/** What the trees' links were made from: the sections' addresses and titles and where the footnotes are. */
|
|
17
|
+
links: string
|
|
18
|
+
nodes: Map<MdastNode, Map<string, CachedTree>>
|
|
19
|
+
recent: Map<CachedTree, true>
|
|
20
|
+
bytes: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Enough for every Markdown block of a long document, so that an edit converts only the blocks it changed.
|
|
24
|
+
const TREE_LIMIT = 4096
|
|
25
|
+
const TREE_BYTES = 16 << 20
|
|
26
|
+
const viewerTrees = new WeakMap<Viewer, ViewerTrees>()
|
|
27
|
+
const signatures = new WeakMap<IRDocument, string>()
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a Markdown node's links read from the whole document: each section's address and title (a reference's text and
|
|
31
|
+
* target) and the sections of the footnotes. Most edits leave it as it was, and the trees of the nodes they did not
|
|
32
|
+
* touch stay; a new title or a moved section changes it.
|
|
33
|
+
*/
|
|
34
|
+
function linkSignature(viewer: Viewer, doc: IRDocument): string {
|
|
35
|
+
let known = signatures.get(doc)
|
|
36
|
+
if (known === undefined) {
|
|
37
|
+
const sections = doc.sections.map(section => [section.id, section.title, viewer.options.sectionHref?.(section.id) ?? ''])
|
|
38
|
+
signatures.set(doc, known = JSON.stringify([sections, [...footnoteSections(doc)]]))
|
|
39
|
+
}
|
|
40
|
+
return known
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The HAST tree of a Markdown node, kept per viewer (least recently used first out) so that remounts, and the nodes a
|
|
45
|
+
* change of the document left as they were (`applyUpdate` keeps their objects), reuse it.
|
|
46
|
+
*/
|
|
47
|
+
export function cachedTree(viewer: Viewer, doc: IRDocument, node: MdastNode, prefix: string, links: MarkdownLinks, locale: string): Root {
|
|
48
|
+
const key = JSON.stringify([prefix, locale])
|
|
49
|
+
const signature = linkSignature(viewer, doc)
|
|
50
|
+
let cache = viewerTrees.get(viewer)
|
|
51
|
+
if (cache?.links !== signature) viewerTrees.set(viewer, cache = { links: signature, nodes: new Map(), recent: new Map(), bytes: 0 })
|
|
52
|
+
const known = cache.nodes.get(node)?.get(key)
|
|
53
|
+
if (known) {
|
|
54
|
+
cache.recent.delete(known)
|
|
55
|
+
cache.recent.set(known, true)
|
|
56
|
+
return known.tree
|
|
57
|
+
}
|
|
58
|
+
const tree = markdownToHast(node, prefix, links)
|
|
59
|
+
// Only immutable HAST survives component unmounts; no VNodes or mounted resources are retained.
|
|
60
|
+
const bytes = JSON.stringify(tree).length * 2 // UTF-16 JSON size is a soft heap estimate.
|
|
61
|
+
if (bytes > TREE_BYTES) return tree
|
|
62
|
+
const entry: CachedTree = { node, key, tree, bytes }
|
|
63
|
+
const variants = cache.nodes.get(node) ?? new Map<string, CachedTree>()
|
|
64
|
+
variants.set(key, entry)
|
|
65
|
+
cache.nodes.set(node, variants)
|
|
66
|
+
cache.recent.set(entry, true)
|
|
67
|
+
cache.bytes += bytes
|
|
68
|
+
while (cache.recent.size > TREE_LIMIT || cache.bytes > TREE_BYTES) {
|
|
69
|
+
const first = cache.recent.keys().next().value!
|
|
70
|
+
cache.recent.delete(first)
|
|
71
|
+
cache.bytes -= first.bytes
|
|
72
|
+
const old = cache.nodes.get(first.node)!
|
|
73
|
+
old.delete(first.key)
|
|
74
|
+
if (old.size === 0) cache.nodes.delete(first.node)
|
|
75
|
+
}
|
|
76
|
+
return tree
|
|
77
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Operations } from '@lincyaw/host'
|
|
2
|
+
import { findView } from './registry.ts'
|
|
3
|
+
import type { Viewer } from './viewer-state.ts'
|
|
4
|
+
export function syncViewerWithOperations(viewer: Viewer, operations: Operations): () => void {
|
|
5
|
+
const report = () => operations.report({ type: 'go', data: { ...viewer.position.getSnapshot(), view: viewer.view.getSnapshot() } })
|
|
6
|
+
const subscriptions = [viewer.position.subscribe(report), viewer.view.subscribe(report)]
|
|
7
|
+
const off = operations.onReplay('go', { apply(data) { const view = typeof data.view === 'string' ? findView(data.view) : undefined; if (view && !view.hidden) viewer.setView(view.id); if (typeof data.section !== 'string') return; const step = Number(data.step); viewer.show({ section: data.section, step: Number.isFinite(step) && step >= 0 ? step : 0 }) } })
|
|
8
|
+
return () => { subscriptions.forEach(stop => stop()); off() }
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import { createStore } from '@lincyaw/host'
|
|
3
|
+
import type { Viewer } from './viewer-state.ts'
|
|
4
|
+
export interface ViewDefinition { id: string, label: string, kind: 'page' | 'overlay', component: ComponentType<any>, hidden?: boolean }
|
|
5
|
+
export type NodeRenderer = ComponentType<any>
|
|
6
|
+
export interface ViewerAction { id: string, keys: readonly string[], run(viewer: Viewer, event?: KeyboardEvent): boolean | void, views?: readonly string[] }
|
|
7
|
+
export interface ViewerLayer { id: string, component: ComponentType<any>, views?: readonly string[], scope?: 'view' | 'viewport' | 'slide' | 'bar' }
|
|
8
|
+
const views = new Map<string, ViewDefinition>(), nodeRenderers = new Map<string, NodeRenderer>(), components = new Map<string, ComponentType<any>>(), actions = new Map<string, ViewerAction>(), layerMap = new Map<string, ViewerLayer>()
|
|
9
|
+
export const registryRevision = createStore(0)
|
|
10
|
+
const changed = () => registryRevision.set(n => n + 1)
|
|
11
|
+
export function registerView(value: ViewDefinition) { views.set(value.id, value); changed() }
|
|
12
|
+
export function registerNodeRenderer(key: string, value: NodeRenderer) { nodeRenderers.set(key, value); changed() }
|
|
13
|
+
export function registerComponent(key: string, value: ComponentType<any>) { components.set(key, value); changed() }
|
|
14
|
+
export function registerAction(value: ViewerAction) { actions.set(value.id, value); changed() }
|
|
15
|
+
export function registerLayer(value: ViewerLayer) { layerMap.set(value.id, value); changed() }
|
|
16
|
+
export const findView = (id: string) => views.get(id)
|
|
17
|
+
export const listViews = () => [...views.values()]
|
|
18
|
+
export const findNodeRenderer = (id: string) => nodeRenderers.get(id)
|
|
19
|
+
export const findComponent = (id: string) => components.get(id)
|
|
20
|
+
export const listActions = (view: string) => [...actions.values()].filter(item => !item.views || item.views.includes(view))
|
|
21
|
+
export const listLayers = (view: string) => [...layerMap.values()].filter(item => !item.views || item.views.includes(view))
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import type { IRSection } from '@lincyaw/kernel'
|
|
3
|
+
export interface SectionContext { section: IRSection, step: number, view: string }
|
|
4
|
+
export const SECTION_CONTEXT = createContext<SectionContext | undefined>(undefined)
|
|
5
|
+
export function useSectionContext() { return useContext(SECTION_CONTEXT) }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { memo, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { useStore } from '@lincyaw/host'
|
|
3
|
+
import { isChapterTitle, narrationAt } from '@lincyaw/kernel'
|
|
4
|
+
import type { IRSection } from '@lincyaw/kernel'
|
|
5
|
+
import { FINAL_STEP, sectionDiagnostics } from '../domain/index.ts'
|
|
6
|
+
import { isLegacyNarration } from '../domain/document-content.ts'
|
|
7
|
+
import { BlockView } from './block.ts'
|
|
8
|
+
import { contentMarks } from './content-marks.ts'
|
|
9
|
+
import { SECTION_CONTEXT } from './section-context.ts'
|
|
10
|
+
import { viewerMessages } from './viewer-messages.ts'
|
|
11
|
+
import { useViewer } from './viewer-state.ts'
|
|
12
|
+
import { h } from './element.ts'
|
|
13
|
+
export type SectionLayout = 'document' | 'scrolly'
|
|
14
|
+
function SectionViewContent({ section, layout = 'document' }: { section: IRSection, layout?: SectionLayout }) {
|
|
15
|
+
const viewer = useViewer(); const doc = useStore(viewer.doc); const view = useStore(viewer.pageView); const [scrolled, setScrolled] = useState(0); const root = useRef<HTMLElement>(null)
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const observer = new IntersectionObserver(entries => { if (entries.some(entry => entry.isIntersecting) && viewer.view.getSnapshot() === viewer.pageView.getSnapshot() && !viewer.restoring.getSnapshot()) { const current = viewer.position.getSnapshot(); if (current.section !== section.id) viewer.go({ section: section.id, step: 0 }) } }, { rootMargin: '-10% 0px -70% 0px' })
|
|
18
|
+
if (root.current) observer.observe(root.current)
|
|
19
|
+
return () => observer.disconnect()
|
|
20
|
+
}, [section.id, viewer])
|
|
21
|
+
useEffect(() => { if (layout !== 'scrolly') return; const update = () => { const markers = [...root.current?.querySelectorAll<HTMLElement>('.pm-step') ?? []]; const step = Math.max(0, markers.filter(el => el.getBoundingClientRect().top < window.innerHeight * .6).length - 1); setScrolled(step); if (viewer.view.getSnapshot() === view) viewer.go({ section: section.id, step }) }; window.addEventListener('scroll', update, true); return () => window.removeEventListener('scroll', update, true) }, [layout, section.id, view])
|
|
22
|
+
const step = layout === 'scrolly' ? scrolled : FINAL_STEP
|
|
23
|
+
const title = isChapterTitle(doc, section) ? 'h1' : 'h2'; const t = viewerMessages().section
|
|
24
|
+
const blocks = section.blocks.filter(block => block.role === 'visual' || (block.only && !block.only.includes('lecture')) || isLegacyNarration(doc, block))
|
|
25
|
+
const findings = viewer.options.diagnostics ? sectionDiagnostics(section, doc.diagnostics) : []
|
|
26
|
+
const body = layout === 'scrolly' ? [h('div', { className: 'pm-stage pm-board' }, blocks.filter(b => b.role === 'visual').map(block => h(BlockView, { key: block.id, block, section, step }))), h('div', { className: 'pm-track' }, Array.from({ length: section.steps + 1 }, (_, k) => h('div', { key: k, className: `pm-step${k === step ? ' pm-step--current' : ''}`, 'data-step': k }, narrationAt(section, k).map(block => h(BlockView, { key: block.id, block, section, step }))))) ] : h('div', { className: 'pm-section__body' }, blocks.map(block => h(BlockView, { key: block.id, block, section, step, expanded: true })))
|
|
27
|
+
return h(SECTION_CONTEXT.Provider, { value: { section, step, view } }, h('section', { ref: root, className: `pm-section pm-section--${layout}`, 'data-section': section.id, 'data-index': section.index, 'data-step': layout === 'scrolly' ? step : undefined }, [h(title, { id: section.id, className: 'pm-section__title', tabIndex: -1, 'data-content-role': 'visual', 'data-content-label': viewerMessages().contentRoles.visual }, [contentMarks(section, 'visual', 0), section.title, viewer.options.sectionHeading?.(section, step), h('a', { className: 'header-anchor', href: `#${section.id}`, 'aria-label': t.anchor(section.title) }, '')]), findings.length ? h('ul', { className: 'pm-diagnostics', role: 'alert' }, findings.map((finding, key) => h('li', { key, 'data-severity': finding.severity }, finding.message))) : null, body]))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const SectionView = memo(SectionViewContent)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { useStep } from '@lincyaw/host'
|
|
4
|
+
import { clickVisible, resolveClicks } from '../../domain/index.ts'
|
|
5
|
+
import type { ClickSpec } from '../../domain/index.ts'
|
|
6
|
+
import { h } from '../element.ts'
|
|
7
|
+
export const BLOCK_CLASS = 'pm-block'
|
|
8
|
+
export function trackClicks(block: HTMLElement, step: number): () => void {
|
|
9
|
+
const items = [...block.querySelectorAll<HTMLElement>('[data-click-at]')]
|
|
10
|
+
const ranges = resolveClicks(items.map(el => ({ kind: el.dataset.clickKind === 'after' ? 'after' : 'click', at: Number(el.dataset.clickAt), hide: el.dataset.clickHide === 'true' })))
|
|
11
|
+
items.forEach((el, i) => el.dataset.pmClick = clickVisible(ranges[i]!, step) ? 'shown' : 'hidden')
|
|
12
|
+
return () => {}
|
|
13
|
+
}
|
|
14
|
+
export function VClick({ at = 1, hide = false, children }: { at?: unknown, hide?: boolean, children?: ReactNode }) { const step = useStep(); const range = resolveClicks([{ kind: 'click', at, hide }])[0]!; return h('span', { className: 'pm-v-click', 'data-pm-click': clickVisible(range, step) ? 'shown' : 'hidden' }, children) }
|
|
15
|
+
export const VAfter = VClick
|
|
16
|
+
export function VClicks({ every = 1, hide, children }: { every?: number, hide?: boolean, children?: ReactNode }) { const root = useRef<HTMLDivElement>(null); const step = useStep(); useEffect(() => { const items = [...root.current?.children ?? []] as HTMLElement[]; const ranges = resolveClicks(items.map((_, k): ClickSpec => ({ kind: k % every ? 'after' : 'click', hide }))); items.forEach((item, k) => item.dataset.pmClick = clickVisible(ranges[k]!, step) ? 'shown' : 'hidden') }, [step, every, hide]); return h('div', { ref: root, className: 'pm-v-clicks' }, children) }
|