@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.
Files changed (70) hide show
  1. package/README.md +6 -4
  2. package/package.json +20 -8
  3. package/src/client/block.ts +70 -0
  4. package/src/client/code/code-block.ts +54 -0
  5. package/src/client/code/code-editor.ts +73 -0
  6. package/src/client/code/code-view.ts +22 -0
  7. package/src/{highlight.ts → client/code/highlight.ts} +2 -2
  8. package/src/{outputs.ts → client/code/outputs.ts} +23 -36
  9. package/src/client/content-marks.ts +15 -0
  10. package/src/client/element.ts +27 -0
  11. package/src/client/index.ts +40 -0
  12. package/src/client/layout.ts +22 -0
  13. package/src/{markdown.ts → client/markdown/handlers.ts} +22 -116
  14. package/src/client/markdown/hast.ts +20 -0
  15. package/src/client/markdown/layout-handlers.ts +57 -0
  16. package/src/client/markdown/math.ts +10 -0
  17. package/src/client/markdown/mermaid.ts +75 -0
  18. package/src/client/markdown/render.ts +79 -0
  19. package/src/client/markdown/role-mark.ts +9 -0
  20. package/src/client/markdown/tables.ts +49 -0
  21. package/src/client/markdown/tabs.ts +52 -0
  22. package/src/client/markdown/tree-cache.ts +77 -0
  23. package/src/client/operations.ts +9 -0
  24. package/src/client/registry.ts +21 -0
  25. package/src/client/section-context.ts +5 -0
  26. package/src/client/section.ts +30 -0
  27. package/src/client/slidev-compat/click-directives.ts +16 -0
  28. package/src/client/slidev-compat/slide-globals.ts +3 -0
  29. package/src/{style.css → client/style.css} +306 -81
  30. package/src/client/viewer-messages.ts +7 -0
  31. package/src/{plugins.ts → client/viewer-plugins.ts} +3 -2
  32. package/src/client/viewer-state.ts +47 -0
  33. package/src/client/views/actions.ts +52 -0
  34. package/src/client/views/controls.css +44 -0
  35. package/src/client/views/document.ts +12 -0
  36. package/src/client/views/outlets.ts +37 -0
  37. package/src/client/views/overlay.ts +16 -0
  38. package/src/client/views/present.ts +132 -0
  39. package/src/client/views/presenter.ts +76 -0
  40. package/src/client/views/scrolly.ts +4 -0
  41. package/src/client/views/slide.css +8 -0
  42. package/src/client/views/slide.ts +57 -0
  43. package/src/domain/clicks.ts +1 -1
  44. package/src/domain/code-width.ts +11 -0
  45. package/src/domain/document-content.ts +6 -0
  46. package/src/domain/{text.ts → footnotes.ts} +0 -12
  47. package/src/domain/index.ts +5 -4
  48. package/src/domain/live.ts +11 -10
  49. package/src/domain/messages.ts +54 -30
  50. package/src/domain/origin.ts +17 -32
  51. package/src/domain/outputs.ts +27 -0
  52. package/src/domain/steps.ts +2 -1
  53. package/src/block.ts +0 -122
  54. package/src/cards.ts +0 -130
  55. package/src/clicks.ts +0 -124
  56. package/src/code.ts +0 -235
  57. package/src/context.ts +0 -16
  58. package/src/dark.ts +0 -5
  59. package/src/domain/follow.ts +0 -32
  60. package/src/globals.ts +0 -66
  61. package/src/i18n.ts +0 -7
  62. package/src/index.ts +0 -55
  63. package/src/operations.ts +0 -28
  64. package/src/present.ts +0 -327
  65. package/src/registry.ts +0 -81
  66. package/src/rich.ts +0 -155
  67. package/src/section.ts +0 -209
  68. package/src/viewer.ts +0 -116
  69. package/src/views.ts +0 -101
  70. /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) }
@@ -0,0 +1,3 @@
1
+ import type { ComponentType } from 'react'
2
+ /** Kept as an identity wrapper for component modules using the public loader API. */
3
+ export function withSlideGlobals<T extends ComponentType<any>>(component: T): T { return component }