feat: add format and copy functionality to JSON schema code editor with tooltip support

This commit is contained in:
twwu 2025-03-19 12:55:15 +08:00
parent 86b1295efa
commit a32bc341fb
8 changed files with 53 additions and 44 deletions

View File

@ -5,6 +5,8 @@ import classNames from '@/utils/classnames'
import { Editor } from '@monaco-editor/react' import { Editor } from '@monaco-editor/react'
import { RiClipboardLine, RiIndentIncrease } from '@remixicon/react' import { RiClipboardLine, RiIndentIncrease } from '@remixicon/react'
import copy from 'copy-to-clipboard' import copy from 'copy-to-clipboard'
import Tooltip from '@/app/components/base/tooltip'
import { useTranslation } from 'react-i18next'
type CodeEditorProps = { type CodeEditorProps = {
value: string value: string
@ -22,6 +24,7 @@ const CodeEditor: FC<CodeEditorProps> = ({
readOnly = false, readOnly = false,
className, className,
}) => { }) => {
const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
const monacoRef = useRef<any>(null) const monacoRef = useRef<any>(null)
const editorRef = useRef<any>(null) const editorRef = useRef<any>(null)
@ -79,6 +82,7 @@ const CodeEditor: FC<CodeEditorProps> = ({
</div> </div>
<div className='flex items-center gap-x-0.5'> <div className='flex items-center gap-x-0.5'>
{showFormatButton && ( {showFormatButton && (
<Tooltip popupContent={t('common.operation.format')}>
<button <button
type='button' type='button'
className='flex items-center justify-center h-6 w-6' className='flex items-center justify-center h-6 w-6'
@ -86,13 +90,16 @@ const CodeEditor: FC<CodeEditorProps> = ({
> >
<RiIndentIncrease className='w-4 h-4 text-text-tertiary' /> <RiIndentIncrease className='w-4 h-4 text-text-tertiary' />
</button> </button>
</Tooltip>
)} )}
<Tooltip popupContent={t('common.operation.copy')}>
<button <button
type='button' type='button'
className='flex items-center justify-center h-6 w-6' className='flex items-center justify-center h-6 w-6'
onClick={() => copy(value)}> onClick={() => copy(value)}>
<RiClipboardLine className='w-4 h-4 text-text-tertiary' /> <RiClipboardLine className='w-4 h-4 text-text-tertiary' />
</button> </button>
</Tooltip>
</div> </div>
</div> </div>
<div className={classNames('relative', editorWrapperClassName)}> <div className={classNames('relative', editorWrapperClassName)}>

View File

@ -1,8 +1,8 @@
import React, { useCallback } from 'react'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import { RiAddCircleFill } from '@remixicon/react' import { RiAddCircleFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useVisualEditorStore } from './store' import { useVisualEditorStore } from './store'
import { useCallback } from 'react'
import { useMittContext } from './context' import { useMittContext } from './context'
const AddField = () => { const AddField = () => {
@ -30,4 +30,4 @@ const AddField = () => {
) )
} }
export default AddField export default React.memo(AddField)

View File

@ -43,4 +43,4 @@ const Card: FC<CardProps> = ({
) )
} }
export default Card export default React.memo(Card)

View File

@ -75,4 +75,4 @@ const AdvancedOptions: FC<AdvancedOptionsProps> = ({
) )
} }
export default AdvancedOptions export default React.memo(AdvancedOptions)

View File

@ -12,7 +12,6 @@ import { useTranslation } from 'react-i18next'
import classNames from '@/utils/classnames' import classNames from '@/utils/classnames'
import { useVisualEditorStore } from '../store' import { useVisualEditorStore } from '../store'
import { useMittContext } from '../context' import { useMittContext } from '../context'
import produce from 'immer'
import { useUnmount } from 'ahooks' import { useUnmount } from 'ahooks'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config' import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
@ -63,13 +62,16 @@ const EditCard: FC<EditCardProps> = ({
const { emit, useSubscribe } = useMittContext() const { emit, useSubscribe } = useMittContext()
const blurWithActions = useRef(false) const blurWithActions = useRef(false)
const disableAddBtn = depth >= JSON_SCHEMA_MAX_DEPTH || (fields.type !== Type.object && fields.type !== ArrayType.object) const disableAddBtn = depth >= JSON_SCHEMA_MAX_DEPTH || (currentFields.type !== Type.object && currentFields.type !== ArrayType.object)
const hasAdvancedOptions = fields.type === Type.string || fields.type === Type.number const hasAdvancedOptions = currentFields.type === Type.string || currentFields.type === Type.number
const isAdvancedEditing = advancedEditing || isAddingNewField const isAdvancedEditing = advancedEditing || isAddingNewField
const advancedOptions = useMemo(() => { const advancedOptions = useMemo(() => {
return { enum: (currentFields.enum || []).join(', ') } let enumValue = ''
}, [currentFields.enum]) if (currentFields.type === Type.string || currentFields.type === Type.number)
enumValue = (currentFields.enum || []).join(', ')
return { enum: enumValue }
}, [currentFields.type, currentFields.enum])
useSubscribe('restorePropertyName', () => { useSubscribe('restorePropertyName', () => {
setCurrentFields(prev => ({ ...prev, name: fields.name })) setCurrentFields(prev => ({ ...prev, name: fields.name }))
@ -83,19 +85,13 @@ const EditCard: FC<EditCardProps> = ({
setAdvancedEditing(false) setAdvancedEditing(false)
}) })
const emitPropertyNameChange = useCallback((name: string) => { const emitPropertyNameChange = useCallback(() => {
const newFields = produce(fields, (draft) => { emit('propertyNameChange', { path, parentPath, oldFields: fields, fields: currentFields })
draft.name = name }, [fields, currentFields, path, parentPath, emit])
})
emit('propertyNameChange', { path, parentPath, oldFields: fields, fields: newFields })
}, [fields, path, parentPath, emit])
const emitPropertyTypeChange = useCallback((type: Type | ArrayType) => { const emitPropertyTypeChange = useCallback(() => {
const newFields = produce(fields, (draft) => { emit('propertyTypeChange', { path, parentPath, oldFields: fields, fields: currentFields })
draft.type = type }, [fields, currentFields, path, parentPath, emit])
})
emit('propertyTypeChange', { path, parentPath, oldFields: fields, fields: newFields })
}, [fields, path, parentPath, emit])
const emitPropertyRequiredToggle = useCallback(() => { const emitPropertyRequiredToggle = useCallback(() => {
emit('propertyRequiredToggle', { path, parentPath, oldFields: fields, fields: currentFields }) emit('propertyRequiredToggle', { path, parentPath, oldFields: fields, fields: currentFields })
@ -121,15 +117,15 @@ const EditCard: FC<EditCardProps> = ({
setCurrentFields(prev => ({ ...prev, name: e.target.value })) setCurrentFields(prev => ({ ...prev, name: e.target.value }))
}, []) }, [])
const handlePropertyNameBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => { const handlePropertyNameBlur = useCallback(() => {
if (isAdvancedEditing) return if (isAdvancedEditing) return
emitPropertyNameChange(e.target.value) emitPropertyNameChange()
}, [isAdvancedEditing, emitPropertyNameChange]) }, [isAdvancedEditing, emitPropertyNameChange])
const handleTypeChange = useCallback((item: TypeItem) => { const handleTypeChange = useCallback((item: TypeItem) => {
setCurrentFields(prev => ({ ...prev, type: item.value })) setCurrentFields(prev => ({ ...prev, type: item.value }))
if (isAdvancedEditing) return if (isAdvancedEditing) return
emitPropertyTypeChange(item.value) emitPropertyTypeChange()
}, [isAdvancedEditing, emitPropertyTypeChange]) }, [isAdvancedEditing, emitPropertyTypeChange])
const toggleRequired = useCallback(() => { const toggleRequired = useCallback(() => {
@ -142,16 +138,17 @@ const EditCard: FC<EditCardProps> = ({
setCurrentFields(prev => ({ ...prev, description: e.target.value })) setCurrentFields(prev => ({ ...prev, description: e.target.value }))
}, []) }, [])
const handleDescriptionBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => { const handleDescriptionBlur = useCallback(() => {
if (isAdvancedEditing) return if (isAdvancedEditing) return
emitPropertyOptionsChange({ description: e.target.value, enum: fields.enum }) emitPropertyOptionsChange({ description: currentFields.description, enum: currentFields.enum })
}, [isAdvancedEditing, emitPropertyOptionsChange, fields]) }, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => { const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => {
const enumValue = options.enum.replace(/\s/g, '').split(',')
setCurrentFields(prev => ({ ...prev, enum: enumValue }))
if (isAdvancedEditing) return if (isAdvancedEditing) return
const enumValue = options.enum.replace(' ', '').split(',') emitPropertyOptionsChange({ description: currentFields.description, enum: enumValue })
emitPropertyOptionsChange({ description: fields.description, enum: enumValue }) }, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
}, [isAdvancedEditing, emitPropertyOptionsChange, fields])
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
blurWithActions.current = true blurWithActions.current = true

View File

@ -1,5 +1,5 @@
import type { FC } from 'react' import type { FC } from 'react'
import React, { useState } from 'react' import React, { useMemo, useState } from 'react'
import { type Field, Type } from '../../../types' import { type Field, Type } from '../../../types'
import classNames from '@/utils/classnames' import classNames from '@/utils/classnames'
import { RiArrowDropDownLine, RiArrowDropRightLine } from '@remixicon/react' import { RiArrowDropDownLine, RiArrowDropRightLine } from '@remixicon/react'
@ -65,8 +65,8 @@ const SchemaNode: FC<SchemaNodeProps> = ({
setHoveringProperty(path) setHoveringProperty(path)
}, { wait: 50 }) }, { wait: 50 })
const hasChildren = getHasChildren(schema) const hasChildren = useMemo(() => getHasChildren(schema), [schema])
const type = getFieldType(schema) const type = useMemo(() => getFieldType(schema), [schema])
const isHovering = hoveringProperty === path.join('.') && depth > 1 const isHovering = hoveringProperty === path.join('.') && depth > 1
const handleExpand = () => { const handleExpand = () => {
@ -137,7 +137,10 @@ const SchemaNode: FC<SchemaNodeProps> = ({
schema.description ? 'h-[calc(100%-3rem)]' : 'h-[calc(100%-1.75rem)]', schema.description ? 'h-[calc(100%-3rem)]' : 'h-[calc(100%-1.75rem)]',
indentLeft[depth + 1], indentLeft[depth + 1],
)}> )}>
<Divider type='vertical' className='bg-divider-subtle mx-0' /> <Divider
type='vertical'
className={classNames('mx-0', isHovering ? 'bg-divider-deep' : 'bg-divider-subtle')}
/>
</div> </div>
{isExpanded && hasChildren && depth < JSON_SCHEMA_MAX_DEPTH && ( {isExpanded && hasChildren && depth < JSON_SCHEMA_MAX_DEPTH && (

View File

@ -54,6 +54,7 @@ const translation = {
regenerate: 'Regenerate', regenerate: 'Regenerate',
submit: 'Submit', submit: 'Submit',
skip: 'Skip', skip: 'Skip',
format: 'Format',
}, },
errorMsg: { errorMsg: {
fieldRequired: '{{field}} is required', fieldRequired: '{{field}} is required',

View File

@ -54,6 +54,7 @@ const translation = {
regenerate: '重新生成', regenerate: '重新生成',
submit: '提交', submit: '提交',
skip: '跳过', skip: '跳过',
format: '格式化',
}, },
errorMsg: { errorMsg: {
fieldRequired: '{{field}} 为必填项', fieldRequired: '{{field}} 为必填项',