@lincyaw/workbench 0.1.0 → 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 (147) hide show
  1. package/README.md +40 -8
  2. package/package.json +27 -22
  3. package/src/MediaClipEditor.tsx +122 -0
  4. package/src/StepProductionEditor.tsx +130 -0
  5. package/src/approvals.tsx +60 -0
  6. package/src/assistant-mascot.ts +62 -0
  7. package/src/audio-recording-messages.ts +44 -0
  8. package/src/audio-recording.ts +192 -0
  9. package/src/audio-wav.ts +25 -0
  10. package/src/audio-waveform.ts +110 -0
  11. package/src/audition.ts +84 -0
  12. package/src/authoring-client.ts +136 -0
  13. package/src/authoring-draft-fragment.ts +28 -0
  14. package/src/base-controls.tsx +47 -0
  15. package/src/caption-timing-drafts.ts +61 -0
  16. package/src/caption-timing-messages.ts +14 -0
  17. package/src/caption-timing.css +20 -0
  18. package/src/caption-timing.ts +14 -0
  19. package/src/component-draft-actions.ts +54 -0
  20. package/src/component-draft-inspection.ts +35 -0
  21. package/src/component-properties.css +24 -0
  22. package/src/component-properties.ts +45 -0
  23. package/src/component-property-drafts.ts +273 -0
  24. package/src/component-property-messages.ts +31 -0
  25. package/src/components.tsx +547 -90
  26. package/src/context.tsx +27 -0
  27. package/src/diagnostics.ts +113 -0
  28. package/src/document-draft-store.ts +179 -0
  29. package/src/document-drafts.ts +178 -0
  30. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  31. package/src/document-editor/ComponentMaterials.tsx +62 -0
  32. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  33. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  34. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  35. package/src/document-editor/DocumentComments.tsx +282 -0
  36. package/src/document-editor/DocumentEditor.tsx +192 -0
  37. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  38. package/src/document-editor/NarrationEditor.tsx +110 -0
  39. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  40. package/src/document-editor/authoring.tsx +259 -0
  41. package/src/document-editor/block-playback.ts +90 -0
  42. package/src/document-editor/block-preview-messages.ts +13 -0
  43. package/src/document-editor/blocknote-messages.ts +98 -0
  44. package/src/document-editor/clipboard.ts +40 -0
  45. package/src/document-editor/colors.ts +57 -0
  46. package/src/document-editor/comment-draft-actions.ts +58 -0
  47. package/src/document-editor/comment-draft-inspection.ts +38 -0
  48. package/src/document-editor/comment-drafts.ts +361 -0
  49. package/src/document-editor/comments.ts +410 -0
  50. package/src/document-editor/file-input.ts +71 -0
  51. package/src/document-editor/focus.ts +54 -0
  52. package/src/document-editor/inline.ts +104 -0
  53. package/src/document-editor/math.tsx +63 -0
  54. package/src/document-editor/messages.ts +14 -0
  55. package/src/document-editor/narration-schema.tsx +85 -0
  56. package/src/document-editor/narration.ts +37 -0
  57. package/src/document-editor/popover.tsx +107 -0
  58. package/src/document-editor/projection.ts +423 -0
  59. package/src/document-editor/rich-text.ts +152 -0
  60. package/src/document-editor/schema.tsx +117 -0
  61. package/src/document-editor/selection.ts +152 -0
  62. package/src/document-editor/style.css +9 -0
  63. package/src/document-editor/styles.d.ts +1 -0
  64. package/src/document-editor/table-identities.ts +67 -0
  65. package/src/document-preview.ts +112 -0
  66. package/src/document-return-point.ts +59 -0
  67. package/src/document-session.ts +678 -0
  68. package/src/document-timing-drafts.ts +61 -0
  69. package/src/document-voices.ts +2 -0
  70. package/src/history.ts +167 -0
  71. package/src/index.ts +33 -43
  72. package/src/jobs.ts +111 -0
  73. package/src/mascot-preference.ts +37 -0
  74. package/src/media-drafts.ts +83 -0
  75. package/src/media-messages.ts +53 -0
  76. package/src/media-time.ts +12 -0
  77. package/src/messages.ts +751 -203
  78. package/src/outline.ts +97 -0
  79. package/src/panels/ChangeTrail.tsx +79 -0
  80. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  81. package/src/panels/DocumentView.tsx +456 -44
  82. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  83. package/src/panels/DocumentVoiceField.tsx +35 -0
  84. package/src/panels/ExportPanel.tsx +133 -0
  85. package/src/panels/HistoryPanel.tsx +88 -0
  86. package/src/panels/HomePanel.tsx +184 -0
  87. package/src/panels/PageDeleteDialog.tsx +33 -0
  88. package/src/panels/PageRenameDialog.tsx +27 -0
  89. package/src/panels/PreviewPanel.tsx +79 -0
  90. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  91. package/src/panels/ProductionMediaClips.tsx +156 -0
  92. package/src/panels/ProductionPanel.tsx +272 -0
  93. package/src/panels/ProductionRecording.tsx +107 -0
  94. package/src/panels/ProductionRecordingRange.tsx +118 -0
  95. package/src/panels/ProductionTimeline.tsx +62 -0
  96. package/src/panels/ProductionTranscription.tsx +140 -0
  97. package/src/panels/SourceDocumentPanel.tsx +215 -0
  98. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  99. package/src/panels/TranscriptionAdoption.tsx +147 -0
  100. package/src/panels/VoiceCloneDialog.tsx +152 -0
  101. package/src/panels/VoicePreviewDialog.tsx +74 -0
  102. package/src/panels/VoiceScopeField.tsx +30 -0
  103. package/src/panels/VoiceoverDialog.tsx +216 -0
  104. package/src/places.ts +74 -6
  105. package/src/preview-playback.ts +125 -0
  106. package/src/preview-pool.ts +320 -0
  107. package/src/preview.ts +57 -6
  108. package/src/production-messages.ts +151 -0
  109. package/src/production-playback.ts +109 -0
  110. package/src/production-voice-playback.ts +114 -0
  111. package/src/recording-adoption.ts +33 -0
  112. package/src/schema-form/PmSchemaForm.tsx +187 -0
  113. package/src/schema-form/context.ts +26 -0
  114. package/src/schema-form/fields.tsx +59 -0
  115. package/src/schema-form/templates.tsx +50 -0
  116. package/src/schema-form/validator.ts +36 -0
  117. package/src/schema-form/widgets.tsx +60 -0
  118. package/src/shortcuts.ts +8 -0
  119. package/src/sortable-list.tsx +201 -0
  120. package/src/speech-settings-messages.ts +40 -0
  121. package/src/step-production.css +5 -0
  122. package/src/step-production.ts +51 -0
  123. package/src/styles.css +757 -361
  124. package/src/telemetry.ts +152 -0
  125. package/src/transcription-messages.ts +26 -0
  126. package/src/transcription-session.ts +159 -0
  127. package/src/use-recording-adoption.ts +84 -0
  128. package/src/voice-clone-messages.ts +92 -0
  129. package/src/voice-drafts.ts +89 -0
  130. package/src/voice-messages.ts +146 -0
  131. package/src/voice-snapshot.ts +55 -0
  132. package/src/voiceover-jobs.ts +87 -0
  133. package/src/workbench.ts +2328 -0
  134. package/src/workspace-client.ts +480 -0
  135. package/src/api.ts +0 -126
  136. package/src/editor.ts +0 -223
  137. package/src/merge.ts +0 -30
  138. package/src/panels/Changes.tsx +0 -128
  139. package/src/panels/Files.tsx +0 -73
  140. package/src/panels/Home.tsx +0 -224
  141. package/src/panels/Preview.tsx +0 -68
  142. package/src/panels/Problems.tsx +0 -42
  143. package/src/panels/Source.tsx +0 -118
  144. package/src/panels/Status.tsx +0 -26
  145. package/src/react.tsx +0 -59
  146. package/src/service.ts +0 -82
  147. package/src/store.ts +0 -599
@@ -0,0 +1,209 @@
1
+ import { forwardRef, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
+ import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react'
3
+ import { filterSuggestionItems } from '@blocknote/core'
4
+ import { BlockNoteView, components } from '@blocknote/mantine'
5
+ import { AddCommentButton, BasicTextStyleButton, BlockNoteViewEditor, BlockTypeSelect, ComponentsContext, CreateLinkButton, FormattingToolbar, FormattingToolbarController, getDefaultReactSlashMenuItems, LinkToolbarController, NestBlockButton, RenderInPortalElement, SideMenuController, SuggestionMenuController, TableCellMergeButton, TableHandlesController, TextAlignButton, UnnestBlockButton, useBlockNoteEditor, useCreateBlockNote, useEditorState } from '@blocknote/react'
6
+ import type { ComponentProps, FormattingToolbarProps } from '@blocknote/react'
7
+ import { useMediaQuery } from '@mantine/hooks'
8
+ import type { Locale } from '../messages.ts'
9
+ import { PmIconButton, PmNotice, PmTooltip } from '../components.tsx'
10
+ import { DOCUMENT_NODE_VIEWS, documentEditorSchema } from './schema.tsx'
11
+ import type { DocumentEditorBlock, DocumentEditorInput, DocumentNodeViews, NativeDocumentEditor } from './schema.tsx'
12
+ import { documentSelection } from './selection.ts'
13
+ import type { NativeDocumentSelection } from './selection.ts'
14
+ import { DOCUMENT_EDITOR_MESSAGES, IMAGE_IMPORT_MESSAGES, slashAliases } from './messages.ts'
15
+ import { blockNoteDictionary } from './blocknote-messages.ts'
16
+ import { imageFileInput } from './file-input.ts'
17
+ import type { ImageImportFailure } from './file-input.ts'
18
+ import { nativeTextClipboard } from './clipboard.ts'
19
+ import { documentBlockColors } from './colors.ts'
20
+ import { DOCUMENT_EDITOR_LOCALE } from './math.tsx'
21
+ import { DOCUMENT_LINKS, DocumentColorButton, DocumentLinkToolbar, DocumentSideMenu, DocumentTableCellButton, DocumentTableHandle, FormulaToolbarButton, formulaSlashItems, openDocumentLink } from './authoring.tsx'
22
+ import type { DocumentLinks } from './authoring.tsx'
23
+ import { excludeCommentMarkHistory, nativeComments, nativeCommentSelection } from './comments.ts'
24
+ import type { DocumentThreadStore } from './comments.ts'
25
+ import { DocumentMenuRoot, linkToolbarFloating } from './popover.tsx'
26
+ import { COMMENT_EDITOR_CONTEXT, DocumentCommentEditor, DocumentFloatingThreadController, DocumentThreadsSidebar } from './DocumentComments.tsx'
27
+ import { DocumentNewCommentController, DocumentNewCommentRecovery, NewCommentDraftProvider } from './NewCommentDrafts.tsx'
28
+
29
+ export const DOCUMENT_EDITOR_THEME = {
30
+ colors: {
31
+ editor: { background: 'var(--pm-color-surface)', text: 'var(--pm-color-ink)' },
32
+ menu: { background: 'var(--pm-color-surface)', text: 'var(--pm-color-ink)' },
33
+ tooltip: { background: 'var(--pm-color-surface)', text: 'var(--pm-color-ink)' },
34
+ hovered: { background: 'var(--pm-color-sunk)', text: 'var(--pm-color-ink)' },
35
+ selected: { background: 'var(--pm-color-accent-soft)', text: 'var(--pm-color-accent)' },
36
+ disabled: { background: 'var(--pm-color-sunk)', text: 'var(--pm-color-muted)' },
37
+ border: 'var(--pm-color-line)', shadow: 'var(--pm-color-backdrop)', sideMenu: 'var(--pm-color-muted)',
38
+ },
39
+ fontFamily: 'var(--pm-font-sans)',
40
+ }
41
+
42
+ const MantineComment = components.Comments.Comment as ForwardRefExoticComponent<ComponentProps['Comments']['Comment'] & RefAttributes<HTMLDivElement>>
43
+ const DocumentComment = forwardRef<HTMLDivElement, ComponentProps['Comments']['Comment']>(function DocumentComment(props, ref) {
44
+ const coarse = useMediaQuery('(pointer: coarse)', false, { getInitialValueInEffect: false })
45
+ const className = [props.className, props.actions ? 'pm-ui-comment-actions-row' : undefined].filter(Boolean).join(' ')
46
+ return <MantineComment {...props} ref={ref} className={className} showActions={coarse && props.showActions === 'hover' ? true : props.showActions} />
47
+ })
48
+ const MantineBadge = components.Generic.Badge.Root as ForwardRefExoticComponent<ComponentProps['Generic']['Badge']['Root'] & RefAttributes<HTMLInputElement>>
49
+ const DocumentReactionBadge = forwardRef<HTMLInputElement, ComponentProps['Generic']['Badge']['Root']>(function DocumentReactionBadge(props, ref) {
50
+ if (!props.className?.split(/\s+/).includes('bn-comment-reaction')) return <MantineBadge {...props} ref={ref} />
51
+ const label = [props.mainTooltip, props.secondaryTooltip?.replace(/\s+/g, ' ')].filter(Boolean).join(' · ')
52
+ // Keep the input mounted while tooltip visibility changes; let its label own activation and focus.
53
+ return <PmTooltip label={label}><span><MantineBadge {...props} mainTooltip={undefined} secondaryTooltip={undefined} ref={ref}
54
+ onClick={event => { if (event.target instanceof HTMLInputElement) props.onClick?.(event) }} /></span></PmTooltip>
55
+ })
56
+
57
+ const MantineToolbarButton = components.Generic.Toolbar.Button as ForwardRefExoticComponent<ComponentProps['Generic']['Toolbar']['Button'] & RefAttributes<HTMLButtonElement>>
58
+ const DocumentToolbarButton = forwardRef<HTMLButtonElement, ComponentProps['Generic']['Toolbar']['Button']>(function DocumentToolbarButton(props, ref) {
59
+ const label = props.label ?? props.mainTooltip
60
+ return label === undefined ? <MantineToolbarButton {...props} ref={ref} /> : <MantineToolbarButton {...props} ref={ref} label={label} />
61
+ })
62
+
63
+ const DOCUMENT_EDITOR_COMPONENTS = { ...components, Generic: { ...components.Generic, Menu: { ...components.Generic.Menu, Root: DocumentMenuRoot }, Badge: { ...components.Generic.Badge, Root: DocumentReactionBadge }, Toolbar: { ...components.Generic.Toolbar, Button: DocumentToolbarButton } }, Comments: { ...components.Comments, Comment: DocumentComment, Editor: DocumentCommentEditor } }
64
+
65
+ export interface BlockNoteSurfaceProps {
66
+ identity: string
67
+ /** Explicit replacement of an external source; an own save acknowledgement must not change it. */
68
+ reset?: number
69
+ initialContent: DocumentEditorInput[]
70
+ locale: Locale
71
+ /** The language the document is written in (its `lang`), so the browser shapes its text and punctuation for it. */
72
+ contentLang?: string
73
+ readOnly?: boolean
74
+ resolveAssetURL?(location: string): Promise<string>
75
+ uploadImage?(file: File, signal: AbortSignal): Promise<string>
76
+ nodeViews?: DocumentNodeViews
77
+ onChange(blocks: DocumentEditorBlock[], editor: NativeDocumentEditor): void
78
+ onSelection?(selection: NativeDocumentSelection | undefined): void
79
+ comments?: DocumentThreadStore
80
+ commentsPortal?: HTMLElement | null
81
+ getPositionToken?(): string | undefined
82
+ onCommentDraftRestore?(): void
83
+ onReady?(editor: NativeDocumentEditor | undefined): void
84
+ onComposition?(composing: boolean): void
85
+ /** Shows a page of this document from an in-document link; false when none matches. */
86
+ onSectionLink?(sectionId: string): boolean
87
+ children?: ReactNode
88
+ }
89
+
90
+ function EditorToolbar(props: FormattingToolbarProps) {
91
+ const editor = useBlockNoteEditor(documentEditorSchema)
92
+ const title = useEditorState({ editor, selector: ({ editor }) => editor.getSelection()?.blocks.some(block => block.type === 'presmiumPage') ?? false })
93
+ if (title) return <FormattingToolbar {...props}><AddCommentButton /></FormattingToolbar>
94
+ return <FormattingToolbar {...props}>
95
+ <BlockTypeSelect items={props.blockTypeSelectItems} />
96
+ <BasicTextStyleButton basicTextStyle="bold" /><BasicTextStyleButton basicTextStyle="italic" />
97
+ <BasicTextStyleButton basicTextStyle="underline" /><BasicTextStyleButton basicTextStyle="strike" /><BasicTextStyleButton basicTextStyle="code" />
98
+ <TextAlignButton textAlignment="left" /><TextAlignButton textAlignment="center" /><TextAlignButton textAlignment="right" />
99
+ <DocumentColorButton /><NestBlockButton /><UnnestBlockButton /><CreateLinkButton /><FormulaToolbarButton />
100
+ <TableCellMergeButton />
101
+ <AddCommentButton />
102
+ </FormattingToolbar>
103
+ }
104
+
105
+ function PlainContentComments({ editor }: { editor: NativeDocumentEditor }) {
106
+ const show = useEditorState({ editor, selector: ({ editor }) => {
107
+ if (editor.prosemirrorState.selection.empty) return false
108
+ let plain = false
109
+ editor.prosemirrorState.selection.content().content.descendants(node => { if (node.type.spec.content === 'text*') plain = true; return !plain })
110
+ return plain
111
+ } })
112
+ // BlockNote hides formatting controls over code; its own comment action still applies.
113
+ return show && editor.getExtension('comments') ? <div className="pm-document-editor__comment-tools"><FormattingToolbar><AddCommentButton /></FormattingToolbar></div> : null
114
+ }
115
+
116
+ // Formulas join the group that holds tables so the menu keeps one heading per group.
117
+ function withFormulas<Item extends { group?: string }>(items: Item[], formulas: Item[]): Item[] {
118
+ const index = items.findLastIndex(item => item.group === formulas[0]?.group)
119
+ return index < 0 ? [...items, ...formulas] : [...items.slice(0, index + 1), ...formulas, ...items.slice(index + 1)]
120
+ }
121
+
122
+ /** One native editor owns selection, transactions and history across the complete document. */
123
+ export function BlockNoteSurface(props: BlockNoteSurfaceProps) {
124
+ const current = useRef(props)
125
+ const uploads = useMemo(() => ({ controllers: new Set<AbortController>(), pending: Promise.resolve() }), [props.identity, props.reset ?? 0])
126
+ const [fileErrors, setFileErrors] = useState<{ identity: string, failures: ImageImportFailure[] }>()
127
+ current.current = props
128
+ const dictionary = blockNoteDictionary(props.locale)
129
+ const t = DOCUMENT_EDITOR_MESSAGES[props.locale]
130
+ const linkToolbarOptions = useMemo(linkToolbarFloating, [])
131
+ const links = useMemo<DocumentLinks>(() => ({ openSection: sectionId => current.current.onSectionLink?.(sectionId) ?? false }), [])
132
+ const fileMessages = IMAGE_IMPORT_MESSAGES[props.locale]
133
+ const editor = useCreateBlockNote({ schema: documentEditorSchema, initialContent: props.initialContent,
134
+ resolveFileUrl: location => current.current.resolveAssetURL?.(location) ?? Promise.resolve(location),
135
+ uploadFile: props.uploadImage ? file => {
136
+ const controller = new AbortController()
137
+ const uploadImage = current.current.uploadImage!
138
+ uploads.controllers.add(controller)
139
+ const request = uploads.pending.then(async () => {
140
+ controller.signal.throwIfAborted()
141
+ if (current.current.identity !== props.identity) throw new DOMException('Document changed', 'AbortError')
142
+ return uploadImage(file, controller.signal)
143
+ })
144
+ uploads.pending = request.then(() => {}, () => {})
145
+ return request.finally(() => { uploads.controllers.delete(controller) })
146
+ } : undefined,
147
+ disableExtensions: ['dropFile'],
148
+ pasteHandler: ({ event, editor, defaultPasteHandler }) => {
149
+ const files = [...event.clipboardData?.files ?? []]
150
+ if (files.length && !event.clipboardData?.getData('text/html')) {
151
+ editor.getExtension(imageFileInput)?.insert(files)
152
+ return true
153
+ }
154
+ return defaultPasteHandler({ prioritizeMarkdownOverHTML: false })
155
+ },
156
+ dictionary, tables: { splitCells: true, cellBackgroundColor: true, cellTextColor: true, headers: true },
157
+ // The surface's own click handler opens links in editable and read-only states alike.
158
+ links: { onClick: () => true },
159
+ extensions: [nativeTextClipboard, documentBlockColors, imageFileInput({
160
+ onStart: () => { if (current.current.identity === props.identity) setFileErrors(undefined) },
161
+ onFailure: failure => { if (current.current.identity === props.identity) setFileErrors(value => ({ identity: props.identity, failures: [...(value?.identity === props.identity ? value.failures : []), failure] })) },
162
+ }), ...props.comments ? [nativeCommentSelection(), nativeComments(props.comments, t.author)] : []],
163
+ domAttributes: { block: { 'data-document-editor-block': '' }, editor: { lang: props.contentLang ?? props.locale } },
164
+ }, [props.identity, props.reset ?? 0])
165
+ useEffect(() => {
166
+ const stopCommentHistory = current.current.comments ? excludeCommentMarkHistory(editor) : undefined
167
+ current.current.onReady?.(editor)
168
+ return () => {
169
+ for (const controller of uploads.controllers) controller.abort()
170
+ uploads.controllers.clear()
171
+ stopCommentHistory?.(); current.current.onReady?.(undefined)
172
+ }
173
+ }, [editor])
174
+ return <DOCUMENT_EDITOR_LOCALE.Provider value={props.locale}><DOCUMENT_LINKS.Provider value={links}><DOCUMENT_NODE_VIEWS.Provider value={props.nodeViews ?? {}}><COMMENT_EDITOR_CONTEXT.Provider value={props.comments ? { store: props.comments, locale: props.locale, sidebar: Boolean(props.commentsPortal), getPositionToken: () => current.current.getPositionToken?.(), onDraftRestore: () => current.current.onCommentDraftRestore?.() } : undefined}>
175
+ <div className="pm-document-editor pm-ui-block-editor pm-ui" data-document-editor={props.identity}
176
+ onDragEnter={event => { if (event.dataTransfer.types.includes('Files')) event.stopPropagation() }}
177
+ onDragOver={event => { if (event.dataTransfer.types.includes('Files')) event.stopPropagation() }}
178
+ onClick={event => {
179
+ const link = event.target instanceof Element ? event.target.closest('a[data-inline-content-type="link"]') : null
180
+ const href = link?.getAttribute('href')
181
+ if (!href || event.button !== 0) return
182
+ event.preventDefault()
183
+ openDocumentLink(href, links)
184
+ }}
185
+ onCompositionStart={() => current.current.onComposition?.(true)}
186
+ onCompositionEnd={() => queueMicrotask(() => current.current.onComposition?.(false))}>
187
+ {fileErrors?.identity === props.identity && <PmNotice tone="danger"><div className="pm-document-editor__file-errors"><ul>{fileErrors.failures.map((failure, index) => <li key={index}>{failure.name} · {fileMessages[failure.kind]}</li>)}</ul><PmIconButton small icon="close" label={fileMessages.dismiss} onClick={() => setFileErrors(undefined)} /></div></PmNotice>}
188
+ <BlockNoteView editor={editor} editable={!props.readOnly} theme={DOCUMENT_EDITOR_THEME} formattingToolbar={false} slashMenu={false} linkToolbar={false} sideMenu={false} tableHandles={false} renderEditor={false} comments={false}
189
+ onChange={() => current.current.onChange(editor.document, editor)}
190
+ onSelectionChange={() => current.current.onSelection?.(documentSelection(editor))}>
191
+ <ComponentsContext.Provider value={DOCUMENT_EDITOR_COMPONENTS}>
192
+ <PlainContentComments editor={editor} />
193
+ <BlockNoteViewEditor><NewCommentDraftProvider>
194
+ <DocumentNewCommentController />{!props.commentsPortal && <DocumentFloatingThreadController />}{props.commentsPortal && <><RenderInPortalElement target={props.commentsPortal}><DocumentThreadsSidebar><DocumentNewCommentRecovery /></DocumentThreadsSidebar></RenderInPortalElement></>}
195
+ <FormattingToolbarController formattingToolbar={EditorToolbar} />
196
+ <LinkToolbarController linkToolbar={DocumentLinkToolbar} floatingUIOptions={linkToolbarOptions} />
197
+ <SideMenuController sideMenu={DocumentSideMenu} />
198
+ <TableHandlesController tableHandle={DocumentTableHandle} tableCellHandle={DocumentTableCellButton} />
199
+ <SuggestionMenuController triggerCharacter="/" getItems={async query => editor.getTextCursorPosition().block.type === 'presmiumPage' ? [] : filterSuggestionItems([...withFormulas(getDefaultReactSlashMenuItems(editor), formulaSlashItems(editor, props.locale, dictionary.slash_menu.table.group)), {
200
+ title: t.page, aliases: slashAliases('pageAliases'), group: t.document,
201
+ onItemClick: () => { const current = editor.getTextCursorPosition().block; editor.insertBlocks([{ type: 'presmiumPage', props: { sectionId: crypto.randomUUID() }, content: t.untitledPage }], current, 'after') },
202
+ }], query)} />
203
+ {props.children}
204
+ </NewCommentDraftProvider></BlockNoteViewEditor>
205
+ </ComponentsContext.Provider>
206
+ </BlockNoteView>
207
+ </div>
208
+ </COMMENT_EDITOR_CONTEXT.Provider></DOCUMENT_NODE_VIEWS.Provider></DOCUMENT_LINKS.Provider></DOCUMENT_EDITOR_LOCALE.Provider>
209
+ }
@@ -0,0 +1,62 @@
1
+ import { useMemo } from 'react'
2
+ import { applyDocumentComponentAction } from '@lincyaw/kernel'
3
+ import type { Definition, TeachingDocument } from '@lincyaw/kernel'
4
+ import { PmButton, PmMenu, PmSearchList } from '../components.tsx'
5
+ import type { ComponentPropertyMessages } from '../component-property-messages.ts'
6
+
7
+ export function componentDefinitionLabel(definition: Definition): string {
8
+ const title = typeof definition.parameterSchema === 'object' && typeof definition.parameterSchema.title === 'string' ? definition.parameterSchema.title.trim() : ''
9
+ if (title) return Array.from(title).slice(0, 80).join('')
10
+ const implementation = definition.implementation
11
+ const name = implementation.type === 'file' ? implementation.path.split('/').at(-1)?.replace(/\.(tsx?|jsx?|mjs|cjs)$/, '')
12
+ : implementation.type === 'plugin' ? implementation.name : implementation.specifier.split('/').at(-1)
13
+ return Array.from(name || definition.kind).slice(0, 80).join('')
14
+ }
15
+
16
+ /** Selection is a proposal; only the caller's source-bound Apply changes a binding. */
17
+ export function ComponentMaterials({ document, blockId, selected, onSelect, onLocate, disabled, messages: t }: {
18
+ document: TeachingDocument, blockId: string, selected: string, onSelect(id: string): void,
19
+ onLocate(blockId: string): void, disabled: boolean, messages: ComponentPropertyMessages,
20
+ }) {
21
+ const current = document.blocks[blockId]
22
+ const choices = useMemo(() => {
23
+ const definitions = Object.values(document.definitions), names = definitions.map(componentDefinitionLabel)
24
+ const counts = new Map<string, number>()
25
+ for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1)
26
+ return definitions.map((definition, index) => {
27
+ let reason: string | undefined
28
+ if (definition.implementation.type === 'module') reason = t.module
29
+ else {
30
+ try { applyDocumentComponentAction(document, { type: 'component.definition.use', blockId, definitionId: definition.id }) }
31
+ catch { reason = t.incompatible }
32
+ }
33
+ const name = names[index]!, duplicate = counts.get(name)! > 1
34
+ return { value: definition.id, label: duplicate ? `${name} · ${index + 1}` : name, keywords: [definition.kind, definition.id, name], disabled: Boolean(reason),
35
+ description: reason ?? (current?.type === 'component' && current.definitionId === definition.id ? t.current : undefined) }
36
+ })
37
+ }, [document, blockId, current, t])
38
+ const references = useMemo(() => {
39
+ const pages = new Map<string, string>()
40
+ const visit = (id: string, page: string) => {
41
+ if (pages.has(id)) return
42
+ pages.set(id, page)
43
+ const block = document.blocks[id]
44
+ if (block?.type === 'component' || block?.type === 'container') for (const child of block.children ?? []) visit(child, page)
45
+ }
46
+ for (const id of document.sectionOrder) for (const child of document.sections[id]!.children) visit(child, document.sections[id]!.title)
47
+ return Object.values(document.blocks).filter(block => block.type === 'component' && block.definitionId === selected)
48
+ .map(block => ({ id: block.id, page: pages.get(block.id) ?? '' }))
49
+ }, [document, selected])
50
+ return <div className="component-properties__content">
51
+ <PmSearchList label={t.definitions} searchLabel={t.search} items={choices} value={selected} onValueChange={onSelect}
52
+ resultsLabel={t.results} emptyLabel={t.noDefinitions} loadingLabel={t.loading} disabled={disabled} />
53
+ <div className="component-properties__actions"><PmMenu label={t.actions} description={references.length ? `${t.copyUnavailable} ${t.inUse}` : t.unavailable}
54
+ items={[{ id: 'copy', label: t.copy, disabled: true }, { id: 'delete', label: t.remove, disabled: true, danger: true }]} onSelect={() => {}} /></div>
55
+ <div className="component-properties__references" role="group" aria-label={t.references}>
56
+ <span>{t.references}</span>
57
+ {references.map((reference, index) => <div className="component-properties__reference" key={reference.id}>
58
+ <span>{reference.page} · {index + 1}</span><PmButton small disabled={disabled} onClick={() => onLocate(reference.id)}>{t.locate}</PmButton>
59
+ </div>)}
60
+ </div>
61
+ </div>
62
+ }
@@ -0,0 +1,282 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
2
+ import { applyDocumentComponentAction, effectiveComponentParameters, isRecord, validateDocumentParameters } from '@lincyaw/kernel'
3
+ import type { CanonicalSource, DocumentComponentAction, DocumentParameterValidation, JsonObject, TeachingDocument } from '@lincyaw/kernel'
4
+ import { PmButton, PmDialog, PmNotice, PmSelect } from '../components.tsx'
5
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
6
+ import { isAgentChanging } from '../workbench.ts'
7
+ import { COMPONENT_PROPERTY_MESSAGES } from '../component-property-messages.ts'
8
+ import type { ComponentPropertyMessages } from '../component-property-messages.ts'
9
+ import { componentPropertyAction, componentSource, rebaseComponentPropertyDraft, sameComponentSource } from '../component-properties.ts'
10
+ import type { ComponentPropertyBackup, ComponentPropertyBackupRead, ComponentPropertyDraft, ComponentPropertyTarget } from '../component-property-drafts.ts'
11
+ import { PmSchemaForm, schemaFormPropertyNames } from '../schema-form/PmSchemaForm.tsx'
12
+ import { ComponentMaterials, componentDefinitionLabel } from './ComponentMaterials.tsx'
13
+
14
+ const subscribeEmpty = () => () => {}, snapshotEmpty = () => 0
15
+ const targetKey = (target: ComponentPropertyTarget) => JSON.stringify([target.identity, target.memberId, target.blockId, target.stepId ?? null])
16
+ function fresh(document: TeachingDocument, source: CanonicalSource, target: ComponentPropertyTarget): ComponentPropertyDraft {
17
+ const block = document.blocks[target.blockId]
18
+ if (block?.type !== 'component') throw new Error('component-block-missing')
19
+ const value = effectiveComponentParameters(document, block.id, target.stepId).parameters
20
+ return { format: 1, id: crypto.randomUUID(), revision: 0, target, source, definitionId: block.definitionId, baseline: value, value,
21
+ composing: false, reset: false, updatedAt: new Date().toISOString() }
22
+ }
23
+ function message(error: unknown, t: ComponentPropertyMessages): string {
24
+ const code = error instanceof Error ? error.message : ''
25
+ if (code.includes('definition-changed')) return t.definitionChanged
26
+ if (/source|context|version|identity/.test(code)) return t.changed
27
+ if (/permission|forbidden|review-required/.test(code)) return t.denied
28
+ if (/draft-limit/.test(code)) return t.limit
29
+ if (/missing|owner/.test(code)) return t.missing
30
+ if (/parameter.*invalid|schema|readonly/.test(code)) return t.invalid
31
+ return t.failed
32
+ }
33
+ interface Applied { commitId?: string, source: CanonicalSource }
34
+ interface Intent { draft?: ComponentPropertyDraft, signature: string, id: string }
35
+
36
+ /** One source-bound form; the existing draft bank and canonical actor own every retained value. */
37
+ export function ComponentPropertiesDialog({ identity, blockId, stepId, onClose }: {
38
+ identity: string, blockId: string, stepId?: string, onClose(): void,
39
+ }) {
40
+ const workbench = useWorkbench(), state = useWorkbenchState(value => value), locale = state.locale, t = COMPONENT_PROPERTY_MESSAGES[locale]
41
+ const source = state.authoring?.identity === identity ? state.authoring : undefined
42
+ const document = source?.content, block = document?.blocks[blockId], definition = block?.type === 'component' ? document?.definitions[block.definitionId] : undefined
43
+ const bank = useMemo(() => workbench.documentPropertyDrafts(identity), [workbench, identity])
44
+ const revision = useSyncExternalStore(bank?.subscribe ?? subscribeEmpty, bank?.snapshot ?? snapshotEmpty)
45
+ const [scope, setScope] = useState<'base' | 'step'>('base'), [page, setPage] = useState<'parameters' | 'materials'>('parameters')
46
+ const target = useMemo<ComponentPropertyTarget>(() => ({ identity, memberId: source?.member?.id ?? '', blockId, ...scope === 'step' && stepId ? { stepId } : {} }), [identity, source?.member?.id, blockId, scope, stepId])
47
+ const key = targetKey(target)
48
+ const [ready, setReady] = useState(false), [openAttempt, setOpenAttempt] = useState(0), [initial, setInitial] = useState<{ key: string, draft: ComponentPropertyDraft }>()
49
+ const [error, setError] = useState<string>(), [busy, setBusy] = useState(false), [validation, setValidation] = useState<DocumentParameterValidation>()
50
+ const [applied, setApplied] = useState<Applied>(), [backups, setBackups] = useState<ComponentPropertyBackupRead>(), [backupError, setBackupError] = useState(false)
51
+ const [backupIndex, setBackupIndex] = useState(0), [backupRead, setBackupRead] = useState(0), [backupBusy, setBackupBusy] = useState(false)
52
+ const [material, setMaterial] = useState<{ definitionId: string, source: CanonicalSource }>()
53
+ const flight = useRef<AbortController>(), backupFlight = useRef<AbortController>(), intent = useRef<Intent>(), mounted = useRef(true), locateOnClose = useRef(false)
54
+ const ownedInput = useRef(new Map<string, ComponentPropertyDraft>())
55
+ const current = useRef({ key, source, target, scope, page, busy })
56
+ current.current = { key, source, target, scope, page, busy }
57
+ const close = () => { flight.current?.abort(); backupFlight.current?.abort(); onClose() }
58
+ useEffect(() => () => backupFlight.current?.abort(), [key])
59
+ useEffect(() => {
60
+ mounted.current = true
61
+ let active = true
62
+ if (workbench.getState().authoring?.identity !== identity) { setError(t.changed); return () => { active = false; mounted.current = false; flight.current?.abort() } }
63
+ void workbench.flushPendingEdits().then(() => {
64
+ if (!active) return
65
+ const source = workbench.getState().authoring
66
+ if (source?.identity !== identity || !source.content || !source.version || !source.fingerprint || source.dirty || source.saving || source.composing || source.error || source.restored) { setError(t.changed); return }
67
+ setReady(true)
68
+ }, () => { if (active) setError(t.failed) })
69
+ return () => { active = false; mounted.current = false; flight.current?.abort() }
70
+ }, [workbench, identity, openAttempt])
71
+ useEffect(() => {
72
+ if (!ready || !bank || !document || !source?.version || !source.fingerprint || block?.type !== 'component' || !target.memberId || busy) return
73
+ if (scope === 'step' && !stepId) return
74
+ if (initial?.key === key && (bank.get(target) || sameComponentSource(initial.draft.source, componentSource({ content: document, version: source.version, fingerprint: source.fingerprint })))) return
75
+ try { setInitial({ key, draft: fresh(document, componentSource({ content: document, version: source.version, fingerprint: source.fingerprint }), target) }) }
76
+ catch (error) { setError(message(error, t)) }
77
+ }, [ready, bank, key, revision, source?.version, source?.fingerprint, busy, initial])
78
+ const draft = bank?.get(target) ?? (initial?.key === key ? initial.draft : undefined)
79
+ const stored = bank?.get(target)
80
+ const editable = workbench.client.capabilities?.readOnly !== true && Boolean(source?.member?.roles.some(role => role === 'editor' || role === 'owner'))
81
+ const clean = Boolean(source?.version && source.fingerprint && !source.loading && !source.dirty && !source.saving && !source.composing && !source.restored && !source.recovering && !source.error
82
+ && editable && !state.isHistoryBusy && !state.isHistoryPreparing && !isAgentChanging(state))
83
+ const canonical = document && source?.version && source.fingerprint ? componentSource({ content: document, version: source.version, fingerprint: source.fingerprint }) : undefined
84
+ const stale = Boolean(draft && (!canonical || !sameComponentSource(draft.source, canonical) || block?.type !== 'component' || block.definitionId !== draft.definitionId))
85
+ const preview = useMemo(() => {
86
+ if (!document || !draft || stale) return {}
87
+ try { const action = componentPropertyAction(draft); return action ? { action, effect: applyDocumentComponentAction(document, action).effect } : {} }
88
+ catch (error) { return { error: message(error, t) } }
89
+ }, [document, draft, stale, t])
90
+ const keep = useCallback((value: JsonObject, composing?: boolean, reset = false) => {
91
+ const latest = current.current
92
+ if (!bank || !draft || latest.key !== key) return
93
+ const record = bank.get(target) ?? draft
94
+ try {
95
+ const activeComposition = composing ?? record.composing
96
+ const saved = bank.keep(target, { source: record.source, definitionId: record.definitionId, baseline: record.baseline, value, composing: activeComposition, reset })
97
+ ownedInput.current.set(key, saved)
98
+ const source = latest.source, block = source?.content?.blocks[target.blockId]
99
+ // An unchanged idle field has no pending intent. During a write, even a return
100
+ // to the old baseline remains later input and must survive its acknowledgement.
101
+ if (!flight.current && !latest.busy && !activeComposition && !reset && source?.content && source.version && source.fingerprint && block?.type === 'component'
102
+ && block.definitionId === saved.definitionId && sameComponentSource(saved.source, componentSource({ content: source.content, version: source.version, fingerprint: source.fingerprint }))
103
+ && !componentPropertyAction(saved)) void bank.clear(saved)
104
+ setError(undefined)
105
+ }
106
+ catch (error) { setError(message(error, t)) }
107
+ }, [bank, draft, key, target, t])
108
+ const composition = (composing: boolean) => {
109
+ const record = bank?.get(target) ?? draft
110
+ if (!bank || !record) return
111
+ if (current.current.key === key) { keep(record.value, composing, record.reset); return }
112
+ // A field leaving its scope may end only the preedit revision it still owns.
113
+ if (!composing && record.composing && ownedInput.current.get(key) === record && bank.canKeep(target)) {
114
+ const saved = bank.keep(target, { source: record.source, definitionId: record.definitionId, baseline: record.baseline, value: record.value, reset: record.reset, composing: false })
115
+ ownedInput.current.set(key, saved)
116
+ }
117
+ }
118
+ useEffect(() => {
119
+ if (!ready || !bank || !target.memberId) return
120
+ const controller = new AbortController(), expected = key
121
+ setBackups(undefined); setBackupError(false); setBackupIndex(0)
122
+ void bank.read(target, controller.signal).then(value => { if (mounted.current && current.current.key === expected) setBackups(value) }, () => {
123
+ if (!controller.signal.aborted && mounted.current && current.current.key === expected) setBackupError(true)
124
+ })
125
+ return () => controller.abort()
126
+ }, [ready, bank, key, backupRead])
127
+ const recheck = () => {
128
+ if (!draft || !document || !canonical || !bank || !clean) return
129
+ try {
130
+ const value = rebaseComponentPropertyDraft(document, draft), baseline = effectiveComponentParameters(document, blockId, target.stepId).parameters
131
+ bank.keep(target, { source: canonical, definitionId: draft.definitionId, baseline, value, reset: draft.reset, composing: false })
132
+ setError(undefined); intent.current = undefined
133
+ } catch (error) { setError(message(error, t)) }
134
+ }
135
+ const restore = async (backup: ComponentPropertyBackup) => {
136
+ if (!bank || !draft || backupBusy || bank.status(target) !== 'available' || draft.composing) return
137
+ const expected = key, previous = bank.get(target), controller = new AbortController()
138
+ backupFlight.current = controller
139
+ setBackupBusy(true)
140
+ try {
141
+ const answer = await bank.read(target, controller.signal)
142
+ if (!mounted.current || current.current.key !== expected) return
143
+ if (bank.get(target) !== previous || !answer.candidates.some(value => value.key === backup.key && value.token === backup.token)) { setError(t.backupChanged); setBackupRead(value => value + 1); return }
144
+ const recovered = await bank.recover(backup, previous, controller.signal)
145
+ if (!mounted.current || current.current.key !== expected) return
146
+ if (!recovered) setError(t.backupChanged)
147
+ else setError(undefined)
148
+ setBackupRead(value => value + 1)
149
+ } catch { if (!controller.signal.aborted && mounted.current && current.current.key === expected) setBackupError(true) }
150
+ finally { if (backupFlight.current === controller) backupFlight.current = undefined; if (mounted.current) setBackupBusy(false) }
151
+ }
152
+ const discardBackup = async (backup: ComponentPropertyBackup) => {
153
+ if (!bank || backupBusy) return
154
+ const expected = key, controller = new AbortController(); backupFlight.current = controller; setBackupBusy(true)
155
+ try { const done = await bank.discard(backup, controller.signal); if (mounted.current && current.current.key === expected) { if (!done) setError(t.backupChanged); setBackupRead(value => value + 1) } }
156
+ catch { if (!controller.signal.aborted && mounted.current && current.current.key === expected) setBackupError(true) }
157
+ finally { if (backupFlight.current === controller) backupFlight.current = undefined; if (mounted.current) setBackupBusy(false) }
158
+ }
159
+ const discardLocal = async () => {
160
+ if (!bank || !stored || busy) return
161
+ const expected = key
162
+ if (await bank.clear(stored) && mounted.current && current.current.key === expected) { setInitial(undefined); setError(undefined) }
163
+ }
164
+ const submit = async (action: DocumentComponentAction, submitted?: ComponentPropertyDraft) => {
165
+ if (!canonical || !source?.version || !clean || current.current.busy) return
166
+ const expected = { identity, version: source.version, fingerprint: source.fingerprint! }, signature = JSON.stringify([canonical, action])
167
+ if (!intent.current || intent.current.signature !== signature || intent.current.draft !== submitted) intent.current = { signature, draft: submitted, id: crypto.randomUUID() }
168
+ const captured = intent.current, controller = new AbortController(), submittedKey = key
169
+ flight.current = controller; current.current.busy = true; setBusy(true); setError(undefined)
170
+ try {
171
+ const plan = await workbench.planDocumentComponentAction(action, captured.id, expected, controller.signal)
172
+ if (submitted && bank?.get(submitted.target) !== submitted) return
173
+ const result = await workbench.applyDocumentComponentPlan(plan, expected, controller.signal)
174
+ if (submitted) await bank?.clear(submitted)
175
+ if (!mounted.current || current.current.key !== submittedKey) return
176
+ setApplied({ commitId: result.commitId, source: componentSource(result.answer) })
177
+ if (current.current.page === 'materials' && action.type === 'component.definition.use') setMaterial({ definitionId: action.definitionId, source: componentSource(result.answer) })
178
+ intent.current = undefined
179
+ } catch (error) {
180
+ if (mounted.current && current.current.key === submittedKey) setError(message(error, t))
181
+ } finally { if (flight.current === controller) flight.current = undefined; current.current.busy = false; if (mounted.current) setBusy(false) }
182
+ }
183
+ const undo = async () => {
184
+ if (!applied?.commitId || !canonical || !sameComponentSource(applied.source, canonical) || !clean || current.current.busy || bank?.has(identity, target.memberId)) return
185
+ const expected = key; current.current.busy = true; setBusy(true); setError(undefined)
186
+ try { if (!await workbench.revertRevision(applied.commitId)) { if (mounted.current && current.current.key === expected) setError(t.failed); return }; if (mounted.current && current.current.key === expected) { setApplied(undefined); setInitial(undefined) } }
187
+ catch (error) { if (mounted.current && current.current.key === expected) setError(message(error, t)) }
188
+ finally { current.current.busy = false; if (mounted.current) setBusy(false) }
189
+ }
190
+ const retrySource = async () => {
191
+ if (current.current.busy || workbench.getState().authoring?.identity !== identity) return
192
+ const expected = key; current.current.busy = true; setBusy(true); setError(undefined)
193
+ try { await workbench.retryDocument(); if (mounted.current && current.current.key === expected && !ready) setOpenAttempt(value => value + 1) }
194
+ catch (error) { if (mounted.current && current.current.key === expected) setError(message(error, t)) }
195
+ finally { current.current.busy = false; if (mounted.current) setBusy(false) }
196
+ }
197
+ const enterMaterials = () => {
198
+ if (!canonical || block?.type !== 'component') return
199
+ setMaterial(before => before ?? { definitionId: block.definitionId, source: canonical }); setPage('materials'); setError(undefined)
200
+ }
201
+ const materialAction: DocumentComponentAction | undefined = material && block?.type === 'component' && material.definitionId !== block.definitionId
202
+ ? { type: 'component.definition.use', blockId, definitionId: material.definitionId } : undefined
203
+ let materialValid = false
204
+ if (document && materialAction && document.definitions[materialAction.definitionId]?.implementation.type !== 'module') {
205
+ try { materialValid = applyDocumentComponentAction(document, materialAction).effect.changed } catch { /* Disabled choice carries the compatibility reason. */ }
206
+ }
207
+ const materialStale = Boolean(material && (!canonical || !sameComponentSource(material.source, canonical)))
208
+ const canUndo = Boolean(applied?.commitId && canonical && sameComponentSource(applied.source, canonical) && clean && !busy && !materialAction && !bank?.has(identity, target.memberId))
209
+ const canApply = Boolean(!busy && clean && ready && (page === 'materials' ? materialAction && materialValid && !materialStale : preview.action && preview.effect?.changed && !stale && !draft?.composing && validation?.valid))
210
+ const selectedBackup = backups?.candidates[backupIndex]
211
+ let inherited: ReturnType<typeof effectiveComponentParameters> | undefined
212
+ try { if (document && stepId) inherited = effectiveComponentParameters(document, blockId, stepId) } catch { /* The missing source is reported below. */ }
213
+ const stateSchema = scope === 'step' ? definition?.states.find(item => item.id === inherited?.visual?.state)?.parameterSchema : undefined
214
+ const baseNames = useMemo(() => definition && draft ? schemaFormPropertyNames(definition.parameterSchema, draft.value) : undefined, [definition, draft?.value])
215
+ const extraValidation = useCallback((value: Parameters<typeof validateDocumentParameters>[1]) => {
216
+ return stateSchema !== undefined ? validateDocumentParameters(stateSchema, value) : { available: true, valid: true, issues: [] }
217
+ }, [stateSchema])
218
+ const name = definition ? componentDefinitionLabel(definition) : t.properties
219
+ return <PmDialog open label={page === 'materials' ? t.materials : name} closeLabel={applied ? t.done : t.cancel} onClose={close}
220
+ onCloseAutoFocus={event => { if (locateOnClose.current) event.preventDefault() }}
221
+ footer={<>{page === 'materials' && <PmButton onClick={() => setPage('parameters')}>{t.back}</PmButton>}
222
+ {applied?.commitId && <PmButton disabled={!canUndo} onClick={() => void undo()}>{t.undo}</PmButton>}
223
+ <PmButton onClick={close}>{applied ? t.done : t.cancel}</PmButton>
224
+ <PmButton variant="primary" loading={busy} disabled={!canApply} onClick={() => {
225
+ const action = page === 'materials' ? materialAction : preview.action
226
+ if (action) void submit(action, page === 'parameters' ? stored : undefined)
227
+ }}>{page === 'materials' ? t.adopt : t.apply}</PmButton></>}>
228
+ <div className="component-properties__content">
229
+ {error && !((stale || materialStale || source?.error) && error === t.changed) && <PmNotice tone="danger">{error}</PmNotice>}
230
+ {!ready && error && <PmButton disabled={busy || !source} onClick={() => void retrySource()}>{t.retry}</PmButton>}
231
+ {!ready && !error && <PmNotice>{t.loading}</PmNotice>}
232
+ {ready && source?.error && <PmNotice tone="warn">{t.changed}<PmButton small disabled={busy} onClick={() => void retrySource()}>{t.retry}</PmButton></PmNotice>}
233
+ {ready && (!document || block?.type !== 'component' || !definition) && <PmNotice tone="warn">{t.missing}</PmNotice>}
234
+ {ready && document && definition && block?.type === 'component' && page === 'materials' && <>
235
+ {materialStale && <PmNotice tone="warn">{t.changed}<PmButton small disabled={!clean || busy} onClick={() => { if (canonical) { setMaterial(value => value && { ...value, source: canonical }); setError(undefined) } }}>{t.recheck}</PmButton></PmNotice>}
236
+ <ComponentMaterials document={document} blockId={blockId} selected={material?.definitionId ?? block.definitionId} disabled={busy}
237
+ messages={t} onSelect={definitionId => setMaterial(value => ({ definitionId, source: value?.source ?? canonical! }))}
238
+ onLocate={id => { locateOnClose.current = true; close(); workbench.revealDocumentComponent(id, identity) }} />
239
+ </>}
240
+ {ready && document && definition && draft && page === 'parameters' && <>
241
+ <PmSelect label={t.scope} value={scope} onChange={event => { setScope(event.currentTarget.value as 'base' | 'step'); setError(undefined); setValidation(undefined) }}>
242
+ <option value="base">{t.base}</option><option value="step" disabled={!stepId}>{t.step}</option>
243
+ </PmSelect>
244
+ {stale && <PmNotice tone="warn">{t.changed}<PmButton small disabled={!clean || busy} onClick={recheck}>{t.recheck}</PmButton></PmNotice>}
245
+ {stored && (stale || error) && <PmButton small variant="ghost" disabled={busy} onClick={() => void discardLocal()}>{t.discardChanges}</PmButton>}
246
+ {preview.effect && <span>{t.impact(preview.effect.affected.steps.length)}{scope === 'base' ? ` · ${t.preserved}` : ''}</span>}
247
+ {preview.error && <PmNotice tone="danger">{preview.error}</PmNotice>}
248
+ {bank?.status(target) === 'session-only' && <PmNotice tone="warn">{t.storage}</PmNotice>}
249
+ {!bank?.canKeep(target) && <PmNotice tone="warn">{t.limit}</PmNotice>}
250
+ {draft.reset && <PmNotice>{t.resetPending}</PmNotice>}
251
+ <PmSchemaForm key={key} schema={definition.parameterSchema} value={draft.value} locale={locale} label={t.properties} empty={stateSchema !== undefined ? null : t.empty}
252
+ accessorSchemas={stateSchema !== undefined ? [stateSchema] : undefined}
253
+ readOnly={!editable || draft.reset} disabled={!bank?.canKeep(target)} extraValidation={scope === 'step' ? extraValidation : undefined}
254
+ onValidationChange={setValidation} onComposition={composition}
255
+ onChange={value => { if (isRecord(value)) keep(value as JsonObject) }} />
256
+ {stateSchema !== undefined && <PmSchemaForm key={`${key}:state`} schema={stateSchema} value={draft.value} locale={locale} label={t.properties} empty={null}
257
+ accessorSchemas={[definition.parameterSchema]}
258
+ hideProperties={baseNames?.available ? baseNames.names : undefined} readOnly={!editable || draft.reset} disabled={!bank?.canKeep(target)}
259
+ onComposition={composition}
260
+ onChange={value => { if (isRecord(value)) keep(value as JsonObject) }} />}
261
+ <div className="component-properties__actions">
262
+ {scope === 'step' && inherited?.ownParameters !== undefined && <PmButton small disabled={busy} onClick={() => {
263
+ try {
264
+ if (draft.reset) keep(draft.baseline)
265
+ else { const reset = applyDocumentComponentAction(document, { type: 'component.step-parameters.reset', blockId, stepId: target.stepId! }).content; keep(effectiveComponentParameters(reset, blockId, target.stepId).parameters, false, true) }
266
+ } catch (error) { setError(message(error, t)) }
267
+ }}>{draft.reset ? t.edit : t.reset}</PmButton>}
268
+ <PmButton variant="ghost" onClick={enterMaterials}>{t.materials}</PmButton>
269
+ </div>
270
+ {backupError && <PmNotice tone="warn">{t.storageRead}<PmButton small onClick={() => setBackupRead(value => value + 1)}>{t.retry}</PmButton></PmNotice>}
271
+ {Boolean(backups?.unreadable) && <PmNotice tone="warn">{t.unreadable}</PmNotice>}
272
+ {Boolean(backups?.candidates.length) && <div className="component-properties__backups">
273
+ <PmSelect label={t.backup} value={backupIndex} disabled={backupBusy} onChange={event => setBackupIndex(Number(event.currentTarget.value))}>
274
+ {backups!.candidates.map((backup, index) => <option value={index} key={backup.key + backup.token}>{new Date(backup.record.updatedAt).toLocaleString(locale)} · {index + 1}</option>)}
275
+ </PmSelect>
276
+ <div className="component-properties__actions"><PmButton small disabled={!selectedBackup || backupBusy || busy || draft.composing || !bank?.canKeep(target) || bank?.status(target) !== 'available'} onClick={() => selectedBackup && void restore(selectedBackup)}>{t.recover}</PmButton>
277
+ <PmButton small disabled={!selectedBackup || backupBusy} onClick={() => selectedBackup && void discardBackup(selectedBackup)}>{t.discard}</PmButton></div>
278
+ </div>}
279
+ </>}
280
+ </div>
281
+ </PmDialog>
282
+ }
@@ -0,0 +1,31 @@
1
+ import type { DocumentBlock, TeachingDocument } from '@lincyaw/kernel'
2
+ import type { Locale } from '../messages.ts'
3
+ import { DOCUMENT_EDITOR_MESSAGES } from './messages.ts'
4
+ import { BLOCK_PREVIEW_MESSAGES } from './block-preview-messages.ts'
5
+ import { PmNotice } from '../components.tsx'
6
+ import { isDiagram } from './projection.ts'
7
+ import { fallbackText } from './rich-text.ts'
8
+
9
+
10
+ const MEDIA_KINDS = ['video', 'audio', 'animation', 'image'] as const
11
+ const authorText = (value: unknown) => typeof value === 'string' && value.trim() ? value.trim() : undefined
12
+
13
+ /**
14
+ * A readable source fallback while the independent component snapshot is unavailable. It names the content only by what
15
+ * the author wrote (a title) or by its kind, never by its code (the component, file or definition names).
16
+ */
17
+ export function DocumentBlockFallback({ block, document, locale }: { block: DocumentBlock, document: TeachingDocument, locale: Locale }) {
18
+ const t = DOCUMENT_EDITOR_MESSAGES[locale], p = BLOCK_PREVIEW_MESSAGES[locale]
19
+ if (isDiagram(block, document)) return <PmNotice>{t.diagram(block.type === 'code' && block.title || '')}</PmNotice>
20
+ if (block.type === 'code') return <PmNotice>{t.codeReference(block.title ?? block.origin?.anchor ?? block.language)}</PmNotice>
21
+ if (block.type !== 'component') return null
22
+ const definition = document.definitions[block.definitionId]
23
+ const schema = definition?.parameterSchema as { title?: unknown } | undefined
24
+ const name = authorText(block.parameters.title) ?? authorText(definition?.data?.title) ?? authorText(schema?.title)
25
+ const media = MEDIA_KINDS.find(kind => kind === block.parameters.kind)
26
+ const kind = definition?.kind === 'scene' ? p.animation : media ? p[media]
27
+ : definition?.implementation.type === 'plugin' && definition.implementation.name === 'PmMarkmap' ? p.mindMap : p.content
28
+ const label = name ? definition?.kind === 'scene' ? t.animation(name) : t.component(name) : kind
29
+ const description = typeof block.parameters.alt === 'string' ? block.parameters.alt : fallbackText(definition?.data?.fallback)
30
+ return <PmNotice><div className="pm-document-editor__fallback"><strong>{label}</strong>{description ? <p>{description}</p> : null}</div></PmNotice>
31
+ }