@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.
- package/README.md +40 -8
- package/package.json +27 -22
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +547 -90
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +33 -43
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +751 -203
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +456 -44
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +74 -6
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +57 -6
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +757 -361
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- package/src/workspace-client.ts +480 -0
- package/src/api.ts +0 -126
- package/src/editor.ts +0 -223
- package/src/merge.ts +0 -30
- package/src/panels/Changes.tsx +0 -128
- package/src/panels/Files.tsx +0 -73
- package/src/panels/Home.tsx +0 -224
- package/src/panels/Preview.tsx +0 -68
- package/src/panels/Problems.tsx +0 -42
- package/src/panels/Source.tsx +0 -118
- package/src/panels/Status.tsx +0 -26
- package/src/react.tsx +0 -59
- package/src/service.ts +0 -82
- 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
|
+
}
|