@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +66 -2
- package/package.json +68 -3
- 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 +631 -0
- 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 +36 -0
- 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 +781 -0
- 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 +462 -0
- 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 +102 -0
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +110 -0
- 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 +1025 -0
- 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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo } from 'react'
|
|
2
|
+
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react'
|
|
3
|
+
import { insertOrUpdateBlockForSlashMenu, isTableCell, mapTableCell } from '@blocknote/core'
|
|
4
|
+
import type { Block } from '@blocknote/core'
|
|
5
|
+
import { SideMenuExtension, SourceBlockWithPreviewExtension, TableHandlesExtension } from '@blocknote/core/extensions'
|
|
6
|
+
import { DeleteButton, AddButton, DeleteLinkButton, DragHandleMenu, EditLinkButton, LinkToolbar, RemoveBlockItem, SideMenu, SplitButton, TableCellButton, TableCellMenu, TableColumnHeaderItem, TableHandle, TableRowHeaderItem, useBlockNoteEditor, useComponentsContext, useDictionary, useEditorState, useExtension, useExtensionState, usePortalElement, useUIMode } from '@blocknote/react'
|
|
7
|
+
import type { DefaultReactSuggestionItem, LinkToolbarProps, SideMenuProps, TableCellButtonProps, TableHandleMenuProps, TableHandleProps } from '@blocknote/react'
|
|
8
|
+
import { TextSelection } from '@tiptap/pm/state'
|
|
9
|
+
import { DOCUMENT_COLOR_NAMES, documentColorToken, documentColorValue } from '@lincyaw/kernel'
|
|
10
|
+
import type { DocumentColorKind } from '@lincyaw/kernel'
|
|
11
|
+
import { PmIcon } from '../components.tsx'
|
|
12
|
+
import { DOCUMENT_EDITOR_MESSAGES, slashAliases } from './messages.ts'
|
|
13
|
+
import { DOCUMENT_EDITOR_LOCALE } from './math.tsx'
|
|
14
|
+
import type { NativeDocumentEditor } from './schema.tsx'
|
|
15
|
+
|
|
16
|
+
const useMessages = () => DOCUMENT_EDITOR_MESSAGES[useContext(DOCUMENT_EDITOR_LOCALE)]
|
|
17
|
+
|
|
18
|
+
interface ColorChoice { color: string, setColor(color: string): void }
|
|
19
|
+
|
|
20
|
+
function Swatch({ kind, value }: { kind: DocumentColorKind, value: string }) {
|
|
21
|
+
const style = value === 'default' ? undefined : { '--pm-swatch': documentColorValue(value, kind) } as CSSProperties
|
|
22
|
+
return <span className="pm-ui-color-swatch" data-kind={kind} style={style} aria-hidden="true">A</span>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Arrow keys move through the swatches as a grid: left and right within a group, up and down to the same colour in
|
|
27
|
+
* the other group. Mantine's own menu keys run after these and would only step through the items in order, so the
|
|
28
|
+
* keys handled here stop there; in a submenu the left arrow on a group's first swatch still returns to the parent item.
|
|
29
|
+
*/
|
|
30
|
+
function moveInGrid(event: KeyboardEvent<HTMLDivElement>, sub: boolean) {
|
|
31
|
+
const item = (event.target as HTMLElement).closest<HTMLElement>('[data-document-color]')
|
|
32
|
+
const rows = [...event.currentTarget.querySelectorAll('[role=group]')].map(group => [...group.querySelectorAll<HTMLElement>('[data-document-color]')])
|
|
33
|
+
const row = rows.findIndex(swatches => item && swatches.includes(item))
|
|
34
|
+
if (row < 0) return
|
|
35
|
+
const swatches = rows[row]!, column = swatches.indexOf(item!)
|
|
36
|
+
const at = (index: number) => rows[(index + rows.length) % rows.length]![column]
|
|
37
|
+
let target: HTMLElement | undefined
|
|
38
|
+
if (event.key === 'ArrowRight') target = swatches[(column + 1) % swatches.length]
|
|
39
|
+
else if (event.key === 'ArrowLeft' && !(sub && column === 0)) target = swatches[(column - 1 + swatches.length) % swatches.length]
|
|
40
|
+
else if (event.key === 'ArrowDown') target = at(row + 1)
|
|
41
|
+
else if (event.key === 'ArrowUp') target = at(row - 1)
|
|
42
|
+
if (!target) return
|
|
43
|
+
event.preventDefault()
|
|
44
|
+
event.stopPropagation()
|
|
45
|
+
target.focus()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The document's semantic palette as two rows of swatches; choosing one saves its design token. */
|
|
49
|
+
function DocumentColorPicker({ text, background, sub = false }: { text?: ColorChoice, background?: ColorChoice, sub?: boolean }) {
|
|
50
|
+
const Components = useComponentsContext()!, t = useMessages()
|
|
51
|
+
const section = (kind: DocumentColorKind, choice: ColorChoice) => {
|
|
52
|
+
const current = choice.color === 'default' ? 'default' : documentColorValue(choice.color, kind)
|
|
53
|
+
const heading = kind === 'text' ? t.textColor : t.backgroundColor
|
|
54
|
+
return <div className="bn-color-picker-group" role="group" aria-label={heading}>
|
|
55
|
+
<Components.Generic.Menu.Label>{heading}</Components.Generic.Menu.Label>
|
|
56
|
+
{(['default', ...DOCUMENT_COLOR_NAMES] as const).map(name => {
|
|
57
|
+
const value = name === 'default' ? 'default' : documentColorToken(name, kind), checked = current === value
|
|
58
|
+
const label = name === 'default' ? kind === 'text' ? t.colorDefault : t.backgroundDefault : (kind === 'text' ? t.colorNames : t.backgroundNames)[name]
|
|
59
|
+
// The item's role is fixed to menuitem, so the current colour is announced as aria-current.
|
|
60
|
+
return <Components.Generic.Menu.Item key={`${kind}-${name}`} data-document-color={`${kind}-${name}`} data-checked={checked || undefined}
|
|
61
|
+
aria-label={label} aria-current={checked || undefined} {...{ title: label }} onClick={() => choice.setColor(value)}>
|
|
62
|
+
<Swatch kind={kind} value={value} />{checked && <PmIcon name="check" />}
|
|
63
|
+
</Components.Generic.Menu.Item>
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
}
|
|
67
|
+
return <div className="bn-color-picker-grid" onKeyDownCapture={event => moveInGrid(event, sub)}>{text && section('text', text)}{background && section('background', background)}</div>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ColorSubmenu({ children, text, background }: { children: ReactNode, text?: ColorChoice, background?: ColorChoice }) {
|
|
71
|
+
const Components = useComponentsContext()!, portalElement = usePortalElement()
|
|
72
|
+
return <Components.Generic.Menu.Root position="right" sub={true} portalElement={portalElement}>
|
|
73
|
+
<Components.Generic.Menu.Trigger sub={true}><Components.Generic.Menu.Item className="bn-menu-item" subTrigger={true}>{children}</Components.Generic.Menu.Item></Components.Generic.Menu.Trigger>
|
|
74
|
+
<Components.Generic.Menu.Dropdown sub={true} className="bn-menu-dropdown bn-color-picker-dropdown"><DocumentColorPicker text={text} background={background} sub={true} /></Components.Generic.Menu.Dropdown>
|
|
75
|
+
</Components.Generic.Menu.Root>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Formatting-toolbar colours for the selected text. */
|
|
79
|
+
export function DocumentColorButton() {
|
|
80
|
+
const Components = useComponentsContext()!, portalElement = usePortalElement(), uiMode = useUIMode(), t = useMessages()
|
|
81
|
+
const editor = useBlockNoteEditor<any, any, any>()
|
|
82
|
+
const state = useEditorState({ editor, selector: ({ editor }) => {
|
|
83
|
+
if (!editor.isEditable || !(editor.getSelection()?.blocks ?? [editor.getTextCursorPosition().block]).some(block => block.content !== undefined)) return undefined
|
|
84
|
+
const styles = editor.getActiveStyles() as { textColor?: string, backgroundColor?: string }
|
|
85
|
+
return { text: styles.textColor || 'default', background: styles.backgroundColor || 'default' }
|
|
86
|
+
} })
|
|
87
|
+
if (!state) return null
|
|
88
|
+
const set = (style: 'textColor' | 'backgroundColor') => (color: string) => {
|
|
89
|
+
if (color === 'default') editor.removeStyles({ [style]: color })
|
|
90
|
+
else editor.addStyles({ [style]: color })
|
|
91
|
+
editor.focus()
|
|
92
|
+
}
|
|
93
|
+
return <Components.Generic.Menu.Root portalElement={portalElement} preventFocusOnOpen={uiMode === 'mobile'}>
|
|
94
|
+
<Components.Generic.Menu.Trigger>
|
|
95
|
+
<Components.FormattingToolbar.Button className="bn-button" data-document-colors="" label={t.colors} mainTooltip={t.colors}
|
|
96
|
+
icon={<span className="pm-ui-color-swatch pm-ui-color-swatch--pair" style={{ ...state.text !== 'default' ? { '--pm-swatch': documentColorValue(state.text, 'text') } : {}, ...state.background !== 'default' ? { '--pm-swatch-background': documentColorValue(state.background, 'background') } : {} } as CSSProperties} aria-hidden="true">A</span>} />
|
|
97
|
+
</Components.Generic.Menu.Trigger>
|
|
98
|
+
<Components.Generic.Menu.Dropdown className="bn-menu-dropdown bn-color-picker-dropdown">
|
|
99
|
+
<DocumentColorPicker text={{ color: state.text, setColor: set('textColor') }} background={{ color: state.background, setColor: set('backgroundColor') }} />
|
|
100
|
+
</Components.Generic.Menu.Dropdown>
|
|
101
|
+
</Components.Generic.Menu.Root>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function BlockColorsItem({ children }: { children: ReactNode }) {
|
|
105
|
+
const editor = useBlockNoteEditor<any, any, any>()
|
|
106
|
+
const block = useExtensionState(SideMenuExtension, { editor, selector: state => state?.block }) as Block<any, any, any> | undefined
|
|
107
|
+
if (!block || !('textColor' in block.props) && !('backgroundColor' in block.props)) return null
|
|
108
|
+
const choice = (property: 'textColor' | 'backgroundColor'): ColorChoice | undefined => property in block.props
|
|
109
|
+
? { color: String(block.props[property] ?? 'default'), setColor: color => editor.updateBlock(block, { props: { [property]: color } }) } : undefined
|
|
110
|
+
return <ColorSubmenu text={choice('textColor')} background={choice('backgroundColor')}>{children}</ColorSubmenu>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function DocumentDragHandleMenu() {
|
|
114
|
+
const dict = useDictionary()
|
|
115
|
+
return <DragHandleMenu>
|
|
116
|
+
<RemoveBlockItem>{dict.drag_handle.delete_menuitem}</RemoveBlockItem>
|
|
117
|
+
<BlockColorsItem>{dict.drag_handle.colors_menuitem}</BlockColorsItem>
|
|
118
|
+
<TableRowHeaderItem>{dict.drag_handle.header_row_menuitem}</TableRowHeaderItem>
|
|
119
|
+
<TableColumnHeaderItem>{dict.drag_handle.header_column_menuitem}</TableColumnHeaderItem>
|
|
120
|
+
</DragHandleMenu>
|
|
121
|
+
}
|
|
122
|
+
export function DocumentSideMenu(props: SideMenuProps) { return <SideMenu {...props} dragHandleMenu={DocumentDragHandleMenu} /> }
|
|
123
|
+
|
|
124
|
+
type TableBlock = Block<any, any, any> & { type: 'table', content: { type: 'tableContent', rows: { cells: any[] }[], headerRows?: number, headerCols?: number } }
|
|
125
|
+
|
|
126
|
+
function useTableColors(cells: { row: number, col: number }[]) {
|
|
127
|
+
const editor = useBlockNoteEditor<any, any, any>()
|
|
128
|
+
const block = useExtensionState(TableHandlesExtension, { selector: state => state?.block }) as TableBlock | undefined
|
|
129
|
+
if (!block || !cells.length) return undefined
|
|
130
|
+
const rows = block.content.rows
|
|
131
|
+
const value = (property: 'textColor' | 'backgroundColor') => {
|
|
132
|
+
const values = cells.map(({ row, col }) => { const cell = rows[row]?.cells[col]; return cell && isTableCell(cell) ? cell.props[property] : 'default' })
|
|
133
|
+
return values.every(item => item === values[0]) ? values[0] ?? 'default' : 'default'
|
|
134
|
+
}
|
|
135
|
+
const update = (property: 'textColor' | 'backgroundColor') => (color: string) => {
|
|
136
|
+
const next = rows.map(row => ({ ...row, cells: row.cells.map(cell => mapTableCell(cell)) }))
|
|
137
|
+
for (const { row, col } of cells) if (next[row]?.cells[col]) next[row]!.cells[col]!.props[property] = color
|
|
138
|
+
editor.updateBlock(block, { type: 'table', content: { ...block.content, rows: next } })
|
|
139
|
+
editor.setTextCursorPosition(block)
|
|
140
|
+
}
|
|
141
|
+
return { text: { color: value('textColor'), setColor: update('textColor') }, background: { color: value('backgroundColor'), setColor: update('backgroundColor') } }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function CellColors() {
|
|
145
|
+
const dict = useDictionary()
|
|
146
|
+
const position = useExtensionState(TableHandlesExtension, { selector: state => state?.rowIndex === undefined || state.colIndex === undefined ? undefined : `${state.rowIndex}:${state.colIndex}` })
|
|
147
|
+
const cells = useMemo(() => position ? [{ row: Number(position.split(':')[0]), col: Number(position.split(':')[1]) }] : [], [position])
|
|
148
|
+
const colors = useTableColors(cells)
|
|
149
|
+
return colors ? <ColorSubmenu {...colors}>{dict.drag_handle.colors_menuitem}</ColorSubmenu> : null
|
|
150
|
+
}
|
|
151
|
+
function DocumentTableCellMenu() { return <TableCellMenu><SplitButton /><CellColors /></TableCellMenu> }
|
|
152
|
+
export function DocumentTableCellButton(props: TableCellButtonProps) { return <TableCellButton {...props} tableCellMenu={DocumentTableCellMenu} /> }
|
|
153
|
+
|
|
154
|
+
function LineColors({ orientation }: TableHandleMenuProps) {
|
|
155
|
+
const dict = useDictionary(), tableHandles = useExtension(TableHandlesExtension)
|
|
156
|
+
const { block, index } = useExtensionState(TableHandlesExtension, { selector: state => ({ block: state?.block, index: orientation === 'column' ? state?.colIndex : state?.rowIndex }) })
|
|
157
|
+
const cells = useMemo(() => !block || index === undefined ? [] : orientation === 'row' ? tableHandles.getCellsAtRowHandle(block, index) : tableHandles.getCellsAtColumnHandle(block, index), [block, index, orientation, tableHandles])
|
|
158
|
+
const colors = useTableColors(cells)
|
|
159
|
+
return colors ? <ColorSubmenu {...colors}>{dict.drag_handle.colors_menuitem}</ColorSubmenu> : null
|
|
160
|
+
}
|
|
161
|
+
// BlockNote's own header buttons are not exported, so the first row and column handles get the same toggle here.
|
|
162
|
+
function LineHeader({ orientation }: TableHandleMenuProps) {
|
|
163
|
+
const Components = useComponentsContext()!, dict = useDictionary(), editor = useBlockNoteEditor<any, any, any>()
|
|
164
|
+
const { block, index } = useExtensionState(TableHandlesExtension, { selector: state => ({ block: state?.block as TableBlock | undefined, index: orientation === 'column' ? state?.colIndex : state?.rowIndex }) })
|
|
165
|
+
if (!block || index !== 0 || !editor.settings.tables.headers) return null
|
|
166
|
+
const key = orientation === 'row' ? 'headerRows' : 'headerCols', checked = Boolean(block.content[key])
|
|
167
|
+
return <Components.Generic.Menu.Item className="bn-menu-item" checked={checked} onClick={() => editor.updateBlock(block, { ...block, content: { ...block.content, [key]: checked ? undefined : 1 } })}>
|
|
168
|
+
{orientation === 'row' ? dict.drag_handle.header_row_menuitem : dict.drag_handle.header_column_menuitem}
|
|
169
|
+
</Components.Generic.Menu.Item>
|
|
170
|
+
}
|
|
171
|
+
function DocumentTableHandleMenu({ orientation }: TableHandleMenuProps) {
|
|
172
|
+
const Components = useComponentsContext()!
|
|
173
|
+
return <Components.Generic.Menu.Dropdown className="bn-table-handle-menu">
|
|
174
|
+
<DeleteButton orientation={orientation} />
|
|
175
|
+
<AddButton orientation={orientation} side={orientation === 'row' ? 'above' : 'left' as any} />
|
|
176
|
+
<AddButton orientation={orientation} side={orientation === 'row' ? 'below' : 'right' as any} />
|
|
177
|
+
<LineHeader orientation={orientation} />
|
|
178
|
+
<LineColors orientation={orientation} />
|
|
179
|
+
</Components.Generic.Menu.Dropdown>
|
|
180
|
+
}
|
|
181
|
+
// BlockNote passes the orientation at runtime, but its public menu type declares no props.
|
|
182
|
+
const RowHandleMenu = () => <DocumentTableHandleMenu orientation="row" />
|
|
183
|
+
const ColumnHandleMenu = () => <DocumentTableHandleMenu orientation="column" />
|
|
184
|
+
export function DocumentTableHandle(props: TableHandleProps) { return <TableHandle {...props} tableHandleMenu={props.orientation === 'row' ? RowHandleMenu : ColumnHandleMenu} /> }
|
|
185
|
+
|
|
186
|
+
function openBlockFormula(editor: NativeDocumentEditor, id: string): void {
|
|
187
|
+
editor.setTextCursorPosition(id, 'end')
|
|
188
|
+
editor.getExtension(SourceBlockWithPreviewExtension)?.store.setState(state => ({ ...state, popupOpen: id }))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Inserts a typed inline formula and places the caret in its source, which opens the formula popup. */
|
|
192
|
+
export function insertInlineFormula(editor: NativeDocumentEditor, source = ''): void {
|
|
193
|
+
const start = editor.prosemirrorState.selection.from
|
|
194
|
+
editor.insertInlineContent([{ type: 'presmiumMathInline', props: {}, content: source }])
|
|
195
|
+
editor.transact(transaction => {
|
|
196
|
+
// The formula replaces the selection at its start; the caret goes to the end of its source.
|
|
197
|
+
const node = transaction.doc.nodeAt(start)
|
|
198
|
+
if (node?.type.name === 'presmiumMathInline') transaction.setSelection(TextSelection.create(transaction.doc, start + node.nodeSize - 1))
|
|
199
|
+
})
|
|
200
|
+
editor.focus()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function formulaSlashItems(editor: NativeDocumentEditor, locale: keyof typeof DOCUMENT_EDITOR_MESSAGES, group: string): DefaultReactSuggestionItem[] {
|
|
204
|
+
const t = DOCUMENT_EDITOR_MESSAGES[locale]
|
|
205
|
+
return [{
|
|
206
|
+
title: t.formulaBlock, subtext: t.formulaBlockHint, group, icon: <PmIcon name="formula" />, aliases: slashAliases('formulaBlockAliases'),
|
|
207
|
+
onItemClick: () => openBlockFormula(editor, insertOrUpdateBlockForSlashMenu(editor, { type: 'presmiumMath', content: '' }).id),
|
|
208
|
+
}, {
|
|
209
|
+
title: t.formulaInline, subtext: t.formulaInlineHint, group, icon: <PmIcon name="formula" />, aliases: slashAliases('formulaInlineAliases'),
|
|
210
|
+
onItemClick: () => insertInlineFormula(editor),
|
|
211
|
+
}]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Turns the selected text into an inline formula, its text becoming the LaTeX source. */
|
|
215
|
+
export function FormulaToolbarButton() {
|
|
216
|
+
const Components = useComponentsContext()!, t = useMessages()
|
|
217
|
+
const editor = useBlockNoteEditor<any, any, any>() as unknown as NativeDocumentEditor
|
|
218
|
+
const show = useEditorState({ editor, selector: ({ editor }) => {
|
|
219
|
+
const blocks = editor.getSelection()?.blocks ?? [editor.getTextCursorPosition().block]
|
|
220
|
+
return editor.isEditable && blocks.length === 1 && blocks[0]!.content !== undefined && blocks[0]!.type !== 'codeBlock' && blocks[0]!.type !== 'presmiumMath' && !editor.getActiveStyles().code
|
|
221
|
+
} })
|
|
222
|
+
if (!show) return null
|
|
223
|
+
return <Components.FormattingToolbar.Button className="bn-button" data-document-formula="" label={t.formulaInline} mainTooltip={t.formulaInline} icon={<PmIcon name="formula" />}
|
|
224
|
+
onClick={() => insertInlineFormula(editor, editor.getSelectedText().replace(/\n/g, ' '))} />
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface DocumentLinks {
|
|
228
|
+
/** Shows a page of this document; false when the anchor names none. */
|
|
229
|
+
openSection?(sectionId: string): boolean
|
|
230
|
+
}
|
|
231
|
+
export const DOCUMENT_LINKS = createContext<DocumentLinks>({})
|
|
232
|
+
|
|
233
|
+
const EXTERNAL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:'])
|
|
234
|
+
|
|
235
|
+
/** In-document anchors navigate the workbench; absolute web links open in a new, unrelated tab. */
|
|
236
|
+
export function openDocumentLink(href: string, links: DocumentLinks): void {
|
|
237
|
+
if (href.startsWith('#')) {
|
|
238
|
+
let id = href.slice(1)
|
|
239
|
+
try { id = decodeURIComponent(id) } catch {}
|
|
240
|
+
links.openSection?.(id)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
let url: URL
|
|
244
|
+
try { url = new URL(href) } catch { return }
|
|
245
|
+
if (EXTERNAL_PROTOCOLS.has(url.protocol)) window.open(url.href, '_blank', 'noopener,noreferrer')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function OpenLinkButton({ url }: { url: string }) {
|
|
249
|
+
const Components = useComponentsContext()!, t = useMessages(), links = useContext(DOCUMENT_LINKS)
|
|
250
|
+
return <Components.LinkToolbar.Button className="bn-button" mainTooltip={t.openLink} label={t.openLink} isSelected={false}
|
|
251
|
+
onClick={() => openDocumentLink(url, links)} icon={<PmIcon name={url.startsWith('#') ? 'link' : 'external'} />} />
|
|
252
|
+
}
|
|
253
|
+
export function DocumentLinkToolbar(props: LinkToolbarProps) {
|
|
254
|
+
return <LinkToolbar {...props}>
|
|
255
|
+
<EditLinkButton url={props.url} text={props.text} range={props.range} setToolbarOpen={props.setToolbarOpen} setToolbarPositionFrozen={props.setToolbarPositionFrozen} />
|
|
256
|
+
<OpenLinkButton url={props.url} />
|
|
257
|
+
<DeleteLinkButton range={props.range} setToolbarOpen={props.setToolbarOpen} />
|
|
258
|
+
</LinkToolbar>
|
|
259
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { BlockPreviewCommand, BlockPreviewPlaybackGranted, BlockPreviewPlaybackRequest } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
interface Member {
|
|
4
|
+
request: BlockPreviewPlaybackRequest
|
|
5
|
+
release?: () => void
|
|
6
|
+
stopped: boolean
|
|
7
|
+
waiting?: { promise: Promise<void>, finish(error?: Error): void }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface PlaybackOptions {
|
|
11
|
+
activate(stop: () => Promise<void>): Promise<() => void>
|
|
12
|
+
current(request: BlockPreviewPlaybackRequest): boolean
|
|
13
|
+
adopt(request: BlockPreviewPlaybackRequest): string
|
|
14
|
+
post(message: BlockPreviewCommand | BlockPreviewPlaybackGranted): void
|
|
15
|
+
failure: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Wait for the existing child's cancellation acknowledgement before admitting another player. */
|
|
19
|
+
export class DocumentBlockPlayback {
|
|
20
|
+
readonly #options: PlaybackOptions
|
|
21
|
+
readonly #members = new Map<number, Member>()
|
|
22
|
+
#disposed = false
|
|
23
|
+
constructor(options: PlaybackOptions) { this.#options = options }
|
|
24
|
+
|
|
25
|
+
request(request: BlockPreviewPlaybackRequest): void {
|
|
26
|
+
if (this.#disposed || this.#members.has(request.request)) return
|
|
27
|
+
if (!this.#options.current(request)) { this.#grant(request, this.#options.failure); return }
|
|
28
|
+
const member: Member = { request, stopped: false }
|
|
29
|
+
this.#members.set(request.request, member)
|
|
30
|
+
void this.#options.activate(() => this.#stop(member)).then(release => {
|
|
31
|
+
member.release = release
|
|
32
|
+
if (this.#disposed || member.stopped) { release(); return }
|
|
33
|
+
if (!this.#options.current(request)) {
|
|
34
|
+
release(); this.#members.delete(request.request)
|
|
35
|
+
this.#grant(request, this.#options.failure)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
const navigation = this.#options.adopt(request)
|
|
39
|
+
this.#grant(request, undefined, navigation)
|
|
40
|
+
}, error => {
|
|
41
|
+
this.#members.delete(request.request)
|
|
42
|
+
if (this.#disposed || member.stopped) return
|
|
43
|
+
if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') this.#pause(request)
|
|
44
|
+
else this.#grant(request, this.#options.failure)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
cancelled(request: number): void {
|
|
49
|
+
const member = this.#members.get(request)
|
|
50
|
+
if (!member) return
|
|
51
|
+
member.stopped = true; member.waiting?.finish(); member.release?.()
|
|
52
|
+
this.#members.delete(request)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#grant(request: BlockPreviewPlaybackRequest, error?: string, navigation = request.navigation): void {
|
|
56
|
+
if (this.#disposed) return
|
|
57
|
+
const { document, blockId, revision } = request
|
|
58
|
+
this.#options.post({ type: 'presmium:block-preview-playback-granted', document, blockId, revision, request: request.request, navigation, ...error ? { error } : {} })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#pause(request: BlockPreviewPlaybackRequest): void {
|
|
62
|
+
const { document, blockId, revision, sectionId, stepId } = request
|
|
63
|
+
this.#options.post({ type: 'presmium:block-preview-command', document, blockId, revision, sectionId, stepId, action: 'pause' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#stop(member: Member): Promise<void> {
|
|
67
|
+
if (this.#disposed || member.stopped) return Promise.resolve()
|
|
68
|
+
if (member.waiting) return member.waiting.promise
|
|
69
|
+
let finish!: (error?: Error) => void
|
|
70
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
71
|
+
const timer = setTimeout(() => finish(new Error('block-playback-pause-timeout')), 5000)
|
|
72
|
+
finish = error => {
|
|
73
|
+
clearTimeout(timer); member.waiting = undefined
|
|
74
|
+
if (error) reject(error); else resolve()
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
member.waiting = { promise, finish }
|
|
78
|
+
this.#pause(member.request)
|
|
79
|
+
return promise
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
dispose(): void {
|
|
83
|
+
if (this.#disposed) return
|
|
84
|
+
this.#disposed = true
|
|
85
|
+
for (const member of this.#members.values()) {
|
|
86
|
+
member.stopped = true; member.waiting?.finish(); member.release?.()
|
|
87
|
+
}
|
|
88
|
+
this.#members.clear()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The words of a block's read-only preview and of its stand-in while the preview is not shown. */
|
|
2
|
+
export const BLOCK_PREVIEW_MESSAGES = {
|
|
3
|
+
'zh-CN': {
|
|
4
|
+
preparing: '正在准备预览…', preparingDetail: '首次打开或修改代码后需要稍候,预览准备好后会自动显示。',
|
|
5
|
+
buildFailure: '这项预览暂时无法准备,请检查最近的修改后重试。', displayFailure: '预览没有显示出来,请重试。',
|
|
6
|
+
content: '内容', animation: '动画', mindMap: '思维导图', video: '视频', audio: '音频', image: '图片',
|
|
7
|
+
},
|
|
8
|
+
en: {
|
|
9
|
+
preparing: 'Preparing preview…', preparingDetail: 'The first time, or after code changes, this takes a moment. The preview appears when it is ready.',
|
|
10
|
+
buildFailure: 'This preview could not be prepared. Check recent changes, then retry.', displayFailure: 'The preview did not appear. Retry.',
|
|
11
|
+
content: 'Content', animation: 'Animation', mindMap: 'Mind map', video: 'Video', audio: 'Audio', image: 'Picture',
|
|
12
|
+
},
|
|
13
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Dictionary } from '@blocknote/core'
|
|
2
|
+
import { en, zh } from '@blocknote/core/locales'
|
|
3
|
+
import type { Locale } from '../messages.ts'
|
|
4
|
+
|
|
5
|
+
type Overrides<T> = { [K in keyof T]?: T[K] extends string ? string : T[K] extends (...args: never[]) => unknown ? T[K] : Overrides<T[K]> }
|
|
6
|
+
|
|
7
|
+
const basic = { 'zh-CN': '基本内容块', en: 'Basic Blocks' }
|
|
8
|
+
const basicGroups = (locale: Locale) => Object.fromEntries(['quote', 'toggle_list', 'numbered_list', 'bullet_list', 'check_list', 'paragraph', 'code_block', 'page_break', 'divider'].map(key => [key, { group: basic[locale] }]))
|
|
9
|
+
const kinds = <T extends string>(image: T, video: T, audio: T, file: T) => ({ image, video, audio, file })
|
|
10
|
+
|
|
11
|
+
// The block editor's own text, changed where it differs from docs/glossary.md (批注 / New Comment, 内容块 / Block,
|
|
12
|
+
// 插入 / Insert, commands in Title Case, one “…”). Every other entry stays BlockNote's.
|
|
13
|
+
export const BLOCKNOTE_MESSAGES: Record<Locale, Overrides<Dictionary>> = {
|
|
14
|
+
'zh-CN': {
|
|
15
|
+
slash_menu: { ...basicGroups('zh-CN'), image: { title: '图片', subtext: '可调整大小、带题注的图片' }, divider: { subtext: '分隔内容块', group: basic['zh-CN'] } },
|
|
16
|
+
placeholders: { default: '输入文字,或输入“/”使用命令', toggleListItem: '折叠项', new_comment: '输入批注…', edit_comment: '编辑批注…', comment_reply: '回复…' },
|
|
17
|
+
file_blocks: { add_button_text: kinds('插入图片', '插入视频', '插入音频', '插入文件') },
|
|
18
|
+
toggle_blocks: { add_block_button: '空的折叠项。单击以插入内容块。', toggle_button: '显示或隐藏' },
|
|
19
|
+
code_block: { add_source_button_text: '插入代码' },
|
|
20
|
+
side_menu: { add_block_label: '插入内容块', drag_handle_label: '打开内容块菜单' },
|
|
21
|
+
drag_handle: { header_row_menuitem: '标题行', header_column_menuitem: '标题列' },
|
|
22
|
+
table_handle: { delete_column_menuitem: '删除列', delete_row_menuitem: '删除行', split_cell_menuitem: '拆分单元格', merge_cells_menuitem: '合并单元格', background_color_menuitem: '背景色', add_left_menuitem: '在左侧插入列', add_right_menuitem: '在右侧插入列', add_above_menuitem: '在上方插入行', add_below_menuitem: '在下方插入行' },
|
|
23
|
+
formatting_toolbar: {
|
|
24
|
+
strike: { tooltip: '删除线' }, code: { tooltip: '代码' }, link: { tooltip: '插入链接' },
|
|
25
|
+
file_caption: { tooltip: '编辑题注', input_placeholder: '输入题注' },
|
|
26
|
+
file_replace: { tooltip: kinds('替换图片', '替换视频', '替换音频', '替换文件') },
|
|
27
|
+
file_rename: { tooltip: kinds('重命名图片', '重命名视频', '重命名音频', '重命名文件'), input_placeholder: kinds('输入名称', '输入名称', '输入名称', '输入名称') },
|
|
28
|
+
file_download: { tooltip: kinds('下载图片', '下载视频', '下载音频', '下载文件') },
|
|
29
|
+
file_delete: { tooltip: kinds('删除图片', '删除视频', '删除音频', '删除文件') },
|
|
30
|
+
file_preview_toggle: { tooltip: '显示或隐藏预览' },
|
|
31
|
+
nest: { tooltip: '增加缩进' }, unnest: { tooltip: '减少缩进' },
|
|
32
|
+
align_left: { tooltip: '左对齐' }, align_center: { tooltip: '居中' }, align_right: { tooltip: '右对齐' }, align_justify: { tooltip: '两端对齐' },
|
|
33
|
+
comment: { tooltip: '新建批注' },
|
|
34
|
+
},
|
|
35
|
+
file_panel: { upload: { file_placeholder: kinds('上传图片', '上传视频', '上传音频', '上传文件'), upload_error: '无法上传文件' }, embed: { embed_button: kinds('嵌入图片', '嵌入视频', '嵌入音频', '嵌入文件'), url_placeholder: '输入网址' } },
|
|
36
|
+
link_toolbar: { delete: { tooltip: '移除链接' }, edit: { text: '编辑链接' }, open: { tooltip: '在新标签页中打开' }, form: { title_placeholder: '链接文字', url_placeholder: '链接地址' } },
|
|
37
|
+
comments: {
|
|
38
|
+
discard_pending_comment: '要放弃这条批注吗?',
|
|
39
|
+
actions: { add_reaction: '添加回应', edit_comment: '编辑批注', delete_comment: '删除批注', more_actions: '更多' },
|
|
40
|
+
},
|
|
41
|
+
versioning: {
|
|
42
|
+
title: '版本历史', start_of_document: '文稿开头', show_named_only: '仅显示已命名的版本', show_all: '显示所有版本', comparison_on: '开启对比', comparison_off: '关闭对比',
|
|
43
|
+
compare_since_beginning_menuitem: '与最初的版本对比', name_version_menuitem: '命名此版本', compare_with_menuitem: '与此版本对比',
|
|
44
|
+
before_restore: '还原前', restore_menuitem: '还原', more_actions: '更多', action_failed: '无法完成操作,请重试。', history_load_failed: '无法加载版本历史',
|
|
45
|
+
},
|
|
46
|
+
exporter: { open_file: '打开文件', open_video_file: '打开视频', open_audio_file: '打开音频' },
|
|
47
|
+
generic: { loading: '正在加载…', load_more: '加载更多' },
|
|
48
|
+
},
|
|
49
|
+
en: {
|
|
50
|
+
slash_menu: { ...basicGroups('en'), image: { title: 'Picture', subtext: 'Resizable picture with caption' }, divider: { subtext: 'Visually divide blocks', group: basic.en } },
|
|
51
|
+
placeholders: { default: 'Enter text, or type “/” for commands', toggleListItem: 'Toggle', new_comment: 'Write a comment…', edit_comment: 'Write a comment…', comment_reply: 'Reply…' },
|
|
52
|
+
file_blocks: { add_button_text: kinds('Insert Picture', 'Insert Video', 'Insert Audio', 'Insert File') },
|
|
53
|
+
toggle_blocks: { add_block_button: 'Empty toggle. Click to insert a block.', toggle_button: 'Show or Hide' },
|
|
54
|
+
code_block: { add_source_button_text: 'Insert Code' },
|
|
55
|
+
side_menu: { add_block_label: 'Insert Block', drag_handle_label: 'Open Block Menu' },
|
|
56
|
+
drag_handle: { header_row_menuitem: 'Header Row', header_column_menuitem: 'Header Column' },
|
|
57
|
+
table_handle: { delete_column_menuitem: 'Delete Column', delete_row_menuitem: 'Delete Row', split_cell_menuitem: 'Split Cell', merge_cells_menuitem: 'Merge Cells', background_color_menuitem: 'Background Color', add_left_menuitem: 'Insert Column Left', add_right_menuitem: 'Insert Column Right', add_above_menuitem: 'Insert Row Above', add_below_menuitem: 'Insert Row Below' },
|
|
58
|
+
formatting_toolbar: {
|
|
59
|
+
strike: { tooltip: 'Strikethrough' }, code: { tooltip: 'Code' }, link: { tooltip: 'Insert Link' },
|
|
60
|
+
file_caption: { tooltip: 'Edit Caption', input_placeholder: 'Enter a caption' },
|
|
61
|
+
file_replace: { tooltip: kinds('Replace Picture', 'Replace Video', 'Replace Audio', 'Replace File') },
|
|
62
|
+
file_rename: { tooltip: kinds('Rename Picture', 'Rename Video', 'Rename Audio', 'Rename File'), input_placeholder: kinds('Enter a name', 'Enter a name', 'Enter a name', 'Enter a name') },
|
|
63
|
+
file_download: { tooltip: kinds('Download Picture', 'Download Video', 'Download Audio', 'Download File') },
|
|
64
|
+
file_delete: { tooltip: kinds('Delete Picture', 'Delete Video', 'Delete Audio', 'Delete File') },
|
|
65
|
+
file_preview_toggle: { tooltip: 'Show or Hide Preview' },
|
|
66
|
+
nest: { tooltip: 'Increase Indent' }, unnest: { tooltip: 'Decrease Indent' },
|
|
67
|
+
align_left: { tooltip: 'Align Left' }, align_center: { tooltip: 'Center' }, align_right: { tooltip: 'Align Right' }, align_justify: { tooltip: 'Justify' },
|
|
68
|
+
comment: { tooltip: 'New Comment' },
|
|
69
|
+
},
|
|
70
|
+
file_panel: { upload: { file_placeholder: kinds('Upload Picture', 'Upload Video', 'Upload Audio', 'Upload File'), upload_error: 'Couldn’t upload the file' }, embed: { embed_button: kinds('Embed Picture', 'Embed Video', 'Embed Audio', 'Embed File'), url_placeholder: 'Enter an address' } },
|
|
71
|
+
link_toolbar: { delete: { tooltip: 'Remove Link' }, edit: { text: 'Edit Link' }, open: { tooltip: 'Open in New Tab' }, form: { title_placeholder: 'Link text', url_placeholder: 'Link address' } },
|
|
72
|
+
comments: {
|
|
73
|
+
discard_pending_comment: 'Discard this comment?',
|
|
74
|
+
actions: { add_reaction: 'Add Reaction', edit_comment: 'Edit Comment', delete_comment: 'Delete Comment', more_actions: 'More' },
|
|
75
|
+
},
|
|
76
|
+
versioning: {
|
|
77
|
+
title: 'Version History', start_of_document: 'Beginning of document', show_named_only: 'Show Named Versions Only', show_all: 'Show All Versions', comparison_on: 'Turn On Comparison', comparison_off: 'Turn Off Comparison',
|
|
78
|
+
compare_since_beginning_menuitem: 'Compare with First Version', name_version_menuitem: 'Name This Version', compare_with_menuitem: 'Compare with This Version',
|
|
79
|
+
before_restore: 'Before restore', restore_menuitem: 'Restore', more_actions: 'More', action_failed: 'Couldn’t finish the operation. Try again.', history_load_failed: 'Couldn’t load Version History',
|
|
80
|
+
},
|
|
81
|
+
exporter: { open_file: 'Open File', open_video_file: 'Open Video', open_audio_file: 'Open Audio' },
|
|
82
|
+
generic: { loading: 'Loading…', load_more: 'Load More' },
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function merged<T>(base: T, overrides: Overrides<T> | undefined): T {
|
|
87
|
+
if (!overrides || typeof base !== 'object' || base === null) return (overrides ?? base) as T
|
|
88
|
+
const out = { ...base } as Record<string, unknown>
|
|
89
|
+
for (const [key, value] of Object.entries(overrides)) out[key] = merged((base as Record<string, unknown>)[key], value as Overrides<unknown>)
|
|
90
|
+
return out as T
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const DICTIONARIES: Record<Locale, Dictionary> = { 'zh-CN': merged(zh, BLOCKNOTE_MESSAGES['zh-CN']), en: merged(en, BLOCKNOTE_MESSAGES.en) }
|
|
94
|
+
|
|
95
|
+
/** BlockNote's dictionary for the interface language, through its public `dictionary` option. */
|
|
96
|
+
export function blockNoteDictionary(locale: Locale): Dictionary {
|
|
97
|
+
return DICTIONARIES[locale]
|
|
98
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createExtension } from '@blocknote/core'
|
|
2
|
+
import { Extension } from '@tiptap/core'
|
|
3
|
+
import { Plugin } from '@tiptap/pm/state'
|
|
4
|
+
import type { EditorView } from '@tiptap/pm/view'
|
|
5
|
+
|
|
6
|
+
function canCopyNativeText(view: EditorView): boolean {
|
|
7
|
+
const selection = view.dom.ownerDocument.getSelection()
|
|
8
|
+
if (!selection || selection.isCollapsed || selection.rangeCount !== 1
|
|
9
|
+
|| !selection.anchorNode || !selection.focusNode
|
|
10
|
+
|| !view.dom.contains(selection.anchorNode) || !view.dom.contains(selection.focusNode)) return false
|
|
11
|
+
const range = selection.getRangeAt(0)
|
|
12
|
+
let hasTextBlock = false
|
|
13
|
+
for (const element of view.dom.querySelectorAll('[data-content-type]')) {
|
|
14
|
+
if (!range.intersectsNode(element)) continue
|
|
15
|
+
const type = element.getAttribute('data-content-type')
|
|
16
|
+
if (type !== 'paragraph' && type !== 'bulletListItem') return false
|
|
17
|
+
hasTextBlock = true
|
|
18
|
+
}
|
|
19
|
+
if (!hasTextBlock) return false
|
|
20
|
+
for (const element of view.dom.querySelectorAll('[data-inline-content-type]:not([data-inline-content-type="text"]):not([data-inline-content-type="link"]), [data-bn-thread-id], [contenteditable="false"]')) {
|
|
21
|
+
if (range.intersectsNode(element)) return false
|
|
22
|
+
}
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Plain native text uses the browser's current range; protected nodes keep BlockNote's serializer. */
|
|
27
|
+
export const nativeTextClipboard = createExtension({
|
|
28
|
+
key: 'presmium-native-text-clipboard',
|
|
29
|
+
tiptapExtensions: [Extension.create({
|
|
30
|
+
name: 'presmium-native-text-clipboard',
|
|
31
|
+
priority: 1000,
|
|
32
|
+
addProseMirrorPlugins() {
|
|
33
|
+
return [new Plugin({ props: { handleDOMEvents: {
|
|
34
|
+
// Consuming the editor handler without preventDefault retains the browser's clipboard action.
|
|
35
|
+
copy: canCopyNativeText,
|
|
36
|
+
cut: view => view.editable && canCopyNativeText(view),
|
|
37
|
+
} } })]
|
|
38
|
+
},
|
|
39
|
+
})],
|
|
40
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createExtension, defaultStyleSpecs } from '@blocknote/core'
|
|
2
|
+
import { documentColorValue } from '@lincyaw/kernel'
|
|
3
|
+
import type { DocumentColorKind } from '@lincyaw/kernel'
|
|
4
|
+
import type { StyleSpec } from '@blocknote/core'
|
|
5
|
+
import { Plugin } from '@tiptap/pm/state'
|
|
6
|
+
import type { EditorState } from '@tiptap/pm/state'
|
|
7
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
8
|
+
import type { Node } from '@tiptap/pm/model'
|
|
9
|
+
|
|
10
|
+
// Document colours are design tokens; older BlockNote palette names paint as the same semantic token the viewer uses.
|
|
11
|
+
export function documentColor(value: string | undefined, kind: DocumentColorKind): string | undefined {
|
|
12
|
+
if (!value || value === 'default') return undefined
|
|
13
|
+
const color = documentColorValue(value, kind)
|
|
14
|
+
return typeof CSS !== 'undefined' && CSS.supports('color', color) ? color : undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function colorStyle<Config extends { type: 'textColor' | 'backgroundColor', propSchema: 'string' }>(spec: StyleSpec<Config>, property: 'color' | 'backgroundColor'): StyleSpec<Config> {
|
|
18
|
+
const mark = spec.implementation.mark.extend({
|
|
19
|
+
addMarkView() {
|
|
20
|
+
// BlockNote's own style marks always define a mark view.
|
|
21
|
+
const parent = this.parent!()
|
|
22
|
+
return props => {
|
|
23
|
+
const view = parent(props)
|
|
24
|
+
const color = documentColor(props.mark.attrs.stringValue as string | undefined, property === 'color' ? 'text' : 'background')
|
|
25
|
+
if (color && view.dom instanceof HTMLElement) view.dom.style[property] = color
|
|
26
|
+
return view
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
return { ...spec, implementation: { ...spec.implementation, mark } }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const documentColorStyles = {
|
|
34
|
+
textColor: colorStyle(defaultStyleSpecs.textColor, 'color'),
|
|
35
|
+
backgroundColor: colorStyle(defaultStyleSpecs.backgroundColor, 'backgroundColor'),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decorations(doc: Node): DecorationSet {
|
|
39
|
+
const found: Decoration[] = []
|
|
40
|
+
doc.descendants((node, position) => {
|
|
41
|
+
const color = documentColor(node.attrs.textColor as string | undefined, 'text'), background = documentColor(node.attrs.backgroundColor as string | undefined, 'background')
|
|
42
|
+
if (color || background) found.push(Decoration.node(position, position + node.nodeSize, { style: [color && `color: ${color}`, background && `background-color: ${background}`].filter(Boolean).join('; ') }))
|
|
43
|
+
})
|
|
44
|
+
return DecorationSet.create(doc, found)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Block and table-cell colours are painted with public node decorations. */
|
|
48
|
+
export const documentBlockColors = createExtension({
|
|
49
|
+
key: 'presmiumDocumentBlockColors',
|
|
50
|
+
prosemirrorPlugins: [new Plugin<DecorationSet>({
|
|
51
|
+
state: {
|
|
52
|
+
init: (_, state: EditorState) => decorations(state.doc),
|
|
53
|
+
apply: (transaction, value) => transaction.docChanged ? decorations(transaction.doc) : value,
|
|
54
|
+
},
|
|
55
|
+
props: { decorations(state) { return this.getState(state) } },
|
|
56
|
+
})],
|
|
57
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AuthoringClientAction, AuthoringClientActionResult, ClientCommentDraftReference } from '@lincyaw/kernel'
|
|
2
|
+
import { draftContentHash } from '../authoring-draft-fragment.ts'
|
|
3
|
+
import { commentDraftReference } from './comment-draft-inspection.ts'
|
|
4
|
+
import { isNewCommentDraft } from './comment-drafts.ts'
|
|
5
|
+
import type { CommentDrafts } from './comment-drafts.ts'
|
|
6
|
+
|
|
7
|
+
const sameReference = (a: ClientCommentDraftReference | null, b: ClientCommentDraftReference | null): boolean => a === null || b === null ? a === b
|
|
8
|
+
: a.id === b.id && a.revision === b.revision && a.token === b.token
|
|
9
|
+
|
|
10
|
+
/** The native composer, rather than its backup, acknowledges actual editor adoption. */
|
|
11
|
+
export async function mutateCommentDrafts(drafts: CommentDrafts, action: Extract<AuthoringClientAction, { type: 'comment-drafts.restore' | 'comment-drafts.discard' }>, signal: AbortSignal, owned: () => void, fits: (result: AuthoringClientActionResult) => boolean): Promise<AuthoringClientActionResult> {
|
|
12
|
+
const read = await drafts.readForAgent(signal, action.draft), candidate = read.candidates[0]!
|
|
13
|
+
owned()
|
|
14
|
+
if (action.type === 'comment-drafts.discard') {
|
|
15
|
+
const guard = () => { signal.throwIfAborted(); owned(); if (drafts.targetActive(candidate.target) || candidate.composing || candidate.pending) throw new Error('client-comment-draft-needs-human') }
|
|
16
|
+
const result: AuthoringClientActionResult = { type: action.type, family: 'comment', draft: action.draft, discarded: true }
|
|
17
|
+
guard()
|
|
18
|
+
if (!fits(result)) throw new Error('client-comment-result-too-large')
|
|
19
|
+
if (!await drafts.discard(action.draft, { signal, guard })) throw new Error('client-comment-drafts-changed')
|
|
20
|
+
guard()
|
|
21
|
+
return result
|
|
22
|
+
}
|
|
23
|
+
if (isNewCommentDraft(candidate.target) || candidate.composing || candidate.pending || candidate.storage !== 'durable') throw new Error('client-comment-draft-needs-human')
|
|
24
|
+
const composer = drafts.composer(action.composerId), choice = await drafts.restoreChoice(candidate, signal)
|
|
25
|
+
owned()
|
|
26
|
+
if (!composer?.restore || choice?.composerId !== action.composerId || !sameReference(choice.expectedCurrent, action.expectedCurrent)) throw new Error('client-comment-composer-unavailable')
|
|
27
|
+
const previous = composer.snapshot().record
|
|
28
|
+
if (previous) {
|
|
29
|
+
if (!action.expectedCurrent) throw new Error('client-comment-drafts-changed')
|
|
30
|
+
const current = await drafts.readForAgent(signal, action.expectedCurrent)
|
|
31
|
+
owned()
|
|
32
|
+
if (current.candidates[0]?.storage !== 'durable') throw new Error('client-comment-draft-needs-human')
|
|
33
|
+
} else if (action.expectedCurrent !== null) throw new Error('client-comment-drafts-changed')
|
|
34
|
+
const guard = () => {
|
|
35
|
+
signal.throwIfAborted(); owned()
|
|
36
|
+
const state = composer.snapshot()
|
|
37
|
+
if (!drafts.ownersKnown || drafts.composer(action.composerId) !== composer || state.record !== previous || !state.available || state.composing || composer.targetState() === 'unavailable') throw new Error('client-comment-composer-changed')
|
|
38
|
+
}
|
|
39
|
+
guard()
|
|
40
|
+
const targetState = composer.targetState()
|
|
41
|
+
if (targetState === 'unavailable') throw new Error('client-comment-composer-unavailable')
|
|
42
|
+
if (!fits({ type: action.type, family: 'comment', draft: action.draft, composerId: action.composerId,
|
|
43
|
+
restored: { id: '0'.repeat(36), revision: 1, token: '0'.repeat(64) }, preservedCurrent: action.expectedCurrent, targetState })) throw new Error('client-comment-result-too-large')
|
|
44
|
+
const record = await composer.restore(candidate, { signal, guard })
|
|
45
|
+
if (!record) throw new Error('client-comment-native-unverified')
|
|
46
|
+
const adopted = () => {
|
|
47
|
+
signal.throwIfAborted(); owned()
|
|
48
|
+
const state = composer.snapshot()
|
|
49
|
+
if (!drafts.ownersKnown || drafts.composer(action.composerId) !== composer || state.record !== record || !state.available || state.composing || !drafts.owns(record)) throw new Error('client-comment-composer-changed')
|
|
50
|
+
}
|
|
51
|
+
adopted()
|
|
52
|
+
const token = await draftContentHash(JSON.stringify(record))
|
|
53
|
+
adopted()
|
|
54
|
+
const result: AuthoringClientActionResult = { type: action.type, family: 'comment', draft: action.draft, composerId: action.composerId,
|
|
55
|
+
restored: commentDraftReference({ ...record, token, storage: 'durable' }), preservedCurrent: action.expectedCurrent, targetState }
|
|
56
|
+
if (!fits(result)) throw new Error('client-comment-result-too-large')
|
|
57
|
+
return result
|
|
58
|
+
}
|