Skip to content

Instantly share code, notes, and snippets.

@guillegette
Created June 29, 2023 04:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save guillegette/14aa13472caf22a3211e8e1ff4b6290c to your computer and use it in GitHub Desktop.
Save guillegette/14aa13472caf22a3211e8e1ff4b6290c to your computer and use it in GitHub Desktop.
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