mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
fix: add YAML front matter support in markdown processing (#9963)
* feat: add YAML front matter support in markdown processing - Introduced a new plugin to parse and render YAML front matter in markdown documents. - Updated the markdown converter to handle YAML front matter, preserving its structure during conversion. - Added corresponding tests to ensure YAML front matter is retained correctly in markdown to HTML and vice versa. - Enhanced i18n files with new translations for front matter properties in English, Chinese (Simplified and Traditional). - Included the 'yaml' package as a dependency in package.json. * feat(i18n): add new translations for editing properties in Traditional Chinese * chore: fix
This commit is contained in:
parent
1640ba2e9e
commit
cb4b26c8a4
@ -323,6 +323,7 @@
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"word-extractor": "^1.0.4",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yaml": "^2.8.1",
|
||||
"yjs": "^13.6.27",
|
||||
"youtubei.js": "^15.0.1",
|
||||
"zipread": "^1.3.3",
|
||||
|
||||
@ -0,0 +1,761 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Checkbox, Dropdown, Input, type MenuProps } from 'antd'
|
||||
import { Calendar, Check, FileText, Hash, MoreHorizontal, Plus, Tag as TagIcon, Trash2, Type, X } from 'lucide-react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { parse, stringify } from 'yaml'
|
||||
|
||||
const logger = loggerService.withContext('YamlFrontMatterNodeView')
|
||||
|
||||
interface ParsedProperty {
|
||||
key: string
|
||||
value: any
|
||||
type: 'string' | 'array' | 'date' | 'number' | 'boolean'
|
||||
}
|
||||
|
||||
const YamlFrontMatterNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes }) => {
|
||||
const { t } = useTranslation()
|
||||
const [editingProperty, setEditingProperty] = useState<string | null>(null)
|
||||
const [newPropertyName, setNewPropertyName] = useState('')
|
||||
const [showAddProperty, setShowAddProperty] = useState(false)
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||
const [arrayInputValues, setArrayInputValues] = useState<Record<string, string>>({})
|
||||
const [showArrayInput, setShowArrayInput] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Parse YAML content into structured properties
|
||||
const parsedProperties = useMemo((): ParsedProperty[] => {
|
||||
try {
|
||||
const content = node.attrs.content || ''
|
||||
const yamlContent = content.replace(/\n---\s*$/, '') // Remove closing fence
|
||||
|
||||
if (!yamlContent.trim()) return []
|
||||
|
||||
const parsed = parse(yamlContent)
|
||||
if (!parsed || typeof parsed !== 'object') return []
|
||||
|
||||
return Object.entries(parsed).map(([key, value]): ParsedProperty => {
|
||||
let type: ParsedProperty['type'] = 'string'
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
type = 'array'
|
||||
} else if (typeof value === 'number') {
|
||||
type = 'number'
|
||||
} else if (typeof value === 'boolean') {
|
||||
type = 'boolean'
|
||||
} else if (value instanceof Date || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))) {
|
||||
type = 'date'
|
||||
}
|
||||
|
||||
return { key, value, type }
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse YAML front matter:', error as Error)
|
||||
return []
|
||||
}
|
||||
}, [node.attrs.content])
|
||||
|
||||
// Get icon for property type
|
||||
const getPropertyIcon = (type: ParsedProperty['type']) => {
|
||||
switch (type) {
|
||||
case 'array':
|
||||
return <TagIcon size={16} />
|
||||
case 'date':
|
||||
return <Calendar size={16} />
|
||||
case 'number':
|
||||
return <Hash size={16} />
|
||||
case 'string':
|
||||
return <FileText size={16} />
|
||||
case 'boolean':
|
||||
return <Check size={16} />
|
||||
default:
|
||||
return <FileText size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
// Update YAML content from properties
|
||||
const updateYamlFromProperties = useCallback(
|
||||
(properties: ParsedProperty[]) => {
|
||||
try {
|
||||
const yamlObject = properties.reduce(
|
||||
(acc, prop) => {
|
||||
acc[prop.key] = prop.value
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
|
||||
const yamlContent = stringify(yamlObject).trim()
|
||||
const contentWithFence = yamlContent + '\n---'
|
||||
updateAttributes({ content: contentWithFence })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update YAML:', error as Error)
|
||||
}
|
||||
},
|
||||
[updateAttributes]
|
||||
)
|
||||
|
||||
// Handle property value change
|
||||
const handlePropertyChange = useCallback(
|
||||
(key: string, newValue: any) => {
|
||||
const updatedProperties = parsedProperties.map((prop) => (prop.key === key ? { ...prop, value: newValue } : prop))
|
||||
updateYamlFromProperties(updatedProperties)
|
||||
setEditingProperty(null)
|
||||
},
|
||||
[parsedProperties, updateYamlFromProperties]
|
||||
)
|
||||
|
||||
// Handle array item removal
|
||||
const handleRemoveArrayItem = useCallback(
|
||||
(key: string, index: number) => {
|
||||
const property = parsedProperties.find((p) => p.key === key)
|
||||
if (property && Array.isArray(property.value)) {
|
||||
const newArray = property.value.filter((_, i) => i !== index)
|
||||
handlePropertyChange(key, newArray)
|
||||
}
|
||||
},
|
||||
[parsedProperties, handlePropertyChange]
|
||||
)
|
||||
|
||||
// Handle array item addition
|
||||
const handleAddArrayItem = useCallback(
|
||||
(key: string, value: string) => {
|
||||
if (!value.trim()) return
|
||||
|
||||
const property = parsedProperties.find((p) => p.key === key)
|
||||
if (property && Array.isArray(property.value)) {
|
||||
const newArray = [...property.value, value.trim()]
|
||||
handlePropertyChange(key, newArray)
|
||||
setArrayInputValues((prev) => ({ ...prev, [key]: '' }))
|
||||
setShowArrayInput((prev) => ({ ...prev, [key]: false }))
|
||||
}
|
||||
},
|
||||
[parsedProperties, handlePropertyChange]
|
||||
)
|
||||
|
||||
// Add new property
|
||||
const handleAddProperty = useCallback(() => {
|
||||
if (newPropertyName.trim()) {
|
||||
const updatedProperties = [
|
||||
...parsedProperties,
|
||||
{
|
||||
key: newPropertyName.trim(),
|
||||
value: '',
|
||||
type: 'string' as const
|
||||
}
|
||||
]
|
||||
updateYamlFromProperties(updatedProperties)
|
||||
setNewPropertyName('')
|
||||
setShowAddProperty(false)
|
||||
}
|
||||
}, [newPropertyName, parsedProperties, updateYamlFromProperties])
|
||||
|
||||
// Delete property
|
||||
const handleDeleteProperty = useCallback(
|
||||
(propertyKey: string) => {
|
||||
const updatedProperties = parsedProperties.filter((prop) => prop.key !== propertyKey)
|
||||
updateYamlFromProperties(updatedProperties)
|
||||
},
|
||||
[parsedProperties, updateYamlFromProperties]
|
||||
)
|
||||
|
||||
// Change property type
|
||||
const handleChangePropertyType = useCallback(
|
||||
(propertyKey: string, newType: ParsedProperty['type']) => {
|
||||
const updatedProperties = parsedProperties.map((prop) => {
|
||||
if (prop.key === propertyKey) {
|
||||
let newValue = prop.value
|
||||
// Convert value based on new type
|
||||
switch (newType) {
|
||||
case 'array':
|
||||
newValue = Array.isArray(prop.value) ? prop.value : [String(prop.value)]
|
||||
break
|
||||
case 'number':
|
||||
newValue = typeof prop.value === 'number' ? prop.value : Number(String(prop.value)) || 0
|
||||
break
|
||||
case 'boolean':
|
||||
newValue = typeof prop.value === 'boolean' ? prop.value : String(prop.value).toLowerCase() === 'true'
|
||||
break
|
||||
case 'string':
|
||||
newValue = String(prop.value)
|
||||
break
|
||||
case 'date':
|
||||
newValue = prop.value instanceof Date ? prop.value.toISOString().split('T')[0] : String(prop.value)
|
||||
break
|
||||
}
|
||||
return { ...prop, type: newType, value: newValue }
|
||||
}
|
||||
return prop
|
||||
})
|
||||
updateYamlFromProperties(updatedProperties)
|
||||
},
|
||||
[parsedProperties, updateYamlFromProperties]
|
||||
)
|
||||
|
||||
// Create context menu for property
|
||||
const getPropertyMenu = useCallback(
|
||||
(property: ParsedProperty): MenuProps => {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Type size={14} />
|
||||
{t('richEditor.frontMatter.editValue')}
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
setEditingProperty(property.key)
|
||||
setOpenDropdown(null)
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: t('richEditor.frontMatter.changeType'),
|
||||
children: [
|
||||
{
|
||||
key: 'string',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileText size={14} />
|
||||
{t('richEditor.frontMatter.changeToText')}
|
||||
</span>
|
||||
),
|
||||
disabled: property.type === 'string',
|
||||
onClick: () => {
|
||||
handleChangePropertyType(property.key, 'string')
|
||||
setOpenDropdown(null)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'number',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Hash size={14} />
|
||||
{t('richEditor.frontMatter.changeToNumber')}
|
||||
</span>
|
||||
),
|
||||
disabled: property.type === 'number',
|
||||
onClick: () => {
|
||||
handleChangePropertyType(property.key, 'number')
|
||||
setOpenDropdown(null)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'boolean',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Check size={14} />
|
||||
{t('richEditor.frontMatter.changeToBoolean')}
|
||||
</span>
|
||||
),
|
||||
disabled: property.type === 'boolean',
|
||||
onClick: () => {
|
||||
handleChangePropertyType(property.key, 'boolean')
|
||||
setOpenDropdown(null)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'array',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<TagIcon size={14} />
|
||||
{t('richEditor.frontMatter.changeToTags')}
|
||||
</span>
|
||||
),
|
||||
disabled: property.type === 'array',
|
||||
onClick: () => {
|
||||
handleChangePropertyType(property.key, 'array')
|
||||
setOpenDropdown(null)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Calendar size={14} />
|
||||
{t('richEditor.frontMatter.changeToDate', 'Date')}
|
||||
</span>
|
||||
),
|
||||
disabled: property.type === 'date',
|
||||
onClick: () => {
|
||||
handleChangePropertyType(property.key, 'date')
|
||||
setOpenDropdown(null)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#ef4444' }}>
|
||||
<Trash2 size={14} />
|
||||
{t('richEditor.frontMatter.deleteProperty')}
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
handleDeleteProperty(property.key)
|
||||
setOpenDropdown(null)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
[t, handleChangePropertyType, handleDeleteProperty]
|
||||
)
|
||||
|
||||
// Render property value based on type
|
||||
const renderPropertyValue = (property: ParsedProperty) => {
|
||||
const isEditing = editingProperty === property.key
|
||||
|
||||
if (property.type === 'array' && Array.isArray(property.value)) {
|
||||
const isShowingInput = showArrayInput[property.key]
|
||||
|
||||
return (
|
||||
<TagContainer>
|
||||
{property.value.map((item, index) => (
|
||||
<Tag key={index}>
|
||||
{String(item)}
|
||||
<TagRemove onClick={() => handleRemoveArrayItem(property.key, index)}>
|
||||
<X size={12} />
|
||||
</TagRemove>
|
||||
</Tag>
|
||||
))}
|
||||
{isShowingInput ? (
|
||||
<ArrayInput
|
||||
placeholder={t('richEditor.frontMatter.addTag')}
|
||||
value={arrayInputValues[property.key] || ''}
|
||||
onChange={(e) => setArrayInputValues((prev) => ({ ...prev, [property.key]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddArrayItem(property.key, arrayInputValues[property.key] || '')
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowArrayInput((prev) => ({ ...prev, [property.key]: false }))
|
||||
setArrayInputValues((prev) => ({ ...prev, [property.key]: '' }))
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const value = arrayInputValues[property.key] || ''
|
||||
if (value.trim()) {
|
||||
handleAddArrayItem(property.key, value)
|
||||
} else {
|
||||
setShowArrayInput((prev) => ({ ...prev, [property.key]: false }))
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<AddTagButton onClick={() => setShowArrayInput((prev) => ({ ...prev, [property.key]: true }))}>
|
||||
<Plus size={12} />
|
||||
</AddTagButton>
|
||||
)}
|
||||
</TagContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (property.type === 'boolean') {
|
||||
return (
|
||||
<Checkbox
|
||||
style={{ paddingLeft: 8 }}
|
||||
checked={property.value}
|
||||
onChange={(e) => handlePropertyChange(property.key, e.target.checked)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<StyledInput
|
||||
defaultValue={String(property.value)}
|
||||
onBlur={(e) => {
|
||||
let newValue: any = e.target.value
|
||||
if (property.type === 'number') {
|
||||
newValue = Number(newValue) || 0
|
||||
} else if (property.type === 'boolean') {
|
||||
newValue = newValue.toLowerCase() === 'true'
|
||||
}
|
||||
handlePropertyChange(property.key, newValue)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingProperty(null)
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyValue onClick={() => setEditingProperty(property.key)}>
|
||||
{property.value ? (
|
||||
String(property.value)
|
||||
) : (
|
||||
<span style={{ color: '#9ca3af', fontStyle: 'italic' }}>{t('richEditor.frontMatter.empty')}</span>
|
||||
)}
|
||||
</PropertyValue>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="yaml-front-matter-wrapper"
|
||||
onContextMenu={(e) => {
|
||||
// Only prevent if the context menu is triggered on the wrapper itself
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}>
|
||||
<PropertiesContainer
|
||||
onClick={(e) => {
|
||||
// Prevent node selection when clicking inside properties
|
||||
e.stopPropagation()
|
||||
}}>
|
||||
{parsedProperties.map((property) => (
|
||||
<Dropdown
|
||||
key={property.key}
|
||||
menu={getPropertyMenu(property)}
|
||||
trigger={['contextMenu']}
|
||||
placement="bottomRight"
|
||||
open={openDropdown === `context-${property.key}`}
|
||||
onOpenChange={(open) => {
|
||||
setOpenDropdown(open ? `context-${property.key}` : null)
|
||||
}}>
|
||||
<PropertyRow
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation()
|
||||
}}>
|
||||
<PropertyIcon>{getPropertyIcon(property.type)}</PropertyIcon>
|
||||
<PropertyName>{property.key}</PropertyName>
|
||||
{renderPropertyValue(property)}
|
||||
<PropertyActions>
|
||||
<Dropdown
|
||||
menu={getPropertyMenu(property)}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
open={openDropdown === `action-${property.key}`}
|
||||
onOpenChange={(open) => {
|
||||
setOpenDropdown(open ? `action-${property.key}` : null)
|
||||
}}>
|
||||
<ActionButton onClick={(e) => e.stopPropagation()} title={t('richEditor.frontMatter.moreActions')}>
|
||||
<MoreHorizontal size={14} />
|
||||
</ActionButton>
|
||||
</Dropdown>
|
||||
</PropertyActions>
|
||||
</PropertyRow>
|
||||
</Dropdown>
|
||||
))}
|
||||
|
||||
{showAddProperty ? (
|
||||
<PropertyRow>
|
||||
<PropertyIcon>
|
||||
<Plus size={16} />
|
||||
</PropertyIcon>
|
||||
<Input
|
||||
placeholder={t('richEditor.frontMatter.propertyName')}
|
||||
value={newPropertyName}
|
||||
onChange={(e) => setNewPropertyName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddProperty()
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowAddProperty(false)
|
||||
setNewPropertyName('')
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (newPropertyName.trim()) {
|
||||
handleAddProperty()
|
||||
} else {
|
||||
setShowAddProperty(false)
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</PropertyRow>
|
||||
) : (
|
||||
<AddPropertyRow onClick={() => setShowAddProperty(true)}>
|
||||
<PropertyIcon>
|
||||
<Plus size={16} />
|
||||
</PropertyIcon>
|
||||
<AddPropertyText>{t('richEditor.frontMatter.addProperty')}</AddPropertyText>
|
||||
</AddPropertyRow>
|
||||
)}
|
||||
</PropertiesContainer>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const PropertiesContainer = styled.div`
|
||||
margin: 16px 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const PropertyRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
margin: 0 -8px;
|
||||
min-height: 32px;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
`
|
||||
|
||||
const PropertyIcon = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
color: var(--color-icon);
|
||||
`
|
||||
|
||||
const PropertyName = styled.div`
|
||||
min-width: 100px;
|
||||
max-width: 100px;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-right: 12px;
|
||||
text-transform: capitalize;
|
||||
`
|
||||
|
||||
const PropertyValue = styled.div`
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
margin: -2px 0;
|
||||
border-radius: 4px;
|
||||
min-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const TagContainer = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const Tag = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
background-color: var(--color-background-mute);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-text);
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
`
|
||||
|
||||
const TagRemove = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-icon);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-error);
|
||||
}
|
||||
`
|
||||
|
||||
const StyledInput = styled(Input)`
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
background: transparent !important;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
margin: -2px 0;
|
||||
border-radius: 4px;
|
||||
min-height: 20px;
|
||||
cursor: text;
|
||||
box-shadow: none !important;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-hover) !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.ant-input {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.ant-input:hover {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.ant-input:focus {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`
|
||||
|
||||
const AddTagButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 10px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: #3b82f6;
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
`
|
||||
|
||||
const ArrayInput = styled(Input)`
|
||||
display: inline-flex;
|
||||
border: 1px solid #e5e7eb !important;
|
||||
outline: none !important;
|
||||
background: transparent !important;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-family);
|
||||
padding: 2px 8px !important;
|
||||
border-radius: 12px;
|
||||
min-width: 80px;
|
||||
max-width: 120px;
|
||||
height: 24px;
|
||||
cursor: text;
|
||||
box-shadow: none !important;
|
||||
vertical-align: top;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary) !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
||||
}
|
||||
|
||||
&.ant-input {
|
||||
font-size: 12px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
&.ant-input:hover {
|
||||
border-color: var(--color-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.ant-input:focus {
|
||||
border-color: var(--color-primary) !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
||||
}
|
||||
`
|
||||
|
||||
const AddPropertyRow = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
margin: 0 -8px;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
`
|
||||
|
||||
const AddPropertyText = styled.div`
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const PropertyActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
margin-left: auto;
|
||||
margin-right: 4px;
|
||||
|
||||
${PropertyRow}:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const ActionButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-hover);
|
||||
color: #374151;
|
||||
}
|
||||
`
|
||||
|
||||
export default YamlFrontMatterNodeView
|
||||
@ -0,0 +1,100 @@
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
|
||||
import YamlFrontMatterNodeView from '../components/YamlFrontMatterNodeView'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
yamlFrontMatter: {
|
||||
insertYamlFrontMatter: (content?: string) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const YamlFrontMatter = Node.create({
|
||||
name: 'yamlFrontMatter',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
content: {
|
||||
default: '',
|
||||
parseHTML: (element) => {
|
||||
const dataContent = element.getAttribute('data-content')
|
||||
if (dataContent) {
|
||||
// Decode HTML entities that might be in the data-content attribute
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.innerHTML = dataContent
|
||||
return textarea.value
|
||||
}
|
||||
return element.textContent || ''
|
||||
},
|
||||
renderHTML: (attributes) => ({
|
||||
'data-content': attributes.content
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'div[data-type="yaml-front-matter"]',
|
||||
getAttrs: (element) => {
|
||||
if (typeof element === 'string') return false
|
||||
|
||||
const htmlElement = element as HTMLElement
|
||||
const dataContent = htmlElement.getAttribute('data-content')
|
||||
const textContent = htmlElement.textContent || ''
|
||||
|
||||
return {
|
||||
content: dataContent || textContent
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const content = node.attrs.content || ''
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'yaml-front-matter',
|
||||
'data-content': content
|
||||
}),
|
||||
content
|
||||
]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertYamlFrontMatter:
|
||||
(content = '') =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: {
|
||||
content
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(YamlFrontMatterNodeView)
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return []
|
||||
}
|
||||
})
|
||||
@ -31,6 +31,7 @@ import { EnhancedImage } from './extensions/enhanced-image'
|
||||
import { EnhancedLink } from './extensions/enhanced-link'
|
||||
import { EnhancedMath } from './extensions/enhanced-math'
|
||||
import { Placeholder } from './extensions/placeholder'
|
||||
import { YamlFrontMatter } from './extensions/yaml-front-matter'
|
||||
import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers/imageUtils'
|
||||
|
||||
const logger = loggerService.withContext('useRichEditor')
|
||||
@ -320,6 +321,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
showOnlyCurrent: true,
|
||||
includeChildren: false
|
||||
}),
|
||||
YamlFrontMatter,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention'
|
||||
|
||||
@ -2221,6 +2221,21 @@
|
||||
}
|
||||
},
|
||||
"dragHandle": "Drag to move",
|
||||
"frontMatter": {
|
||||
"addProperty": "Add a property",
|
||||
"addTag": "Add tag",
|
||||
"changeToBoolean": "Checkbox",
|
||||
"changeToDate": "Date",
|
||||
"changeToNumber": "Number",
|
||||
"changeToTags": "Tags",
|
||||
"changeToText": "Text",
|
||||
"changeType": "Change type",
|
||||
"deleteProperty": "Delete property",
|
||||
"editValue": "Edit value",
|
||||
"empty": "Empty",
|
||||
"moreActions": "More actions",
|
||||
"propertyName": "Property name"
|
||||
},
|
||||
"image": {
|
||||
"placeholder": "Add a picture"
|
||||
},
|
||||
|
||||
@ -2221,6 +2221,21 @@
|
||||
}
|
||||
},
|
||||
"dragHandle": "拖拽块",
|
||||
"frontMatter": {
|
||||
"addProperty": "添加属性",
|
||||
"addTag": "添加标签",
|
||||
"changeToBoolean": "复选框",
|
||||
"changeToDate": "日期",
|
||||
"changeToNumber": "数字",
|
||||
"changeToTags": "标签",
|
||||
"changeToText": "文本",
|
||||
"changeType": "更改类型",
|
||||
"deleteProperty": "删除属性",
|
||||
"editValue": "编辑值",
|
||||
"empty": "空",
|
||||
"moreActions": "更多操作",
|
||||
"propertyName": "属性名称"
|
||||
},
|
||||
"image": {
|
||||
"placeholder": "添加图片"
|
||||
},
|
||||
|
||||
@ -2221,6 +2221,21 @@
|
||||
}
|
||||
},
|
||||
"dragHandle": "拖拽塊",
|
||||
"frontMatter": {
|
||||
"addProperty": "新增屬性",
|
||||
"addTag": "新增標籤",
|
||||
"changeToBoolean": "核取方塊",
|
||||
"changeToDate": "日期",
|
||||
"changeToNumber": "數字",
|
||||
"changeToTags": "標籤",
|
||||
"changeToText": "文字",
|
||||
"changeType": "更改類型",
|
||||
"deleteProperty": "刪除屬性",
|
||||
"editValue": "編輯值",
|
||||
"empty": "空",
|
||||
"moreActions": "更多操作",
|
||||
"propertyName": "屬性名稱"
|
||||
},
|
||||
"image": {
|
||||
"placeholder": "添加圖片"
|
||||
},
|
||||
|
||||
@ -484,4 +484,25 @@ describe('markdownConverter', () => {
|
||||
expect(backToHtml).toBe('<p>This is a simple paragraph being typed</p>\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shoud keep YAML front matter', () => {
|
||||
it('should keep YAML front matter', () => {
|
||||
const markdown = `---
|
||||
tags:
|
||||
- 你好
|
||||
aliases:
|
||||
- "1111"
|
||||
- "222"
|
||||
- "333"
|
||||
- "3333"
|
||||
cssclasses:
|
||||
- fffff
|
||||
- ssss
|
||||
- s12
|
||||
---`
|
||||
const result = markdownToHtml(markdown)
|
||||
const backToMarkdown = htmlToMarkdown(result)
|
||||
expect(backToMarkdown).toBe(markdown)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -229,6 +229,87 @@ interface InlineStateLike {
|
||||
push: (type: string, tag: string, nesting: number) => TokenLike & { content?: string }
|
||||
}
|
||||
|
||||
function yamlFrontMatterPlugin(md: MarkdownIt) {
|
||||
// Parser: recognize YAML front matter
|
||||
md.block.ruler.before(
|
||||
'table',
|
||||
'yaml_front_matter',
|
||||
(stateLike: unknown, startLine: number, endLine: number, silent: boolean): boolean => {
|
||||
const state = stateLike as BlockStateLike
|
||||
|
||||
// Only check at the very beginning of the document
|
||||
if (startLine !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const startPos = state.bMarks[startLine] + state.tShift[startLine]
|
||||
const maxPos = state.eMarks[startLine]
|
||||
|
||||
// Must begin with --- at document start
|
||||
if (startPos + 3 > maxPos) return false
|
||||
if (
|
||||
state.src.charCodeAt(startPos) !== 0x2d /* - */ ||
|
||||
state.src.charCodeAt(startPos + 1) !== 0x2d /* - */ ||
|
||||
state.src.charCodeAt(startPos + 2) !== 0x2d /* - */
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If requested only to validate existence
|
||||
if (silent) return true
|
||||
|
||||
// Search for closing ---
|
||||
let nextLine = startLine + 1
|
||||
let found = false
|
||||
|
||||
for (nextLine = startLine + 1; nextLine < endLine; nextLine++) {
|
||||
const lineStart = state.bMarks[nextLine] + state.tShift[nextLine]
|
||||
const lineEnd = state.eMarks[nextLine]
|
||||
const line = state.src.slice(lineStart, lineEnd).trim()
|
||||
|
||||
if (line === '---') {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract YAML content between the --- delimiters, preserving original indentation
|
||||
const yamlLines: string[] = []
|
||||
for (let lineIdx = startLine + 1; lineIdx < nextLine; lineIdx++) {
|
||||
// Use the original line markers without shift to preserve indentation
|
||||
const lineStart = state.bMarks[lineIdx]
|
||||
const lineEnd = state.eMarks[lineIdx]
|
||||
yamlLines.push(state.src.slice(lineStart, lineEnd))
|
||||
}
|
||||
|
||||
// Also capture the closing --- line with its indentation
|
||||
const closingLineStart = state.bMarks[nextLine]
|
||||
const closingLineEnd = state.eMarks[nextLine]
|
||||
const closingLine = state.src.slice(closingLineStart, closingLineEnd)
|
||||
|
||||
const yamlContent = yamlLines.join('\n') + '\n' + closingLine
|
||||
|
||||
const token = state.push('yaml_front_matter', 'div', 0)
|
||||
token.block = true
|
||||
token.map = [startLine, nextLine + 1]
|
||||
token.content = yamlContent
|
||||
|
||||
state.line = nextLine + 1
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
// Renderer: output YAML front matter as special HTML element
|
||||
md.renderer.rules.yaml_front_matter = (tokens: Array<{ content?: string }>, idx: number): string => {
|
||||
const content = tokens[idx]?.content ?? ''
|
||||
return `<div data-type="yaml-front-matter" data-content="${he.encode(content)}">${content}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function tipTapKatexPlugin(md: MarkdownIt) {
|
||||
// 1) Parser: recognize $$ ... $$ as a block math token
|
||||
md.block.ruler.before(
|
||||
@ -371,6 +452,8 @@ function tipTapKatexPlugin(md: MarkdownIt) {
|
||||
}
|
||||
}
|
||||
|
||||
md.use(yamlFrontMatterPlugin)
|
||||
|
||||
md.use(taskListPlugin, {
|
||||
label: true
|
||||
})
|
||||
@ -437,6 +520,28 @@ turndownService.addRule('br', {
|
||||
replacement: () => '<br>'
|
||||
})
|
||||
|
||||
// Custom rule to preserve YAML front matter
|
||||
turndownService.addRule('yamlFrontMatter', {
|
||||
filter: (node: Element) => {
|
||||
return node.nodeName === 'DIV' && node.getAttribute?.('data-type') === 'yaml-front-matter'
|
||||
},
|
||||
replacement: (_content: string, node: Node) => {
|
||||
const element = node as Element
|
||||
const yamlContent = element.getAttribute?.('data-content') || ''
|
||||
const decodedContent = he.decode(yamlContent, {
|
||||
isAttributeValue: false,
|
||||
strict: false
|
||||
})
|
||||
// The decodedContent already includes the complete YAML with closing ---
|
||||
// We just need to add the opening --- if it's not there
|
||||
if (decodedContent.startsWith('---')) {
|
||||
return decodedContent
|
||||
} else {
|
||||
return `---\n${decodedContent}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Helper function to safely get text content and clean it with LaTeX support
|
||||
function cleanCellContent(content: string, cellElement?: Element): string {
|
||||
// First check for math elements in the cell
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@ -10127,6 +10127,7 @@ __metadata:
|
||||
winston-daily-rotate-file: "npm:^5.0.0"
|
||||
word-extractor: "npm:^1.0.4"
|
||||
y-protocols: "npm:^1.0.6"
|
||||
yaml: "npm:^2.8.1"
|
||||
yjs: "npm:^13.6.27"
|
||||
youtubei.js: "npm:^15.0.1"
|
||||
zipread: "npm:^1.3.3"
|
||||
@ -25505,6 +25506,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yaml@npm:^2.8.1":
|
||||
version: 2.8.1
|
||||
resolution: "yaml@npm:2.8.1"
|
||||
bin:
|
||||
yaml: bin.mjs
|
||||
checksum: 10c0/7c587be00d9303d2ae1566e03bc5bc7fe978ba0d9bf39cc418c3139d37929dfcb93a230d9749f2cb578b6aa5d9ebebc322415e4b653cb83acd8bc0bc321707f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yargs-parser@npm:^21.1.1":
|
||||
version: 21.1.1
|
||||
resolution: "yargs-parser@npm:21.1.1"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user