diff --git a/src/components/config-form/index.tsx b/src/components/config-form/index.tsx index fe23765d6..f0e7dcf71 100644 --- a/src/components/config-form/index.tsx +++ b/src/components/config-form/index.tsx @@ -13,7 +13,6 @@ import { NSwitch, NText, } from 'naive-ui' -import { isVNode } from 'vue' import type { ComputedRef, InjectionKey, PropType, Ref, VNode } from 'vue' import { useStoreRef } from '~/hooks/use-store-ref' @@ -187,7 +186,7 @@ const SchemaSection = defineComponent({ const { definitions, getKey } = inject(JSONSchemaFormInjectKey, {} as any) return () => { - const { schema, formData, dataKey: key, property } = props + const { schema, formData, dataKey: key } = props if (!schema) { return null @@ -198,6 +197,33 @@ const SchemaSection = defineComponent({ {Object.keys(schema.properties).map((property) => { const current = schema.properties[property] + // Check conditional visibility + const uiOptions = current?.['ui:options'] || {} + if (uiOptions.dependsOn) { + const dependsOn = Array.isArray(uiOptions.dependsOn) + ? uiOptions.dependsOn + : [uiOptions.dependsOn] + + // Check all conditions (AND logic) + const allConditionsMet = dependsOn.every((condition) => { + const { field, value: expectedValue } = condition + const actualValue = get( + formData.value, + `${getKey(key)}.${field}`, + ) + + if (Array.isArray(expectedValue)) { + return expectedValue.includes(actualValue) + } else { + return actualValue === expectedValue + } + }) + + if (!allConditionsMet) { + return null + } + } + if (current.$ref) { const nestSchmea = definitions.value.get( current.$ref.split('/').at(-1), @@ -286,7 +312,7 @@ const ScheamFormItem = defineComponent({ const { type: uiType } = options switch (uiType) { - case 'select': + case 'select': { const { values } = options as { values: { label: string; value: string }[] } @@ -298,8 +324,9 @@ const ScheamFormItem = defineComponent({ }} options={values} filterable - > + /> ) + } default: return ( + /> ) } } @@ -332,7 +359,7 @@ const ScheamFormItem = defineComponent({ onUpdateValue={(val) => { innerValue.value = val }} - > + /> ) } case 'boolean': { @@ -342,7 +369,7 @@ const ScheamFormItem = defineComponent({ onUpdateValue={(val) => { innerValue.value = val }} - > + /> ) } diff --git a/src/components/editor/codemirror/codemirror.css b/src/components/editor/codemirror/codemirror.css index 7bb982020..ac26943ca 100644 --- a/src/components/editor/codemirror/codemirror.css +++ b/src/components/editor/codemirror/codemirror.css @@ -25,4 +25,40 @@ & .cm-gutter { @apply !min-w-[3rem]; } + + .cm-uploading-widget { + display: inline-block; + font-size: 0.9em; + padding: 0 4px; + user-select: none; + } + + .cm-upload-shimmer { + display: inline-block; + background: linear-gradient( + 90deg, + #666 0%, + #666 40%, + #3b82f6 50%, + #666 60%, + #666 100% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer 2s linear infinite; + } + + @keyframes shimmer { + 0% { + background-position: 100% 0; + } + 50% { + background-position: 0% 0; + } + 100% { + background-position: 100% 0; + } + } } diff --git a/src/components/editor/codemirror/codemirror.tsx b/src/components/editor/codemirror/codemirror.tsx index ef7c04817..ce88b1396 100644 --- a/src/components/editor/codemirror/codemirror.tsx +++ b/src/components/editor/codemirror/codemirror.tsx @@ -1,9 +1,11 @@ /* eslint-disable vue/no-setup-props-destructure */ -import { useSaveConfirm } from '~/hooks/use-save-confirm' import { defineComponent } from 'vue' import type { EditorState } from '@codemirror/state' import type { PropType } from 'vue' +import { useImageUpload } from '~/hooks/use-image-upload' +import { useSaveConfirm } from '~/hooks/use-save-confirm' + import styles from '../universal/editor.module.css' import { editorBaseProps } from '../universal/props' @@ -24,12 +26,15 @@ export const CodemirrorEditor = defineComponent({ }, }, setup(props, { expose }) { + const { upload } = useImageUpload() + const [refContainer, editorView] = useCodeMirror({ initialDoc: props.text, onChange: (state) => { props.onChange(state.doc.toString()) props.onStateChange?.(state) }, + onUploadImage: upload, }) watch( diff --git a/src/components/editor/codemirror/use-codemirror.ts b/src/components/editor/codemirror/use-codemirror.ts index 6cb133bcd..f7eff4a30 100644 --- a/src/components/editor/codemirror/use-codemirror.ts +++ b/src/components/editor/codemirror/use-codemirror.ts @@ -24,10 +24,16 @@ import { codemirrorReconfigureExtension } from './extension' import { syntaxTheme } from './syntax-highlight' import { useCodeMirrorConfigureFonts } from './use-auto-fonts' import { useCodeMirrorAutoToggleTheme } from './use-auto-theme' +import { + addUpload, + removeUpload, + uploadStateField, +} from './use-upload-extensions' interface Props { initialDoc: string onChange?: (state: EditorState) => void + onUploadImage?: (file: File) => Promise } export const useCodeMirror = ( @@ -36,7 +42,7 @@ export const useCodeMirror = ( const refContainer = ref() const editorView = ref() const { general } = useEditorConfig() - const { onChange } = props + const { onChange, onUploadImage } = props const format = () => { const ev = editorView.value @@ -80,16 +86,97 @@ export const useCodeMirror = ( }) }) .catch(() => { - console.log('not support wasm') + console.info( + 'Editor wasm formatter is not supported by this browser.', + ) }) } } + + const handleImageUpload = ( + file: File, + view: EditorView, + insertPos: number, + ) => { + if (!onUploadImage) return + + const uploadId = crypto.randomUUID() + view.dispatch({ + effects: addUpload.of({ id: uploadId, pos: insertPos }), + }) + + onUploadImage(file) + .then((url) => { + const state = view.state + const decorations = state.field(uploadStateField) + let foundFrom: number | null = null + + decorations.between(0, state.doc.length, (from, _to, value) => { + if (value.spec.id === uploadId) { + foundFrom = from + return false // find the decoration by the id set before + } + }) + + if (foundFrom !== null) { + // replace the widget with the actual image markdown + view.dispatch({ + changes: { + from: foundFrom, + insert: `![${file.name}](${url})`, + }, + effects: removeUpload.of({ id: uploadId }), + }) + } + }) + .catch((_err) => { + view.dispatch({ + effects: removeUpload.of({ id: uploadId }), + }) + }) + } + + // handle paste event + const handlePaste = (event: ClipboardEvent, view: EditorView) => { + if (!onUploadImage) return + + const files = event.clipboardData?.files + if (!files || files.length === 0) return + + const file = files[0] + if (!file.type.startsWith('image/')) return + + event.preventDefault() + const insertPos = view.state.selection.main.head + handleImageUpload(file, view, insertPos) + } onMounted(() => { if (!refContainer.value) return const startState = EditorState.create({ doc: props.initialDoc, extensions: [ + uploadStateField, + + EditorView.domEventHandlers({ + paste: (event, view) => handlePaste(event, view), + drop: (event, view) => { + if (!onUploadImage) return + + const files = event.dataTransfer?.files + if (!files || files.length === 0) return + + const file = files[0] + if (!file.type.startsWith('image/')) return + + event.preventDefault() + + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }) + if (pos === null) return + view.dispatch({ selection: { anchor: pos } }) + handleImageUpload(file, view, pos) + }, + }), keymap.of([ { key: 'Mod-s', diff --git a/src/components/editor/codemirror/use-upload-extensions.ts b/src/components/editor/codemirror/use-upload-extensions.ts new file mode 100644 index 000000000..cf7b1c0f8 --- /dev/null +++ b/src/components/editor/codemirror/use-upload-extensions.ts @@ -0,0 +1,61 @@ +import type { DecorationSet } from '@codemirror/view' + +import { StateEffect, StateField } from '@codemirror/state' +import { Decoration, EditorView, WidgetType } from '@codemirror/view' + +export const addUpload = StateEffect.define<{ id: string; pos: number }>() +export const removeUpload = StateEffect.define<{ id: string }>() + +class UploadWidget extends WidgetType { + constructor(readonly id: string) { + super() + } + + toDOM() { + const container = document.createElement('span') + container.className = 'cm-uploading-widget' + + const text = document.createElement('span') + text.className = 'cm-upload-shimmer' + text.textContent = '图片上传中...' + + container.appendChild(text) + return container + } + + ignoreEvent() { + return false + } +} + +export const uploadStateField = StateField.define({ + create() { + return Decoration.none + }, + update(uploads, tr) { + uploads = uploads.map(tr.changes) + + for (const effect of tr.effects) { + if (effect.is(addUpload)) { + const decoration = Decoration.widget({ + widget: new UploadWidget(effect.value.id), + side: 1, // right side of the position + id: effect.value.id, + }) + uploads = uploads.update({ + add: [decoration.range(effect.value.pos)], + }) + } else if (effect.is(removeUpload)) { + // remove the decoration with the matching id + uploads = uploads.update({ + filter: (from, to, value) => { + return value.spec.id !== effect.value.id + }, + }) + } + } + return uploads + }, + // render the decorations + provide: (f) => EditorView.decorations.from(f), +}) diff --git a/src/hooks/use-image-upload.ts b/src/hooks/use-image-upload.ts new file mode 100644 index 000000000..cdf573d99 --- /dev/null +++ b/src/hooks/use-image-upload.ts @@ -0,0 +1,67 @@ +import type { MxServerOptions } from '~/models/options' + +import { RESTManager } from '~/utils' + +let cachedConfig: MxServerOptions.ImageUploadOption | null = null + +export const useImageUpload = () => { + const upload = async (file: File): Promise => { + // get config + if (!cachedConfig) { + const { data } = await RESTManager.api.options('imageUploadOptions').get<{ + data: MxServerOptions.ImageUploadOption + }>() + cachedConfig = data + } + + // enable message + if (cachedConfig.provider === 'none') { + message.info('图片自动上传未开启,请前往设置页面开启哦') + throw new Error('Image upload is disabled') + } + + try { + let url: string + + if (cachedConfig.provider === 'self') { + // use self-hosted API + const formData = new FormData() + formData.append('file', file) + + const { url: uploadedUrl } = await RESTManager.api.objects.upload.post<{ + url: string + }>({ + params: { type: 'file' }, + data: formData, + }) + + url = uploadedUrl + } else { + // S3 or custom handle by backend + const formData = new FormData() + formData.append('file', file) + + const { url: uploadedUrl } = await RESTManager.api.files[ + 'upload/image' + ].post<{ + url: string + }>({ + data: formData, + }) + + url = uploadedUrl + } + + message.success('图片上传成功') + return url + } catch (error) { + console.error('Auto upload image failed:', error) + message.error('图片上传失败,请检查您的配置或重新尝试~') + throw error + } + } + + return { + upload, + } +} diff --git a/src/models/options.ts b/src/models/options.ts index 8acda09bd..4ca378e3d 100644 --- a/src/models/options.ts +++ b/src/models/options.ts @@ -96,4 +96,19 @@ export module MxServerOptions { enableSummary: boolean enableAutoGenerateSummary: boolean } + + export interface ImageUploadOption { + provider: 'none' | 'self' | 's3' | 'custom' + s3Endpoint: string + s3SecretId: string + s3SecretKey: string + s3Bucket: string + s3Region: string + s3PathPrefix: string + customEndpoint: string + customMethod: 'POST' | 'PUT' + customFileFieldName: string + customHeaders: string + customResponseUrlPath: string + } }