@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.
- package/LICENSE +202 -0
- package/README.md +31 -2
- package/package.json +65 -3
- 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/client/code/highlight.ts +73 -0
- package/src/client/code/outputs.ts +84 -0
- package/src/client/content-marks.ts +15 -0
- package/src/client/element.ts +27 -0
- package/src/client/env.d.ts +3 -0
- package/src/client/index.ts +40 -0
- package/src/client/layout.ts +22 -0
- package/src/client/markdown/handlers.ts +161 -0
- 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/client/style.css +1058 -0
- package/src/client/viewer-messages.ts +7 -0
- package/src/client/viewer-plugins.ts +35 -0
- 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 +38 -0
- package/src/domain/code-width.ts +11 -0
- package/src/domain/document-content.ts +6 -0
- package/src/domain/footnotes.ts +15 -0
- package/src/domain/index.ts +22 -0
- package/src/domain/live.ts +44 -0
- package/src/domain/messages.ts +139 -0
- package/src/domain/origin.ts +78 -0
- package/src/domain/outputs.ts +27 -0
- package/src/domain/steps.ts +46 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { currentLocale } from '@lincyaw/host'
|
|
2
|
+
import { htmlAsText, isProseRun } from '@lincyaw/kernel'
|
|
3
|
+
import type { MdastNode, RunOutput, RunResult } from '@lincyaw/kernel'
|
|
4
|
+
import { AnsiUp } from 'ansi_up'
|
|
5
|
+
import { fromMarkdown } from 'mdast-util-from-markdown'
|
|
6
|
+
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
|
7
|
+
import { gfm } from 'micromark-extension-gfm'
|
|
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'
|
|
16
|
+
|
|
17
|
+
const ORDER = ['text/html', 'text/markdown', 'text/latex', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'application/json', 'text/plain']
|
|
18
|
+
|
|
19
|
+
function text(value: unknown): string {
|
|
20
|
+
return Array.isArray(value) ? value.join('') : typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ansi(value: string): string {
|
|
24
|
+
return new AnsiUp().ansi_to_html(value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function markdownOutput(markdown: string, prefix: string): ReactNode[] {
|
|
28
|
+
const mdast = htmlAsText(fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) as unknown as MdastNode)
|
|
29
|
+
return hastToVNodes(markdownToHast(mdast, prefix).children, {})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let purify: Promise<(html: string) => string> | undefined
|
|
33
|
+
|
|
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
|
+
}
|
|
39
|
+
|
|
40
|
+
function display(data: Record<string, unknown>, metadata: Record<string, unknown>, prefix: string, key: number): ReactNode {
|
|
41
|
+
for (const type of Object.keys(data)) {
|
|
42
|
+
const renderer = findNodeRenderer(`mime:${type}`)
|
|
43
|
+
if (renderer) return h(renderer, { key, type, data: data[type], metadata })
|
|
44
|
+
}
|
|
45
|
+
const type = ORDER.find(t => data[t] !== undefined)
|
|
46
|
+
if (!type) return null
|
|
47
|
+
const value = data[type]
|
|
48
|
+
const attrs = { 'key': key, 'class': 'pm-output__display', 'data-mime': type }
|
|
49
|
+
if (type === 'text/html') return h('div', attrs, [h(HtmlOutput, { html: text(value), plain: text(data['text/plain'] ?? '') })])
|
|
50
|
+
if (type === 'text/markdown') return h('div', { ...attrs, class: ['pm-output__display', 'pm-output__markdown'] }, markdownOutput(text(value), prefix))
|
|
51
|
+
if (type === 'text/latex') return h('div', attrs, [h(PmMath, { tex: text(value).trim().replace(/^\$\$?|\$\$?$/g, ''), display: true })])
|
|
52
|
+
if (type === 'image/svg+xml') return h('div', attrs, [h('img', { src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(text(value))}`, alt: '' })])
|
|
53
|
+
if (type.startsWith('image/')) return h('div', attrs, [h('img', { src: `data:${type};base64,${text(value).replace(/\s/g, '')}`, alt: '' })])
|
|
54
|
+
if (type === 'application/json') return h('pre', attrs, JSON.stringify(value, null, 2))
|
|
55
|
+
return h('pre', { ...attrs, innerHTML: ansi(text(value)) })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function richOutput(output: RunOutput, prefix: string, key: number): ReactNode {
|
|
59
|
+
switch (output.output_type) {
|
|
60
|
+
case 'stream': return h('pre', { key, class: `pm-output__${output.name}`, innerHTML: ansi(text(output.text)) })
|
|
61
|
+
case 'error': return h('pre', { key, class: 'pm-output__error', innerHTML: ansi(text(output.traceback.join('\n')) || `${output.ename}: ${output.evalue}`) })
|
|
62
|
+
default: return display(output.data, output.metadata ?? {}, prefix, key)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
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}`)))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function runOutput(run: RunResult & { cacheKey?: string }, key: string | number, prefix: string, options: { prose?: boolean, note?: string } = {}): ReactElement {
|
|
72
|
+
const notes = [...outputNotes(run, currentLocale()), ...options.note ? [options.note] : []]
|
|
73
|
+
const content = run.outputs
|
|
74
|
+
? run.outputs.map((output, i) => richOutput(output, prefix, i))
|
|
75
|
+
: [
|
|
76
|
+
run.stdout ? h('pre', { class: 'pm-output__stdout' }, run.stdout) : null,
|
|
77
|
+
run.stderr ? h('pre', { class: 'pm-output__stderr' }, run.stderr) : null,
|
|
78
|
+
]
|
|
79
|
+
if (options.prose) return h('div', { 'key': key, 'class': 'pm-output pm-output--prose', 'data-executor': run.executorId }, content)
|
|
80
|
+
return h('div', { 'key': key, 'class': ['pm-output', { 'pm-output--failed': !run.ok }], 'data-executor': run.executorId }, [
|
|
81
|
+
...content,
|
|
82
|
+
notes.length ? h('p', { class: 'pm-output__note' }, notes.join(' · ')) : null,
|
|
83
|
+
])
|
|
84
|
+
}
|
|
@@ -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 }
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { isButton, roleMark, safeDecodeURIComponent, urlSlug } from '@lincyaw/kernel'
|
|
2
|
+
import type { MdastNode } from '@lincyaw/kernel'
|
|
3
|
+
import type { Element, ElementContent, Nodes, Root } from 'hast'
|
|
4
|
+
import { raw } from 'hast-util-raw'
|
|
5
|
+
import type { Handler, Handlers, State } from 'mdast-util-to-hast'
|
|
6
|
+
import { defaultHandlers, toHast } from 'mdast-util-to-hast'
|
|
7
|
+
import { viewerMessages } from '../viewer-messages.ts'
|
|
8
|
+
import { footnoteId } from '../viewer-state.ts'
|
|
9
|
+
import type { Viewer } from '../viewer-state.ts'
|
|
10
|
+
import { classed, classes, element, text } from './hast.ts'
|
|
11
|
+
import { layoutHandlers } from './layout-handlers.ts'
|
|
12
|
+
import { tabHandlers } from './tabs.ts'
|
|
13
|
+
import { tableHandler } from './tables.ts'
|
|
14
|
+
|
|
15
|
+
export interface MarkdownLinks {
|
|
16
|
+
footnoteHref?(identifier: string): string
|
|
17
|
+
section?(id: string): { href: string, title: string } | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function viewerLinks(viewer: Viewer | undefined): MarkdownLinks {
|
|
21
|
+
if (!viewer) return {}
|
|
22
|
+
return {
|
|
23
|
+
footnoteHref: viewer.footnoteHref,
|
|
24
|
+
section(id) {
|
|
25
|
+
const section = viewer.section(id)
|
|
26
|
+
return section && { href: viewer.options.sectionHref?.(id) ?? `#${id}`, title: section.title }
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The nodes the viewer renders with its own components; they pass through the HAST tree untouched. */
|
|
32
|
+
export type ViewerNode =
|
|
33
|
+
| { type: 'pmMath', tex: string, display: boolean, html?: string }
|
|
34
|
+
| { type: 'pmMermaid', code: string }
|
|
35
|
+
| { type: 'pmCode', lang: string, code: string }
|
|
36
|
+
| { type: 'pmRole', mark: string }
|
|
37
|
+
|
|
38
|
+
const VIEWER_NODES: ViewerNode['type'][] = ['pmMath', 'pmMermaid', 'pmCode', 'pmRole']
|
|
39
|
+
|
|
40
|
+
function viewerNode(node: ViewerNode): ElementContent {
|
|
41
|
+
return node as unknown as ElementContent
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function asViewerNode(node: { type: string }): ViewerNode | undefined {
|
|
45
|
+
return (VIEWER_NODES as string[]).includes(node.type) ? node as ViewerNode : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const BLOCK_ELEMENTS = new Set([
|
|
49
|
+
'address', 'article', 'aside', 'blockquote', 'details', 'div', 'dl', 'dt', 'dd', 'fieldset', 'figcaption', 'figure', 'footer',
|
|
50
|
+
'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'iframe', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
function isBlock(node: ElementContent): boolean {
|
|
54
|
+
const own = asViewerNode(node)
|
|
55
|
+
if (own) return own.type === 'pmMermaid' || own.type === 'pmCode' || (own.type === 'pmMath' && own.display)
|
|
56
|
+
return node.type === 'element' && BLOCK_ELEMENTS.has(node.tagName)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unknown(state: State, node: MdastNode): ElementContent | undefined {
|
|
60
|
+
if (!node.children) return typeof node.value === 'string' ? text(node.value) : undefined
|
|
61
|
+
const children = state.all(node as never)
|
|
62
|
+
return element(children.some(isBlock) ? 'div' : 'span', {}, children)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function handlers(links: MarkdownLinks): Handlers {
|
|
66
|
+
const footnoteHref = links.footnoteHref ?? ((identifier: string) => `#${footnoteId(identifier)}`)
|
|
67
|
+
const wrap = (tagName: string, className?: string): Handler => (state, node) =>
|
|
68
|
+
element(tagName, className ? { className: [className] } : {}, state.all(node))
|
|
69
|
+
const value = (node: { value?: unknown }) => (typeof node.value === 'string' ? node.value : '')
|
|
70
|
+
const identifier = (node: { identifier?: unknown, label?: unknown }) => String(node.identifier ?? node.label)
|
|
71
|
+
const label = (node: { identifier?: unknown, label?: unknown }) => String(node.label ?? node.identifier)
|
|
72
|
+
const footnoteMark = (node: { identifier?: unknown, label?: unknown, enumerator?: unknown }) => (node.enumerator !== undefined ? String(node.enumerator) : label(node))
|
|
73
|
+
const math = (node: MdastNode, display: boolean) => viewerNode({ type: 'pmMath', tex: value(node), display, html: typeof node.html === 'string' ? node.html : undefined })
|
|
74
|
+
const reference = (el: Element, id: string, fallback: string): Element => {
|
|
75
|
+
const target = id ? links.section?.(id) : undefined
|
|
76
|
+
if (target) el.properties.href = target.href
|
|
77
|
+
if (el.children.length === 0) el.children = [text(target?.title ?? fallback)]
|
|
78
|
+
return el
|
|
79
|
+
}
|
|
80
|
+
const custom: Record<string, Handler> = {
|
|
81
|
+
table: tableHandler,
|
|
82
|
+
math: (_, node) => math(node, true),
|
|
83
|
+
inlineMath: (_, node) => math(node, false),
|
|
84
|
+
mermaid: (_, node) => viewerNode({ type: 'pmMermaid', code: value(node) }),
|
|
85
|
+
code: (_, node) => viewerNode({ type: 'pmCode', lang: typeof node.lang === 'string' ? node.lang : '', code: value(node) }),
|
|
86
|
+
iframe: (_, node) => element('iframe', {
|
|
87
|
+
src: typeof node.src === 'string' ? node.src : '',
|
|
88
|
+
width: node.width == null || node.width === '' ? undefined : String(node.width),
|
|
89
|
+
height: node.height == null || node.height === '' ? undefined : String(node.height),
|
|
90
|
+
loading: 'lazy',
|
|
91
|
+
}),
|
|
92
|
+
admonition(state, node) {
|
|
93
|
+
const names = viewerMessages().markdown.admonitions
|
|
94
|
+
const extra = classes(node)
|
|
95
|
+
const kind = typeof node.kind === 'string' ? node.kind : extra.find(c => c in names) ?? 'note'
|
|
96
|
+
const children = state.all(node)
|
|
97
|
+
const titled = (node.children as MdastNode[] | undefined)?.some(c => c.type === 'admonitionTitle')
|
|
98
|
+
const title = titled ? [] : [element('p', { className: ['pm-admonition__title'] }, [text(names[kind] ?? kind)])]
|
|
99
|
+
return element('aside', { className: [...new Set(['pm-admonition', `pm-admonition--${kind}`, ...extra])] }, [...title, ...children])
|
|
100
|
+
},
|
|
101
|
+
admonitionTitle: wrap('p', 'pm-admonition__title'),
|
|
102
|
+
footnoteReference: (_, node) => element('sup', { className: ['pm-footnote-ref'] }, [
|
|
103
|
+
element('a', { href: footnoteHref(identifier(node)), dataLabel: footnoteMark(node), ariaLabel: viewerMessages().markdown.footnote(footnoteMark(node)) }, []),
|
|
104
|
+
]),
|
|
105
|
+
footnoteDefinition: (state, node) => element('div', { id: footnoteId(identifier(node)), className: ['pm-footnote'] }, [
|
|
106
|
+
element('sup', { dataLabel: footnoteMark(node) }, []),
|
|
107
|
+
...state.all(node),
|
|
108
|
+
]),
|
|
109
|
+
container: (state, node) => element('figure', { id: typeof node.identifier === 'string' ? node.identifier : undefined, className: ['pm-figure'] }, state.all(node)),
|
|
110
|
+
caption: wrap('figcaption'),
|
|
111
|
+
legend: wrap('div', 'pm-legend'),
|
|
112
|
+
abbreviation: (state, node) => element('abbr', typeof node.title === 'string' ? { title: node.title } : {}, state.all(node)),
|
|
113
|
+
crossReference: (state, node) => reference(element('a', { href: `#${identifier(node)}` }, state.all(node)), identifier(node), label(node)),
|
|
114
|
+
link(state, node) {
|
|
115
|
+
const el = defaultHandlers.link(state, node as never)
|
|
116
|
+
if (isButton(node)) el.properties.className = ['pm-button']
|
|
117
|
+
const url = typeof node.url === 'string' ? node.url : ''
|
|
118
|
+
return url.startsWith('#') ? reference(el, safeDecodeURIComponent(url.slice(1)), url.slice(1)) : reference(el, '', url)
|
|
119
|
+
},
|
|
120
|
+
keyboard: wrap('kbd'),
|
|
121
|
+
subscript: wrap('sub'),
|
|
122
|
+
superscript: wrap('sup'),
|
|
123
|
+
underline: wrap('u'),
|
|
124
|
+
definitionList: wrap('dl'),
|
|
125
|
+
definitionTerm: wrap('dt'),
|
|
126
|
+
definitionDescription: wrap('dd'),
|
|
127
|
+
details: (state, node) => element('details', classed(node, { open: node.open === true }), state.all(node)),
|
|
128
|
+
summary: wrap('summary'),
|
|
129
|
+
div: (state, node) => element('div', classed(node), state.all(node)),
|
|
130
|
+
span: (state, node) => element('span', isButton(node) ? { className: ['pm-button'] } : classed(node), state.all(node)),
|
|
131
|
+
...layoutHandlers(),
|
|
132
|
+
...tabHandlers(),
|
|
133
|
+
presmiumRole(state, node) {
|
|
134
|
+
const children = state.all(node)
|
|
135
|
+
return children.length
|
|
136
|
+
? element('span', { className: ['pm-role'], dataRole: String(node.name ?? '') }, children)
|
|
137
|
+
: viewerNode({ type: 'pmRole', mark: roleMark(node) })
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
return custom as Handlers
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function textOf(node: ElementContent | Root): string {
|
|
144
|
+
if (node.type === 'text') return node.value
|
|
145
|
+
return 'children' in node ? node.children.map(c => textOf(c as ElementContent)).join('') : ''
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function markdownToHast(mdast: MdastNode, prefix: string, links: MarkdownLinks = {}): Root {
|
|
149
|
+
const html = toHast(mdast as never, { allowDangerousHtml: true, handlers: handlers(links), unknownHandler: unknown as never }) as Nodes
|
|
150
|
+
const tree = raw(html, { passThrough: VIEWER_NODES }) as Root
|
|
151
|
+
let item = 0
|
|
152
|
+
for (const child of tree.children) {
|
|
153
|
+
if (child.type !== 'element') continue
|
|
154
|
+
if (/^h[1-6]$/.test(child.tagName) && child.properties.id === undefined) child.properties.id = `${prefix}-${urlSlug(textOf(child))}`
|
|
155
|
+
if (child.tagName !== 'ul' && child.tagName !== 'ol') continue
|
|
156
|
+
for (const li of child.children) {
|
|
157
|
+
if (li.type === 'element' && li.tagName === 'li') li.properties.dataPmItem = item++
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return tree
|
|
161
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MdastNode } from '@lincyaw/kernel'
|
|
2
|
+
import type { Element, ElementContent, Properties } from 'hast'
|
|
3
|
+
|
|
4
|
+
export function element(tagName: string, properties: Properties, children: ElementContent[] = []): Element {
|
|
5
|
+
return { type: 'element', tagName, properties, children }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function text(value: string): ElementContent {
|
|
9
|
+
return { type: 'text', value }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The classes a MyST node names in its `class` option. */
|
|
13
|
+
export function classes(node: MdastNode): string[] {
|
|
14
|
+
return typeof node.class === 'string' ? node.class.split(/\s+/).filter(Boolean) : []
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function classed(node: MdastNode, properties: Properties = {}): Properties {
|
|
18
|
+
const names = classes(node)
|
|
19
|
+
return names.length ? { ...properties, className: names } : properties
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { MdastNode } from '@lincyaw/kernel'
|
|
2
|
+
import type { ElementContent } from 'hast'
|
|
3
|
+
import type { Handler, State } from 'mdast-util-to-hast'
|
|
4
|
+
import { classes, element } from './hast.ts'
|
|
5
|
+
|
|
6
|
+
const WIDTHS = ['xs', 'sm', 'md', 'lg'] as const
|
|
7
|
+
|
|
8
|
+
function gridColumns(node: MdastNode): number[] {
|
|
9
|
+
const given = Array.isArray(node.columns) ? node.columns.map(Number).filter(n => Number.isInteger(n) && n >= 1) : []
|
|
10
|
+
return WIDTHS.map((_, i) => given[Math.min(i, given.length - 1)] ?? 1)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const style = (values: Record<string, number>) => Object.entries(values).map(([key, value]) => `${key}: ${value}`).join('; ')
|
|
14
|
+
|
|
15
|
+
const grid: Handler = (state, node: MdastNode) => {
|
|
16
|
+
const columns = gridColumns(node)
|
|
17
|
+
const children = state.all(node as never).map((child) => {
|
|
18
|
+
const twelfths = child.type === 'element' ? Number(child.properties.dataPmColumns) : Number.NaN
|
|
19
|
+
if (child.type !== 'element' || !(twelfths >= 1)) return child
|
|
20
|
+
const spans = Object.fromEntries(WIDTHS.map((width, i) => [`--pm-span-${width}`, Math.min(columns[i]!, Math.max(1, Math.round(twelfths * columns[i]! / 12)))]))
|
|
21
|
+
return { ...child, properties: { ...child.properties, style: style(spans) } }
|
|
22
|
+
})
|
|
23
|
+
const widths = Object.fromEntries(WIDTHS.map((width, i) => [`--pm-grid-${width}`, columns[i]!]))
|
|
24
|
+
return element('div', { className: ['pm-grid', ...classes(node)], style: style(widths) }, children)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const gridItem: Handler = (state, node: MdastNode) =>
|
|
28
|
+
element('div', { className: ['pm-grid__item', ...classes(node)], dataPmColumns: typeof node.columns === 'number' ? node.columns : undefined }, state.all(node as never))
|
|
29
|
+
|
|
30
|
+
const card: Handler = (state: State, node: MdastNode) => {
|
|
31
|
+
const items = node.children ?? []
|
|
32
|
+
const part = (type: string) => items.find(child => child.type === type)
|
|
33
|
+
const url = typeof node.url === 'string' && node.url ? node.url : undefined
|
|
34
|
+
const header = part('header')
|
|
35
|
+
const footer = part('footer')
|
|
36
|
+
const title = part('cardTitle')
|
|
37
|
+
const content: ElementContent[] = []
|
|
38
|
+
if (header) content.push(element('header', { className: ['pm-card__header'] }, state.all(header as never)))
|
|
39
|
+
if (title || url) {
|
|
40
|
+
const words: MdastNode[] = title?.children?.length ? title.children : [{ type: 'text', value: url ?? '' }]
|
|
41
|
+
const inner = url ? [state.one({ type: 'link', url, children: words } as never, node as never) as ElementContent] : state.all({ type: 'paragraph', children: words } as never)
|
|
42
|
+
content.push(element('p', { className: ['pm-card__title'] }, inner))
|
|
43
|
+
}
|
|
44
|
+
const body = items.filter(child => child !== header && child !== title && child !== footer)
|
|
45
|
+
content.push(element('div', { className: ['pm-card__body'] }, state.all({ type: 'root', children: body } as never)))
|
|
46
|
+
if (footer) content.push(element('footer', { className: ['pm-card__footer'] }, state.all(footer as never)))
|
|
47
|
+
return element('div', { className: ['pm-card', ...url ? ['pm-card--link'] : [], ...classes(node)] }, content)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The handlers of MyST's grids and cards. */
|
|
51
|
+
export function layoutHandlers(): Record<string, Handler> {
|
|
52
|
+
return {
|
|
53
|
+
'grid': grid,
|
|
54
|
+
'grid-item': gridItem,
|
|
55
|
+
'card': card,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { escapeHtml } from '@lincyaw/kernel'
|
|
3
|
+
import { h } from '../element.ts'
|
|
4
|
+
import 'katex/dist/katex.min.css'
|
|
5
|
+
export function PmMath({ tex, display, html }: { tex: string, display?: boolean, html?: string }) {
|
|
6
|
+
const [rendered, setRendered] = useState<string>()
|
|
7
|
+
useEffect(() => { let active = true; if (!html) void import('katex').then(m => { if (active) setRendered(m.default.renderToString(tex, { displayMode: display, throwOnError: false })) }); return () => { active = false } }, [tex, display, html])
|
|
8
|
+
const output = html ?? rendered
|
|
9
|
+
return h(display ? 'div' : 'span', { className: `pm-math pm-math--${display ? 'display' : 'inline'}`, 'data-rendered': output ? '' : undefined, innerHTML: output ?? escapeHtml(tex) })
|
|
10
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useAppearance } from '@lincyaw/host'
|
|
2
|
+
import { errorMessage, escapeHtml } from '@lincyaw/kernel'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { h } from '../element.ts'
|
|
5
|
+
|
|
6
|
+
type Mermaid = typeof import('mermaid').default
|
|
7
|
+
|
|
8
|
+
let mermaid: Promise<Mermaid> | undefined
|
|
9
|
+
let diagrams = 0
|
|
10
|
+
|
|
11
|
+
function loadMermaid(): Promise<Mermaid> {
|
|
12
|
+
if (import.meta.env.SSR) return new Promise(() => {})
|
|
13
|
+
mermaid ??= import('mermaid').then(m => m.default)
|
|
14
|
+
return mermaid
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Mermaid's theme variables from the page's design tokens; undefined when the page has none. */
|
|
18
|
+
function mermaidThemeVariables(dark: boolean): Record<string, string | boolean> | undefined {
|
|
19
|
+
const style = getComputedStyle(document.documentElement)
|
|
20
|
+
const colorToken = (name: string) => style.getPropertyValue(`--pm-color-${name}`).trim()
|
|
21
|
+
const [ink, muted, surface, sunk, line, accent, accentSoft, warn, warnSoft] = ['ink', 'muted', 'surface', 'sunk', 'line', 'accent', 'accent-soft', 'warn', 'warn-soft'].map(colorToken)
|
|
22
|
+
if (!ink || !surface || !accent) return undefined
|
|
23
|
+
return {
|
|
24
|
+
darkMode: dark,
|
|
25
|
+
background: surface,
|
|
26
|
+
fontFamily: style.getPropertyValue('--pm-font-sans').trim() || 'sans-serif',
|
|
27
|
+
textColor: ink,
|
|
28
|
+
lineColor: muted!,
|
|
29
|
+
primaryColor: accentSoft!,
|
|
30
|
+
primaryTextColor: ink,
|
|
31
|
+
primaryBorderColor: accent,
|
|
32
|
+
secondaryColor: sunk!,
|
|
33
|
+
secondaryTextColor: ink,
|
|
34
|
+
secondaryBorderColor: line!,
|
|
35
|
+
tertiaryColor: surface,
|
|
36
|
+
tertiaryTextColor: ink,
|
|
37
|
+
tertiaryBorderColor: line!,
|
|
38
|
+
edgeLabelBackground: surface,
|
|
39
|
+
clusterBkg: sunk!,
|
|
40
|
+
clusterBorder: line!,
|
|
41
|
+
titleColor: ink,
|
|
42
|
+
noteBkgColor: warnSoft!,
|
|
43
|
+
noteTextColor: ink,
|
|
44
|
+
noteBorderColor: warn!,
|
|
45
|
+
actorBkg: accentSoft!,
|
|
46
|
+
actorBorder: accent,
|
|
47
|
+
actorTextColor: ink,
|
|
48
|
+
signalColor: ink,
|
|
49
|
+
signalTextColor: ink,
|
|
50
|
+
labelBoxBkgColor: sunk!,
|
|
51
|
+
labelTextColor: ink,
|
|
52
|
+
loopTextColor: ink,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function PmMermaid({ code }: { code: string }) {
|
|
57
|
+
const [drawn, setDrawn] = useState<{ svg: string, theme: string }>()
|
|
58
|
+
const [failed, setFailed] = useState<string>()
|
|
59
|
+
const { dark } = useAppearance()
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
let active = true
|
|
62
|
+
void (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const library = await loadMermaid()
|
|
65
|
+
const variables = mermaidThemeVariables(dark)
|
|
66
|
+
const appearance = dark ? 'dark' : 'default'
|
|
67
|
+
library.initialize({ startOnLoad: false, securityLevel: 'strict', theme: variables ? 'base' : appearance, ...variables ? { themeVariables: variables } : {}, flowchart: { useMaxWidth: false } })
|
|
68
|
+
const { svg } = await library.render(`pm-mermaid-${++diagrams}`, code)
|
|
69
|
+
if (active) { setDrawn({ svg, theme: appearance }); setFailed(undefined) }
|
|
70
|
+
} catch (error) { if (active) setFailed(errorMessage(error)) }
|
|
71
|
+
})()
|
|
72
|
+
return () => { active = false }
|
|
73
|
+
}, [code, dark])
|
|
74
|
+
return h('div', { className: `pm-mermaid${failed ? ' pm-mermaid--error' : ''}`, 'data-rendered': drawn ? '' : undefined, 'data-theme': drawn?.theme, title: failed, innerHTML: drawn?.svg ?? `<pre>${escapeHtml(code)}</pre>` })
|
|
75
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { blockStepState } from '@lincyaw/kernel'
|
|
2
|
+
import { currentLocale } from '@lincyaw/host'
|
|
3
|
+
import type { IRBlock, MarkdownNode } from '@lincyaw/kernel'
|
|
4
|
+
import type { ElementContent, Properties, RootContent } from 'hast'
|
|
5
|
+
import { find, html as schema } from 'property-information'
|
|
6
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
7
|
+
import { useContext } from 'react'
|
|
8
|
+
import { useStore } from '@lincyaw/host'
|
|
9
|
+
import { h } from '../element.ts'
|
|
10
|
+
import { itemVisible } from '../../domain/index.ts'
|
|
11
|
+
import { CodeView } from '../code/code-view.ts'
|
|
12
|
+
import { VIEWER } from '../viewer-state.ts'
|
|
13
|
+
import { asViewerNode, markdownToHast, viewerLinks } from './handlers.ts'
|
|
14
|
+
import type { ViewerNode } from './handlers.ts'
|
|
15
|
+
import { PmMath } from './math.ts'
|
|
16
|
+
import { PmMermaid } from './mermaid.ts'
|
|
17
|
+
import { PmRoleMark } from './role-mark.ts'
|
|
18
|
+
import { tabEntries, TabSetView } from './tabs.ts'
|
|
19
|
+
import { cachedTree } from './tree-cache.ts'
|
|
20
|
+
|
|
21
|
+
function attributes(properties: Properties): Record<string, unknown> {
|
|
22
|
+
const attrs: Record<string, unknown> = {}
|
|
23
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
24
|
+
if (value === null || value === undefined || value === false) continue
|
|
25
|
+
const info = find(schema, key)
|
|
26
|
+
attrs[info.attribute] = value === true ? (info.boolean ? true : '') : Array.isArray(value) ? value.join(info.commaSeparated ? ', ' : ' ') : value
|
|
27
|
+
}
|
|
28
|
+
return attrs
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function viewerComponent(node: ViewerNode): [ComponentType<any>, Record<string, unknown>] {
|
|
32
|
+
switch (node.type) {
|
|
33
|
+
case 'pmMath': return [PmMath, { tex: node.tex, display: node.display, html: node.html }]
|
|
34
|
+
case 'pmMermaid': return [PmMermaid, { code: node.code }]
|
|
35
|
+
case 'pmCode': return [CodeView, { lang: node.lang, value: node.code }]
|
|
36
|
+
case 'pmRole': return [PmRoleMark, { mark: node.mark }]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HastRenderContext {
|
|
41
|
+
hidden?(item: number): boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const RAW_TEXT = new Set(['script', 'style'])
|
|
45
|
+
const VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
|
|
46
|
+
|
|
47
|
+
export function hastToVNodes(nodes: readonly (RootContent | ElementContent)[], ctx: HastRenderContext): ReactNode[] {
|
|
48
|
+
return nodes.map((node): ReactNode => {
|
|
49
|
+
const own = asViewerNode(node)
|
|
50
|
+
if (own) {
|
|
51
|
+
const [component, props] = viewerComponent(own)
|
|
52
|
+
return h(component, props)
|
|
53
|
+
}
|
|
54
|
+
if (node.type === 'text') return node.value
|
|
55
|
+
if (node.type !== 'element') return null
|
|
56
|
+
const attrs = attributes(node.properties)
|
|
57
|
+
const item = node.properties.dataPmItem
|
|
58
|
+
if (typeof item === 'number' && ctx.hidden?.(item)) attrs.class = [attrs.class, 'pm-hidden']
|
|
59
|
+
// HAST represents a void element with an empty children array. React treats
|
|
60
|
+
// even that array as children and refuses img/br/input before mounting.
|
|
61
|
+
if (VOID_ELEMENTS.has(node.tagName)) return h(node.tagName, attrs)
|
|
62
|
+
if (RAW_TEXT.has(node.tagName)) return h(node.tagName, { ...attrs, innerHTML: node.children.map(c => (c.type === 'text' ? c.value : '')).join('') })
|
|
63
|
+
if (node.properties.dataPmTabs !== undefined) {
|
|
64
|
+
return h(TabSetView, { class: attrs.class, tabs: tabEntries(node, children => hastToVNodes(children, ctx)) })
|
|
65
|
+
}
|
|
66
|
+
return h(node.tagName, attrs, hastToVNodes(node.children, ctx))
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function MarkdownView(props: { block: IRBlock, step: number, expanded?: boolean, prefix?: string }) {
|
|
71
|
+
const viewer = useContext(VIEWER)
|
|
72
|
+
const doc = useStore(viewer?.doc)
|
|
73
|
+
const links = viewerLinks(viewer)
|
|
74
|
+
const node = props.block.node as MarkdownNode
|
|
75
|
+
const tree = viewer && doc ? cachedTree(viewer, doc, node.mdast, props.prefix ?? '', links, currentLocale()) : markdownToHast(node.mdast, props.prefix ?? '', links)
|
|
76
|
+
const revealed = blockStepState(props.block, props.step)?.parameters?.revealedItems
|
|
77
|
+
const hidden = node.reveal === 'items' && !props.expanded ? (k: number) => typeof revealed === 'number' ? k >= revealed : !itemVisible(props.block, k, props.step) : undefined
|
|
78
|
+
return h('div', { className: 'pm-markdown' }, hastToVNodes(tree.children, { hidden }))
|
|
79
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { AUTHOR_MARKS_CLASS } from '@lincyaw/kernel'
|
|
3
|
+
import { useSectionContext } from '../section-context.ts'
|
|
4
|
+
import { h } from '../element.ts'
|
|
5
|
+
export function PmRoleMark({ mark }: { mark: string }) {
|
|
6
|
+
const section = useSectionContext(); const [shown, setShown] = useState(false)
|
|
7
|
+
useEffect(() => { const root = document.documentElement; const update = () => setShown(root.classList.contains(AUTHOR_MARKS_CLASS)); update(); const observer = new MutationObserver(update); observer.observe(root, { attributes: true, attributeFilter: ['class'] }); return () => observer.disconnect() }, [])
|
|
8
|
+
return shown || section?.view === 'presenter' ? h('span', { className: 'pm-role-mark' }, mark) : null
|
|
9
|
+
}
|