@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,26 @@
|
|
|
1
|
+
import type { ControlDensity } from '../components.tsx'
|
|
2
|
+
import type { RJSFSchema } from '@rjsf/utils'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import type { DocumentParameterAccess } from '@lincyaw/kernel'
|
|
5
|
+
export type FieldPathList = (string | number)[]
|
|
6
|
+
|
|
7
|
+
export interface SchemaFormMessages { empty: string, unsupported: string, yes: string, no: string, choose: string, add: string, remove: string, up: string, down: string, number: string, composing: string, access: string, union: string }
|
|
8
|
+
export const SCHEMA_FORM_MESSAGES: Record<'zh-CN' | 'en', SchemaFormMessages> = {
|
|
9
|
+
'zh-CN': { empty: '无公开参数', unsupported: '此参数暂不支持表单编辑', yes: '是', no: '否', choose: '请选择', add: '添加', remove: '移除', up: '上移', down: '下移', number: '请输入有效数字', composing: '请完成输入', access: '此参数暂不可编辑', union: '此分支暂不可编辑' },
|
|
10
|
+
en: { empty: 'No public parameters', unsupported: 'This parameter cannot be edited in this form', yes: 'Yes', no: 'No', choose: 'Choose', add: 'Add', remove: 'Remove', up: 'Move Up', down: 'Move Down', number: 'Enter a valid number', composing: 'Finish composing', access: 'This parameter is currently read-only', union: 'This branch is currently read-only' },
|
|
11
|
+
}
|
|
12
|
+
export interface SchemaFormContext {
|
|
13
|
+
density: ControlDensity
|
|
14
|
+
messages: SchemaFormMessages
|
|
15
|
+
paths: Map<string, FieldPathList>
|
|
16
|
+
schemas: Map<string, RJSFSchema>
|
|
17
|
+
readOnly: Map<string, boolean>
|
|
18
|
+
intent?: FieldPathList
|
|
19
|
+
intentReadOnly?: boolean
|
|
20
|
+
branch?: string
|
|
21
|
+
mark(id: string): void
|
|
22
|
+
inputError(id: string, error: string | undefined): void
|
|
23
|
+
access(path: FieldPathList): DocumentParameterAccess
|
|
24
|
+
hideProperties: ReadonlySet<string>
|
|
25
|
+
empty?: ReactNode
|
|
26
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createContext, useContext, useLayoutEffect } from 'react'
|
|
2
|
+
import { getDefaultRegistry } from '@rjsf/core'
|
|
3
|
+
import { ADDITIONAL_PROPERTY_FLAG, getDiscriminatorFieldFromSchema, getOptionMatchingSimpleDiscriminator } from '@rjsf/utils'
|
|
4
|
+
import type { FieldProps, RJSFMarkedSchema, RJSFSchema } from '@rjsf/utils'
|
|
5
|
+
import type { FieldPathList, SchemaFormContext } from './context.ts'
|
|
6
|
+
import { PmNotice } from '../components.tsx'
|
|
7
|
+
|
|
8
|
+
export const SchemaFormPath = createContext<{ path: FieldPathList, id?: string }>({ path: [] })
|
|
9
|
+
export const SchemaFormUnionFields = createContext<readonly { id: string, names: ReadonlySet<string> }[]>([])
|
|
10
|
+
const native = getDefaultRegistry().fields, NativeSchemaField = native.SchemaField!
|
|
11
|
+
|
|
12
|
+
/** The public marker distinguishes authored fields from properties inferred from additional data. */
|
|
13
|
+
export function declaredSchemaProperties(schema: RJSFSchema): string[] {
|
|
14
|
+
return Object.entries(schema.properties ?? {}).filter(([, child]) => typeof child === 'boolean' || !(child as RJSFMarkedSchema)[ADDITIONAL_PROPERTY_FLAG]).map(([name]) => name)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Public SchemaField props retain exact property names, including punctuation, without parsing DOM IDs. */
|
|
18
|
+
function SchemaField(props: FieldProps<unknown, any, SchemaFormContext>) {
|
|
19
|
+
const id = props.fieldPathId.$id, path = props.fieldPathId.path
|
|
20
|
+
const context = props.registry.formContext
|
|
21
|
+
const access = context.access(path), blocked = !access.available || !access.public
|
|
22
|
+
const hidden = Boolean((props.schema as RJSFMarkedSchema)[ADDITIONAL_PROPERTY_FLAG] || access.available && !access.public)
|
|
23
|
+
const readonly = Boolean(props.readonly || props.schema.readOnly || access.readOnly || blocked)
|
|
24
|
+
useLayoutEffect(() => {
|
|
25
|
+
if (hidden) return
|
|
26
|
+
context.paths.set(id, path); context.schemas.set(id, props.schema); context.readOnly.set(id, readonly)
|
|
27
|
+
return () => { if (context.schemas.get(id) === props.schema) { context.paths.delete(id); context.schemas.delete(id); context.readOnly.delete(id) } }
|
|
28
|
+
}, [context, id, props.schema, readonly, hidden, JSON.stringify(path)])
|
|
29
|
+
if (hidden) return null
|
|
30
|
+
return <SchemaFormPath.Provider value={{ path, id }}>{blocked && !props.readonly && <PmNotice>{context.messages.access}</PmNotice>}<NativeSchemaField {...props} readonly={readonly} /></SchemaFormPath.Provider>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Keep native branch state; omit only fields already rendered outside this exact union scope. */
|
|
34
|
+
function unionField(Native: typeof NativeSchemaField) {
|
|
35
|
+
return function UnionField(props: FieldProps<unknown, any, SchemaFormContext>) {
|
|
36
|
+
const inherited = useContext(SchemaFormUnionFields)
|
|
37
|
+
let names: string[], supported = false
|
|
38
|
+
try {
|
|
39
|
+
const { schemaUtils, formContext } = props.registry, resolved = schemaUtils.retrieveSchema(props.schema, props.formData)
|
|
40
|
+
names = declaredSchemaProperties(resolved)
|
|
41
|
+
const selector = getDiscriminatorFieldFromSchema(props.schema)
|
|
42
|
+
const options = (props.options as RJSFSchema[]).map(option => schemaUtils.retrieveSchema(option, props.formData))
|
|
43
|
+
if (selector && options.length) {
|
|
44
|
+
const values = options.map(option => {
|
|
45
|
+
const property = option.properties?.[selector]
|
|
46
|
+
if (!property || typeof property === 'boolean' || !option.required?.includes(selector)) return undefined
|
|
47
|
+
const marker = schemaUtils.retrieveSchema(property, (props.formData as Record<string, unknown> | undefined)?.[selector])
|
|
48
|
+
if (!Object.hasOwn(marker, 'const') || marker.readOnly || marker.const !== null && !['string', 'number', 'boolean'].includes(typeof marker.const) || typeof marker.const === 'number' && !Number.isFinite(marker.const)) return undefined
|
|
49
|
+
return JSON.stringify(marker.const)
|
|
50
|
+
})
|
|
51
|
+
const access = formContext.access([...props.fieldPathId.path, selector])
|
|
52
|
+
supported = values.every(value => value !== undefined) && new Set(values).size === options.length && getOptionMatchingSimpleDiscriminator(props.formData, options, selector) !== undefined && access.available && access.public && !access.readOnly
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch { return <PmNotice>{props.registry.formContext.messages.unsupported}</PmNotice> }
|
|
56
|
+
return <SchemaFormUnionFields.Provider value={[...inherited, { id: props.fieldPathId.$id, names: new Set(names) }]}>{!supported && !props.readonly && <PmNotice>{props.registry.formContext.messages.union}</PmNotice>}<Native {...props} readonly={props.readonly || !supported} /></SchemaFormUnionFields.Provider>
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export const schemaFormFields = { SchemaField, AnyOfField: unionField(native.AnyOfField!), OneOfField: unionField(native.OneOfField!) }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useContext } from 'react'
|
|
2
|
+
import type { FormProps } from '@rjsf/core'
|
|
3
|
+
import type { ArrayFieldItemButtonsTemplateProps, ArrayFieldItemTemplateProps, ArrayFieldTemplateProps, FieldTemplateProps, ObjectFieldTemplateProps } from '@rjsf/utils'
|
|
4
|
+
import { PmButton, PmField, PmIconButton, PmNotice } from '../components.tsx'
|
|
5
|
+
import type { SchemaFormContext } from './context.ts'
|
|
6
|
+
import { declaredSchemaProperties, SchemaFormPath, SchemaFormUnionFields } from './fields.tsx'
|
|
7
|
+
|
|
8
|
+
function Field(props: FieldTemplateProps<unknown, any, SchemaFormContext>) {
|
|
9
|
+
if (props.hidden) return null
|
|
10
|
+
return <div className="pm-ui-schema-form__field">{props.children}{(props.schema.type === 'object' || props.schema.type === 'array') && props.rawErrors?.length ? <PmNotice tone="danger">{props.rawErrors.join(' · ')}</PmNotice> : null}</div>
|
|
11
|
+
}
|
|
12
|
+
function ObjectFields(props: ObjectFieldTemplateProps<unknown, any, SchemaFormContext>) {
|
|
13
|
+
const declared = new Set(declaredSchemaProperties(props.schema))
|
|
14
|
+
const fields = props.properties.filter(property => declared.has(property.name))
|
|
15
|
+
const scope = useContext(SchemaFormPath), context = props.registry.formContext
|
|
16
|
+
const unions = useContext(SchemaFormUnionFields)
|
|
17
|
+
const duplicate = (name: string) => unions.some(union => union.id === props.fieldPathId.$id && union.names.has(name))
|
|
18
|
+
const shown = fields.filter(property => !duplicate(property.name) && (scope.path.length || !context.hideProperties.has(property.name)))
|
|
19
|
+
if (scope.path.length && !fields.length && !props.schema.oneOf && !props.schema.anyOf) return null
|
|
20
|
+
if (fields.length && fields.every(property => duplicate(property.name))) return null
|
|
21
|
+
const children = shown.length ? <div className="pm-ui-schema-form__fields">{shown.map(property => <div key={property.name}>{property.content}</div>)}</div>
|
|
22
|
+
: props.schema.oneOf || props.schema.anyOf ? null : context.empty === undefined ? <span>{context.messages.empty}</span> : context.empty
|
|
23
|
+
return props.title ? <PmField label={props.title} group density={props.registry.formContext.density}>{children}</PmField> : children
|
|
24
|
+
}
|
|
25
|
+
function ArrayFields(props: ArrayFieldTemplateProps<unknown, any, SchemaFormContext>) {
|
|
26
|
+
const context = props.registry.formContext
|
|
27
|
+
return <PmField label={props.title} group density={context.density}><div className="pm-ui-schema-form__fields">{props.items}</div>
|
|
28
|
+
{props.canAdd && <PmButton small icon="plus" disabled={props.disabled || props.readonly} onClick={event => { context.intent = props.fieldPathId.path; context.intentReadOnly = Boolean(props.disabled || props.readonly); props.onAddClick(event) }}>{context.messages.add}</PmButton>}
|
|
29
|
+
</PmField>
|
|
30
|
+
}
|
|
31
|
+
function ArrayButtons(props: ArrayFieldItemButtonsTemplateProps<unknown, any, SchemaFormContext>) {
|
|
32
|
+
const context = props.registry.formContext
|
|
33
|
+
const invoke = (action: (event: unknown) => void, event: unknown) => { context.intent = props.fieldPathId.path.slice(0, -1); context.intentReadOnly = Boolean(props.disabled || props.readonly); action(event) }
|
|
34
|
+
return <div className="pm-ui-schema-form__actions">
|
|
35
|
+
{props.hasMoveUp && <PmButton small disabled={props.disabled || props.readonly} onClick={event => invoke(props.onMoveUpItem, event)}>{context.messages.up}</PmButton>}
|
|
36
|
+
{props.hasMoveDown && <PmButton small disabled={props.disabled || props.readonly} onClick={event => invoke(props.onMoveDownItem, event)}>{context.messages.down}</PmButton>}
|
|
37
|
+
{props.hasRemove && <PmIconButton small icon="trash" label={context.messages.remove} disabled={props.disabled || props.readonly} onClick={event => invoke(props.onRemoveItem, event)} />}
|
|
38
|
+
</div>
|
|
39
|
+
}
|
|
40
|
+
function ArrayItem(props: ArrayFieldItemTemplateProps<unknown, any, SchemaFormContext>) {
|
|
41
|
+
return <div className="pm-ui-schema-form__item"><div className="pm-ui-schema-form__field">{props.children}</div>{props.hasToolbar && <ArrayButtons {...props.buttonsProps} />}</div>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const schemaFormTemplates: NonNullable<FormProps['templates']> = {
|
|
45
|
+
FieldTemplate: Field, ObjectFieldTemplate: ObjectFields, ArrayFieldTemplate: ArrayFields,
|
|
46
|
+
ArrayFieldItemTemplate: ArrayItem, ArrayFieldItemButtonsTemplate: ArrayButtons,
|
|
47
|
+
WrapIfAdditionalTemplate: () => null, ErrorListTemplate: () => null,
|
|
48
|
+
UnsupportedFieldTemplate: props => <PmNotice>{props.registry.formContext.messages.unsupported}</PmNotice>,
|
|
49
|
+
ButtonTemplates: { SubmitButton: () => null },
|
|
50
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { validateDocumentParameters } from '@lincyaw/kernel'
|
|
2
|
+
import type { DocumentParameterValidation, JsonSchema } from '@lincyaw/kernel'
|
|
3
|
+
import { createErrorHandler, ErrorSchemaBuilder, ROOT_SCHEMA_PREFIX, unwrapErrorHandler, validationDataMerge, withIdRefPrefix } from '@rjsf/utils'
|
|
4
|
+
import type { RJSFSchema, RJSFValidationError, ValidatorType } from '@rjsf/utils'
|
|
5
|
+
|
|
6
|
+
const pathOf = (pointer: string) => pointer ? pointer.slice(1).split('/').map(value => value.replaceAll('~1', '/').replaceAll('~0', '~')) : []
|
|
7
|
+
|
|
8
|
+
/** RJSF owns field discovery; CFworker remains the canonical validation implementation. */
|
|
9
|
+
export function schemaFormValidator(root: RJSFSchema): ValidatorType {
|
|
10
|
+
const validate = (schema: RJSFSchema, data: unknown, rootSchema = root): DocumentParameterValidation => {
|
|
11
|
+
if (schema === rootSchema) return validateDocumentParameters(schema as JsonSchema, data)
|
|
12
|
+
const fragment = withIdRefPrefix(schema) as RJSFSchema
|
|
13
|
+
// RJSF's fragment refs point at the registered root, not the fragment's original resource ID.
|
|
14
|
+
delete fragment.$id
|
|
15
|
+
return validateDocumentParameters(fragment as JsonSchema, data, [{ id: ROOT_SCHEMA_PREFIX, schema: rootSchema as JsonSchema }])
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
rawValidation<Result>(schema: RJSFSchema, data?: unknown) {
|
|
19
|
+
const result = validate(schema, data)
|
|
20
|
+
return result.available ? { errors: result.issues as Result[] } : { validationError: new Error(result.issues[0]?.message) }
|
|
21
|
+
},
|
|
22
|
+
isValid: (schema, data, rootSchema) => validate(schema, data, rootSchema).valid,
|
|
23
|
+
validateFormData(data, schema, customValidate, transformErrors, uiSchema) {
|
|
24
|
+
const result = validate(schema, data)
|
|
25
|
+
const issues = result.issues.filter(issue => !['properties', 'items', '$ref', 'allOf'].includes(issue.keyword)
|
|
26
|
+
|| !result.issues.some(child => child !== issue && child.path.startsWith(issue.path) && child.keyword !== issue.keyword))
|
|
27
|
+
let errors: RJSFValidationError[] = issues.map(issue => ({ name: issue.keyword, message: issue.message, params: { instancePath: issue.path },
|
|
28
|
+
property: pathOf(issue.path).map(part => `[${JSON.stringify(part)}]`).join(''), schemaPath: issue.schemaPath, stack: `${issue.path} ${issue.message}` }))
|
|
29
|
+
if (transformErrors) errors = transformErrors(errors, uiSchema)
|
|
30
|
+
const builder = new ErrorSchemaBuilder()
|
|
31
|
+
errors.forEach(error => builder.addErrors(error.message ?? error.stack, typeof error.params?.instancePath === 'string' ? pathOf(error.params.instancePath) : []))
|
|
32
|
+
const validated = { errors, errorSchema: builder.ErrorSchema }
|
|
33
|
+
return customValidate ? validationDataMerge(validated, unwrapErrorHandler(customValidate(data, createErrorHandler(data), uiSchema))) : validated
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { enumOptionsValueForIndex } from '@rjsf/utils'
|
|
3
|
+
import type { WidgetProps } from '@rjsf/utils'
|
|
4
|
+
import { PmField, PmInput, PmNotice, PmSegmented, PmSelect, PmTextarea } from '../components.tsx'
|
|
5
|
+
import type { SchemaFormContext } from './context.ts'
|
|
6
|
+
|
|
7
|
+
type Props = WidgetProps<unknown, any, SchemaFormContext>
|
|
8
|
+
const feedback = (props: Props) => props.rawErrors?.join(' · ')
|
|
9
|
+
const change = (props: Props, value: unknown) => { if (!props.disabled && !props.readonly) { props.registry.formContext.mark(props.id); props.onChange(value) } }
|
|
10
|
+
|
|
11
|
+
function Input(props: Props) {
|
|
12
|
+
const { id, value, schema, disabled, readonly, label, placeholder, registry, onBlur, onFocus } = props
|
|
13
|
+
const numeric = schema.type === 'number' || schema.type === 'integer'
|
|
14
|
+
const [draft, setDraft] = useState<string>(), [invalid, setInvalid] = useState(false)
|
|
15
|
+
useEffect(() => { setDraft(undefined); setInvalid(false); registry.formContext.inputError(id, undefined) }, [value])
|
|
16
|
+
useEffect(() => () => registry.formContext.inputError(id, undefined), [id])
|
|
17
|
+
return <PmInput id={id} label={label} value={draft ?? (value === undefined || value === null ? '' : String(value))} placeholder={placeholder}
|
|
18
|
+
disabled={disabled} readOnly={readonly} density={registry.formContext.density} inputMode={numeric ? 'decimal' : undefined} width={numeric ? 'number' : undefined}
|
|
19
|
+
error={invalid ? registry.formContext.messages.number : feedback(props)} hint={schema.description}
|
|
20
|
+
onFocus={() => onFocus(id, value)} onBlur={() => onBlur(id, value)} onChange={event => {
|
|
21
|
+
if (disabled || readonly) return
|
|
22
|
+
const text = event.currentTarget.value
|
|
23
|
+
if (!numeric) { change(props, text); return }
|
|
24
|
+
setDraft(text)
|
|
25
|
+
const number = Number(text), valid = text === '' || /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?$/i.test(text) && Number.isFinite(number)
|
|
26
|
+
setInvalid(!valid); registry.formContext.inputError(id, valid ? undefined : registry.formContext.messages.number)
|
|
27
|
+
change(props, !valid ? text : text === '' ? undefined : number)
|
|
28
|
+
}} />
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Select(props: Props) {
|
|
32
|
+
const { id, label, value, options, disabled, readonly, registry } = props
|
|
33
|
+
const choices = options.enumOptions ?? []
|
|
34
|
+
const selected = choices.findIndex(option => JSON.stringify(option.value) === JSON.stringify(value))
|
|
35
|
+
if (props.multiple) return <PmNotice>{registry.formContext.messages.unsupported}</PmNotice>
|
|
36
|
+
return <PmSelect id={id} label={label} value={selected < 0 ? '' : String(selected)} disabled={disabled || readonly} density={registry.formContext.density}
|
|
37
|
+
error={feedback(props)} onBlur={() => props.onBlur(id, value)} onFocus={() => props.onFocus(id, value)} onChange={event => {
|
|
38
|
+
change(props, enumOptionsValueForIndex(event.currentTarget.value, choices, options.emptyValue))
|
|
39
|
+
}}><option value="">{registry.formContext.messages.choose}</option>{choices.map((option, index) => <option key={index} value={index}>{option.label}</option>)}</PmSelect>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Boolean(props: Props) {
|
|
43
|
+
const context = props.registry.formContext
|
|
44
|
+
return <PmField label={props.label} group error={feedback(props)} density={context.density}>
|
|
45
|
+
<PmSegmented label={props.label} value={typeof props.value === 'boolean' ? String(props.value) : ''} options={[{ value: 'true', label: context.messages.yes }, { value: 'false', label: context.messages.no }]}
|
|
46
|
+
disabled={props.disabled || props.readonly} density={context.density} onChange={value => change(props, value === 'true')} />
|
|
47
|
+
</PmField>
|
|
48
|
+
}
|
|
49
|
+
function Textarea(props: Props) {
|
|
50
|
+
return <PmTextarea id={props.id} label={props.label} value={props.value ?? ''} disabled={props.disabled} readOnly={props.readonly} density={props.registry.formContext.density}
|
|
51
|
+
error={feedback(props)} onChange={event => change(props, event.currentTarget.value)} />
|
|
52
|
+
}
|
|
53
|
+
function Unsupported(props: Props) { return <PmNotice>{props.registry.formContext.messages.unsupported}</PmNotice> }
|
|
54
|
+
|
|
55
|
+
export const schemaFormWidgets = {
|
|
56
|
+
TextWidget: Input, UpDownWidget: Input, EmailWidget: Input, URLWidget: Input, PasswordWidget: Input,
|
|
57
|
+
DateWidget: Input, DateTimeWidget: Input, TimeWidget: Input, ColorWidget: Input, RangeWidget: Input,
|
|
58
|
+
TextareaWidget: Textarea, SelectWidget: Select, RadioWidget: Select, CheckboxWidget: Boolean,
|
|
59
|
+
CheckboxesWidget: Unsupported, FileWidget: Unsupported, HiddenWidget: Unsupported,
|
|
60
|
+
}
|
package/src/shortcuts.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const apple = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The keys shown for the workbench's commands, one form per platform (docs/glossary.md, Undo and Redo): ⌘Z and ⌘⇧Z on
|
|
5
|
+
* Apple devices, Ctrl+Z and Ctrl+Y elsewhere. Both Ctrl+Y and Ctrl+Shift+Z (⌘⇧Z) redo, in the page and in the editor.
|
|
6
|
+
* Key names stay in English in both languages, as on the keys.
|
|
7
|
+
*/
|
|
8
|
+
export const SHORTCUTS = { ...apple ? { undo: '⌘Z', redo: '⌘⇧Z', save: '⌘S' } : { undo: 'Ctrl+Z', redo: 'Ctrl+Y', save: 'Ctrl+S' }, playPause: 'Space' }
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { DragDropProvider, useDragDropManager } from '@dnd-kit/react'
|
|
2
|
+
import type { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/react'
|
|
3
|
+
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
|
|
4
|
+
import { Accessibility, Feedback } from '@dnd-kit/dom'
|
|
5
|
+
import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import type { MutableRefObject, ReactNode } from 'react'
|
|
7
|
+
import { PmIconButton } from './base-controls.tsx'
|
|
8
|
+
import type { ControlDensity } from './base-controls.tsx'
|
|
9
|
+
|
|
10
|
+
export interface SortableListItem {
|
|
11
|
+
id: string
|
|
12
|
+
label: string
|
|
13
|
+
prefix?: ReactNode
|
|
14
|
+
actions?: ReactNode
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
/** Reordering can be unavailable while selection and the item's actions remain usable. */
|
|
17
|
+
reorderDisabled?: boolean
|
|
18
|
+
}
|
|
19
|
+
export interface SortableListLabels {
|
|
20
|
+
handle(label: string): string
|
|
21
|
+
instructions: string
|
|
22
|
+
pickedUp(label: string, index: number, count: number): string
|
|
23
|
+
position(label: string, index: number, count: number): string
|
|
24
|
+
dropped(label: string): string
|
|
25
|
+
cancelled(label: string): string
|
|
26
|
+
saving(label: string): string
|
|
27
|
+
saved(label: string, index: number, count: number): string
|
|
28
|
+
failed(label: string): string
|
|
29
|
+
}
|
|
30
|
+
export interface SortableListProps {
|
|
31
|
+
label: string
|
|
32
|
+
scopeKey: string
|
|
33
|
+
/** An external source replacement cancels a gesture without replacing the navigation control. */
|
|
34
|
+
resetKey?: string | number
|
|
35
|
+
items: readonly SortableListItem[]
|
|
36
|
+
value?: string
|
|
37
|
+
onSelect(id: string): void
|
|
38
|
+
onMove?(id: string, before: string | null): void | Promise<void>
|
|
39
|
+
/** These states block reordering, without blocking selection or caller-owned actions. */
|
|
40
|
+
reorderDisabled?: boolean
|
|
41
|
+
loading?: boolean
|
|
42
|
+
density?: ControlDensity
|
|
43
|
+
/** The workbench's interface language (its state's `locale`); the list does not choose one itself. */
|
|
44
|
+
locale: 'zh-CN' | 'en'
|
|
45
|
+
labels?: Partial<SortableListLabels>
|
|
46
|
+
className?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MESSAGES: Record<'zh-CN' | 'en', SortableListLabels> = {
|
|
50
|
+
'zh-CN': {
|
|
51
|
+
handle: label => `移动:${label}`,
|
|
52
|
+
instructions: '按空格或回车开始移动,用方向键调整位置,再按空格或回车放下,按 Escape 取消。',
|
|
53
|
+
pickedUp: (label, index, count) => `已拾起“${label}”,第 ${index} 项,共 ${count} 项。`,
|
|
54
|
+
position: (label, index, count) => `${label},位置 ${index},共 ${count} 项。`,
|
|
55
|
+
dropped: label => `已放下“${label}”。`, cancelled: label => `已取消移动“${label}”。`,
|
|
56
|
+
saving: label => `正在移动“${label}”…`, saved: (label, index, count) => `已移动“${label}”,第 ${index} 项,共 ${count} 项。`,
|
|
57
|
+
failed: label => `无法移动“${label}”。`,
|
|
58
|
+
},
|
|
59
|
+
en: {
|
|
60
|
+
handle: label => `Move: ${label}`,
|
|
61
|
+
instructions: 'Press Space or Enter to pick up an item, use the arrow keys to move, then press Space or Enter to drop, or Escape to cancel.',
|
|
62
|
+
pickedUp: (label, index, count) => `Picked up ${label}, item ${index} of ${count}.`,
|
|
63
|
+
position: (label, index, count) => `${label}, position ${index} of ${count}.`,
|
|
64
|
+
dropped: label => `Dropped ${label}.`, cancelled: label => `Canceled moving ${label}.`,
|
|
65
|
+
saving: label => `Moving ${label}…`, saved: (label, index, count) => `Moved ${label}, item ${index} of ${count}.`,
|
|
66
|
+
failed: label => `Couldn’t move ${label}.`,
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A changed list identity owns a fresh gesture and cannot receive an earlier move's completion. */
|
|
71
|
+
export function PmSortableList(props: SortableListProps) {
|
|
72
|
+
const gesture = useRef(false), movePending = useRef(false), focus = useRef<string>()
|
|
73
|
+
const order = JSON.stringify(props.items.map(item => item.id)), members = JSON.stringify(props.items.map(item => item.id).sort())
|
|
74
|
+
const availability = JSON.stringify([props.reorderDisabled, props.loading, Boolean(props.onMove), props.items.map(item => [item.id, item.disabled, item.reorderDisabled])])
|
|
75
|
+
const previous = useRef({ scope: props.scopeKey, order, members, reset: props.resetKey, availability, generation: 0 })
|
|
76
|
+
if (previous.current.scope !== props.scopeKey) focus.current = undefined
|
|
77
|
+
if (gesture.current && (previous.current.scope !== props.scopeKey || previous.current.order !== order || previous.current.reset !== props.resetKey || previous.current.availability !== availability)
|
|
78
|
+
|| movePending.current && previous.current.members !== members) {
|
|
79
|
+
previous.current.generation++; gesture.current = false; movePending.current = false
|
|
80
|
+
}
|
|
81
|
+
previous.current = { scope: props.scopeKey, order, members, reset: props.resetKey, availability, generation: previous.current.generation }
|
|
82
|
+
const signature = JSON.stringify([props.scopeKey, previous.current.generation, props.locale, props.labels?.instructions])
|
|
83
|
+
return <SortableSession key={signature} {...props} gesture={gesture} movePending={movePending} focus={focus} />
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type AccessibilityOptions = NonNullable<ConstructorParameters<typeof Accessibility>[1]>
|
|
87
|
+
|
|
88
|
+
function SortableGuard({ signature, revision, active, onReplace }: { signature: string, revision: string, active: MutableRefObject<boolean>, onReplace(): void }) {
|
|
89
|
+
const manager = useDragDropManager(), previous = useRef({ signature, revision })
|
|
90
|
+
useLayoutEffect(() => {
|
|
91
|
+
if (previous.current.signature !== signature || previous.current.revision !== revision) {
|
|
92
|
+
if (active.current) manager?.actions.stop({ canceled: true })
|
|
93
|
+
if (previous.current.revision !== revision) onReplace()
|
|
94
|
+
}
|
|
95
|
+
previous.current = { signature, revision }
|
|
96
|
+
}, [manager, signature, revision, onReplace])
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function SortableSession(props: SortableListProps & { gesture: MutableRefObject<boolean>, movePending: MutableRefObject<boolean>, focus: MutableRefObject<string | undefined> }) {
|
|
101
|
+
const { items, onMove, density = 'compact', locale } = props
|
|
102
|
+
const labels = useMemo(() => ({ ...MESSAGES[locale], ...props.labels }), [locale, props.labels])
|
|
103
|
+
const [pending, setPending] = useState<{ id: string, order: string[] }>(), [announcement, setAnnouncement] = useState('')
|
|
104
|
+
const alive = useRef(true), active = props.gesture, movePending = props.movePending, focus = props.focus, request = useRef<object>(), current = useRef({ props, labels })
|
|
105
|
+
const list = useRef<HTMLOListElement>(null), handles = useRef(new Map<string, HTMLButtonElement>())
|
|
106
|
+
const focusFrame = useRef<number>()
|
|
107
|
+
const pendingOrder = useRef<string[]>()
|
|
108
|
+
current.current = { props, labels }
|
|
109
|
+
useLayoutEffect(() => { alive.current = true; return () => { alive.current = false; request.current = undefined; active.current = false; movePending.current = false; if (focusFrame.current !== undefined) cancelAnimationFrame(focusFrame.current) } }, [])
|
|
110
|
+
const group = useId(), disabled = Boolean(props.reorderDisabled || props.loading || pending || !onMove)
|
|
111
|
+
const signature = JSON.stringify([props.reorderDisabled, props.loading, Boolean(onMove), items.map(item => [item.id, item.disabled, item.reorderDisabled])])
|
|
112
|
+
const orderKey = JSON.stringify(items.map(item => item.id)), revision = JSON.stringify([orderKey, props.resetKey])
|
|
113
|
+
const restoreFocus = () => {
|
|
114
|
+
const handle = focus.current && handles.current.get(focus.current), element = list.current?.ownerDocument.activeElement
|
|
115
|
+
if (handle && (element === document.body || [...handles.current.values()].includes(element as HTMLButtonElement))) handle.focus({ preventScroll: true })
|
|
116
|
+
}
|
|
117
|
+
useLayoutEffect(() => {
|
|
118
|
+
const select = (event: FocusEvent) => { if (event.target !== document.body) { const entry = [...handles.current].find(([, handle]) => handle === event.target); focus.current = entry?.[0] } }
|
|
119
|
+
const point = (event: PointerEvent) => { if (![...handles.current.values()].some(handle => handle.contains(event.target as Node))) focus.current = undefined }
|
|
120
|
+
const tab = (event: KeyboardEvent) => { if (event.key === 'Tab') focus.current = undefined }
|
|
121
|
+
const blur = () => { focus.current = undefined }
|
|
122
|
+
document.addEventListener('focusin', select); document.addEventListener('pointerdown', point); document.addEventListener('keydown', tab); window.addEventListener('blur', blur)
|
|
123
|
+
return () => { document.removeEventListener('focusin', select); document.removeEventListener('pointerdown', point); document.removeEventListener('keydown', tab); window.removeEventListener('blur', blur) }
|
|
124
|
+
}, [])
|
|
125
|
+
useLayoutEffect(restoreFocus, [orderKey, props.resetKey, Boolean(pending), signature])
|
|
126
|
+
const ordered = pending ? pending.order.flatMap(id => { const item = items.find(item => item.id === id); return item ? [item] : [] }) : items
|
|
127
|
+
const named = (id?: string | number) => current.current.props.items.find(item => item.id === id)?.label ?? ''
|
|
128
|
+
const accessibility = useMemo<AccessibilityOptions>(() => ({
|
|
129
|
+
screenReaderInstructions: { draggable: labels.instructions },
|
|
130
|
+
announcements: {
|
|
131
|
+
dragstart: ({ operation: { source } }: DragStartEvent) => source ? current.current.labels.pickedUp(named(source.id), isSortable(source) ? source.initialIndex + 1 : 1, current.current.props.items.length) : undefined,
|
|
132
|
+
dragover: ({ operation: { source } }: DragOverEvent) => source && isSortable(source) ? current.current.labels.position(named(source.id), source.index + 1, current.current.props.items.length) : undefined,
|
|
133
|
+
dragend: ({ operation: { source }, canceled }: DragEndEvent) => source ? canceled ? current.current.labels.cancelled(named(source.id)) : current.current.labels.dropped(named(source.id)) : undefined,
|
|
134
|
+
},
|
|
135
|
+
}), [labels])
|
|
136
|
+
return <DragDropProvider plugins={defaults => [...defaults, Feedback.configure({ dropAnimation: null, keyboardTransition: null }), Accessibility.configure(accessibility)]}
|
|
137
|
+
onBeforeDragStart={event => {
|
|
138
|
+
const source = event.operation.source, item = items.find(item => item.id === source?.id)
|
|
139
|
+
if (event.defaultPrevented || disabled || !item || item.disabled || item.reorderDisabled) event.preventDefault()
|
|
140
|
+
else active.current = true
|
|
141
|
+
}}
|
|
142
|
+
onDragStart={() => { active.current = true; setAnnouncement('') }}
|
|
143
|
+
onDragEnd={event => {
|
|
144
|
+
active.current = false
|
|
145
|
+
const source = event.operation.source
|
|
146
|
+
const element = document.activeElement
|
|
147
|
+
if (alive.current && source && event.nativeEvent instanceof KeyboardEvent && (element === document.body || [...handles.current.values()].includes(element as HTMLButtonElement))) {
|
|
148
|
+
focus.current = String(source.id)
|
|
149
|
+
if (focusFrame.current !== undefined) cancelAnimationFrame(focusFrame.current)
|
|
150
|
+
focusFrame.current = requestAnimationFrame(() => { focusFrame.current = undefined; if (alive.current) restoreFocus() })
|
|
151
|
+
}
|
|
152
|
+
if (!alive.current || disabled || event.canceled || !onMove || !isSortable(source) || !event.operation.target || source.initialIndex === source.index) return
|
|
153
|
+
const item = items[source.initialIndex]
|
|
154
|
+
if (!item || item.id !== source.id || item.disabled || item.reorderDisabled || source.index < 0 || source.index >= items.length) return
|
|
155
|
+
const order = items.map(item => item.id)
|
|
156
|
+
order.splice(source.initialIndex, 1); order.splice(source.index, 0, item.id)
|
|
157
|
+
const before = order[source.index + 1] ?? null, owner = {}
|
|
158
|
+
request.current = owner; movePending.current = true; pendingOrder.current = order; setPending({ id: item.id, order }); setAnnouncement(labels.saving(item.label))
|
|
159
|
+
void (async () => {
|
|
160
|
+
try {
|
|
161
|
+
await onMove(item.id, before)
|
|
162
|
+
if (alive.current && request.current === owner) setAnnouncement(current.current.labels.saved(item.label, order.indexOf(item.id) + 1, order.length))
|
|
163
|
+
} catch {
|
|
164
|
+
if (alive.current && request.current === owner) setAnnouncement(current.current.labels.failed(item.label))
|
|
165
|
+
} finally {
|
|
166
|
+
if (alive.current && request.current === owner) { request.current = undefined; movePending.current = false; pendingOrder.current = undefined; setPending(undefined) }
|
|
167
|
+
}
|
|
168
|
+
})()
|
|
169
|
+
}}>
|
|
170
|
+
<ol ref={list} className={['pm-ui-sortable-list', 'pm-ui', props.className].filter(Boolean).join(' ')} aria-label={props.label} aria-busy={Boolean(props.loading || pending) || undefined} data-density={density}>
|
|
171
|
+
{ordered.map((item, index) => <SortableRow key={item.id} item={item} index={index} group={group} density={density} selected={item.id === props.value} disabled={disabled} loading={Boolean(props.loading || pending?.id === item.id)} label={labels.handle(item.label)} onSelect={props.onSelect} onHandle={element => { if (element) handles.current.set(item.id, element); else handles.current.delete(item.id) }} onSettled={restoreFocus} />)}
|
|
172
|
+
</ol>
|
|
173
|
+
<p role="status" className="pm-ui-sr-only">{announcement}</p>
|
|
174
|
+
<SortableGuard signature={signature} revision={revision} active={active} onReplace={() => {
|
|
175
|
+
if (pendingOrder.current && JSON.stringify(pendingOrder.current) === orderKey) return
|
|
176
|
+
request.current = undefined; movePending.current = false; pendingOrder.current = undefined; setPending(undefined); setAnnouncement('')
|
|
177
|
+
}} />
|
|
178
|
+
</DragDropProvider>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function SortableRow({ item, index, group, density, selected, disabled, loading, label, onSelect, onHandle, onSettled }: {
|
|
182
|
+
item: SortableListItem, index: number, group: string, density: ControlDensity, selected: boolean, disabled: boolean, loading: boolean, label: string, onSelect(id: string): void, onHandle(element: HTMLButtonElement | null): void, onSettled(): void,
|
|
183
|
+
}) {
|
|
184
|
+
const { ref, handleRef, isDragSource } = useSortable({ id: item.id, index, group, type: group, accept: group, disabled: disabled || item.disabled || item.reorderDisabled, transition: null })
|
|
185
|
+
const callbacks = useRef({ handleRef, onHandle })
|
|
186
|
+
callbacks.current = { handleRef, onHandle }
|
|
187
|
+
const attachHandle = useCallback((element: HTMLButtonElement | null) => {
|
|
188
|
+
callbacks.current.handleRef(element); callbacks.current.onHandle(element)
|
|
189
|
+
}, [])
|
|
190
|
+
const wasDragging = useRef(false)
|
|
191
|
+
useLayoutEffect(() => { if (wasDragging.current && !isDragSource) onSettled(); wasDragging.current = isDragSource }, [isDragSource, onSettled])
|
|
192
|
+
return <li ref={ref} className="pm-ui-sortable-list__row" data-sortable-id={item.id} data-density={density} data-selected={selected || undefined} data-dragging={isDragSource || undefined}>
|
|
193
|
+
<PmIconButton ref={attachHandle} icon="grip" label={label} density={density} className="pm-ui-sortable-list__handle" disabled={disabled || item.disabled || item.reorderDisabled} loading={loading} tooltip={false} onPointerDownCapture={event => {
|
|
194
|
+
if (event.isPrimary && event.button === 0 && !event.currentTarget.disabled) event.currentTarget.focus({ preventScroll: true })
|
|
195
|
+
}} />
|
|
196
|
+
<button type="button" className="pm-ui-sortable-list__select" disabled={item.disabled} aria-current={selected ? 'location' : undefined} onClick={() => onSelect(item.id)}>
|
|
197
|
+
{item.prefix !== undefined && <span className="pm-ui-sortable-list__prefix">{item.prefix}</span>}<span className="pm-ui-sortable-list__label">{item.label}</span>
|
|
198
|
+
</button>
|
|
199
|
+
{item.actions && <span className="pm-ui-sortable-list__actions">{item.actions}</span>}
|
|
200
|
+
</li>
|
|
201
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Locale } from './messages.ts'
|
|
2
|
+
|
|
3
|
+
const zh = {
|
|
4
|
+
title: '语音服务', provider: '火山引擎', service: '默认服务', model: '语音合成 2.0',
|
|
5
|
+
description: '配置此工作区的配音服务,保存后立即生效。',
|
|
6
|
+
key: '语音密钥', keyPlaceholder: '输入 API Key 或 Access Token', keySaved: '已配置,留空保留',
|
|
7
|
+
keyHint: '使用语音 API Key,或与 App ID 配套的 Access Token。',
|
|
8
|
+
appId: 'App ID(可选)', appIdPlaceholder: '使用 API Key 时留空', appIdHint: '使用 Access Token 时填写对应 App ID;不需要 Secret Key。输入新的密钥且留空此项时,使用 API Key。',
|
|
9
|
+
console: '打开语音控制台',
|
|
10
|
+
source: { saved: '使用本机保存的密钥', environment: '使用启动环境中的密钥', none: '尚未配置密钥' },
|
|
11
|
+
configured: '已配置,尚未验证服务权限', unconfigured: '未配置',
|
|
12
|
+
verification: '检查配置仅检查已保存的本机设置,服务权限在播放或试听时验证。请先开通语音合成服务;播放会按需生成并缓存配音,可能产生服务费用。',
|
|
13
|
+
privacy: '密钥仅保存在本机此工作区的服务配置中,不写入讲义。',
|
|
14
|
+
check: '检查配置', save: '保存', saved: '语音服务已保存,无需重启。', checked: '本机配置可用。请通过试听验证服务权限。',
|
|
15
|
+
remove: '清除已保存密钥', removeTitle: '清除语音服务密钥', removeHint: '清除本机保存的密钥后恢复启动环境中的配置;讲稿、音色选择和已有配音保留。', cancel: '取消',
|
|
16
|
+
cleared: '已清除本机保存的密钥。', retry: '重新加载', loading: '正在加载语音服务设置…',
|
|
17
|
+
unavailable: '无法读取语音服务设置,请重试。', invalid: '请输入有效的语音密钥和对应 App ID。',
|
|
18
|
+
conflict: '设置已在别处更新。当前输入已保留,请重新加载后再保存。', busy: '设置正在被更新,请稍后重试。',
|
|
19
|
+
failed: '无法保存语音服务设置。当前输入已保留,请重试。',
|
|
20
|
+
}
|
|
21
|
+
export const SPEECH_SETTINGS_MESSAGES: Record<Locale, typeof zh> = {
|
|
22
|
+
'zh-CN': zh,
|
|
23
|
+
en: {
|
|
24
|
+
title: 'Voice Service', provider: 'Volcengine', service: 'Default service', model: 'Speech synthesis 2.0',
|
|
25
|
+
description: 'Configure voiceover for this workspace. Saved settings apply immediately.',
|
|
26
|
+
key: 'Speech Key', keyPlaceholder: 'Enter an API key or access token', keySaved: 'Configured; leave blank to keep',
|
|
27
|
+
keyHint: 'Use a speech API key, or an access token paired with an App ID.',
|
|
28
|
+
appId: 'App ID (optional)', appIdPlaceholder: 'Leave blank for an API key', appIdHint: 'Enter the matching App ID for an access token; no secret key is needed. A new key with this field blank uses API key authentication.',
|
|
29
|
+
console: 'Open Speech Console',
|
|
30
|
+
source: { saved: 'Using a key saved on this machine', environment: 'Using the launch environment key', none: 'No key configured' },
|
|
31
|
+
configured: 'Configured; service permissions not verified', unconfigured: 'Not configured',
|
|
32
|
+
verification: 'Checking configuration checks saved local settings. Playback or a sample verifies service permissions. Enable speech synthesis first; playback generates and caches missing voiceover and may incur service charges.',
|
|
33
|
+
privacy: 'The key stays in this workspace’s service settings on this machine, outside the document.',
|
|
34
|
+
check: 'Check Configuration', save: 'Save', saved: 'Voice service saved. No restart is needed.', checked: 'Local settings are available. Play a sample to verify service permissions.',
|
|
35
|
+
remove: 'Clear Saved Key', removeTitle: 'Clear Voice Service Key', removeHint: 'Clearing the saved key restores the launch environment configuration. Scripts, voice choices, and generated audio are kept.', cancel: 'Cancel',
|
|
36
|
+
cleared: 'The saved key was cleared.', retry: 'Reload', loading: 'Reading voice service settings…',
|
|
37
|
+
unavailable: 'Couldn’t read voice service settings. Try again.', invalid: 'Enter a valid speech key and matching App ID.',
|
|
38
|
+
conflict: 'Settings changed elsewhere. Your input is kept; reload before saving.', busy: 'Settings are being updated. Try again shortly.', failed: 'Couldn’t save voice service settings. Your input is kept; try again.',
|
|
39
|
+
},
|
|
40
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
.pm-ui-step-production { display: grid; gap: var(--pm-space-3); min-width: 0; }
|
|
2
|
+
.pm-ui-step-production__heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--pm-space-2); }
|
|
3
|
+
.pm-ui-step-production__durations { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: calc(var(--pm-control-time-width) * 2 + var(--pm-space-3)); align-items: start; gap: var(--pm-space-3); }
|
|
4
|
+
.pm-ui-step-production__actions { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--pm-space-3); }
|
|
5
|
+
.pm-ui-step-production__narration-settings { display: grid; gap: var(--pm-space-1); }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { StepProduction, TeachingDocument } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export const STEP_TIMING_KEYS = ['narrationStart', 'minimumHoldMs', 'trailingPauseMs'] as const
|
|
4
|
+
export type StepTimingKey = typeof STEP_TIMING_KEYS[number]
|
|
5
|
+
export type StepTimingPatch = { [K in StepTimingKey]?: StepProduction[K] | null }
|
|
6
|
+
export type ProductionSource = 'step' | 'section' | 'document' | 'unset'
|
|
7
|
+
export type StepDurationKey = 'minimumHoldMs' | 'trailingPauseMs'
|
|
8
|
+
/** An unaccepted form value; never serialized into Production. */
|
|
9
|
+
export interface StepDurationDraft { text: string, error: boolean, review?: boolean }
|
|
10
|
+
|
|
11
|
+
/** The source is author intent; an absent value is not a fabricated playback default. */
|
|
12
|
+
export function stepTimingValue<K extends StepTimingKey>(document: TeachingDocument, stepId: string, key: K): { value: StepProduction[K], source: ProductionSource } {
|
|
13
|
+
const production = document.production, step = document.steps[stepId]
|
|
14
|
+
const layers = [[production.stepOverrides[stepId], 'step'], [step && production.sectionOverrides?.[step.sectionId], 'section'], [production.defaults, 'document']] as const
|
|
15
|
+
for (const [settings, source] of layers) if (settings?.[key] !== undefined) return { value: settings[key], source }
|
|
16
|
+
return { value: undefined, source: 'unset' }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Migrated narration defaults are inherited until this step explicitly overrides its start. */
|
|
20
|
+
export function stepNarrationStarts(document: TeachingDocument, stepId: string) {
|
|
21
|
+
const inherited = stepTimingValue(document, stepId, 'narrationStart')
|
|
22
|
+
return (document.steps[stepId]?.narrationIds ?? []).map(id => ({ id,
|
|
23
|
+
value: inherited.source === 'step' ? inherited.value : document.production.narrationOverrides?.[id]?.narrationStart ?? inherited.value,
|
|
24
|
+
overridden: inherited.source !== 'step' && document.production.narrationOverrides?.[id]?.narrationStart !== undefined,
|
|
25
|
+
}))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Patches only author-owned timing; voice, cues, media and other steps retain their source values. */
|
|
29
|
+
export function changeStepTiming(document: TeachingDocument, stepId: string, patch: StepTimingPatch): TeachingDocument {
|
|
30
|
+
const step = document.steps[stepId]
|
|
31
|
+
if (!step || !document.sections[step.sectionId]?.stepOrder.includes(stepId)) return document
|
|
32
|
+
for (const key of Object.keys(patch)) if (!STEP_TIMING_KEYS.includes(key as StepTimingKey)) throw new Error('document-production-key')
|
|
33
|
+
const previous = document.production.stepOverrides[stepId], next = { ...previous }
|
|
34
|
+
for (const key of STEP_TIMING_KEYS) {
|
|
35
|
+
if (!Object.hasOwn(patch, key)) continue
|
|
36
|
+
const value = patch[key]
|
|
37
|
+
if (value === null || value === undefined) delete next[key]
|
|
38
|
+
else if (key === 'narrationStart') {
|
|
39
|
+
if (value !== 'with-animation' && value !== 'after-animation') throw new Error('document-production-start')
|
|
40
|
+
next.narrationStart = value
|
|
41
|
+
} else {
|
|
42
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new Error('document-production-duration')
|
|
43
|
+
next[key] = value
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const stepOverrides = { ...document.production.stepOverrides }
|
|
47
|
+
if (Object.keys(next).length) stepOverrides[stepId] = next
|
|
48
|
+
else delete stepOverrides[stepId]
|
|
49
|
+
const production = { ...document.production, stepOverrides }
|
|
50
|
+
return JSON.stringify(document.production) === JSON.stringify(production) ? document : { ...document, production }
|
|
51
|
+
}
|