From e823842ba37e27c9569c93b6c2b88d0749ea9a04 Mon Sep 17 00:00:00 2001 From: grtsinry43 Date: Fri, 28 Nov 2025 09:52:45 +0800 Subject: [PATCH 1/2] feat: add editor auto upload support --- .../editor/codemirror/codemirror.css | 36 ++++++++ .../editor/codemirror/codemirror.tsx | 48 +++++++++- .../editor/codemirror/use-codemirror.ts | 88 ++++++++++++++++++- .../codemirror/use-upload-extensions.ts | 61 +++++++++++++ 4 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/codemirror/use-upload-extensions.ts 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..b697393a0 100644 --- a/src/components/editor/codemirror/codemirror.tsx +++ b/src/components/editor/codemirror/codemirror.tsx @@ -1,16 +1,61 @@ /* 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 { useSaveConfirm } from '~/hooks/use-save-confirm' + import styles from '../universal/editor.module.css' import { editorBaseProps } from '../universal/props' import './codemirror.css' +import { getToken, RESTManager } from '~/utils' + import { useCodeMirror } from './use-codemirror' +const handleUploadImage = async (file: File): Promise => { + if (!file) { + throw new Error('No file provided.') + } + + try { + const formData = new FormData() + formData.append('file', file, file.name) + + // use fetch api instead of RESTManager (umi-request) + // https://github.com/umijs/umi-request/issues/168 + const token = getToken() + const response = await fetch( + `${RESTManager.endpoint}/files/upload?type=file`, + { + method: 'POST', + body: formData, + headers: { + Authorization: token || '', + }, + }, + ) + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`) + } + + const result = await response.json() + const imageUrl = result.url + + if (!imageUrl) { + throw new Error('Upload failed: invalid url.') + } + + return imageUrl + } catch (error) { + console.error('Auto upload image failed:', error) + message.error('图片上传失败,请稍候重新尝试~') + throw error + } +} + export const CodemirrorEditor = defineComponent({ name: 'CodemirrorEditor', props: { @@ -30,6 +75,7 @@ export const CodemirrorEditor = defineComponent({ props.onChange(state.doc.toString()) props.onStateChange?.(state) }, + onUploadImage: handleUploadImage, }) watch( diff --git a/src/components/editor/codemirror/use-codemirror.ts b/src/components/editor/codemirror/use-codemirror.ts index 6cb133bcd..88d303416 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 @@ -84,12 +90,92 @@ export const useCodeMirror = ( }) } } + + 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 }), + }) + message.success('自动上传图片成功~') + } + }) + .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), +}) From 09073cadf4ab4846982f07d0076c7563826d32b5 Mon Sep 17 00:00:00 2001 From: grtsinry43 Date: Sat, 29 Nov 2025 14:28:50 +0800 Subject: [PATCH 2/2] feat: add editor upload s3 and serverless support --- src/components/config-form/index.tsx | 41 ++++++++++-- .../editor/codemirror/codemirror.tsx | 49 ++------------ .../editor/codemirror/use-codemirror.ts | 9 +-- src/hooks/use-image-upload.ts | 67 +++++++++++++++++++ src/models/options.ts | 15 +++++ 5 files changed, 125 insertions(+), 56 deletions(-) create mode 100644 src/hooks/use-image-upload.ts 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.tsx b/src/components/editor/codemirror/codemirror.tsx index b697393a0..ce88b1396 100644 --- a/src/components/editor/codemirror/codemirror.tsx +++ b/src/components/editor/codemirror/codemirror.tsx @@ -3,6 +3,7 @@ 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' @@ -10,52 +11,8 @@ import { editorBaseProps } from '../universal/props' import './codemirror.css' -import { getToken, RESTManager } from '~/utils' - import { useCodeMirror } from './use-codemirror' -const handleUploadImage = async (file: File): Promise => { - if (!file) { - throw new Error('No file provided.') - } - - try { - const formData = new FormData() - formData.append('file', file, file.name) - - // use fetch api instead of RESTManager (umi-request) - // https://github.com/umijs/umi-request/issues/168 - const token = getToken() - const response = await fetch( - `${RESTManager.endpoint}/files/upload?type=file`, - { - method: 'POST', - body: formData, - headers: { - Authorization: token || '', - }, - }, - ) - - if (!response.ok) { - throw new Error(`Upload failed: ${response.statusText}`) - } - - const result = await response.json() - const imageUrl = result.url - - if (!imageUrl) { - throw new Error('Upload failed: invalid url.') - } - - return imageUrl - } catch (error) { - console.error('Auto upload image failed:', error) - message.error('图片上传失败,请稍候重新尝试~') - throw error - } -} - export const CodemirrorEditor = defineComponent({ name: 'CodemirrorEditor', props: { @@ -69,13 +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: handleUploadImage, + onUploadImage: upload, }) watch( diff --git a/src/components/editor/codemirror/use-codemirror.ts b/src/components/editor/codemirror/use-codemirror.ts index 88d303416..f7eff4a30 100644 --- a/src/components/editor/codemirror/use-codemirror.ts +++ b/src/components/editor/codemirror/use-codemirror.ts @@ -86,7 +86,9 @@ export const useCodeMirror = ( }) }) .catch(() => { - console.log('not support wasm') + console.info( + 'Editor wasm formatter is not supported by this browser.', + ) }) } } @@ -109,7 +111,7 @@ export const useCodeMirror = ( const decorations = state.field(uploadStateField) let foundFrom: number | null = null - decorations.between(0, state.doc.length, (from, to, value) => { + 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 @@ -125,10 +127,9 @@ export const useCodeMirror = ( }, effects: removeUpload.of({ id: uploadId }), }) - message.success('自动上传图片成功~') } }) - .catch((err) => { + .catch((_err) => { view.dispatch({ effects: removeUpload.of({ id: uploadId }), }) 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 + } }