@lincyaw/viewer 1.0.0-rc.0 → 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.
- package/README.md +6 -4
- package/package.json +20 -8
- package/src/client/block.ts +70 -0
- package/src/client/code/code-block.ts +54 -0
- package/src/client/code/code-editor.ts +73 -0
- package/src/client/code/code-view.ts +22 -0
- package/src/{highlight.ts → client/code/highlight.ts} +2 -2
- package/src/{outputs.ts → client/code/outputs.ts} +23 -36
- package/src/client/content-marks.ts +15 -0
- package/src/client/element.ts +27 -0
- package/src/client/index.ts +40 -0
- package/src/client/layout.ts +22 -0
- package/src/{markdown.ts → client/markdown/handlers.ts} +22 -116
- package/src/client/markdown/hast.ts +20 -0
- package/src/client/markdown/layout-handlers.ts +57 -0
- package/src/client/markdown/math.ts +10 -0
- package/src/client/markdown/mermaid.ts +75 -0
- package/src/client/markdown/render.ts +79 -0
- package/src/client/markdown/role-mark.ts +9 -0
- package/src/client/markdown/tables.ts +49 -0
- package/src/client/markdown/tabs.ts +52 -0
- package/src/client/markdown/tree-cache.ts +77 -0
- package/src/client/operations.ts +9 -0
- package/src/client/registry.ts +21 -0
- package/src/client/section-context.ts +5 -0
- package/src/client/section.ts +30 -0
- package/src/client/slidev-compat/click-directives.ts +16 -0
- package/src/client/slidev-compat/slide-globals.ts +3 -0
- package/src/{style.css → client/style.css} +306 -81
- package/src/client/viewer-messages.ts +7 -0
- package/src/{plugins.ts → client/viewer-plugins.ts} +3 -2
- package/src/client/viewer-state.ts +47 -0
- package/src/client/views/actions.ts +52 -0
- package/src/client/views/controls.css +44 -0
- package/src/client/views/document.ts +12 -0
- package/src/client/views/outlets.ts +37 -0
- package/src/client/views/overlay.ts +16 -0
- package/src/client/views/present.ts +132 -0
- package/src/client/views/presenter.ts +76 -0
- package/src/client/views/scrolly.ts +4 -0
- package/src/client/views/slide.css +8 -0
- package/src/client/views/slide.ts +57 -0
- package/src/domain/clicks.ts +1 -1
- package/src/domain/code-width.ts +11 -0
- package/src/domain/document-content.ts +6 -0
- package/src/domain/{text.ts → footnotes.ts} +0 -12
- package/src/domain/index.ts +5 -4
- package/src/domain/live.ts +11 -10
- package/src/domain/messages.ts +54 -30
- package/src/domain/origin.ts +17 -32
- package/src/domain/outputs.ts +27 -0
- package/src/domain/steps.ts +2 -1
- package/src/block.ts +0 -122
- package/src/cards.ts +0 -130
- package/src/clicks.ts +0 -124
- package/src/code.ts +0 -235
- package/src/context.ts +0 -16
- package/src/dark.ts +0 -5
- package/src/domain/follow.ts +0 -32
- package/src/globals.ts +0 -66
- package/src/i18n.ts +0 -7
- package/src/index.ts +0 -55
- package/src/operations.ts +0 -28
- package/src/present.ts +0 -327
- package/src/registry.ts +0 -81
- package/src/rich.ts +0 -155
- package/src/section.ts +0 -209
- package/src/viewer.ts +0 -116
- package/src/views.ts +0 -101
- /package/src/{env.d.ts → client/env.d.ts} +0 -0
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
|
|
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
|
|
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.
|
|
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.
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
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
|
-
"
|
|
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
|
-
"
|
|
55
|
+
"react": "18.3.1",
|
|
56
|
+
"react-dom": "18.3.1",
|
|
47
57
|
"shiki": "4.5.0",
|
|
48
|
-
"
|
|
49
|
-
"@lincyaw/host": "1.0.0-rc.
|
|
50
|
-
"@lincyaw/kernel": "1.0.0-rc.
|
|
58
|
+
"style-to-js": "2.0.2",
|
|
59
|
+
"@lincyaw/host": "1.0.0-rc.1",
|
|
60
|
+
"@lincyaw/kernel": "1.0.0-rc.1"
|
|
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
|
|
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
|
-
|
|
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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { PmMath } from '
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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):
|
|
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 =
|
|
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):
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
return
|
|
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 } = {}):
|
|
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 }
|