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