Created
June 29, 2023 04:21
-
-
Save guillegette/14aa13472caf22a3211e8e1ff4b6290c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { | |
forwardRef, useEffect, useImperativeHandle, useState, | |
} from 'react' | |
import { useEditor, EditorContent, Editor } from '@tiptap/react'; | |
import Mention from '@tiptap/extension-mention'; | |
import StarterKit from '@tiptap/starter-kit' | |
import { ReactRenderer } from '@tiptap/react' | |
import tippy from 'tippy.js' | |
import { Marked } from 'marked'; | |
import TurndownService from 'turndown'; | |
const markdownToHtml = function (text, items){ | |
const questionTag = { | |
name: 'questionTag', | |
level: 'inline', | |
start(src) { | |
return src.match(/{{[^}}]+}}/)?.index; | |
}, | |
tokenizer(src, tokens) { | |
const rule = /^{{([^}}]+)}}/; | |
const match = rule.exec(src); | |
if (match) { | |
const identifier = match[1].trim(); | |
const item = items.find(i => i.Identifier === identifier); | |
if (item) { | |
return { | |
type: 'questionTag', | |
raw: match[0], | |
id: item.Identifier, | |
label: item.Question | |
}; | |
} | |
} | |
}, | |
renderer(token) { | |
return `<span data-type="mention" class="badge badge-primary" data-id="${token.id}" data-label="${token.label}" contenteditable="false">${token.label}</span>`; | |
}, | |
}; | |
const marked = new Marked({ extensions: [questionTag], mangle: false, headerIds: false }); | |
return marked.parse(text); | |
} | |
const htmlToMarkdown = function (text){ | |
const turndownService = new TurndownService(); | |
const questionTag = { | |
filter: (node) => node.nodeName === 'SPAN' && node.getAttribute('data-type') === 'mention', | |
replacement: (content, node) => `{{${node.getAttribute('data-id')}}}`, | |
}; | |
turndownService.addRule('questionTag', questionTag); | |
return turndownService.turndown(text); | |
} | |
const MenuList = forwardRef(function MenuList(props, ref) { | |
const [selectedIndex, setSelectedIndex] = useState(0) | |
const selectItem = index => { | |
const item = props.items[index] | |
if (item) { | |
props.command({ id: item.Identifier, label: item.Question }) | |
} | |
} | |
const upHandler = () => { | |
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length) | |
} | |
const downHandler = () => { | |
setSelectedIndex((selectedIndex + 1) % props.items.length) | |
} | |
const enterHandler = () => { | |
selectItem(selectedIndex) | |
} | |
useEffect(() => setSelectedIndex(0), [props.items]) | |
useImperativeHandle(ref, () => ({ | |
onKeyDown: ({ event }) => { | |
if (event.key === 'ArrowUp') { | |
upHandler() | |
return true | |
} | |
if (event.key === 'ArrowDown') { | |
downHandler() | |
return true | |
} | |
if (event.key === 'Enter') { | |
enterHandler() | |
return true | |
} | |
return false | |
}, | |
})) | |
return ( | |
<ul className="p-2 shadow menu bg-base-100 rounded-box w-52"> | |
{props.items.length | |
? props.items.map((item, index) => ( | |
<li key={item.id}> | |
<a className={`${index === selectedIndex ? 'bg-primary' : ''}`} onClick={() => selectItem(index)}> | |
{item.Question} | |
</a> | |
</li> | |
)) | |
: <li>No result</li> | |
} | |
</ul> | |
) | |
}) | |
const QuestionsExtension = (formFields) => Mention.configure({ | |
HTMLAttributes: { | |
class: 'badge badge-primary', | |
}, | |
renderLabel({ options, node }) { | |
return `${node.attrs.label ?? node.attrs.id}` | |
}, | |
suggestion: { | |
items: function({ query }){ | |
return formFields.filter(({Question}) => Question.toLowerCase().startsWith(query.toLowerCase())) | |
}, | |
render: () => { | |
let component | |
let popup | |
return { | |
onStart: props => { | |
component = new ReactRenderer(MenuList, { | |
props, | |
editor: props.editor, | |
}) | |
if (!props.clientRect) { | |
return | |
} | |
popup = tippy('body', { | |
getReferenceClientRect: props.clientRect, | |
appendTo: () => document.body, | |
content: component.element, | |
showOnCreate: true, | |
interactive: true, | |
trigger: 'manual', | |
placement: 'bottom-start', | |
}) | |
}, | |
onUpdate(props) { | |
component.updateProps(props) | |
if (!props.clientRect) { | |
return | |
} | |
popup[0].setProps({ | |
getReferenceClientRect: props.clientRect, | |
}) | |
}, | |
onKeyDown(props) { | |
if (props.event.key === 'Escape') { | |
popup[0].hide() | |
return true | |
} | |
return component.ref?.onKeyDown(props) | |
}, | |
onExit() { | |
popup[0].destroy() | |
component.destroy() | |
}, | |
} | |
}, | |
} | |
}); | |
export default function PromptEditor({prompt, formFields, onUpdate}){ | |
const [localFormFields, setLocalFormFields] = useState(formFields); | |
useEffect(() => { | |
setLocalFormFields(formFields); | |
}, [formFields]); | |
const editor = useEditor({ | |
extensions: [ | |
StarterKit, | |
QuestionsExtension(localFormFields) | |
], | |
content: markdownToHtml(prompt, localFormFields), | |
onUpdate: ({ editor }) => { | |
const html = editor.getHTML(); | |
const markdown = htmlToMarkdown(html); | |
if (onUpdate && typeof onUpdate === 'function') { | |
onUpdate(markdown); | |
} | |
}, | |
}) | |
if (!editor) { | |
return null | |
} | |
return <EditorContent editor={editor} /> | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment