Skip to content

Commit 5daf8a3

Browse files
authored
Feat/Add Markdown support to RichInput with Edit/Source toggle (#6021)
* update @tiptap dependencies and enhance RichTextEditor functionality * feat(customMention): add Markdown support for custom mentions - Implemented a custom tokenizer for recognizing {{...}} syntax in Markdown. - Added parsing and rendering functions to handle mentions in Markdown format. - Enhanced the CustomMention component to support Markdown serialization and deserialization. * update tiptap to latest 3.20.4
1 parent 03a0cf6 commit 5daf8a3

File tree

6 files changed

+645
-64
lines changed

6 files changed

+645
-64
lines changed

packages/server/src/utils/buildAgentflow.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,10 +247,13 @@ export const resolveVariables = async (
247247
// If value is not a string, return as is
248248
if (typeof value !== 'string') return value
249249

250-
const turndownService = new TurndownService()
251-
value = turndownService.turndown(value)
252-
// After conversion, replace any escaped underscores with regular underscores
253-
value = value.replace(/\\_/g, '_')
250+
// Convert legacy HTML content to markdown, preserving any markdown syntax within
251+
if (/<[a-z][a-z0-9]*[^>]*>/i.test(value)) {
252+
const turndownService = new TurndownService()
253+
// Disable escaping so markdown characters (e.g. ###, -, *) inside HTML are preserved as-is
254+
turndownService.escape = (str: string) => str
255+
value = turndownService.turndown(value)
256+
}
254257

255258
const matches = value.match(/{{(.*?)}}/g)
256259

packages/ui/package.json

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@
2626
"@mui/x-tree-view": "^7.25.0",
2727
"@reduxjs/toolkit": "^2.2.7",
2828
"@tabler/icons-react": "^3.30.0",
29-
"@tiptap/extension-code-block-lowlight": "^3.4.3",
30-
"@tiptap/extension-mention": "^2.11.5",
31-
"@tiptap/extension-placeholder": "^2.11.5",
32-
"@tiptap/pm": "^2.11.5",
33-
"@tiptap/react": "^2.11.5",
34-
"@tiptap/starter-kit": "^2.11.5",
29+
"@tiptap/extension-code-block-lowlight": "^3.20.4",
30+
"@tiptap/extension-mention": "^3.20.4",
31+
"@tiptap/extension-placeholder": "^3.20.4",
32+
"@tiptap/markdown": "^3.20.4",
33+
"@tiptap/pm": "^3.20.4",
34+
"@tiptap/react": "^3.20.4",
35+
"@tiptap/starter-kit": "^3.20.4",
3536
"@uiw/codemirror-theme-sublime": "^4.21.21",
3637
"@uiw/codemirror-theme-vscode": "^4.21.21",
3738
"@uiw/react-codemirror": "^4.21.21",

packages/ui/src/ui-component/dialog/ExpandRichInputDialog.jsx

Lines changed: 147 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { createPortal } from 'react-dom'
2-
import { useState, useEffect } from 'react'
2+
import { useState, useEffect, useRef, useCallback } from 'react'
33
import { useDispatch, useSelector } from 'react-redux'
44
import PropTypes from 'prop-types'
55
import PerfectScrollbar from 'react-perfect-scrollbar'
66

77
// MUI
8-
import { Button, Dialog, DialogActions, DialogContent, Typography, Box } from '@mui/material'
8+
import { Button, Dialog, DialogActions, DialogContent, Typography, Box, ToggleButton, ToggleButtonGroup } from '@mui/material'
99
import { styled } from '@mui/material/styles'
10+
import { IconCode, IconPencil } from '@tabler/icons-react'
1011

1112
// Project Import
1213
import { StyledButton } from '@/ui-component/button/StyledButton'
@@ -16,6 +17,7 @@ import { useEditor, EditorContent } from '@tiptap/react'
1617
import Placeholder from '@tiptap/extension-placeholder'
1718
import { mergeAttributes } from '@tiptap/core'
1819
import StarterKit from '@tiptap/starter-kit'
20+
import { Markdown } from '@tiptap/markdown'
1921
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
2022
import { common, createLowlight } from 'lowlight'
2123
import { suggestionOptions } from '@/ui-component/input/suggestionOption'
@@ -24,10 +26,16 @@ import { CustomMention } from '@/utils/customMention'
2426

2527
const lowlight = createLowlight(common)
2628

29+
// Detect if content is legacy HTML (from old getHTML() storage) vs markdown
30+
const isHtmlContent = (content) => {
31+
if (!content || typeof content !== 'string') return false
32+
return /<(?:p|div|span|h[1-6]|ul|ol|li|br|code|pre|blockquote|table|strong|em)\b/i.test(content)
33+
}
34+
2735
// Store
2836
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'
2937

30-
// Add styled component for editor wrapper
38+
// Styled editor content for preview mode
3139
const StyledEditorContent = styled(EditorContent)(({ theme, rows, disabled, isDarkMode }) => ({
3240
'& .ProseMirror': {
3341
padding: '0px 14px',
@@ -36,7 +44,8 @@ const StyledEditorContent = styled(EditorContent)(({ theme, rows, disabled, isDa
3644
overflowX: rows ? 'auto' : 'hidden',
3745
lineHeight: rows ? '1.4375em' : '0.875em',
3846
fontWeight: 500,
39-
color: disabled ? theme.palette.action.disabled : theme.palette.grey[900],
47+
color: theme.palette.grey[900],
48+
opacity: disabled ? 0.7 : 1,
4049
border: `1px solid ${theme.palette.grey[900] + 25}`,
4150
borderRadius: '10px',
4251
backgroundColor: theme.palette.textBackground.main,
@@ -54,7 +63,7 @@ const StyledEditorContent = styled(EditorContent)(({ theme, rows, disabled, isDa
5463
'& p.is-editor-empty:first-of-type::before': {
5564
content: 'attr(data-placeholder)',
5665
float: 'left',
57-
color: disabled ? theme.palette.action.disabled : theme.palette.text.primary,
66+
color: theme.palette.text.primary,
5867
opacity: disabled ? 0.6 : 0.4,
5968
pointerEvents: 'none',
6069
height: 0
@@ -73,11 +82,45 @@ const StyledEditorContent = styled(EditorContent)(({ theme, rows, disabled, isDa
7382
}
7483
}))
7584

85+
// Styled textarea for raw mode
86+
const StyledTextarea = styled('textarea')(({ theme, disabled }) => ({
87+
width: '100%',
88+
padding: '8px 14px',
89+
height: `${15 * 1.4375}rem`,
90+
overflowY: 'auto',
91+
overflowX: 'auto',
92+
lineHeight: '1.4375em',
93+
fontWeight: 500,
94+
fontFamily: 'inherit',
95+
fontSize: 'inherit',
96+
color: theme.palette.grey[900],
97+
opacity: disabled ? 0.7 : 1,
98+
border: `1px solid ${theme.palette.grey[900] + 25}`,
99+
borderRadius: '10px',
100+
backgroundColor: theme.palette.textBackground.main,
101+
boxSizing: 'border-box',
102+
resize: 'none',
103+
outline: 'none',
104+
whiteSpace: 'pre-wrap',
105+
'&:hover': {
106+
borderColor: disabled ? `${theme.palette.grey[900] + 25}` : theme.palette.text.primary,
107+
cursor: disabled ? 'default' : 'text'
108+
},
109+
'&:focus': {
110+
borderColor: disabled ? `${theme.palette.grey[900] + 25}` : theme.palette.primary.main
111+
},
112+
'&::placeholder': {
113+
color: theme.palette.text.primary,
114+
opacity: disabled ? 0.6 : 0.4
115+
}
116+
}))
117+
76118
// define your extension array
77119
const extensions = (availableNodesForVariable, availableState, acceptNodeOutputAsVariable, nodes, nodeData, isNodeInsideInteration) => [
78120
StarterKit.configure({
79121
codeBlock: false
80122
}),
123+
Markdown,
81124
CustomMention.configure({
82125
HTMLAttributes: {
83126
class: 'variable'
@@ -113,12 +156,14 @@ const ExpandRichInputDialog = ({ show, dialogProps, onCancel, onInputHintDialogC
113156
const customization = useSelector((state) => state.customization)
114157
const isDarkMode = customization.isDarkMode
115158

159+
const [viewMode, setViewMode] = useState('preview')
116160
const [inputValue, setInputValue] = useState('')
117161
const [inputParam, setInputParam] = useState(null)
118162
const [availableNodesForVariable, setAvailableNodesForVariable] = useState([])
119163
const [availableState, setAvailableState] = useState([])
120164
const [nodeData, setNodeData] = useState({})
121165
const [isNodeInsideInteration, setIsNodeInsideInteration] = useState(false)
166+
const isSwitchingRef = useRef(false)
122167

123168
useEffect(() => {
124169
if (dialogProps.value) {
@@ -131,6 +176,7 @@ const ExpandRichInputDialog = ({ show, dialogProps, onCancel, onInputHintDialogC
131176
return () => {
132177
setInputValue('')
133178
setInputParam(null)
179+
setViewMode('preview')
134180
}
135181
}, [dialogProps])
136182

@@ -171,33 +217,104 @@ const ExpandRichInputDialog = ({ show, dialogProps, onCancel, onInputHintDialogC
171217
),
172218
Placeholder.configure({ placeholder: inputParam?.placeholder })
173219
],
174-
content: inputValue,
220+
content: '',
175221
onUpdate: ({ editor }) => {
176-
setInputValue(editor.getHTML())
222+
if (!isSwitchingRef.current) {
223+
try {
224+
setInputValue(editor.getMarkdown())
225+
} catch {
226+
setInputValue(editor.getHTML())
227+
}
228+
}
177229
},
178230
editable: !dialogProps.disabled
179231
},
180232
[availableNodesForVariable]
181233
)
182234

183-
// Focus the editor when dialog opens
235+
// Load content into the editor once it's ready
236+
useEffect(() => {
237+
if (editor && inputValue) {
238+
isSwitchingRef.current = true
239+
if (isHtmlContent(inputValue)) {
240+
editor.commands.setContent(inputValue)
241+
try {
242+
setInputValue(editor.getMarkdown())
243+
} catch {
244+
// keep original value if conversion fails
245+
}
246+
} else {
247+
editor.commands.setContent(inputValue, { contentType: 'markdown' })
248+
}
249+
isSwitchingRef.current = false
250+
}
251+
}, [editor]) // eslint-disable-line react-hooks/exhaustive-deps
252+
253+
// Focus the editor when dialog opens in preview mode
184254
useEffect(() => {
185-
if (show && editor) {
255+
if (show && editor && viewMode === 'preview') {
186256
setTimeout(() => {
187257
editor.commands.focus()
188258
}, 100)
189259
}
190-
}, [show, editor])
260+
}, [show, editor, viewMode])
261+
262+
const handleViewModeChange = useCallback(
263+
(event, newMode) => {
264+
if (!newMode || newMode === viewMode) return
265+
266+
if (newMode === 'preview' && editor) {
267+
isSwitchingRef.current = true
268+
const contentType = isHtmlContent(inputValue) ? 'html' : 'markdown'
269+
editor.commands.setContent(inputValue, { contentType })
270+
isSwitchingRef.current = false
271+
setTimeout(() => editor.commands.focus(), 50)
272+
} else if (newMode === 'raw' && editor) {
273+
try {
274+
setInputValue(editor.getMarkdown())
275+
} catch {
276+
setInputValue(editor.getHTML())
277+
}
278+
}
279+
280+
setViewMode(newMode)
281+
},
282+
[viewMode, editor, inputValue]
283+
)
191284

192285
const component = show ? (
193286
<Dialog open={show} fullWidth maxWidth='md' aria-labelledby='alert-dialog-title' aria-describedby='alert-dialog-description'>
194287
<DialogContent>
195288
<div style={{ display: 'flex', flexDirection: 'row' }}>
196289
{inputParam && (
197290
<div style={{ flex: 70, width: '100%' }}>
198-
<div style={{ marginBottom: '10px', display: 'flex', flexDirection: 'row' }}>
291+
<div style={{ marginBottom: '10px', display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
199292
<Typography variant='h4'>{inputParam.label}</Typography>
200293
<div style={{ flex: 1 }} />
294+
<ToggleButtonGroup
295+
value={viewMode}
296+
exclusive
297+
onChange={handleViewModeChange}
298+
size='small'
299+
sx={{
300+
mr: inputParam.hint ? 1 : 0,
301+
'& .MuiToggleButton-root': {
302+
color: isDarkMode ? 'rgba(255,255,255,0.5)' : undefined,
303+
'&.Mui-selected': {
304+
color: isDarkMode ? '#fff' : undefined
305+
}
306+
}
307+
}}
308+
>
309+
<ToggleButton value='preview' sx={{ px: 1.5, py: 0.5, textTransform: 'none' }}>
310+
<IconPencil size={16} style={{ marginRight: 4 }} />
311+
Edit
312+
</ToggleButton>
313+
<ToggleButton value='raw' sx={{ px: 1.5, py: 0.5, textTransform: 'none' }}>
314+
<IconCode size={16} style={{ marginRight: 4 }} />
315+
Source
316+
</ToggleButton>
317+
</ToggleButtonGroup>
201318
{inputParam.hint && (
202319
<Button
203320
sx={{ p: 0, px: 2 }}
@@ -219,14 +336,25 @@ const ExpandRichInputDialog = ({ show, dialogProps, onCancel, onInputHintDialogC
219336
overflowX: 'hidden'
220337
}}
221338
>
222-
<Box sx={{ mt: 1, border: '' }}>
223-
<StyledEditorContent
224-
editor={editor}
225-
rows={15}
226-
disabled={dialogProps.disabled}
227-
isDarkMode={isDarkMode}
228-
/>
229-
</Box>
339+
{viewMode === 'raw' ? (
340+
<Box sx={{ mt: 1 }}>
341+
<StyledTextarea
342+
value={inputValue}
343+
onChange={(e) => setInputValue(e.target.value)}
344+
placeholder={inputParam?.placeholder}
345+
disabled={dialogProps.disabled}
346+
/>
347+
</Box>
348+
) : (
349+
<Box sx={{ mt: 1 }}>
350+
<StyledEditorContent
351+
editor={editor}
352+
rows={15}
353+
disabled={dialogProps.disabled}
354+
isDarkMode={isDarkMode}
355+
/>
356+
</Box>
357+
)}
230358
</PerfectScrollbar>
231359
</div>
232360
)}

packages/ui/src/ui-component/input/RichInput.jsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useEditor, EditorContent } from '@tiptap/react'
55
import Placeholder from '@tiptap/extension-placeholder'
66
import { mergeAttributes } from '@tiptap/core'
77
import StarterKit from '@tiptap/starter-kit'
8+
import { Markdown } from '@tiptap/markdown'
89
import { styled } from '@mui/material/styles'
910
import { Box } from '@mui/material'
1011
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
@@ -15,8 +16,15 @@ import { CustomMention } from '@/utils/customMention'
1516

1617
const lowlight = createLowlight(common)
1718

19+
// Detect if content is legacy HTML (from old getHTML() storage) vs markdown
20+
const isHtmlContent = (content) => {
21+
if (!content || typeof content !== 'string') return false
22+
return /<(?:p|div|span|h[1-6]|ul|ol|li|br|code|pre|blockquote|table|strong|em)\b/i.test(content)
23+
}
24+
1825
// define your extension array
1926
const extensions = (availableNodesForVariable, availableState, acceptNodeOutputAsVariable, nodes, nodeData, isNodeInsideInteration) => [
27+
Markdown,
2028
StarterKit.configure({
2129
codeBlock: false
2230
}),
@@ -131,15 +139,30 @@ export const RichInput = ({ inputParam, value, nodes, edges, nodeId, onChange, d
131139
),
132140
Placeholder.configure({ placeholder: inputParam?.placeholder })
133141
],
134-
content: value,
142+
content: '',
135143
onUpdate: ({ editor }) => {
136-
onChange(editor.getHTML())
144+
try {
145+
onChange(editor.getMarkdown())
146+
} catch {
147+
onChange(editor.getHTML())
148+
}
137149
},
138150
editable: !disabled
139151
},
140152
[availableNodesForVariable]
141153
)
142154

155+
// Load initial content after editor is ready, detecting HTML vs markdown
156+
useEffect(() => {
157+
if (editor && value) {
158+
if (isHtmlContent(value)) {
159+
editor.commands.setContent(value)
160+
} else {
161+
editor.commands.setContent(value, { contentType: 'markdown' })
162+
}
163+
}
164+
}, [editor]) // eslint-disable-line react-hooks/exhaustive-deps
165+
143166
return (
144167
<Box sx={{ mt: 1, border: '' }}>
145168
<StyledEditorContent editor={editor} rows={inputParam?.rows} disabled={disabled} isDarkMode={isDarkMode} />

0 commit comments

Comments
 (0)