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: ``,
+ },
+ 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
+ }
}