@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +66 -2
  3. package/package.json +68 -3
  4. package/src/MediaClipEditor.tsx +122 -0
  5. package/src/StepProductionEditor.tsx +130 -0
  6. package/src/approvals.tsx +60 -0
  7. package/src/assistant-mascot.ts +62 -0
  8. package/src/audio-recording-messages.ts +44 -0
  9. package/src/audio-recording.ts +192 -0
  10. package/src/audio-wav.ts +25 -0
  11. package/src/audio-waveform.ts +110 -0
  12. package/src/audition.ts +84 -0
  13. package/src/authoring-client.ts +136 -0
  14. package/src/authoring-draft-fragment.ts +28 -0
  15. package/src/base-controls.tsx +47 -0
  16. package/src/caption-timing-drafts.ts +61 -0
  17. package/src/caption-timing-messages.ts +14 -0
  18. package/src/caption-timing.css +20 -0
  19. package/src/caption-timing.ts +14 -0
  20. package/src/component-draft-actions.ts +54 -0
  21. package/src/component-draft-inspection.ts +35 -0
  22. package/src/component-properties.css +24 -0
  23. package/src/component-properties.ts +45 -0
  24. package/src/component-property-drafts.ts +273 -0
  25. package/src/component-property-messages.ts +31 -0
  26. package/src/components.tsx +631 -0
  27. package/src/context.tsx +27 -0
  28. package/src/diagnostics.ts +113 -0
  29. package/src/document-draft-store.ts +179 -0
  30. package/src/document-drafts.ts +178 -0
  31. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  32. package/src/document-editor/ComponentMaterials.tsx +62 -0
  33. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  34. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  35. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  36. package/src/document-editor/DocumentComments.tsx +282 -0
  37. package/src/document-editor/DocumentEditor.tsx +192 -0
  38. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  39. package/src/document-editor/NarrationEditor.tsx +110 -0
  40. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  41. package/src/document-editor/authoring.tsx +259 -0
  42. package/src/document-editor/block-playback.ts +90 -0
  43. package/src/document-editor/block-preview-messages.ts +13 -0
  44. package/src/document-editor/blocknote-messages.ts +98 -0
  45. package/src/document-editor/clipboard.ts +40 -0
  46. package/src/document-editor/colors.ts +57 -0
  47. package/src/document-editor/comment-draft-actions.ts +58 -0
  48. package/src/document-editor/comment-draft-inspection.ts +38 -0
  49. package/src/document-editor/comment-drafts.ts +361 -0
  50. package/src/document-editor/comments.ts +410 -0
  51. package/src/document-editor/file-input.ts +71 -0
  52. package/src/document-editor/focus.ts +54 -0
  53. package/src/document-editor/inline.ts +104 -0
  54. package/src/document-editor/math.tsx +63 -0
  55. package/src/document-editor/messages.ts +14 -0
  56. package/src/document-editor/narration-schema.tsx +85 -0
  57. package/src/document-editor/narration.ts +37 -0
  58. package/src/document-editor/popover.tsx +107 -0
  59. package/src/document-editor/projection.ts +423 -0
  60. package/src/document-editor/rich-text.ts +152 -0
  61. package/src/document-editor/schema.tsx +117 -0
  62. package/src/document-editor/selection.ts +152 -0
  63. package/src/document-editor/style.css +9 -0
  64. package/src/document-editor/styles.d.ts +1 -0
  65. package/src/document-editor/table-identities.ts +67 -0
  66. package/src/document-preview.ts +112 -0
  67. package/src/document-return-point.ts +59 -0
  68. package/src/document-session.ts +678 -0
  69. package/src/document-timing-drafts.ts +61 -0
  70. package/src/document-voices.ts +2 -0
  71. package/src/history.ts +167 -0
  72. package/src/index.ts +36 -0
  73. package/src/jobs.ts +111 -0
  74. package/src/mascot-preference.ts +37 -0
  75. package/src/media-drafts.ts +83 -0
  76. package/src/media-messages.ts +53 -0
  77. package/src/media-time.ts +12 -0
  78. package/src/messages.ts +781 -0
  79. package/src/outline.ts +97 -0
  80. package/src/panels/ChangeTrail.tsx +79 -0
  81. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  82. package/src/panels/DocumentView.tsx +462 -0
  83. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  84. package/src/panels/DocumentVoiceField.tsx +35 -0
  85. package/src/panels/ExportPanel.tsx +133 -0
  86. package/src/panels/HistoryPanel.tsx +88 -0
  87. package/src/panels/HomePanel.tsx +184 -0
  88. package/src/panels/PageDeleteDialog.tsx +33 -0
  89. package/src/panels/PageRenameDialog.tsx +27 -0
  90. package/src/panels/PreviewPanel.tsx +79 -0
  91. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  92. package/src/panels/ProductionMediaClips.tsx +156 -0
  93. package/src/panels/ProductionPanel.tsx +272 -0
  94. package/src/panels/ProductionRecording.tsx +107 -0
  95. package/src/panels/ProductionRecordingRange.tsx +118 -0
  96. package/src/panels/ProductionTimeline.tsx +62 -0
  97. package/src/panels/ProductionTranscription.tsx +140 -0
  98. package/src/panels/SourceDocumentPanel.tsx +215 -0
  99. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  100. package/src/panels/TranscriptionAdoption.tsx +147 -0
  101. package/src/panels/VoiceCloneDialog.tsx +152 -0
  102. package/src/panels/VoicePreviewDialog.tsx +74 -0
  103. package/src/panels/VoiceScopeField.tsx +30 -0
  104. package/src/panels/VoiceoverDialog.tsx +216 -0
  105. package/src/places.ts +102 -0
  106. package/src/preview-playback.ts +125 -0
  107. package/src/preview-pool.ts +320 -0
  108. package/src/preview.ts +110 -0
  109. package/src/production-messages.ts +151 -0
  110. package/src/production-playback.ts +109 -0
  111. package/src/production-voice-playback.ts +114 -0
  112. package/src/recording-adoption.ts +33 -0
  113. package/src/schema-form/PmSchemaForm.tsx +187 -0
  114. package/src/schema-form/context.ts +26 -0
  115. package/src/schema-form/fields.tsx +59 -0
  116. package/src/schema-form/templates.tsx +50 -0
  117. package/src/schema-form/validator.ts +36 -0
  118. package/src/schema-form/widgets.tsx +60 -0
  119. package/src/shortcuts.ts +8 -0
  120. package/src/sortable-list.tsx +201 -0
  121. package/src/speech-settings-messages.ts +40 -0
  122. package/src/step-production.css +5 -0
  123. package/src/step-production.ts +51 -0
  124. package/src/styles.css +1025 -0
  125. package/src/telemetry.ts +152 -0
  126. package/src/transcription-messages.ts +26 -0
  127. package/src/transcription-session.ts +159 -0
  128. package/src/use-recording-adoption.ts +84 -0
  129. package/src/voice-clone-messages.ts +92 -0
  130. package/src/voice-drafts.ts +89 -0
  131. package/src/voice-messages.ts +146 -0
  132. package/src/voice-snapshot.ts +55 -0
  133. package/src/voiceover-jobs.ts +87 -0
  134. package/src/workbench.ts +2328 -0
  135. package/src/workspace-client.ts +480 -0
@@ -0,0 +1,63 @@
1
+ import { createContext, useCallback, useContext, useMemo, useRef } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import katex from 'katex'
4
+ import { createReactBlockSpec, createReactInlineContentSpec, SourceBlockWithPreview, SourceInlineContentWithPreview, useSourceInlineContentPreviewPopup } from '@blocknote/react'
5
+ import type { Locale } from '../messages.ts'
6
+ import { DOCUMENT_EDITOR_MESSAGES } from './messages.ts'
7
+ import { useInlineSourcePopupPlacement } from './popover.tsx'
8
+
9
+ export const DOCUMENT_EDITOR_LOCALE = createContext<Locale>('zh-CN')
10
+
11
+ // MathML needs no font assets in the workbench bundle; the viewer typesets the same TeX with the same library.
12
+ function typeset(source: string, display: boolean): { html?: string, error?: string } {
13
+ if (!source) return {}
14
+ try { return { html: katex.renderToString(source, { displayMode: display, throwOnError: true, output: 'mathml' }) } }
15
+ catch (error) { return { error: error instanceof Error ? error.message : String(error) } }
16
+ }
17
+
18
+ function useMathPreview(source: string, display: boolean): { preview?: ReactNode, error?: ReactNode, empty: string, invalid: string, placeholder: string } {
19
+ const t = DOCUMENT_EDITOR_MESSAGES[useContext(DOCUMENT_EDITOR_LOCALE)]
20
+ const result = useMemo(() => typeset(source, display), [source, display])
21
+ const lastGood = useRef<string>()
22
+ if (result.html) lastGood.current = result.html
23
+ const html = result.html ?? lastGood.current
24
+ const Element = display ? 'div' : 'span'
25
+ return {
26
+ preview: html ? <Element className={`pm-document-editor__math pm-document-editor__math--${display ? 'display' : 'inline'}`} dangerouslySetInnerHTML={{ __html: html }} /> : undefined,
27
+ error: result.error ? <><strong>{t.mathInvalid}</strong> {result.error}</> : undefined,
28
+ empty: t.mathEmpty, invalid: t.mathInvalid, placeholder: t.mathPlaceholder,
29
+ }
30
+ }
31
+
32
+ /** A typed formula block: its TeX is the block's plain content, edited in BlockNote's source popup. */
33
+ export const mathBlock = createReactBlockSpec({
34
+ type: 'presmiumMath',
35
+ propSchema: {},
36
+ content: 'plain',
37
+ }, {
38
+ meta: { hasPreview: true, code: true },
39
+ render: function MathBlock({ block, editor, contentRef }) {
40
+ const source = block.content.map(part => part.text).join('')
41
+ const math = useMathPreview(source, true)
42
+ return <SourceBlockWithPreview block={block} editor={editor} contentRef={contentRef} source={source} preview={math.preview} error={math.error}
43
+ emptySourcePlaceholder={math.empty} errorPreview={math.invalid} sourcePlaceholder={math.placeholder} />
44
+ },
45
+ })()
46
+
47
+ /** An inline formula inside text; selecting it opens the same source popup. */
48
+ export const mathInline = createReactInlineContentSpec({
49
+ type: 'presmiumMathInline',
50
+ propSchema: {},
51
+ content: 'plain',
52
+ }, {
53
+ meta: { hasPreview: true, code: true },
54
+ render: function MathInline({ inlineContent, editor, node, getPos, contentRef }) {
55
+ const math = useMathPreview(inlineContent.content, false)
56
+ const { isOpen } = useSourceInlineContentPreviewPopup({ editor, node, getPos })
57
+ const sourceElement = useRef<HTMLElement | null>(null)
58
+ const sourceRef = useCallback((element: HTMLElement | null) => { sourceElement.current = element; contentRef(element) }, [contentRef])
59
+ useInlineSourcePopupPlacement(useCallback(() => sourceElement.current, []), isOpen)
60
+ return <SourceInlineContentWithPreview editor={editor} node={node} getPos={getPos} contentRef={sourceRef} source={inlineContent.content} preview={math.preview} error={math.error}
61
+ emptySourcePlaceholder={math.empty} errorPreview={math.invalid} sourcePlaceholder={math.placeholder} />
62
+ },
63
+ })
@@ -0,0 +1,14 @@
1
+ export const IMAGE_IMPORT_MESSAGES = {
2
+ 'zh-CN': { unsupported: '仅支持图片', format: '图片格式不支持', size: '图片超过 8 MiB', dimensions: '图片尺寸过大', failed: '图片未导入', dismiss: '关闭' },
3
+ en: { unsupported: 'Pictures only', format: 'Unsupported picture format', size: 'Picture exceeds 8 MiB', dimensions: 'Picture dimensions exceed the limit', failed: 'Picture not imported', dismiss: 'Close' },
4
+ }
5
+
6
+ export const DOCUMENT_EDITOR_MESSAGES = {
7
+ 'zh-CN': { colors: '颜色', textColor: '文字颜色', backgroundColor: '背景颜色', colorDefault: '默认', backgroundDefault: '无底色', colorNames: { muted: '灰色 · 次要', accent: '青色 · 强调', ok: '绿色 · 通过', warn: '橙色 · 提醒', danger: '红色 · 警示' }, backgroundNames: { muted: '灰底', accent: '青底', ok: '绿底', warn: '橙底', danger: '红底' }, formula: '公式', formulaInline: '行内公式', formulaBlock: '公式块', formulaInlineHint: '在文字中插入 LaTeX 公式', formulaBlockHint: '单独一行显示的 LaTeX 公式', openLink: '打开链接', goToSection: (title: string) => `跳到“${title}”`, mathEmpty: '插入公式', mathInvalid: '公式无法识别', mathPlaceholder: '输入 LaTeX 公式', commentRecoveryChanged: '已取消恢复草稿,当前输入已保留。', commentNewDraft: '新建批注', commentsEmpty: '还没有批注。选中正文中的文字,再选“新建批注”。', commentTargetUnavailable: '原选区已改变或不可用,草稿仍可恢复。', commentSessionOnly: '仅本次会话', commentDrafts: '批注草稿', commentChooseDraft: '请选择草稿', commentEditDraft: '编辑稿', commentReplyDraft: '回复稿', commentRecover: '恢复草稿', commentDiscard: '放弃草稿', commentBackupFailed: '最新的批注草稿只在本次会话中保留,请发布后再关闭窗口。', stepNarration: '讲稿', stepCaptions: '字幕', previousStep: '上一步', nextStep: '下一步', narrationStep: (step: number, total: number, title: string) => `第 ${step} / ${total} 步${title ? ` · ${title}` : ''}`, previewFailure: '预览失败', previewLoading: '正在更新预览…', previewPrevious: '上次已保存的预览', previewSnapshot: '文稿内预览', previewRetry: '重试', previewRefresh: '刷新', previewTitle: '内容块只读预览', comment: '新建批注', commentChanged: '这条批注已在别处修改。请先复制草稿,再取消以查看已保存的内容。', commentUnavailable: '无法确认这条批注的版本。请先复制草稿,再关闭并重新打开。', author: '作者', page: '新页面', document: '文稿', untitledPage: '未命名页面', opaque: '组件', animation: (title: string) => `动画:${title}`, component: (title: string) => `组件:${title}`, codeReference: (title: string) => `引用代码:${title}`, diagram: (title: string) => title ? `图表:${title}` : '图表', adapterError: '无法保存这次编辑。请保持页面打开,并查看诊断。', conflict: '文稿已在别处修改。草稿已保留,请先核对当前文稿,再重试或放弃草稿。', saveFailure: '文稿尚未确认保存,草稿已保留。请保持页面打开后重试。', restored: '已恢复尚未保存的草稿,请检查后重新保存或放弃。', loading: '正在加载文稿…', unavailable: '暂时无法加载文稿,请重试。', diagnostics: (count: number) => `${count} 项诊断`, formulaBlockAliases: '公式|数学', formulaInlineAliases: '行内公式|公式', pageAliases: '页面|章节' },
8
+ en: { colors: 'Colors', textColor: 'Text color', backgroundColor: 'Highlight', colorDefault: 'Default', backgroundDefault: 'No highlight', colorNames: { muted: 'Gray · subtle', accent: 'Teal · accent', ok: 'Green · success', warn: 'Amber · caution', danger: 'Red · alert' }, backgroundNames: { muted: 'Gray', accent: 'Teal', ok: 'Green', warn: 'Amber', danger: 'Red' }, formula: 'Formula', formulaInline: 'Inline Formula', formulaBlock: 'Formula Block', formulaInlineHint: 'A LaTeX formula within the text', formulaBlockHint: 'A LaTeX formula on its own line', openLink: 'Open Link', goToSection: (title: string) => `Go to “${title}”`, mathEmpty: 'Insert Formula', mathInvalid: 'Formula not recognized', mathPlaceholder: 'Enter a LaTeX formula', commentRecoveryChanged: 'Draft recovery canceled. Your current input is kept.', commentNewDraft: 'New Comment', commentsEmpty: 'No comments yet. Select text in the document, then choose New Comment.', commentTargetUnavailable: 'The original selection changed or is unavailable. You can still recover your draft.', commentSessionOnly: 'This session only', commentDrafts: 'Comment drafts', commentChooseDraft: 'Choose a draft', commentEditDraft: 'Comment edit', commentReplyDraft: 'Reply', commentRecover: 'Recover Draft', commentDiscard: 'Discard Draft', commentBackupFailed: 'The latest comment draft is kept in this session only. Post it before closing this window.', stepNarration: 'Script', stepCaptions: 'Captions', previousStep: 'Previous Step', nextStep: 'Next Step', narrationStep: (step: number, total: number, title: string) => `Step ${step} / ${total}${title ? ` · ${title}` : ''}`, previewFailure: 'Preview failed', previewLoading: 'Updating preview…', previewPrevious: 'Last saved preview', previewSnapshot: 'Document preview', previewRetry: 'Retry', previewRefresh: 'Refresh', previewTitle: 'Read-only block preview', comment: 'New Comment', commentChanged: 'This comment changed elsewhere. Copy your draft, then cancel to view the saved comment.', commentUnavailable: 'Couldn’t confirm this comment’s version. Copy your draft before closing and reopening it.', author: 'Author', page: 'New Page', document: 'Document', untitledPage: 'Untitled page', opaque: 'Component', animation: (title: string) => `Animation: ${title}`, component: (title: string) => `Component: ${title}`, codeReference: (title: string) => `Referenced code: ${title}`, diagram: (title: string) => title ? `Diagram: ${title}` : 'Diagram', adapterError: 'Couldn’t save this edit. Keep this page open and check the diagnostics.', conflict: 'The document changed elsewhere. Your draft is kept; review the current document, then try again or discard your draft.', saveFailure: 'Couldn’t confirm the save. Your draft is kept; keep this page open and try again.', restored: 'A draft that was not saved was recovered. Review it, then save it again or discard it.', loading: 'Loading document…', unavailable: 'Couldn’t load the document. Try again.', diagnostics: (count: number) => `${count} ${count === 1 ? 'diagnostic' : 'diagnostics'}`, formulaBlockAliases: 'formula|math|equation|latex|tex', formulaInlineAliases: 'inline formula|inline math|latex|tex', pageAliases: 'page|section' },
9
+ }
10
+
11
+ /** Slash-menu search words (`|`-separated): an author finds an item by its name in either language, whatever the interface's. */
12
+ export function slashAliases(key: 'formulaBlockAliases' | 'formulaInlineAliases' | 'pageAliases'): string[] {
13
+ return [...new Set([...DOCUMENT_EDITOR_MESSAGES.en[key].split('|'), ...DOCUMENT_EDITOR_MESSAGES['zh-CN'][key].split('|')])]
14
+ }
@@ -0,0 +1,85 @@
1
+ import { createContext, useContext, useState } from 'react'
2
+ import { BlockNoteSchema, createExtension, defaultBlockSpecs, defaultInlineContentSpecs } from '@blocknote/core'
3
+ import type { Block, BlockNoteEditor, InlineContent, PartialBlock } from '@blocknote/core'
4
+ import { createReactInlineContentSpec } from '@blocknote/react'
5
+ import { Extension } from '@tiptap/core'
6
+ import { Plugin } from '@tiptap/pm/state'
7
+ import { Fragment, Slice } from '@tiptap/pm/model'
8
+ import type { Node } from '@tiptap/pm/model'
9
+ import { PmButton, PmDialog, PmInput } from '../components.tsx'
10
+ import type { Locale } from '../messages.ts'
11
+
12
+ export const NARRATION_MESSAGES = {
13
+ 'zh-CN': { field: '讲稿', captions: '字幕', pronunciation: '发音', display: '显示文字', spoken: '实际读音', done: '确定', cancel: '取消', cue: '提示', empty: '输入本步讲稿…', emptyCaptions: '输入字幕…',
14
+ cueTitle: (action: string) => `提示:${action}`,
15
+ actions: { next: '下一步', spot: '聚光', hint: '高亮', point: '指示', click: '点击', run: '运行', ink: '标注', pause: '停顿', mark: '标记', call: '调用', hover: '悬停', move: '移动' } as Record<string, string> },
16
+ en: { field: 'Script', captions: 'Captions', pronunciation: 'Pronunciation', display: 'Written text', spoken: 'Spoken text', done: 'Apply', cancel: 'Cancel', cue: 'Cue', empty: 'Write this step’s script…', emptyCaptions: 'Write captions…',
17
+ cueTitle: (action: string) => `Cue: ${action}`,
18
+ actions: { next: 'Next Step', spot: 'Spotlight', hint: 'Highlight', point: 'Pointer', click: 'Click', run: 'Run', ink: 'Ink', pause: 'Pause', mark: 'Mark', call: 'Call', hover: 'Hover', move: 'Move' } as Record<string, string> },
19
+ }
20
+ export const NARRATION_CONTEXT = createContext<{ locale: Locale, readOnly?: boolean, advanced?: boolean }>({ locale: 'zh-CN' })
21
+
22
+ const atom = createReactInlineContentSpec({
23
+ type: 'narrationAtom',
24
+ propSchema: { kind: { default: 'cue' }, cueId: { default: '' }, payload: { default: '' }, display: { default: '' }, spoken: { default: '' } },
25
+ content: 'none',
26
+ }, {
27
+ render: ({ inlineContent, updateInlineContent }) => {
28
+ const { locale, readOnly, advanced } = useContext(NARRATION_CONTEXT), t = NARRATION_MESSAGES[locale]
29
+ const [open, setOpen] = useState(false), [display, setDisplay] = useState(''), [spoken, setSpoken] = useState('')
30
+ const props = inlineContent.props
31
+ if (props.kind === 'cue') {
32
+ if (advanced === false) return <span contentEditable={false} className="pm-ui-narration-editor__cue">{t.cue}</span>
33
+ let action = ''
34
+ try { action = String(JSON.parse(props.payload).action ?? '') } catch { /* The adapter reports malformed content. */ }
35
+ const name = t.actions[action]
36
+ return <span contentEditable={false} className="pm-ui-narration-editor__cue" title={name ? t.cueTitle(name) : t.cue}>{t.cue}{name ? ` · ${name}` : ''}</span>
37
+ }
38
+ if (advanced === false) return <span contentEditable={false}>{props.display}</span>
39
+ return <span contentEditable={false}>
40
+ <PmButton small variant="ghost" disabled={readOnly} title={props.spoken} onClick={() => { setDisplay(props.display); setSpoken(props.spoken); setOpen(true) }}>{props.display || t.pronunciation}</PmButton>
41
+ <PmDialog open={open} label={t.pronunciation} closeLabel={t.cancel} onClose={() => setOpen(false)} actions={<>
42
+ <PmButton small onClick={() => setOpen(false)}>{t.cancel}</PmButton>
43
+ <PmButton small variant="primary" onClick={() => { updateInlineContent({ type: 'narrationAtom', props: { ...props, display, spoken } }); setOpen(false) }}>{t.done}</PmButton>
44
+ </>}>
45
+ <PmInput label={t.display} value={display} onChange={event => setDisplay(event.target.value)} />
46
+ <PmInput label={t.spoken} value={spoken} onChange={event => setSpoken(event.target.value)} />
47
+ </PmDialog>
48
+ </span>
49
+ },
50
+ })
51
+
52
+ const cueIdentities = createExtension({
53
+ key: 'presmium-narration-cue-identities',
54
+ tiptapExtensions: [Extension.create({ name: 'presmiumNarrationCueIdentities', addProseMirrorPlugins: () => [new Plugin({
55
+ props: { transformPasted: slice => {
56
+ const copied = (fragment: Fragment): Fragment => {
57
+ const nodes: Node[] = []
58
+ fragment.forEach(node => nodes.push(node.type.name === 'narrationAtom' && node.attrs.kind === 'cue'
59
+ ? node.type.create({ ...node.attrs, cueId: crypto.randomUUID() }, node.content, node.marks)
60
+ : node.isLeaf ? node : node.copy(copied(node.content))))
61
+ return Fragment.fromArray(nodes)
62
+ }
63
+ return new Slice(copied(slice.content), slice.openStart, slice.openEnd)
64
+ } },
65
+ appendTransaction: (_transactions, _old, state) => {
66
+ const seen = new Set<string>(), transaction = state.tr
67
+ state.doc.descendants((node, position) => {
68
+ if (node.type.name !== 'narrationAtom' || node.attrs.kind !== 'cue') return
69
+ const id = node.attrs.cueId as string
70
+ if (!id || seen.has(id)) transaction.setNodeMarkup(position, undefined, { ...node.attrs, cueId: crypto.randomUUID() })
71
+ else seen.add(id)
72
+ })
73
+ return transaction.docChanged ? transaction : null
74
+ },
75
+ })] })],
76
+ })
77
+ export const narrationSchema = BlockNoteSchema.create({
78
+ blockSpecs: { paragraph: defaultBlockSpecs.paragraph },
79
+ inlineContentSpecs: { ...defaultInlineContentSpecs, narrationAtom: atom }, styleSpecs: {},
80
+ })
81
+ export const narrationExtensions = [cueIdentities]
82
+ export type NativeNarrationEditor = BlockNoteEditor<typeof narrationSchema.blockSchema, typeof narrationSchema.inlineContentSchema, typeof narrationSchema.styleSchema>
83
+ export type NativeNarrationBlock = Block<typeof narrationSchema.blockSchema, typeof narrationSchema.inlineContentSchema, typeof narrationSchema.styleSchema>
84
+ export type NativeNarrationInput = PartialBlock<typeof narrationSchema.blockSchema, typeof narrationSchema.inlineContentSchema, typeof narrationSchema.styleSchema>
85
+ export type NativeNarrationInline = InlineContent<typeof narrationSchema.inlineContentSchema, typeof narrationSchema.styleSchema>
@@ -0,0 +1,37 @@
1
+ import type { NarrationNode } from '@lincyaw/kernel'
2
+ import type { NativeNarrationBlock, NativeNarrationInput, NativeNarrationInline } from './narration-schema.tsx'
3
+ import type { NativeNarrationEditor } from './narration-schema.tsx'
4
+ import { nativeFieldFocus } from './focus.ts'
5
+
6
+ export const narrationFieldFocus = (editor: NativeNarrationEditor) => nativeFieldFocus(editor, () => JSON.stringify(nativeToNarration(editor.document)))
7
+
8
+ export function narrationToNative(id: string, content: readonly NarrationNode[]): NativeNarrationInput[] {
9
+ return [{ id: `narration:${id}`, type: 'paragraph', content: content.map((node): NativeNarrationInline => {
10
+ if (node.type === 'text') return { type: 'text', text: node.text, styles: {} }
11
+ return { type: 'narrationAtom', content: undefined, props: node.type === 'pronunciation'
12
+ ? { kind: 'pronunciation', display: node.display, spoken: node.spoken, cueId: '', payload: '' }
13
+ : { kind: 'cue', cueId: node.id, payload: JSON.stringify(node), display: '', spoken: '' } }
14
+ }) }]
15
+ }
16
+ export function nativeToNarration(blocks: readonly NativeNarrationBlock[]): NarrationNode[] {
17
+ const content: NarrationNode[] = []
18
+ const text = (value: string) => {
19
+ const last = content.at(-1)
20
+ if (last?.type === 'text') last.text += value
21
+ else if (value) content.push({ type: 'text', text: value })
22
+ }
23
+ blocks.forEach((block, index) => {
24
+ if (index) text('\n')
25
+ for (const inline of block.content) {
26
+ if (inline.type === 'text') text(inline.text)
27
+ else if (inline.type === 'link') text(inline.content.map(value => value.text).join(''))
28
+ else if (inline.props.kind === 'pronunciation') content.push({ type: 'pronunciation', display: inline.props.display, spoken: inline.props.spoken })
29
+ else {
30
+ const node = JSON.parse(inline.props.payload) as NarrationNode
31
+ if (node.type !== 'cue' || !inline.props.cueId) throw new Error('narration-cue-reference')
32
+ content.push({ ...node, id: inline.props.cueId })
33
+ }
34
+ }
35
+ })
36
+ return content
37
+ }
@@ -0,0 +1,107 @@
1
+ import { useLayoutEffect } from 'react'
2
+ import { Menu } from '@mantine/core'
3
+ import { components } from '@blocknote/mantine'
4
+ import type { ComponentProps, FloatingUIOptions } from '@blocknote/react'
5
+ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/react'
6
+
7
+ /** A length token in CSS pixels, for positioning code that only takes numbers. */
8
+ function tokenPixels(name: string, element: Element = document.documentElement): number {
9
+ const value = getComputedStyle(element).getPropertyValue(name).trim(), number = Number.parseFloat(value)
10
+ if (!Number.isFinite(number)) return 0
11
+ return value.endsWith('rem') ? number * Number.parseFloat(getComputedStyle(document.documentElement).fontSize) : number
12
+ }
13
+
14
+ /** Popovers keep this distance from the screen edges. */
15
+ const edge = (element?: Element) => tokenPixels('--pm-space-2', element)
16
+
17
+ const BlockNoteMenuRoot = components.Generic.Menu.Root
18
+
19
+ /**
20
+ * BlockNote's menus, with a margin to the screen edge when a menu is taller than the room on either side of its
21
+ * button: it then keeps its border and shadow inside the screen and scrolls, rather than being cut by the edge.
22
+ * Placement (flip and shift) stays BlockNote's, so menus that fit open where they always did.
23
+ */
24
+ export function DocumentMenuRoot(props: ComponentProps['Generic']['Menu']['Root']) {
25
+ // Submenus already slide along the screen edge beside their item.
26
+ if (props.sub) return <BlockNoteMenuRoot {...props} />
27
+ const { children, onOpenChange, position, portalElement, preventFocusOnOpen } = props
28
+ return <Menu withinPortal={!!portalElement} portalProps={portalElement ? { target: portalElement } : undefined}
29
+ // As in BlockNote: on touch screens the menu must not take focus from the text, or the keyboard closes.
30
+ trapFocus={preventFocusOnOpen ? false : undefined} returnFocus={false} onChange={onOpenChange} position={position}
31
+ middlewares={{ flip: true, shift: true, inline: false, size: { padding: edge() } }}>
32
+ {children}
33
+ </Menu>
34
+ }
35
+
36
+ // BlockNote's own gap between a link and its toolbar, kept so the toolbar does not move on wide screens.
37
+ const LINK_TOOLBAR_GAP = 10
38
+
39
+ /** The link toolbar also slides back inside the screen when its link ends near the right edge. */
40
+ export function linkToolbarFloating(): FloatingUIOptions {
41
+ return { useFloatingOptions: { middleware: [offset(LINK_TOOLBAR_GAP), flip(), shift({ padding: edge() })] } }
42
+ }
43
+
44
+ // BlockNote's own gap between commented text and its floating card or new-comment form.
45
+ const COMMENT_GAP = 10
46
+
47
+ type FloatingMiddleware = NonNullable<NonNullable<FloatingUIOptions['useFloatingOptions']>['middleware']>[number]
48
+
49
+ /**
50
+ * Positions against one line box of the commented text instead of the box around the whole range, so a card for a
51
+ * long or wrapped comment opens at the line that was clicked. `line` gives that line in viewport coordinates.
52
+ */
53
+ function commentLine(line: () => DOMRect | undefined): FloatingMiddleware {
54
+ return {
55
+ name: 'commentLine',
56
+ async fn({ rects, elements, middlewareData }) {
57
+ const target = middlewareData.commentLine?.applied ? undefined : line()
58
+ if (!target) return { data: { applied: true } }
59
+ // The reference rect is relative to the card's offset parent; move it by the line's offset from the whole range.
60
+ const whole = elements.reference.getBoundingClientRect()
61
+ const reference = { x: rects.reference.x + target.left - whole.left, y: rects.reference.y + target.top - whole.top, width: target.width, height: target.height }
62
+ return { data: { applied: true }, reset: { rects: { reference, floating: rects.floating } } }
63
+ },
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Comment cards and the new-comment form keep BlockNote's place under the text (above when there is no room below)
69
+ * and slide back inside the screen with the same margin as the other popovers, instead of touching its edge. With
70
+ * `line`, the card follows that line of the text and starts where it starts.
71
+ */
72
+ export function commentFloating(line?: () => DOMRect | undefined): NonNullable<FloatingUIOptions['useFloatingOptions']> {
73
+ const padding = edge()
74
+ return {
75
+ ...line ? { placement: 'bottom-start' as const } : {},
76
+ middleware: [...line ? [commentLine(line)] : [], offset(COMMENT_GAP), flip({ padding, flipAlignment: false }), shift({ padding })],
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Keeps an inline source popup (BlockNote positions it under the left end of its formula) inside the screen:
82
+ * it flips above the line near the bottom and slides left near the right edge.
83
+ */
84
+ export function useInlineSourcePopupPlacement(source: () => HTMLElement | null, open: boolean): void {
85
+ useLayoutEffect(() => {
86
+ // The formula's own box is the reference, as for BlockNote's placement, so a popup with room keeps its place.
87
+ const reference = source()?.closest<HTMLElement>('.bn-preview-with-source-popup'), popup = reference?.querySelector<HTMLElement>('.bn-source-block-popup')
88
+ if (!open || !reference || !popup) return
89
+ let current = true
90
+ const place = () => {
91
+ const padding = edge(popup)
92
+ computePosition(reference, popup, { strategy: 'absolute', placement: 'bottom-start', middleware: [offset(tokenPixels('--pm-space-1', popup)), flip({ padding, flipAlignment: false }), shift({ padding })] })
93
+ .then(({ x, y }) => {
94
+ if (!current) return
95
+ popup.style.setProperty('--pm-source-popup-x', `${x}px`)
96
+ popup.style.setProperty('--pm-source-popup-y', `${y}px`)
97
+ })
98
+ }
99
+ const stop = autoUpdate(reference, popup, place)
100
+ return () => {
101
+ current = false
102
+ stop()
103
+ popup.style.removeProperty('--pm-source-popup-x')
104
+ popup.style.removeProperty('--pm-source-popup-y')
105
+ }
106
+ }, [open, source])
107
+ }