@lincyaw/viewer 0.0.0-stage → 1.0.0-rc.1

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 (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/package.json +65 -3
  4. package/src/client/block.ts +70 -0
  5. package/src/client/code/code-block.ts +54 -0
  6. package/src/client/code/code-editor.ts +73 -0
  7. package/src/client/code/code-view.ts +22 -0
  8. package/src/client/code/highlight.ts +73 -0
  9. package/src/client/code/outputs.ts +84 -0
  10. package/src/client/content-marks.ts +15 -0
  11. package/src/client/element.ts +27 -0
  12. package/src/client/env.d.ts +3 -0
  13. package/src/client/index.ts +40 -0
  14. package/src/client/layout.ts +22 -0
  15. package/src/client/markdown/handlers.ts +161 -0
  16. package/src/client/markdown/hast.ts +20 -0
  17. package/src/client/markdown/layout-handlers.ts +57 -0
  18. package/src/client/markdown/math.ts +10 -0
  19. package/src/client/markdown/mermaid.ts +75 -0
  20. package/src/client/markdown/render.ts +79 -0
  21. package/src/client/markdown/role-mark.ts +9 -0
  22. package/src/client/markdown/tables.ts +49 -0
  23. package/src/client/markdown/tabs.ts +52 -0
  24. package/src/client/markdown/tree-cache.ts +77 -0
  25. package/src/client/operations.ts +9 -0
  26. package/src/client/registry.ts +21 -0
  27. package/src/client/section-context.ts +5 -0
  28. package/src/client/section.ts +30 -0
  29. package/src/client/slidev-compat/click-directives.ts +16 -0
  30. package/src/client/slidev-compat/slide-globals.ts +3 -0
  31. package/src/client/style.css +1058 -0
  32. package/src/client/viewer-messages.ts +7 -0
  33. package/src/client/viewer-plugins.ts +35 -0
  34. package/src/client/viewer-state.ts +47 -0
  35. package/src/client/views/actions.ts +52 -0
  36. package/src/client/views/controls.css +44 -0
  37. package/src/client/views/document.ts +12 -0
  38. package/src/client/views/outlets.ts +37 -0
  39. package/src/client/views/overlay.ts +16 -0
  40. package/src/client/views/present.ts +132 -0
  41. package/src/client/views/presenter.ts +76 -0
  42. package/src/client/views/scrolly.ts +4 -0
  43. package/src/client/views/slide.css +8 -0
  44. package/src/client/views/slide.ts +57 -0
  45. package/src/domain/clicks.ts +38 -0
  46. package/src/domain/code-width.ts +11 -0
  47. package/src/domain/document-content.ts +6 -0
  48. package/src/domain/footnotes.ts +15 -0
  49. package/src/domain/index.ts +22 -0
  50. package/src/domain/live.ts +44 -0
  51. package/src/domain/messages.ts +139 -0
  52. package/src/domain/origin.ts +78 -0
  53. package/src/domain/outputs.ts +27 -0
  54. package/src/domain/steps.ts +46 -0
@@ -0,0 +1,7 @@
1
+ import { currentLocale } from '@lincyaw/host'
2
+ import { VIEWER_MESSAGES } from '../domain/index.ts'
3
+ import type { Messages } from '../domain/index.ts'
4
+
5
+ export function viewerMessages(): Messages {
6
+ return VIEWER_MESSAGES[currentLocale()]
7
+ }
@@ -0,0 +1,35 @@
1
+ import { errorMessage } from '@lincyaw/kernel'
2
+ import { BlockView } from './block.ts'
3
+ import { registerAction, registerComponent, registerLayer, registerNodeRenderer, registerView } from './registry.ts'
4
+ import { SectionView } from './section.ts'
5
+ import { useViewer } from './viewer-state.ts'
6
+
7
+ export interface ViewerPluginContext {
8
+ registerView: typeof registerView
9
+ registerNodeRenderer: typeof registerNodeRenderer
10
+ registerComponent: typeof registerComponent
11
+ registerAction: typeof registerAction
12
+ registerLayer: typeof registerLayer
13
+ useViewer: typeof useViewer
14
+ SectionView: typeof SectionView
15
+ BlockView: typeof BlockView
16
+ }
17
+
18
+ export type ViewerPlugin = (context: ViewerPluginContext) => void
19
+
20
+ const context: ViewerPluginContext = { registerView, registerNodeRenderer, registerComponent, registerAction, registerLayer, useViewer, SectionView, BlockView }
21
+
22
+ /** Installs a viewer plugin; returns why it could not be installed, or undefined when it was. */
23
+ export function tryInstallViewerPlugin(plugin: unknown, name = 'viewer plugin'): string | undefined {
24
+ if (typeof plugin !== 'function') {
25
+ console.error(`[presmium] ${name}: the default export is not a function`)
26
+ return 'the default export is not a function'
27
+ }
28
+ try {
29
+ (plugin as ViewerPlugin)(context)
30
+ return undefined
31
+ } catch (error) {
32
+ console.error(`[presmium] ${name} failed`, error)
33
+ return errorMessage(error)
34
+ }
35
+ }
@@ -0,0 +1,47 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { createStore, joinWindows } from '@lincyaw/host'
4
+ import type { Store } from '@lincyaw/host'
5
+ import { PREVIEW_REVISION } from '@lincyaw/kernel'
6
+ import type { IRDocument, IRSection, SourcePos } from '@lincyaw/kernel'
7
+ import { applyUpdate, clampPosition, footnoteSections, keepPosition, nextPosition, prevPosition } from '../domain/index.ts'
8
+ import type { LiveUpdate, Position } from '../domain/index.ts'
9
+ import { findView } from './registry.ts'
10
+ export interface ViewerOptions { renderer?: string, embedded?: boolean, externalControls?: boolean, documentIdentity?: string, editingActive?: Store<boolean>, confirmedRevision?: Store<string | undefined>, slidesOnly?: boolean, pageLink?(position: Position): { href: string, label: string } | undefined, sectionHref?(id: string): string, sectionHeading?(section: IRSection, step: number): ReactNode, diagnostics?: boolean, navigate?(position: Position): void, openPresenter?(position: Position): void, openSource?(source: SourcePos): void, refreshPresentation?(): void, exitPresentation?(): void }
11
+ export interface Viewer {
12
+ doc: Store<IRDocument>, view: Store<string>, pageView: Store<string>, position: Store<Position>, restoring: Store<boolean>, options: ViewerOptions & { renderer: string },
13
+ section(id: string): IRSection | undefined, footnoteHref(identifier: string): string,
14
+ go(position: Position, remote?: boolean): void, show(position: Position): void, next(): boolean, prev(): boolean, setView(id: string): void, update(update: LiveUpdate): void, replace(doc: IRDocument): void,
15
+ }
16
+ /** A live presentation opens only the revision explicitly confirmed by its embedding author. */
17
+ export function presentationConfirmed(viewer: Viewer): boolean {
18
+ const confirmed = viewer.options.confirmedRevision
19
+ return !confirmed || Boolean(confirmed.getSnapshot() && confirmed.getSnapshot() === viewer.doc.getSnapshot().meta[PREVIEW_REVISION])
20
+ }
21
+ export const VIEWER = createContext<Viewer | undefined>(undefined)
22
+ export const footnoteId = (identifier: string) => `fn-${identifier}`
23
+ export function createViewer(initial: IRDocument, options: ViewerOptions = {}): Viewer {
24
+ const doc = createStore(initial), view = createStore('document'), pageView = createStore('document'), position = createStore(clampPosition(initial, { section: initial.sections[0]?.id ?? '', step: 0 })), restoring = createStore(false)
25
+ const presenting = () => findView(view.getSnapshot())?.kind === 'overlay'
26
+ const windows = joinWindows(`presmium.viewer.position:${initial.meta.title}`, value => { if (presenting()) viewer.go(value as Position, true) }, () => undefined)
27
+ const replace = (next: IRDocument) => { const kept = keepPosition(doc.getSnapshot(), next, position.getSnapshot()); doc.set(next); position.set(kept) }
28
+ const viewer: Viewer = {
29
+ doc, view, pageView, position, restoring, options: { ...options, renderer: options.renderer ?? 'web' }, section: id => doc.getSnapshot().sections.find(s => s.id === id),
30
+ footnoteHref(identifier) { const anchor = `#${footnoteId(identifier)}`; const section = footnoteSections(doc.getSnapshot()).get(identifier); return section && options.sectionHref ? `${options.sectionHref(section).split('#')[0]}${anchor}` : anchor },
31
+ go(target, remote = false) { const next = clampPosition(doc.getSnapshot(), target); const before = position.getSnapshot(); if (before.section === next.section && before.step === next.step && before.boundary === next.boundary) return; position.set(next); if (!remote && presenting()) windows?.send(next) },
32
+ show(target) {
33
+ const before = position.getSnapshot()
34
+ viewer.go(target)
35
+ // Explicitly selecting the current step also pauses and repositions an
36
+ // active lecture through the existing navigation operation subscribers.
37
+ if (position.getSnapshot() === before) position.set({ ...before })
38
+ if (!presenting()) restoring.set(true)
39
+ },
40
+ next() { const next = nextPosition(doc.getSnapshot(), position.getSnapshot()); if (next) viewer.go(next); return Boolean(next) },
41
+ prev() { const next = prevPosition(doc.getSnapshot(), position.getSnapshot()); if (next) viewer.go(next); return Boolean(next) },
42
+ setView(id) { const target = findView(id); if (!target || id === view.getSnapshot() || (options.slidesOnly && target.kind === 'page')) return; view.set(id); if (target.kind === 'page') pageView.set(id); restoring.set(true) },
43
+ update: update => replace(applyUpdate(doc.getSnapshot(), update)), replace,
44
+ }
45
+ return viewer
46
+ }
47
+ export function useViewer(): Viewer { const viewer = useContext(VIEWER); if (!viewer) throw new Error('[presmium] no viewer provider'); return viewer }
@@ -0,0 +1,52 @@
1
+ import { useEffect } from 'react'
2
+ import { createStore } from '@lincyaw/host'
3
+ import { keyBelongsToTarget, watchFocusSource } from '@lincyaw/kernel'
4
+ import { listActions } from '../registry.ts'
5
+ import type { ViewerAction } from '../registry.ts'
6
+ import type { Viewer } from '../viewer-state.ts'
7
+ import { presentationConfirmed } from '../viewer-state.ts'
8
+
9
+ /** Whether the present view shows its overview of the sections. */
10
+ export const overviewOpen = createStore(false)
11
+
12
+ export const BUILTIN_ACTIONS: readonly ViewerAction[] = [
13
+ { id: 'next', keys: ['ArrowRight', 'ArrowDown', 'PageDown', ' '], run: (viewer) => { viewer.next() } },
14
+ { id: 'prev', keys: ['ArrowLeft', 'ArrowUp', 'PageUp', 'Backspace'], run: (viewer) => { viewer.prev() } },
15
+ { id: 'first', keys: ['Home'], run: viewer => viewer.go({ section: viewer.doc.getSnapshot().sections[0]?.id ?? '', step: 0 }) },
16
+ { id: 'last', keys: ['End'], run: viewer => viewer.go({ section: viewer.doc.getSnapshot().sections.at(-1)?.id ?? '', step: Number.POSITIVE_INFINITY }) },
17
+ { id: 'exit', keys: ['Escape'], views: ['present'], run: viewer => (viewer.options.slidesOnly ? false : viewer.options.exitPresentation ? viewer.options.exitPresentation() : viewer.setView(viewer.pageView.getSnapshot())) },
18
+ {
19
+ id: 'presenter',
20
+ keys: ['p'],
21
+ views: ['present'],
22
+ run: viewer => viewer.options.openPresenter ? viewer.options.openPresenter(viewer.position.getSnapshot()) : false,
23
+ },
24
+ { id: 'overview', keys: ['o'], views: ['present'], run: () => { overviewOpen.set(!overviewOpen.getSnapshot()) } },
25
+ ]
26
+
27
+ const builtin = new Set(BUILTIN_ACTIONS.map(action => action.id))
28
+
29
+ /**
30
+ * Runs the registered actions of a view on their keys while the view is mounted. A plugin's action comes before the
31
+ * built-in one on the same key and hands the key on by returning false: the lecture player takes Space to play or
32
+ * pause, and without a player Space moves to the next step.
33
+ */
34
+ export function useActions(viewer: Viewer, view: string, extra: readonly ViewerAction[] = []): void {
35
+ const onKey = (event: KeyboardEvent) => {
36
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || keyBelongsToTarget(event)) return
37
+ const actions = listActions(view)
38
+ for (const action of [...extra, ...actions.filter(action => !builtin.has(action.id)), ...actions.filter(action => builtin.has(action.id))]) {
39
+ if (!action.keys.includes(event.key)) continue
40
+ if (!presentationConfirmed(viewer) && action.id !== 'exit') return
41
+ if (action.run(viewer, event) === false) continue
42
+ event.preventDefault()
43
+ return
44
+ }
45
+ }
46
+ useEffect(() => {
47
+ watchFocusSource(document)
48
+ window.addEventListener('keydown', onKey)
49
+ document.documentElement.classList.add('pm-presenting')
50
+ return () => { window.removeEventListener('keydown', onKey); document.documentElement.classList.remove('pm-presenting') }
51
+ }, [viewer, view, extra])
52
+ }
@@ -0,0 +1,44 @@
1
+ .pm-present .pm-present__bar,
2
+ .pm-presenter .pm-presenter__bar {
3
+ gap: var(--pm-space-1);
4
+ padding: var(--pm-space-1) var(--pm-space-2);
5
+ font-size: var(--pm-text-xs);
6
+ }
7
+
8
+ .pm-present__bar .pm-present__counter {
9
+ font-family: var(--pm-font-mono);
10
+ font-variant-numeric: tabular-nums;
11
+ }
12
+
13
+ .pm-content-marks { display: none; }
14
+ .pm-present__captions {
15
+ position: absolute;
16
+ box-sizing: border-box;
17
+ inset: 0;
18
+ display: flex;
19
+ align-items: flex-end;
20
+ justify-content: center;
21
+ padding: var(--pm-space-4) var(--pm-space-4) var(--pm-caption-safe-bottom);
22
+ pointer-events: none;
23
+ }
24
+ .pm-present__captions > * {
25
+ pointer-events: auto;
26
+ }
27
+
28
+ .pm-present .pm-present__bar {
29
+ position: relative;
30
+ flex-wrap: nowrap;
31
+ min-width: 0;
32
+ }
33
+ .pm-present .pm-present__bar[hidden] { display: none; }
34
+ @media (max-width: 479px) {
35
+ .pm-present__bar:has(.pm-lecture-player) .pm-present__previous,
36
+ .pm-present__bar:has(.pm-lecture-player) .pm-present__next,
37
+ .pm-present__bar:has(.pm-lecture-player) .pm-present__presenter,
38
+ .pm-present__bar:has(.pm-lecture-player) .pm-present__overview {
39
+ display: none;
40
+ }
41
+ }
42
+
43
+ /* Keep recovery controls in the existing bar while unconfirmed content stays unavailable. */
44
+ .pm-present[data-pending-confirmation] .pm-present__slide { visibility: hidden; }
@@ -0,0 +1,12 @@
1
+ import type { IRSection } from '@lincyaw/kernel'
2
+ import { SectionView } from '../section.ts'
3
+ import { useViewer } from '../viewer-state.ts'
4
+ import { h } from '../element.ts'
5
+ export function DocumentView({ sections }: { sections: IRSection[] }) {
6
+ const viewer = useViewer()
7
+ return h('div', { className: 'pm-view pm-view--document' }, sections.map(section => h(SectionView, {
8
+ key: section.id,
9
+ section,
10
+ layout: section.hints[viewer.options.renderer]?.layout === 'scrolly' ? 'scrolly' : 'document',
11
+ })))
12
+ }
@@ -0,0 +1,37 @@
1
+ import { useEffect } from 'react'
2
+ import { useStore } from '@lincyaw/host'
3
+ import { blockVisible, irBlocks } from '@lincyaw/kernel'
4
+ import { findView, registryRevision } from '../registry.ts'
5
+ import { useViewer } from '../viewer-state.ts'
6
+ import { h } from '../element.ts'
7
+
8
+ /** A later step may belong to a block far below the page heading. */
9
+ function stepElement(viewer: ReturnType<typeof useViewer>, sectionId: string, step: number): HTMLElement | null {
10
+ const heading = document.getElementById(sectionId)
11
+ const section = viewer.section(sectionId)
12
+ const root = heading?.closest('.pm-section')
13
+ if (!root || !section) return heading
14
+ if (root.classList.contains('pm-section--scrolly'))
15
+ return root.querySelector<HTMLElement>(`.pm-step[data-step="${step}"]`) ?? heading
16
+ if (step === 0) return heading
17
+ const candidates = [...irBlocks(section.blocks)]
18
+ .filter(block => block.role === 'visual' && blockVisible(block, step) && !(block.node.type === 'component' && block.node.layer === 'overlay'))
19
+ .sort((a, b) => {
20
+ const aCurrent = step <= a.at + (a.span ?? 0)
21
+ const bCurrent = step <= b.at + (b.span ?? 0)
22
+ return Number(bCurrent) - Number(aCurrent) || b.at - a.at
23
+ })
24
+ for (const block of candidates) {
25
+ const id = CSS.escape(block.id)
26
+ const element = root.querySelector<HTMLElement>(`[data-block="${id}"]`)
27
+ if (element && element.getBoundingClientRect().height > 0 && getComputedStyle(element).visibility !== 'hidden') return element
28
+ }
29
+ return heading
30
+ }
31
+ export function PageViewOutlet({ sections }: { sections: string[] }) {
32
+ const viewer = useViewer(); useStore(viewer.doc); const page = useStore(viewer.pageView); const view = useStore(viewer.view); const restoring = useStore(viewer.restoring); const position = useStore(viewer.position); useStore(registryRevision)
33
+ useEffect(() => { if (!restoring || findView(view)?.kind !== 'page') return; const frame = requestAnimationFrame(() => { const element = stepElement(viewer, position.section, position.step); if (element) { element.scrollIntoView({ block: 'start' }); viewer.restoring.set(false) } else { viewer.options.navigate?.(position) } }); return () => cancelAnimationFrame(frame) }, [restoring, view, position, sections])
34
+ const definition = findView(page) ?? findView('document')
35
+ return h('div', { className: 'pm-view-host pm-document', 'aria-busy': restoring }, definition ? h(definition.component, { sections: sections.flatMap(id => viewer.section(id) ?? []) }) : null)
36
+ }
37
+ export function OverlayViewOutlet() { const viewer = useViewer(); const view = useStore(viewer.view); useStore(registryRevision); const definition = findView(view); return definition?.kind === 'overlay' ? h(definition.component, { key: view }) : null }
@@ -0,0 +1,16 @@
1
+ import { useEffect } from 'react'
2
+ import type { RefObject } from 'react'
3
+ import { PmIconButton, useStore } from '@lincyaw/host'
4
+ import type { IconName } from '@lincyaw/host'
5
+ import type { IRSection } from '@lincyaw/kernel'
6
+ import { BlockView } from '../block.ts'
7
+ import { listLayers } from '../registry.ts'
8
+ import type { ViewerLayer } from '../registry.ts'
9
+ import type { Viewer } from '../viewer-state.ts'
10
+ import { h } from '../element.ts'
11
+ export function useModal(root: RefObject<HTMLElement | null>, embedded = false) { useEffect(() => { if (!embedded || document.hasFocus()) root.current?.focus({ preventScroll: true }) }, [root, embedded]) }
12
+ export function useOverlayBar(bar: RefObject<HTMLElement | null>) { useEffect(() => { const element = bar.current; if (!element) return; const update = () => document.documentElement.style.setProperty('--pm-overlay-bar', `${element.offsetHeight}px`); const observer = new ResizeObserver(update); observer.observe(element); update(); return () => { observer.disconnect(); document.documentElement.style.removeProperty('--pm-overlay-bar') } }, [bar]) }
13
+ export function useCurrentSection(viewer: Viewer, _view: string) { const position = useStore(viewer.position); useStore(viewer.doc); return { section: viewer.section(position.section), step: position.step } }
14
+ export function visuals(section: IRSection, step: number) { return section.blocks.filter(block => block.role === 'visual' && (!block.stepStates?.length || block.stepStates.some(state => state.visible))).map(block => h(BlockView, { key: `${section.id}/${block.id}`, block, section, step })) }
15
+ export function button(label: string, onClick: () => void, icon: IconName, shortcut?: string, attrs: Record<string, unknown> = {}) { const keys: Record<string, string> = { ArrowLeft: '←', ArrowRight: '→', Escape: 'Esc', 'Shift+F': '⇧ F' }; return h(PmIconButton, { ...attrs, icon, label, small: true, hint: shortcut ? `${label} · ${keys[shortcut] ?? shortcut.toUpperCase()}` : label, 'aria-keyshortcuts': shortcut, onClick }) }
16
+ export function layers(view: string, scope: NonNullable<ViewerLayer['scope']>, props: Record<string, unknown> = {}) { return listLayers(view).filter(layer => (layer.scope ?? 'viewport') === scope).map(layer => h(layer.component, { key: layer.id, view, ...props })) }
@@ -0,0 +1,132 @@
1
+ import { FocusScope } from '@radix-ui/react-focus-scope'
2
+ import { PmApprovals, PmCaption, PmCaptionProvider, PmIcon, TIMED_CAPTIONS, useService, PLAYBACK_CONTROL, presmiumService } from '@lincyaw/host'
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { useStore } from '@lincyaw/host'
5
+ import { h } from '../element.ts'
6
+ import { viewerMessages } from '../viewer-messages.ts'
7
+ import { useViewer, presentationConfirmed } from '../viewer-state.ts'
8
+ import type { Viewer } from '../viewer-state.ts'
9
+ import { overviewOpen, useActions } from './actions.ts'
10
+ import { button, layers, useCurrentSection, useModal, useOverlayBar } from './overlay.ts'
11
+ import { SlideFace, SlideFit, useSlides } from './slide.ts'
12
+
13
+ function moveInOverview(root: HTMLElement | null | undefined, key: string): boolean {
14
+ const items = [...root?.querySelectorAll<HTMLElement>('.pm-overview__item') ?? []]
15
+ if (!items.length) return false
16
+ const at = Math.max(items.indexOf(document.activeElement as HTMLElement), 0)
17
+ const columns = items.filter(item => item.offsetTop === items[0]!.offsetTop).length
18
+ const moves: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns, Home: -at, End: items.length }
19
+ if (!(key in moves)) return false
20
+ items[Math.min(Math.max(at + moves[key]!, 0), items.length - 1)]!.focus()
21
+ return true
22
+ }
23
+
24
+ /** The slides form's way out: a link to the same place on the document's page, when there is one. */
25
+ function pageLink(viewer: Viewer) {
26
+ const link = viewer.options.pageLink?.(viewer.position.getSnapshot())
27
+ return link ? h('a', { 'class': 'pm-ui-icon-button pm-ui-icon-button--small', 'href': link.href, 'aria-label': link.label, 'title': link.label }, [h(PmIcon, { name: 'document' })]) : null
28
+ }
29
+
30
+ export function PresentView() {
31
+ const viewer = useViewer()
32
+ const { section, step } = useCurrentSection(viewer, 'present')
33
+ const root = useRef<HTMLDivElement>(null)
34
+ const bar = useRef<HTMLDivElement>(null)
35
+ const [captionContainer, setCaptionContainer] = useState<HTMLDivElement | null>(null)
36
+ const { shape } = useSlides()
37
+ useOverlayBar(bar)
38
+ const ownsFullscreen = useRef(false)
39
+ const [fullscreen, setFullscreen] = useState(false)
40
+ const [captions, setCaptions] = useState(true)
41
+ const timedCaptions = useStore(useService(TIMED_CAPTIONS)?.active, false)
42
+ const overview = useStore(overviewOpen)
43
+ const doc = useStore(viewer.doc)
44
+ useStore(viewer.options.confirmedRevision)
45
+ const pending = !presentationConfirmed(viewer)
46
+ useEffect(() => { if (pending) { overviewOpen.set(false); presmiumService(PLAYBACK_CONTROL)?.pause() } }, [pending])
47
+ const position = useStore(viewer.position)
48
+ const pageView = useStore(viewer.pageView)
49
+ const syncFullscreen = () => { setFullscreen(Boolean(document.fullscreenElement)) }
50
+ const enterFullscreen = () => {
51
+ if (document.fullscreenElement) return void document.exitFullscreen().catch(() => {})
52
+ void document.documentElement.requestFullscreen().then(() => {
53
+ if (!root.current) return document.exitFullscreen()
54
+ ownsFullscreen.current = true
55
+ }).catch(() => {})
56
+ }
57
+ const closeOverview = () => {
58
+ overviewOpen.set(false)
59
+ root.current?.focus()
60
+ }
61
+ useActions(viewer, 'present', [{ id: 'fullscreen', keys: ['F'], run: enterFullscreen }])
62
+ const onOverviewKey = (event: KeyboardEvent) => {
63
+ if (pending || !overview || event.key === 'Tab' || event.altKey || event.ctrlKey || event.metaKey) return
64
+ event.stopImmediatePropagation()
65
+ if (event.key === 'Escape' || event.key === 'o') closeOverview()
66
+ else if (!moveInOverview(root.current, event.key)) return
67
+ event.preventDefault()
68
+ }
69
+ useModal(root, viewer.options.embedded)
70
+ useEffect(() => {
71
+ window.addEventListener('keydown', onOverviewKey, { capture: true })
72
+ document.addEventListener('fullscreenchange', syncFullscreen)
73
+ return () => {
74
+ window.removeEventListener('keydown', onOverviewKey, { capture: true })
75
+ document.removeEventListener('fullscreenchange', syncFullscreen)
76
+ }
77
+ }, [overview])
78
+ useEffect(() => () => { overviewOpen.set(false); if (ownsFullscreen.current && document.fullscreenElement === document.documentElement) void document.exitFullscreen().catch(() => {}) }, [])
79
+ useEffect(() => { if (overview) root.current?.querySelector<HTMLElement>('.pm-overview__item[aria-current="true"]')?.focus() }, [overview])
80
+ const jump = (id: string) => {
81
+ viewer.go({ section: id, step: 0 })
82
+ closeOverview()
83
+ }
84
+
85
+ const current = section
86
+ if (!current) return null
87
+ const t = viewerMessages()
88
+ const { sections } = doc
89
+ const stepId = current.stepIds?.[step]
90
+ const narration = stepId ? current.stepNarrations?.[stepId] ?? [] : []
91
+ const hasNarration = sections.some(section => Object.values(section.stepNarrations ?? {}).some(lines => lines.some(line => line.text.trim())))
92
+ return h(PmCaptionProvider, { container: captionContainer }, h(FocusScope, { trapped: !viewer.options.embedded, loop: !viewer.options.embedded, asChild: true,
93
+ onMountAutoFocus: (event: Event) => { if (viewer.options.embedded) event.preventDefault() },
94
+ onUnmountAutoFocus: (event: Event) => { if (viewer.options.embedded) event.preventDefault() },
95
+ }, h('div', null, [h('div', {
96
+ 'ref': root,
97
+ 'class': 'pm-present',
98
+ 'role': 'region',
99
+ 'aria-label': t.present.region,
100
+ 'tabindex': -1,
101
+ 'data-section': current.id,
102
+ 'data-step': step,
103
+ 'data-pending-confirmation': pending || undefined,
104
+ }, [
105
+ h('div', { class: 'pm-present__slide', inert: pending ? '' : undefined, 'aria-hidden': pending || undefined }, [
106
+ h(SlideFit, { shape: shape, overlay: h('div', { ref: setCaptionContainer, className: 'pm-present__captions' }) }, h(SlideFace, { key: current.id, section: current, step: step })),
107
+ overview
108
+ ? 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', {
109
+ 'type': 'button',
110
+ 'class': 'pm-overview__item',
111
+ 'data-section': s.id,
112
+ 'aria-current': s.id === current.id ? 'true' : undefined,
113
+ 'onClick': () => jump(s.id),
114
+ }, [h('span', { class: 'pm-overview__index' }, String(s.index)), h('span', { class: 'pm-overview__title' }, s.title)])))))
115
+ : null,
116
+ ]),
117
+ !pending && captions && !timedCaptions && narration.some(item => item.text.trim()) ? h(PmCaption, { overlay: true, label: t.present.captions, lines: narration.map(item => item.text).filter(text => text.trim()), lang: doc.meta.lang }) : null,
118
+ h('div', { ref: bar, class: 'pm-present__bar', hidden: viewer.options.externalControls || undefined }, [
119
+ button(t.present.prev, () => viewer.prev(), 'chevron-left', 'ArrowLeft', { className: 'pm-present__previous', disabled: pending }),
120
+ button(t.present.next, () => viewer.next(), 'chevron-right', 'ArrowRight', { className: 'pm-present__next', disabled: pending }),
121
+ ...pending ? [] : layers('present', 'bar'),
122
+ hasNarration && !timedCaptions ? button(t.present.captions, () => setCaptions(value => !value), 'captions', undefined, { 'aria-pressed': captions, disabled: pending }) : null,
123
+ button(t.present.overview, () => overviewOpen.set(!overview), 'overview', 'o', { disabled: pending, 'aria-pressed': overview, className: 'pm-present__overview' }),
124
+ viewer.options.openPresenter ? button(t.present.presenter, () => viewer.options.openPresenter!(position), 'slides', 'p', { className: 'pm-present__presenter', disabled: pending }) : null,
125
+ viewer.options.refreshPresentation ? button(t.present.refresh, () => viewer.options.refreshPresentation!(), 'refresh') : null,
126
+ button(fullscreen ? t.present.exitFullscreen : t.present.fullscreen, enterFullscreen, fullscreen ? 'exit-fullscreen' : 'fullscreen', 'Shift+F', { 'aria-pressed': fullscreen, disabled: pending }),
127
+ viewer.options.slidesOnly ? pageLink(viewer) : button(t.present.exit, () => viewer.options.exitPresentation ? viewer.options.exitPresentation() : viewer.setView(pageView), 'close', 'Escape'),
128
+ pending ? null : h(PmApprovals, { placement: 'above' }),
129
+ ]),
130
+ ...pending ? [] : layers('present', 'view'),
131
+ ]), ...pending ? [] : layers('present', 'viewport')])))
132
+ }
@@ -0,0 +1,76 @@
1
+ import { FocusScope } from '@radix-ui/react-focus-scope'
2
+ import { PmApprovals } from '@lincyaw/host'
3
+ import { blockText, sectionNarration } from '@lincyaw/kernel'
4
+ import { useEffect, useRef, useState } from 'react'
5
+ import { useStore } from '@lincyaw/host'
6
+ import { h } from '../element.ts'
7
+ import { nextPosition } from '../../domain/index.ts'
8
+ import { BlockView } from '../block.ts'
9
+ import { viewerMessages } from '../viewer-messages.ts'
10
+ import { useViewer } from '../viewer-state.ts'
11
+ import { useActions } from './actions.ts'
12
+ import { button, layers, useCurrentSection, useModal, useOverlayBar } from './overlay.ts'
13
+ import { SlideFace, SlideFit, useSlides } from './slide.ts'
14
+
15
+ function clock(ms: number): string {
16
+ const s = Math.floor(ms / 1000)
17
+ return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
18
+ }
19
+
20
+ export function PresenterView() {
21
+ const viewer = useViewer()
22
+ const { section, step } = useCurrentSection(viewer, 'presenter')
23
+ const root = useRef<HTMLDivElement>(null)
24
+ const bar = useRef<HTMLDivElement>(null)
25
+ useActions(viewer, 'presenter')
26
+ useOverlayBar(bar)
27
+ useModal(root, viewer.options.embedded)
28
+ const [started, setStarted] = useState(Date.now)
29
+ const [now, setNow] = useState(Date.now)
30
+ useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer) }, [])
31
+ const { shape } = useSlides()
32
+ const doc = useStore(viewer.doc)
33
+ const position = useStore(viewer.position)
34
+ const following = nextPosition(doc, position)
35
+ const t = viewerMessages()
36
+ const target = following ? viewer.section(following.section) : undefined
37
+ const upcoming = !following ? t.presenter.end : following.section !== section?.id ? t.presenter.nextSection(target!.title) : sectionNarration(target!).filter(block => block.at === following.step).map(blockText).join('\n') || t.presenter.nextStep(following.step)
38
+ const current = section
39
+ if (!current) return null
40
+ const notes = sectionNarration(current)
41
+ return h(FocusScope, { trapped: !viewer.options.embedded, loop: !viewer.options.embedded, asChild: true,
42
+ onMountAutoFocus: (event: Event) => { if (viewer.options.embedded) event.preventDefault() },
43
+ onUnmountAutoFocus: (event: Event) => { if (viewer.options.embedded) event.preventDefault() },
44
+ }, h('div', null, [h('div', { 'ref': root, 'tabindex': -1, 'class': 'pm-presenter', 'data-section': current.id, 'data-step': step }, [
45
+ h('div', { ref: bar, class: 'pm-presenter__bar' }, [
46
+ h('strong', { class: 'pm-presenter__title' }, `${current.index}. ${current.title}`),
47
+ current.steps > 0 ? h('span', { class: 'pm-presenter__counter' }, t.presenter.step(step, current.steps)) : null,
48
+ h('span', { 'class': 'pm-presenter__timer', 'aria-label': t.presenter.timer }, clock(now - started)),
49
+ button(t.presenter.restart, () => { const time = Date.now(); setStarted(time); setNow(time) }, 'redo'),
50
+ button(t.present.prev, () => viewer.prev(), 'chevron-left', 'ArrowLeft'),
51
+ button(t.present.next, () => viewer.next(), 'chevron-right', 'ArrowRight'),
52
+ ...layers('presenter', 'bar'),
53
+ h(PmApprovals),
54
+ ]),
55
+ h('div', { class: 'pm-presenter__main' }, [
56
+ h('div', { class: 'pm-presenter__slide' }, h(SlideFit, { shape: shape }, h(SlideFace, { key: current.id, section: current, step: step, view: 'presenter' }))),
57
+ h('div', { class: 'pm-presenter__side' }, [
58
+ h('p', { class: 'pm-presenter__label' }, t.presenter.notes),
59
+ h('div', { class: 'pm-presenter__notes' }, notes.length
60
+ ? notes.map(block => h('div', {
61
+ 'key': block.id,
62
+ 'class': ['pm-presenter__note', { 'pm-current': block.at === step }],
63
+ 'data-step': block.at,
64
+ 'aria-current': block.at === step ? 'step' : undefined,
65
+ }, [h(BlockView, { block, section: current, step: step, expanded: true })]))
66
+ : [h('p', { class: 'pm-presenter__empty' }, t.presenter.noNotes)]),
67
+ h('p', { class: 'pm-presenter__label' }, t.presenter.upcoming),
68
+ following
69
+ ? h('div', { class: 'pm-presenter__upcoming', inert: '' }, h(SlideFit, { shape: shape }, h(SlideFace, { key: `${following!.section}@${following!.step}`, section: viewer.section(following!.section)!, step: following!.step, view: 'presenter-next' })))
70
+ : null,
71
+ h('p', { class: 'pm-presenter__next' }, upcoming),
72
+ ]),
73
+ ]),
74
+ ...layers('presenter', 'view'),
75
+ ]), ...layers('presenter', 'viewport')]))
76
+ }
@@ -0,0 +1,4 @@
1
+ import type { IRSection } from '@lincyaw/kernel'
2
+ import { SectionView } from '../section.ts'
3
+ import { h } from '../element.ts'
4
+ export function ScrollyView({ sections }: { sections: IRSection[] }) { return h('div', { className: 'pm-view pm-view--scrolly' }, sections.map(section => h(SectionView, { key: section.id, section, layout: 'scrolly' }))) }
@@ -0,0 +1,8 @@
1
+ .pm-slide--cover .pm-slide__body {
2
+ flex: 0 1 auto;
3
+ }
4
+
5
+ .pm-slide-fit__overlay {
6
+ position: absolute;
7
+ pointer-events: none;
8
+ }
@@ -0,0 +1,57 @@
1
+ import { useMessages, useStore } from '@lincyaw/host'
2
+ import { slideChapters, slideShape } from '@lincyaw/kernel'
3
+ import type { IRSection, SlideShape } from '@lincyaw/kernel'
4
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
5
+ import type { ReactNode } from 'react'
6
+ import { SECTION_CONTEXT } from '../section-context.ts'
7
+ import { useViewer } from '../viewer-state.ts'
8
+ import { h } from '../element.ts'
9
+ import { layers, visuals } from './overlay.ts'
10
+ import './slide.css'
11
+ const MESSAGES = { 'zh-CN': { steps: (at: number, of: number) => `第 ${at} 步,共 ${of} 步` }, en: { steps: (at: number, of: number) => `Step ${at} of ${of}` } }
12
+ const LAYOUTS = new Set(['default', 'cover', 'center', 'full'])
13
+ function slideOptions(section: IRSection, cover: boolean) { const hints = section.hints.slides ?? {}; const zoom = Number(hints.zoom); return { layout: typeof hints.layout === 'string' && LAYOUTS.has(hints.layout) ? hints.layout : cover ? 'cover' : 'default', className: typeof hints.class === 'string' ? hints.class : undefined, zoom: Number.isFinite(zoom) && zoom > 0 ? zoom : undefined } }
14
+ export function useSlides() { const viewer = useViewer(); const doc = useStore(viewer.doc); return { viewer, shape: slideShape(doc), chapters: slideChapters(doc) } }
15
+ export function SlideFit({ shape, scale: fixed, children, overlay }: { shape: SlideShape, scale?: number, children?: ReactNode, overlay?: ReactNode }) {
16
+ const box = useRef<HTMLDivElement>(null)
17
+ const [size, setSize] = useState<{ width: number, height: number }>()
18
+ useEffect(() => {
19
+ const observer = new ResizeObserver(([entry]) => setSize({ width: entry!.contentRect.width, height: entry!.contentRect.height }))
20
+ if (box.current) observer.observe(box.current)
21
+ return () => observer.disconnect()
22
+ }, [])
23
+ const { width, height } = shape
24
+ const scale = fixed ?? (size ? Math.min(size.width / width, size.height / height) : 1)
25
+ const left = size && fixed === undefined ? (size.width - width * scale) / 2 : 0
26
+ const top = size && fixed === undefined ? (size.height - height * scale) / 2 : 0
27
+ return h('div', { ref: box, className: 'pm-slide-fit' }, [
28
+ h('div', { className: 'pm-slide-fit__canvas', style: { width: `${width}px`, height: `${height}px`, transform: `translate(${left}px, ${top}px) scale(${scale})` } }, children),
29
+ // Captions follow the canvas bounds without shrinking their text with the slide.
30
+ overlay ? h('div', { className: 'pm-slide-fit__overlay', style: { left, top, width: width * scale, height: height * scale } }, overlay) : null,
31
+ ])
32
+ }
33
+
34
+ // The shrink is measured after layout and before paint and written to the element itself, not through a render:
35
+ // a render would come a frame late, and the new slide would show for that frame at full size.
36
+ function SlideBody({ zoom, children }: { zoom?: number, children?: ReactNode }) {
37
+ const frame = useRef<HTMLDivElement>(null)
38
+ const content = useRef<HTMLDivElement>(null)
39
+ useLayoutEffect(() => {
40
+ const outer = frame.current, inner = content.current
41
+ if (!outer || !inner) return
42
+ const measure = () => {
43
+ const scale = Math.min(1, outer.clientHeight / Math.max(1, inner.scrollHeight), outer.clientWidth / Math.max(1, inner.scrollWidth))
44
+ const left = Math.max(0, (outer.clientWidth - inner.offsetWidth * scale) / 2)
45
+ inner.style.transform = scale < 1 ? `translateX(${left}px) scale(${scale})` : ''
46
+ }
47
+ const observer = new ResizeObserver(measure)
48
+ observer.observe(outer)
49
+ observer.observe(inner)
50
+ measure()
51
+ return () => observer.disconnect()
52
+ }, [])
53
+ return h('div', { ref: frame, className: 'pm-slide__body' }, h('div', { ref: content, className: 'pm-slide__content', style: zoom ? { zoom } : undefined }, children))
54
+ }
55
+
56
+ export function SlideFace({ section, step, view = 'present' }: { section: IRSection, step: number, view?: string }) { const { viewer, chapters } = useSlides(); const doc = useStore(viewer.doc); const t = useMessages(MESSAGES); const { sections } = doc; const options = slideOptions(section, section.index === 1 && sections.length > 1); const chapter = options.layout === 'cover' || options.layout === 'full' ? undefined : chapters.get(section.id); const done = Math.min(step, section.steps); return h(SECTION_CONTEXT.Provider, { value: { section, step, view } }, h('div', { class: ['pm-slide', `pm-slide--${options.layout}`, options.className], 'data-section': section.id, 'data-slide': section.index, 'data-step': step }, [chapter ? h('p', { className: 'pm-slide__chapter' }, chapter) : null, h(SlideBody, { key: section.id, zoom: options.zoom }, [h(options.layout === 'cover' ? 'h1' : 'h2', { className: 'pm-slide__title' }, section.title), ...visuals(section, step)]), options.layout === 'full' ? null : h('footer', { className: 'pm-slide__footer' }, [options.layout === 'cover' ? null : h('span', { className: 'pm-slide__deck' }, doc.meta.title), section.steps > 0 ? h('span', { className: 'pm-slide__steps', role: 'img', 'aria-label': t.steps(done, section.steps), style: { '--pm-slide-steps': section.steps } }, Array.from({ length: section.steps }, (_, i) => h('span', { key: i, 'data-done': i < done ? '' : undefined }))) : null, h('span', { className: 'pm-slide__page' }, `${section.index} / ${sections.length}`)]), h('div', { className: 'pm-slide__layers' }, layers(view, 'slide', { section: section.id, step }))])) }
57
+ export function SlidesPrint({ steps = false, slides }: { steps?: boolean, slides?: readonly number[] }) { const { viewer, shape } = useSlides(); const doc = useStore(viewer.doc); useEffect(() => { let disposed = false; void (async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.complete ? undefined : new Promise<void>(done => { image.addEventListener('load', () => done(), { once: true }); image.addEventListener('error', () => done(), { once: true }) }))); if (!disposed) document.documentElement.setAttribute('data-presmium-printed', '') })(); return () => { disposed = true; document.documentElement.removeAttribute('data-presmium-printed') } }, []); const chosen = slides && new Set(slides); const pages = doc.sections.filter(section => !chosen || chosen.has(section.index)).flatMap(section => steps ? Array.from({ length: section.steps + 1 }, (_, step) => ({ section, step })) : [{ section, step: section.steps }]); return h('div', { className: 'pm-slides-print', style: { '--pm-slide-width': `${shape.width}px`, '--pm-slide-height': `${shape.height}px` } }, pages.map(({ section, step }) => h('div', { key: `${section.id}@${step}`, className: 'pm-slides-print__page', style: { width: `${shape.width}px`, height: `${shape.height}px` } }, h(SlideFace, { section, step, view: 'print' })))) }
@@ -0,0 +1,38 @@
1
+ import { stepStateAt } from '@lincyaw/kernel'
2
+
3
+ export interface ClickSpec {
4
+ kind: 'click' | 'after'
5
+ at?: unknown
6
+ hide?: boolean
7
+ }
8
+
9
+ export interface ClickRange {
10
+ from: number
11
+ to?: number
12
+ hide: boolean
13
+ }
14
+
15
+ function isRelative(at: unknown): at is string {
16
+ return typeof at === 'string' && /^[+-]\d+$/.test(at.trim())
17
+ }
18
+
19
+ export function resolveClicks(specs: readonly ClickSpec[]): ClickRange[] {
20
+ let last = 0
21
+ const position = (at: unknown, base: number): number => {
22
+ if (isRelative(at)) return base + Number(at.trim())
23
+ const value = Number(at)
24
+ return Number.isFinite(value) ? value : base + 1
25
+ }
26
+ return specs.map((spec) => {
27
+ const at = spec.kind === 'after' ? '+0' : spec.at ?? '+1'
28
+ const [enter, leave] = Array.isArray(at) ? at : [at]
29
+ const from = position(enter, last)
30
+ if (isRelative(enter) || !Number.isFinite(Number(enter))) last = from
31
+ const to = leave === undefined ? undefined : position(leave, from)
32
+ return { from, to, hide: spec.hide ?? false }
33
+ })
34
+ }
35
+
36
+ export function clickVisible(range: ClickRange, step: number): boolean {
37
+ return stepStateAt(range, step).active !== range.hide
38
+ }
@@ -0,0 +1,11 @@
1
+ const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|[\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
2
+
3
+ export function codeColumns(code: string): number {
4
+ let widest = 0
5
+ for (const line of code.split('\n')) {
6
+ let width = 0
7
+ for (const char of line) width += char === '\t' ? 4 : WIDE.test(char) ? 2 : 1
8
+ widest = Math.max(widest, width)
9
+ }
10
+ return widest
11
+ }
@@ -0,0 +1,6 @@
1
+ import type { IRBlock, IRDocument } from '@lincyaw/kernel'
2
+
3
+ /** Legacy prose remains visible without changing its authored narration binding. */
4
+ export function isLegacyNarration(document: IRDocument, block: IRBlock): boolean {
5
+ return document.meta.contentModel !== 'blocks' && block.role === 'narration' && (!block.only || block.only.includes('lecture'))
6
+ }
@@ -0,0 +1,15 @@
1
+ import { docBlocks } from '@lincyaw/kernel'
2
+ import type { IRDocument, MdastNode } from '@lincyaw/kernel'
3
+
4
+ function definitions(node: MdastNode, found: (identifier: string) => void): void {
5
+ if (node.type === 'footnoteDefinition' && node.identifier !== undefined) found(String(node.identifier))
6
+ node.children?.forEach(child => definitions(child, found))
7
+ }
8
+
9
+ export function footnoteSections(doc: IRDocument): Map<string, string> {
10
+ const sections = new Map<string, string>()
11
+ for (const { section, block: { node } } of docBlocks(doc)) {
12
+ if (node.type === 'markdown') definitions(node.mdast, identifier => sections.set(identifier, section.id))
13
+ }
14
+ return sections
15
+ }