@lincyaw/viewer 1.0.0-rc.0 → 1.0.0-rc.10

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
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @lincyaw/viewer
2
2
 
3
- Presmium's IR runtime: renders compiled documents in the browser with Vue, in pluggable runtime views (the document, scrollytelling, the present view, the presenter view), and applies live updates without reloading.
3
+ Presmium's IR runtime: renders compiled documents in the browser with React, in pluggable runtime views (the document, scrollytelling, the present view, the presenter view), and applies live updates without reloading. The web form and the slides are both this runtime: a slide is a section on a canvas of a fixed size, scaled as a whole. Runnable code blocks can be edited in place before they run (CodeMirror 6, loaded the first time a block is edited).
4
4
 
5
- Part of [Presmium](https://github.com/Lincyaw/presmium), which turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
5
+ Part of [Presmium](https://github.com/Lincyaw/presmium), which compiles canonical lecture.json teaching documents into web pages, slides, Jupyter notebooks and narrated playback.
6
6
 
7
7
  ## Install
8
8
 
@@ -10,7 +10,7 @@ The web renderer `@lincyaw/web` brings it. Plugins that extend it declare it as
10
10
 
11
11
  ## Viewer plugins
12
12
 
13
- A plugin's `src/client/viewer.ts` exports a `ViewerPlugin`, which registers views, node renderers, components, actions and layers:
13
+ A plugin's `src/client/viewer.ts` exports a `ViewerPlugin`, which registers views, node renderers, components, actions and layers. A layer's `scope` puts it over the window (`viewport`, the default), over the view (`view`), on each slide (`slide`, in the canvas's coordinates, its props also giving the slide's `section` and `step`) or in the presentation bar (`bar`):
14
14
 
15
15
  ```ts
16
16
  import type { ViewerPlugin } from '@lincyaw/viewer'
@@ -21,9 +21,11 @@ const plugin: ViewerPlugin = (ctx) => {
21
21
  export default plugin
22
22
  ```
23
23
 
24
+ Author editing lives in one native BlockNote document editor in the workbench. It saves normalized `lecture.json` through document-version transactions. The viewer remains a runtime snapshot consumer; independent readers and presentations do not load the authoring editor. React extensions subscribe to public viewer stores with `useStore(viewer.position)` or `useStore(viewer.doc)`.
25
+
24
26
  ## Documentation
25
27
 
26
- - [Plugin guide](https://github.com/Lincyaw/presmium/blob/main/site/plugins/lecture.en.md)
28
+ - [Plugin guide](https://github.com/Lincyaw/presmium/blob/main/site/plugins/locales/en/lecture.json)
27
29
 
28
30
  ## License
29
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/viewer",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.10",
4
4
  "description": "Presmium's IR runtime: renders the IR in the browser, with runtime views and live updates",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -13,7 +13,7 @@
13
13
  "keywords": [
14
14
  "presmium",
15
15
  "ir",
16
- "vue"
16
+ "react"
17
17
  ],
18
18
  "type": "module",
19
19
  "engines": {
@@ -23,7 +23,7 @@
23
23
  "access": "public"
24
24
  },
25
25
  "exports": {
26
- ".": "./src/index.ts",
26
+ ".": "./src/client/index.ts",
27
27
  "./domain": "./src/domain/index.ts",
28
28
  "./package.json": "./package.json"
29
29
  },
@@ -31,6 +31,15 @@
31
31
  "src"
32
32
  ],
33
33
  "dependencies": {
34
+ "@codemirror/commands": "6.11.1",
35
+ "@codemirror/lang-javascript": "6.2.5",
36
+ "@codemirror/lang-python": "6.2.1",
37
+ "@codemirror/language": "6.13.1",
38
+ "@codemirror/state": "6.7.6",
39
+ "@codemirror/view": "6.43.14",
40
+ "@lezer/highlight": "1.2.5",
41
+ "@radix-ui/react-focus-scope": "^1.1.8",
42
+ "@radix-ui/react-tabs": "^1.1.13",
34
43
  "@shikijs/magic-move": "4.5.0",
35
44
  "ansi_up": "6.0.6",
36
45
  "dompurify": "3.4.17",
@@ -43,14 +52,17 @@
43
52
  "mermaid": "12.2.0",
44
53
  "micromark-extension-gfm": "3.0.0",
45
54
  "property-information": "7.2.0",
46
- "reka-ui": "2.11.0",
55
+ "react": "18.3.1",
56
+ "react-dom": "18.3.1",
47
57
  "shiki": "4.5.0",
48
- "vue": "^3.5.42",
49
- "@lincyaw/host": "1.0.0-rc.0",
50
- "@lincyaw/kernel": "1.0.0-rc.0"
58
+ "style-to-js": "2.0.2",
59
+ "@lincyaw/host": "1.0.0-rc.10",
60
+ "@lincyaw/kernel": "1.0.0-rc.10"
51
61
  },
52
62
  "devDependencies": {
53
63
  "@types/hast": "3.0.5",
54
- "@types/mdast": "4.0.4"
64
+ "@types/mdast": "4.0.4",
65
+ "@types/react": "18.3.28",
66
+ "@types/react-dom": "18.3.7"
55
67
  }
56
68
  }
@@ -0,0 +1,70 @@
1
+ import { createContext, memo, useContext, useEffect, useRef, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { PmBoundary, StepScope, useStaticRender, useStore } from '@lincyaw/host'
4
+ import { blockStepState, blockVisible, componentProps, fnv1a64 } from '@lincyaw/kernel'
5
+ import type { ComponentNode, IRBlock, IRSection } from '@lincyaw/kernel'
6
+ import { FINAL_STEP } from '../domain/index.ts'
7
+ import { CODE_OWNED, CodeBlockView } from './code/code-block.ts'
8
+ import { contentMarks } from './content-marks.ts'
9
+ import { markdownToHast } from './markdown/handlers.ts'
10
+ import { PmMermaid } from './markdown/mermaid.ts'
11
+ import { hastToVNodes, MarkdownView } from './markdown/render.ts'
12
+ import { findComponent, findNodeRenderer, registryRevision } from './registry.ts'
13
+ import { BLOCK_CLASS, trackClicks } from './slidev-compat/click-directives.ts'
14
+ import { viewerMessages } from './viewer-messages.ts'
15
+ import { VIEWER } from './viewer-state.ts'
16
+ import { h } from './element.ts'
17
+ import { SECTION_CONTEXT } from './section-context.ts'
18
+ export interface BlockProps { block: IRBlock, section: IRSection, step: number, expanded?: boolean, localPreview?: boolean }
19
+ const COMPONENT_NODE = createContext<{ node: ComponentNode, section: string } | undefined>(undefined)
20
+ function fallbackOf(node: ComponentNode, section: string) { return node.fallback ? hastToVNodes(markdownToHast(node.fallback, section).children, {}) : [] }
21
+ export function PmComponentFallback() { const current = useContext(COMPONENT_NODE); return current ? h('div', { className: 'pm-component-fallback' }, fallbackOf(current.node, current.section)) : null }
22
+ function ComponentView(props: BlockProps) {
23
+ const node = props.block.node as ComponentNode
24
+ const printed = useStaticRender()
25
+ useStore(registryRevision)
26
+ const component = findComponent(node.name)
27
+ if (printed && node.fallback) return h('div', { className: 'pm-static-fallback', 'data-component': node.name }, fallbackOf(node, props.section.id))
28
+ if (!component) { const fallback = fallbackOf(node, props.section.id); return h('div', { className: 'pm-component-missing', 'data-component': node.name }, fallback.length ? fallback : viewerMessages().missingComponent(node.name)) }
29
+ const state = blockStepState(props.block, props.step)
30
+ const children = node.children?.map(child => h(BlockView, { key: child.id, block: child, section: props.section, step: props.step, expanded: props.expanded }))
31
+ const key = fnv1a64(JSON.stringify([node.props, node.children ?? []]))
32
+ return h(CODE_OWNED.Provider, { value: node.ownsCode !== false }, h(COMPONENT_NODE.Provider, { value: { node, section: props.section.id } }, h(PmBoundary, { name: node.name, resetKey: props.expanded ? key : `${key}@${props.step}` }, h(component, { ...componentProps(node), ...state?.parameters }, children))))
33
+ }
34
+ function content(props: BlockProps): ReactNode {
35
+ const node = props.block.node
36
+ const custom = (node.type === 'component' && findNodeRenderer(node.name)) || (node.type === 'code' && findNodeRenderer(`code:${node.lang}`)) || findNodeRenderer(node.type)
37
+ if (custom) return h(custom, props)
38
+ switch (node.type) {
39
+ case 'markdown': return h(MarkdownView, { ...props, prefix: props.section.id })
40
+ case 'code': return node.lang === 'mermaid' ? h(PmMermaid, { code: node.value }) : h(CodeBlockView, props)
41
+ case 'component': return h(ComponentView, props)
42
+ }
43
+ }
44
+ function BlockViewContent(props: BlockProps) {
45
+ const viewer = useContext(VIEWER); const el = useRef<HTMLDivElement>(null)
46
+ useStore(registryRevision)
47
+ useEffect(() => el.current ? trackClicks(el.current, props.expanded ? FINAL_STEP : props.step) : undefined, [props.step, props.expanded, props.block])
48
+ const { block } = props
49
+ const visible = props.expanded || blockVisible(block, props.step)
50
+ const role = block.role === 'narration' && block.only && !block.only.includes('lecture') ? 'reading' : block.role
51
+ return h(StepScope, { at: block.at, until: block.until, states: block.stepStates, step: props.step, localPreview: props.localPreview }, h('div', {
52
+ ref: el, className: [BLOCK_CLASS, `pm-block--${block.role}`, `pm-block--${block.node.type}`, block.until !== undefined ? 'pm-step-range' : '', block.node.type === 'component' && block.node.layer === 'overlay' ? 'pm-block--overlay' : '', !visible ? 'pm-hidden' : ''].filter(Boolean).join(' '),
53
+ 'data-block': block.id, 'data-content-role': role, 'data-content-label': viewerMessages().contentRoles[role], 'data-at': block.at, 'data-component': block.node.type === 'component' ? block.node.name : undefined, 'aria-hidden': visible ? undefined : true,
54
+ onClick: viewer?.options.openSource ? (event: MouseEvent) => { if (event.altKey) { event.preventDefault(); event.stopPropagation(); viewer.options.openSource!(block.source) } } : undefined,
55
+ }, [contentMarks(props.section, role, block.at, block.span ?? 0), content(props)]))
56
+ }
57
+
58
+ export const BlockView = memo(BlockViewContent)
59
+
60
+ /** A compiled block's standalone read-only surface, using the same host scope as the full viewer. */
61
+ export function BlockPreview({ block, section }: { block: IRBlock, section: IRSection }) {
62
+ const viewer = useContext(VIEWER)
63
+ const [selected, setSelected] = useState<{ section: string, step: number }>()
64
+ useEffect(() => viewer?.position.subscribe(() => {
65
+ const position = viewer.position.getSnapshot()
66
+ if (position.section === section.id && section.stepIds?.[position.step]) setSelected(position)
67
+ }), [viewer, section.id])
68
+ const step = selected?.section === section.id ? selected.step : FINAL_STEP
69
+ return h(SECTION_CONTEXT.Provider, { value: { section, step, view: 'document' } }, h('div', { className: 'pm-document pm-block-preview', 'data-section': section.id }, h(BlockView, { block, section, step, expanded: true, localPreview: true })))
70
+ }
@@ -0,0 +1,54 @@
1
+ import { createContext, useContext, useEffect, useRef, useState } from 'react'
2
+ import { currentLocale, OPERATIONS, PmIconButton, useService, useStartReport, useStaticRender } from '@lincyaw/host'
3
+ import type { Operations } from '@lincyaw/host'
4
+ import { blockState, EXECUTOR, errorMessage, failedRun } from '@lincyaw/kernel'
5
+ import type { CodeNode, IRBlock, RecordedRun, RunRequest, RunResult } from '@lincyaw/kernel'
6
+ import { originBar } from '../../domain/index.ts'
7
+ import { useSectionContext } from '../section-context.ts'
8
+ import { viewerMessages } from '../viewer-messages.ts'
9
+ import { h } from '../element.ts'
10
+ import { CodeEditor } from './code-editor.ts'
11
+ import { CodeView, VariantView } from './code-view.ts'
12
+ import { isProseOutput, runOutput } from './outputs.ts'
13
+ export const CODE_OWNED = createContext(false)
14
+ const replays = new Set<(data: Record<string, unknown>) => void>()
15
+ const listening = new WeakSet<Operations>()
16
+ function listenRunReplays(operations: Operations) { if (listening.has(operations)) return; listening.add(operations); operations.onReplay('run', { apply: data => replays.forEach(replay => replay(data)) }) }
17
+ export function CodeBlockView(props: { block: IRBlock, step: number, expanded?: boolean }) {
18
+ const owned = useContext(CODE_OWNED); const executor = useService(EXECUTOR); const operations = useService(OPERATIONS); const section = useSectionContext(); const report = useStartReport(); const printed = useStaticRender()
19
+ const [live, setLive] = useState<RunResult>(); const [fallback, setFallback] = useState<string>(); const [running, setRunning] = useState(false); const [edited, setEdited] = useState<string>(); const [editing, setEditing] = useState(false); const [mounted, setMounted] = useState(false); const current = useRef<AbortController>()
20
+ const node = props.block.node as CodeNode
21
+ const stop = () => { current.current?.abort(); current.current = undefined; setRunning(false) }
22
+ const show = (result: RunResult | undefined) => { const recorded = result && !result.ok && result.exitCode === null && node.outputs?.length; setFallback(recorded ? result.stderr.trim() || viewerMessages().code.unavailable : undefined); setLive(recorded ? undefined : result) }
23
+ useEffect(() => { setMounted(true); return () => current.current?.abort() }, [])
24
+ useEffect(() => { stop(); setLive(undefined); setFallback(undefined); setEdited(undefined); setEditing(false) }, [node.value])
25
+ useEffect(() => { if (operations) listenRunReplays(operations); const replay = (data: Record<string, unknown>) => { if (data.block === props.block.id && (data.section === undefined || data.section === section?.section.id)) { stop(); show(undefined) } }; replays.add(replay); return () => { replays.delete(replay) } }, [operations, props.block.id, section?.section.id])
26
+ const run = async () => {
27
+ if (!node.run || !executor || running) return
28
+ stop(); const controller = new AbortController(); current.current = controller; setRunning(true)
29
+ const { lang, args, stdin, timeoutMs, executor: named, session } = node.run
30
+ const request: RunRequest = { lang, code: edited ?? node.value, args, stdin, timeoutMs, executor: named, session, ...section ? { place: { section: section.section.id, block: props.block.id } } : {} }
31
+ const started = Date.now(); let result: RunResult
32
+ try { result = await executor.run(request, controller.signal) } catch (error) { result = failedRun(executor.id, viewerMessages().code.executorFailed(executor.id, errorMessage(error)), Date.now() - started) }
33
+ if (controller.signal.aborted) return
34
+ show(result); current.current = undefined; setRunning(false)
35
+ const recording = node.outputs?.[0] as RecordedRun | undefined
36
+ operations?.report({ type: 'run', data: { section: section?.section.id, block: props.block.id, result: !result.ok && recording ? `freeze:${recording.cacheKey}` : 'live' } })
37
+ }
38
+ const state = blockState(props.block, props.step); const variants = node.variants ?? []; const shown = variants.length && !props.expanded ? Math.min(state, variants.length - 1) : undefined
39
+ const listings = !variants.length ? [h(CodeView, { lang: node.lang, value: node.value, highlight: props.expanded ? undefined : node.highlight?.[state] })] : shown === undefined ? variants.map(v => h('div', { key: v.label, className: 'pm-code-block__variant' }, [h('p', { className: 'pm-code-block__label' }, v.label), h(CodeView, { lang: node.lang, value: v.value })])) : [h(VariantView, { lang: node.lang, value: variants[shown]!.value })]
40
+ const echo = node.echo !== false; const runnable = !owned && echo && node.run && node.run.when !== 'compile'; const editable = runnable && !variants.length && !node.highlight?.length && !printed; const t = viewerMessages().code; const available = mounted && executor
41
+ if (editable && (editing || edited !== undefined)) listings.splice(0, listings.length, editing ? h(CodeEditor, { lang: node.lang, value: edited ?? node.value, onChange: setEdited, onRun: run }) : h(CodeView, { lang: node.lang, value: edited! }))
42
+ const controls = runnable ? h('div', { className: 'pm-code-block__run', role: 'group', 'aria-label': t.actions }, [
43
+ h(PmIconButton, { icon: 'play', label: live ? t.rerun : t.run, hint: running ? t.running : live ? t.rerun : t.run, small: true, loading: running, disabled: !available, onClick: run }),
44
+ editable ? h(PmIconButton, { icon: editing ? 'check' : 'edit', label: editing ? t.doneEditing : t.edit, small: true, 'aria-pressed': editing, onClick: () => setEditing(!editing) }) : null,
45
+ edited !== undefined && edited !== node.value ? h(PmIconButton, { icon: 'undo', label: t.revert, small: true, onClick: () => { setEdited(undefined); setEditing(false) } }) : null,
46
+ node.run?.session ? h(PmIconButton, { icon: 'refresh', label: t.resetRunSession, small: true, disabled: !available, 'data-session': node.run.session, onClick: async () => { stop(); await executor?.endSession?.(node.run!.session!); show(undefined) } }) : null,
47
+ mounted && !executor && report ? h('span', { className: 'pm-code-block__hint' }, t.unavailable) : null,
48
+ ]) : null
49
+ const runs = owned ? [] : live ? [live] : node.outputs ?? []; const prose = !echo && !live && runs.length > 0 && runs.every(isProseOutput); const prefix = section?.section.id ?? props.block.id
50
+ const outputs = runs.length ? h('div', { className: `pm-outputs${prose ? ' pm-outputs--prose' : ''}`, 'data-source': live ? 'live' : 'recorded' }, runs.map((r, i) => runOutput(r, (r as RecordedRun).cacheKey || i, prefix, { prose, note: fallback }))) : null
51
+ const bar = originBar(node, shown, currentLocale())
52
+ const origin = bar ? h('div', { className: 'pm-code-origin', 'data-anchor': bar.anchor }, bar.parts.map((part, key) => h(part.href ? 'a' : 'span', { key, className: `pm-code-origin__${part.kind}`, href: part.href, title: part.title, 'data-active': part.active ? '' : undefined, 'data-status': part.status, 'data-drift': part.drift }, part.text))) : null
53
+ return h('div', { className: `pm-code-block${!echo ? ' pm-code-block--output-only' : ''}`, 'data-state': props.expanded ? undefined : state, 'data-run': runnable ? node.run!.when : undefined, 'data-session': node.run?.session }, echo ? [origin, ...listings, controls, outputs] : [outputs])
54
+ }
@@ -0,0 +1,73 @@
1
+ import { languageId } from '@lincyaw/kernel'
2
+ import type { Extension } from '@codemirror/state'
3
+ import type { EditorView } from '@codemirror/view'
4
+ import { useEffect, useRef } from 'react'
5
+ import { h } from '../element.ts'
6
+
7
+ /**
8
+ * A runnable code block's code, edited in place (CodeMirror 6) before it is run: loaded the first time a block is
9
+ * edited, so that pages which are only read never load it. Mod-Enter runs the code; Escape then Tab leaves the editor.
10
+ */
11
+
12
+ const LANGUAGES: Record<string, () => Promise<Extension>> = {
13
+ python: () => import('@codemirror/lang-python').then(m => m.python()),
14
+ javascript: () => import('@codemirror/lang-javascript').then(m => m.javascript()),
15
+ typescript: () => import('@codemirror/lang-javascript').then(m => m.javascript({ typescript: true })),
16
+ jsx: () => import('@codemirror/lang-javascript').then(m => m.javascript({ jsx: true })),
17
+ tsx: () => import('@codemirror/lang-javascript').then(m => m.javascript({ jsx: true, typescript: true })),
18
+ }
19
+
20
+ const ALIASES: Record<string, string> = { py: 'python', js: 'javascript', mjs: 'javascript', ts: 'typescript', mts: 'typescript' }
21
+
22
+ async function editorModules(lang: string) {
23
+ const id = languageId(lang)
24
+ const grammar = LANGUAGES[ALIASES[id] ?? id]
25
+ const [view, state, commands, language, highlight, extension] = await Promise.all([
26
+ import('@codemirror/view'),
27
+ import('@codemirror/state'),
28
+ import('@codemirror/commands'),
29
+ import('@codemirror/language'),
30
+ import('@lezer/highlight'),
31
+ grammar ? grammar() : Promise.resolve([]),
32
+ ])
33
+ const { tags } = highlight
34
+ // The colours of the page's highlighted code, from the design tokens: light and dark alike.
35
+ const style = language.HighlightStyle.define([
36
+ { tag: [tags.keyword, tags.controlKeyword, tags.operatorKeyword, tags.modifier], color: 'var(--pm-color-syntax-keyword)' },
37
+ { tag: [tags.string, tags.special(tags.string), tags.regexp], color: 'var(--pm-color-syntax-string)' },
38
+ { tag: [tags.function(tags.variableName), tags.function(tags.propertyName), tags.definition(tags.variableName), tags.className, tags.typeName], color: 'var(--pm-color-syntax-key)' },
39
+ { tag: [tags.number, tags.bool, tags.null, tags.atom], color: 'var(--pm-color-accent)' },
40
+ { tag: [tags.comment, tags.lineComment, tags.blockComment], color: 'var(--pm-color-gutter)', fontStyle: 'italic' },
41
+ ])
42
+ return { view, state, commands, language, style, extension, python: (ALIASES[id] ?? id) === 'python' }
43
+ }
44
+
45
+ export function CodeEditor({ lang = '', label, value, onChange, onRun, onSave, onError, shouldFocus }: { lang?: string, label?: string, value: string, onChange?(value: string): void, onRun?(): void, onSave?(): void, onError?(error: unknown): void, shouldFocus?(): boolean }) {
46
+ const host = useRef<HTMLDivElement>(null)
47
+ const active = useRef<EditorView>()
48
+ const replacing = useRef(false)
49
+ const currentValue = useRef(value); currentValue.current = value
50
+ const callbacks = useRef({ onChange, onRun, onSave, onError, shouldFocus }); callbacks.current = { onChange, onRun, onSave, onError, shouldFocus }
51
+ useEffect(() => {
52
+ let editor: EditorView | undefined; let disposed = false
53
+ void editorModules(lang).then(({ view, state, commands, language, style, extension, python }) => {
54
+ if (disposed || !host.current) return
55
+ editor = new view.EditorView({ parent: host.current, state: state.EditorState.create({ doc: currentValue.current, extensions: [commands.history(), view.EditorView.contentAttributes.of(label ? { 'aria-label': label, 'aria-multiline': 'true', role: 'textbox' } : {}), view.keymap.of([{ key: 'Mod-Enter', run: () => { if (!callbacks.current.onRun) return false; callbacks.current.onRun(); return true } }, { key: 'Mod-s', run: () => { if (!callbacks.current.onSave) return false; callbacks.current.onSave(); return true } }, ...commands.defaultKeymap, ...commands.historyKeymap, commands.indentWithTab]), language.indentUnit.of(python ? ' ' : ' '), language.syntaxHighlighting(style), extension, view.EditorView.updateListener.of(update => { if (update.docChanged && !replacing.current) callbacks.current.onChange?.(update.state.doc.toString()) })] }) })
56
+ active.current = editor
57
+ if (callbacks.current.shouldFocus?.() !== false) editor.focus()
58
+ }).catch(error => {
59
+ if (disposed) return
60
+ console.error('[presmium] the code editor failed to initialize', error)
61
+ callbacks.current.onError?.(error)
62
+ })
63
+ return () => { disposed = true; if (active.current === editor) active.current = undefined; editor?.destroy() }
64
+ }, [lang, label])
65
+ useEffect(() => {
66
+ const editor = active.current
67
+ if (!editor || editor.state.doc.toString() === value) return
68
+ replacing.current = true
69
+ try { editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } }) }
70
+ finally { replacing.current = false }
71
+ }, [value])
72
+ return h('div', { ref: host, className: 'pm-code-editor' })
73
+ }
@@ -0,0 +1,22 @@
1
+ import { useAppearance, usePrefersReducedMotion } from '@lincyaw/host'
2
+ import { ShikiMagicMove } from '@shikijs/magic-move/react'
3
+ import '@shikijs/magic-move/style.css'
4
+ import { useEffect, useState } from 'react'
5
+ import type { Element, ElementContent, Root } from 'hast'
6
+ import { toHtml } from 'hast-util-to-html'
7
+ import { codeColumns } from '../../domain/index.ts'
8
+ import { highlighterFor, SHIKI_THEMES } from './highlight.ts'
9
+ import { h } from '../element.ts'
10
+ type Loaded = Awaited<ReturnType<typeof highlighterFor>>
11
+ function useHighlighter(lang: string) { const [loaded, setLoaded] = useState<Loaded>(); useEffect(() => { let active = true; void highlighterFor(lang).then(value => { if (active) setLoaded(value) }).catch(() => {}); return () => { active = false } }, [lang]); return loaded }
12
+ function lineElements(tree: Root): Element[] { const pre = tree.children.find((node): node is Element => node.type === 'element'); const code = pre?.children.find((node): node is Element => node.type === 'element' && node.tagName === 'code'); return (code?.children ?? []).filter((node: ElementContent): node is Element => node.type === 'element') }
13
+ export function CodeView({ lang = '', value, highlight }: { lang?: string, value: string, highlight?: number[] }) {
14
+ const shiki = useHighlighter(lang); const code = value.replace(/\n$/, ''); const lines = new Set(highlight ?? [])
15
+ let html: string | undefined
16
+ if (shiki) { const tree = shiki.highlighter.codeToHast(code, { lang: shiki.lang, themes: SHIKI_THEMES, defaultColor: false }); lineElements(tree).forEach((line, i) => line.properties.class = lines.has(i + 1) ? 'line highlighted' : 'line'); html = toHtml(tree) }
17
+ const attrs = { className: `pm-code${lines.size ? ' pm-code--highlight' : ''}`, style: { '--pm-code-columns': codeColumns(code) }, 'data-lang': lang || undefined }
18
+ if (html) return h('div', { ...attrs, innerHTML: html })
19
+ const rows = code.split('\n').flatMap((text, i) => [h('span', { key: i, className: `line${lines.has(i + 1) ? ' highlighted' : ''}` }, text), '\n'])
20
+ return h('div', attrs, h('pre', { className: 'shiki' }, h('code', {}, rows.slice(0, -1))))
21
+ }
22
+ export function VariantView({ lang = '', value }: { lang?: string, value: string }) { const loaded = useHighlighter(lang); const reduced = usePrefersReducedMotion(); const { dark } = useAppearance(); if (!loaded || reduced) return h(CodeView, { lang, value }); const code = value.replace(/\n$/, ''); return h('div', { className: 'pm-code pm-code--magic-move', style: { '--pm-code-columns': codeColumns(code) }, 'data-lang': lang || undefined }, h(ShikiMagicMove, { highlighter: loaded.highlighter, lang: loaded.lang, theme: dark ? SHIKI_THEMES.dark : SHIKI_THEMES.light, code })) }
@@ -41,7 +41,7 @@ const ALIASES: Record<string, string> = {
41
41
  rb: 'ruby',
42
42
  }
43
43
 
44
- export const THEMES = { light: 'github-light', dark: 'github-dark' } as const
44
+ export const SHIKI_THEMES = { light: 'github-light', dark: 'github-dark' } as const
45
45
 
46
46
  let highlighter: Promise<HighlighterCore> | undefined
47
47
  const loading = new Map<string, Promise<void>>()
@@ -57,7 +57,7 @@ function load(): Promise<HighlighterCore> {
57
57
  return highlighter
58
58
  }
59
59
 
60
- export function grammarOf(lang: string): string | undefined {
60
+ function grammarOf(lang: string): string | undefined {
61
61
  const id = languageId(lang)
62
62
  const name = ALIASES[id] ?? id
63
63
  return name in GRAMMARS ? name : undefined
@@ -1,15 +1,18 @@
1
1
  import { currentLocale } from '@lincyaw/host'
2
+ import { htmlAsText, isProseRun } from '@lincyaw/kernel'
2
3
  import type { MdastNode, RunOutput, RunResult } from '@lincyaw/kernel'
3
4
  import { AnsiUp } from 'ansi_up'
4
5
  import { fromMarkdown } from 'mdast-util-from-markdown'
5
6
  import { gfmFromMarkdown } from 'mdast-util-gfm'
6
7
  import { gfm } from 'micromark-extension-gfm'
7
- import type { VNode, VNodeChild } from 'vue'
8
- import { defineComponent, h, onMounted, ref, watch } from 'vue'
9
- import { outputNotes } from './domain/index.ts'
10
- import { hastToVNodes, markdownToHast } from './markdown.ts'
11
- import { getNodeRenderer } from './registry.ts'
12
- import { PmMath } from './rich.ts'
8
+ import type { ReactElement, ReactNode } from 'react'
9
+ import { useEffect, useState } from 'react'
10
+ import { h } from '../element.ts'
11
+ import { outputNotes } from '../../domain/index.ts'
12
+ import { markdownToHast } from '../markdown/handlers.ts'
13
+ import { PmMath } from '../markdown/math.ts'
14
+ import { hastToVNodes } from '../markdown/render.ts'
15
+ import { findNodeRenderer } from '../registry.ts'
13
16
 
14
17
  const ORDER = ['text/html', 'text/markdown', 'text/latex', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'application/json', 'text/plain']
15
18
 
@@ -21,38 +24,22 @@ function ansi(value: string): string {
21
24
  return new AnsiUp().ansi_to_html(value)
22
25
  }
23
26
 
24
- function literalHtml(node: MdastNode): MdastNode {
25
- if (node.type === 'html') return { type: 'text', value: String(node.value ?? '') }
26
- return node.children ? { ...node, children: node.children.map(literalHtml) } : node
27
- }
28
-
29
- export function markdownOutput(markdown: string, prefix: string): VNodeChild[] {
30
- const mdast = literalHtml(fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) as unknown as MdastNode)
27
+ function markdownOutput(markdown: string, prefix: string): ReactNode[] {
28
+ const mdast = htmlAsText(fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) as unknown as MdastNode)
31
29
  return hastToVNodes(markdownToHast(mdast, prefix).children, {})
32
30
  }
33
31
 
34
32
  let purify: Promise<(html: string) => string> | undefined
35
33
 
36
- const HtmlOutput = defineComponent({
37
- name: 'PmHtmlOutput',
38
- props: { html: { type: String, required: true }, plain: { type: String, default: '' } },
39
- setup(props) {
40
- const clean = ref<string>()
41
- const render = async () => {
42
- purify ??= import('dompurify').then(m => (html: string) => m.default.sanitize(html))
43
- clean.value = (await purify)(props.html)
44
- }
45
- onMounted(render)
46
- watch(() => props.html, render)
47
- return () => clean.value === undefined
48
- ? h('pre', { class: 'pm-output__text' }, props.plain)
49
- : h('div', { class: 'pm-output__html', innerHTML: clean.value })
50
- },
51
- })
34
+ function HtmlOutput({ html, plain = '' }: { html: string, plain?: string }) {
35
+ const [clean, setClean] = useState<string>()
36
+ useEffect(() => { let active = true; purify ??= import('dompurify').then(m => (html: string) => m.default.sanitize(html)); void purify.then(sanitize => { if (active) setClean(sanitize(html)) }); return () => { active = false } }, [html])
37
+ return clean === undefined ? h('pre', { className: 'pm-output__text' }, plain) : h('div', { className: 'pm-output__html', innerHTML: clean })
38
+ }
52
39
 
53
- function display(data: Record<string, unknown>, metadata: Record<string, unknown>, prefix: string, key: number): VNodeChild {
40
+ function display(data: Record<string, unknown>, metadata: Record<string, unknown>, prefix: string, key: number): ReactNode {
54
41
  for (const type of Object.keys(data)) {
55
- const renderer = getNodeRenderer(`mime:${type}`)
42
+ const renderer = findNodeRenderer(`mime:${type}`)
56
43
  if (renderer) return h(renderer, { key, type, data: data[type], metadata })
57
44
  }
58
45
  const type = ORDER.find(t => data[t] !== undefined)
@@ -68,7 +55,7 @@ function display(data: Record<string, unknown>, metadata: Record<string, unknown
68
55
  return h('pre', { ...attrs, innerHTML: ansi(text(value)) })
69
56
  }
70
57
 
71
- function richOutput(output: RunOutput, prefix: string, key: number): VNodeChild {
58
+ function richOutput(output: RunOutput, prefix: string, key: number): ReactNode {
72
59
  switch (output.output_type) {
73
60
  case 'stream': return h('pre', { key, class: `pm-output__${output.name}`, innerHTML: ansi(text(output.text)) })
74
61
  case 'error': return h('pre', { key, class: 'pm-output__error', innerHTML: ansi(text(output.traceback.join('\n')) || `${output.ename}: ${output.evalue}`) })
@@ -76,12 +63,12 @@ function richOutput(output: RunOutput, prefix: string, key: number): VNodeChild
76
63
  }
77
64
  }
78
65
 
79
- export function proseOnly(run: RunResult): boolean {
80
- const outputs = run.outputs ?? []
81
- return outputs.length > 0 && outputs.every(o => (o.output_type === 'display_data' || o.output_type === 'execute_result') && o.data['text/markdown'] !== undefined && !Object.keys(o.data).some(type => getNodeRenderer(`mime:${type}`)))
66
+ /** Whether a run only displays Markdown that no plugin renders itself, which shows as prose. */
67
+ export function isProseOutput(run: RunResult): boolean {
68
+ return isProseRun(run, type => Boolean(findNodeRenderer(`mime:${type}`)))
82
69
  }
83
70
 
84
- export function runOutput(run: RunResult & { cacheKey?: string }, key: string | number, prefix: string, options: { prose?: boolean, note?: string } = {}): VNode {
71
+ export function runOutput(run: RunResult & { cacheKey?: string }, key: string | number, prefix: string, options: { prose?: boolean, note?: string } = {}): ReactElement {
85
72
  const notes = [...outputNotes(run, currentLocale()), ...options.note ? [options.note] : []]
86
73
  const content = run.outputs
87
74
  ? run.outputs.map((output, i) => richOutput(output, prefix, i))
@@ -0,0 +1,15 @@
1
+ import { PmIcon } from '@lincyaw/host'
2
+ import type { IRSection } from '@lincyaw/kernel'
3
+ import { h } from './element.ts'
4
+ import { viewerMessages } from './viewer-messages.ts'
5
+
6
+ /** Author controls are excluded by the preview's text reader and are hidden outside document editing. */
7
+ export function contentMarks(section: IRSection, role: 'visual' | 'narration' | 'reading', at: number, span = 0) {
8
+ const label = viewerMessages().contentRoles[role]
9
+ const place = `${section.index} · ${at + 1}${span > 0 ? `–${at + span + 1}` : ''}`
10
+ return h('span', { class: 'pm-content-marks', contenteditable: 'false', 'data-preview-ui': '' }, [h('button', {
11
+ 'type': 'button', 'class': 'pm-content-mark',
12
+ 'data-content-section': section.id, 'data-content-step': at,
13
+ 'title': `${label} · ${place}`, 'aria-label': `${label} · ${place}`,
14
+ }, [h(PmIcon, { name: role === 'visual' ? 'slides' : role === 'reading' ? 'notebook' : 'captions' }), h('span', place)])])
15
+ }
@@ -0,0 +1,27 @@
1
+ import { createElement, isValidElement } from 'react'
2
+ import type { ElementType, ReactElement, ReactNode } from 'react'
3
+ import styleToJs from 'style-to-js'
4
+ export function classNames(value: unknown): string | undefined {
5
+ if (!value) return undefined
6
+ if (typeof value === 'string') return value
7
+ if (Array.isArray(value)) return value.map(classNames).filter(Boolean).join(' ')
8
+ if (typeof value === 'object') return Object.entries(value).filter(([, active]) => active).map(([key]) => key).join(' ')
9
+ return undefined
10
+ }
11
+ /** Shared DOM attribute normalization for HAST and the viewer's generated elements. */
12
+ export function h(type: ElementType | any, attrs?: Record<string, any> | ReactNode | null, children?: ReactNode, ...rest: ReactNode[]): ReactElement {
13
+ if (attrs !== null && attrs !== undefined && (typeof attrs !== 'object' || Array.isArray(attrs) || isValidElement(attrs))) return createElement(type, null, attrs as ReactNode, children, ...rest)
14
+ const props = { ...attrs as Record<string, any> }
15
+ if (typeof props.style === 'string') {
16
+ try { props.style = styleToJs(props.style, { reactCompat: true }) }
17
+ catch {
18
+ props.style = {}
19
+ console.warn('[presmium.viewer.style] Invalid inline style was ignored; content is preserved.')
20
+ }
21
+ }
22
+ if (props.class !== undefined) { props.className = classNames(props.class); delete props.class }
23
+ if (props.innerHTML !== undefined) { props.dangerouslySetInnerHTML = { __html: props.innerHTML }; delete props.innerHTML }
24
+ if (props.tabindex !== undefined) { props.tabIndex = props.tabindex; delete props.tabindex }
25
+ if (props.contenteditable !== undefined) { props.contentEditable = props.contenteditable; delete props.contenteditable }
26
+ return createElement(type, props, children, ...rest)
27
+ }
@@ -0,0 +1,40 @@
1
+ import { createElement } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { registerAction, registerComponent, registerView } from './registry.ts'
4
+ import { DOCUMENT_LAYOUT_COMPONENTS } from './layout.ts'
5
+ import { viewerMessages } from './viewer-messages.ts'
6
+ import { VIEWER } from './viewer-state.ts'
7
+ import type { Viewer } from './viewer-state.ts'
8
+ import { BUILTIN_ACTIONS } from './views/actions.ts'
9
+ import { DocumentView } from './views/document.ts'
10
+ import { PresentView } from './views/present.ts'
11
+ import { PresenterView } from './views/presenter.ts'
12
+ import { ScrollyView } from './views/scrolly.ts'
13
+ import './style.css'
14
+
15
+ registerView({ id: 'document', get label() { return viewerMessages().views.document }, kind: 'page', component: DocumentView })
16
+ registerView({ id: 'scrolly', get label() { return viewerMessages().views.scrolly }, kind: 'page', component: ScrollyView })
17
+ registerView({ id: 'present', get label() { return viewerMessages().views.present }, kind: 'overlay', component: PresentView })
18
+ registerView({ id: 'presenter', get label() { return viewerMessages().views.presenter }, kind: 'overlay', component: PresenterView, hidden: true })
19
+ BUILTIN_ACTIONS.forEach(registerAction)
20
+ Object.entries(DOCUMENT_LAYOUT_COMPONENTS).forEach(([name, component]) => registerComponent(name, component))
21
+
22
+ export function ViewerProvider({ viewer, children }: { viewer: Viewer, children?: ReactNode }) { return createElement(VIEWER.Provider, { value: viewer }, children) }
23
+
24
+ export { BlockView, BlockPreview, PmComponentFallback } from './block.ts'
25
+ export { syncViewerWithOperations } from './operations.ts'
26
+ export { findView, findComponent, listViews, registerComponent, registryRevision } from './registry.ts'
27
+ export type { NodeRenderer, ViewDefinition, ViewerAction, ViewerLayer } from './registry.ts'
28
+ export { SectionView } from './section.ts'
29
+ export type { SectionLayout } from './section.ts'
30
+ export { useSectionContext } from './section-context.ts'
31
+ export type { SectionContext } from './section-context.ts'
32
+ export { withSlideGlobals } from './slidev-compat/slide-globals.ts'
33
+ export { tryInstallViewerPlugin } from './viewer-plugins.ts'
34
+ export type { ViewerPlugin, ViewerPluginContext } from './viewer-plugins.ts'
35
+ export { createViewer, useViewer } from './viewer-state.ts'
36
+ export type { Viewer, ViewerOptions } from './viewer-state.ts'
37
+ export { OverlayViewOutlet, PageViewOutlet } from './views/outlets.ts'
38
+ export { SlideFace, SlideFit, SlidesPrint } from './views/slide.ts'
39
+
40
+ export { VClick, VAfter, VClicks } from "./slidev-compat/click-directives.ts"
@@ -0,0 +1,22 @@
1
+ import { Children, createElement as h, isValidElement } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { PmColumns, PmStack, PmTabGroup, useMessages, useStaticRender } from '@lincyaw/host'
4
+ import type { ContentLayoutProps } from '@lincyaw/host'
5
+ import { blockVisible } from '@lincyaw/kernel'
6
+ import type { BlockProps } from './block.ts'
7
+ import { FINAL_STEP } from '../domain/index.ts'
8
+ import { useSectionContext } from './section-context.ts'
9
+
10
+ const MESSAGES = { 'zh-CN': { tabs: '分组内容', item: (index: number) => `内容 ${index}` }, en: { tabs: 'Grouped content', item: (index: number) => `Content ${index}` } }
11
+ function LayoutTabs({ title, tabTitles, children }: ContentLayoutProps & { tabTitles?: string[] }) {
12
+ const t = useMessages(MESSAGES), printed = useStaticRender(), section = useSectionContext()
13
+ const tabs = Children.toArray(children).map((content, index) => {
14
+ const props = isValidElement<BlockProps>(content) ? content.props : undefined
15
+ return { id: props?.block?.id ?? String(index), title: tabTitles?.[index] || t.item(index + 1), content,
16
+ visible: !props?.block || props.expanded || blockVisible(props.block, section?.step ?? FINAL_STEP) }
17
+ })
18
+ return h(PmTabGroup, { title, label: t.tabs, tabs, staticView: printed })
19
+ }
20
+
21
+ /** Layout nodes are part of the document runtime and need no separately installed plugin. */
22
+ export const DOCUMENT_LAYOUT_COMPONENTS = { PmColumns, PmStack, PmTabs: LayoutTabs }