mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
194 lines
5.5 KiB
194 lines
5.5 KiB
<script setup lang="ts"> |
|
import Placeholder from '@tiptap/extension-placeholder' |
|
import StarterKit from '@tiptap/starter-kit' |
|
import Mention from '@tiptap/extension-mention' |
|
import { EditorContent, useEditor } from '@tiptap/vue-3' |
|
import { type ColumnType, UITypes } from 'nocodb-sdk' |
|
import FieldList from '~/helpers/tiptapExtensions/mention/FieldList' |
|
import suggestion from '~/helpers/tiptapExtensions/mention/suggestion.ts' |
|
|
|
const props = withDefaults( |
|
defineProps<{ |
|
modelValue: string |
|
options?: ColumnType[] |
|
autoFocus?: boolean |
|
promptFieldTagClassName?: string |
|
suggestionIconClassName?: string |
|
placeholder?: string |
|
}>(), |
|
{ |
|
options: () => [], |
|
autoFocus: true, |
|
promptFieldTagClassName: '', |
|
suggestionIconClassName: '', |
|
/** |
|
* Use \n to show placeholder as preline |
|
* @example: :placeholder="`Enter prompt here...\n\neg : Categorise this {Notes}`" |
|
*/ |
|
placeholder: 'Write your prompt here...', |
|
}, |
|
) |
|
|
|
const emits = defineEmits(['update:modelValue']) |
|
|
|
const vModel = computed({ |
|
get: () => props.modelValue, |
|
set: (v) => { |
|
emits('update:modelValue', v) |
|
}, |
|
}) |
|
|
|
const { autoFocus } = toRefs(props) |
|
|
|
const editor = useEditor({ |
|
content: vModel.value, |
|
extensions: [ |
|
StarterKit.configure({ |
|
heading: false, |
|
}) as any, |
|
Placeholder.configure({ |
|
emptyEditorClass: 'is-editor-empty', |
|
placeholder: props.placeholder, |
|
}), |
|
Mention.configure({ |
|
suggestion: { |
|
...suggestion(FieldList), |
|
items: ({ query }) => { |
|
if (query.length === 0) return props.options ?? [] |
|
return props.options?.filter((o) => o.title?.toLowerCase()?.includes(query.toLowerCase())) ?? [] |
|
}, |
|
char: '{', |
|
allowSpaces: true, |
|
}, |
|
renderHTML: ({ node }) => { |
|
return [ |
|
'span', |
|
{ |
|
class: `prompt-field-tag ${ |
|
props.options?.find((option) => option.title === node.attrs.id)?.uidt === UITypes.Attachment ? '!bg-green-200' : '' |
|
} ${props.promptFieldTagClassName}`, |
|
}, |
|
`${node.attrs.id}`, |
|
] |
|
}, |
|
}), |
|
], |
|
onUpdate: ({ editor }) => { |
|
let text = '' |
|
|
|
// replace all mentions with id & prepare vModel |
|
editor.state.doc.descendants((node) => { |
|
if (node.type.name === 'mention') { |
|
text += `{${node.attrs.id}}` |
|
} else if (node.text) { |
|
text += node.text |
|
} else if (node.type.name === 'paragraph') { |
|
text += '\n' |
|
} else if (node.type.name === 'hardBreak') { |
|
text += '\n' |
|
} |
|
}) |
|
|
|
// remove leading & trailing new lines |
|
text = text.trim() |
|
|
|
vModel.value = text |
|
}, |
|
editable: true, |
|
autofocus: autoFocus.value, |
|
editorProps: { scrollThreshold: 100 }, |
|
}) |
|
|
|
const newFieldSuggestionNode = () => { |
|
if (!editor.value) return |
|
|
|
const { $from } = editor.value.state.selection |
|
const textBefore = editor.value.state.doc.textBetween($from.pos - 1, $from.pos, '\n', '\n') |
|
|
|
const lastCharacter = editor.value.state.doc.textBetween($from.pos - 1, $from.pos) |
|
|
|
// Check if the text before cursor contains a newline |
|
const hasNewlineBefore = textBefore.includes('\n') |
|
|
|
if (lastCharacter === '{') { |
|
editor.value |
|
.chain() |
|
.deleteRange({ from: $from.pos - 1, to: $from.pos }) |
|
.run() |
|
} else if (lastCharacter !== ' ' && $from.pos !== 1 && !hasNewlineBefore) { |
|
editor.value?.commands.insertContent(' {') |
|
editor.value?.chain().focus().run() |
|
} else { |
|
editor.value?.commands.insertContent('{') |
|
editor.value?.chain().focus().run() |
|
} |
|
} |
|
|
|
onMounted(async () => { |
|
await until(() => vModel.value !== null && vModel.value !== undefined).toBeTruthy() |
|
|
|
// replace {id} with <span data-type="mention" data-id="id"></span> |
|
const renderContent = vModel.value |
|
.replace(/\{(.*?)\}/g, '<span data-type="mention" data-id="$1"></span>') |
|
.trim() |
|
.replace(/\n/g, '<br>') |
|
|
|
editor.value?.commands.setContent(renderContent) |
|
|
|
if (autoFocus.value) { |
|
setTimeout(() => { |
|
editor.value?.chain().focus().setTextSelection(vModel.value.length).run() |
|
}, 100) |
|
} |
|
}) |
|
</script> |
|
|
|
<template> |
|
<div class="nc-ai-prompt-with-fields w-full"> |
|
<EditorContent ref="editorDom" :editor="editor" @keydown.alt.enter.stop @keydown.shift.enter.stop /> |
|
|
|
<NcButton size="xs" type="text" class="nc-prompt-with-field-suggestion-btn !px-1" @click.stop="newFieldSuggestionNode"> |
|
<slot name="triggerIcon"> |
|
<GeneralIcon icon="ncPlusSquareSolid" class="text-nc-content-brand" :class="`${suggestionIconClassName}`" /> |
|
</slot> |
|
</NcButton> |
|
</div> |
|
</template> |
|
|
|
<style lang="scss"> |
|
.nc-ai-prompt-with-fields { |
|
@apply relative; |
|
|
|
.nc-prompt-with-field-suggestion-btn { |
|
@apply absolute top-[1px] right-[1px]; |
|
} |
|
|
|
.prompt-field-tag { |
|
@apply bg-gray-100 rounded-md px-1; |
|
} |
|
|
|
.ProseMirror { |
|
@apply px-3 pb-3 pt-2 h-[120px] min-h-[120px] overflow-y-auto nc-scrollbar-thin outline-none border-1 border-gray-200 bg-white text-nc-content-gray rounded-lg !rounded-b-none transition-shadow ease-linear -mx-[1px] -mt-[1px]; |
|
resize: vertical; |
|
min-width: 100%; |
|
max-height: min(800px, calc(100vh - 200px)) !important; |
|
} |
|
|
|
.ProseMirror-focused { |
|
@apply !rounded-b-lg outline-none border-nc-fill-purple-medium shadow-selected-ai; |
|
} |
|
|
|
.tiptap p.is-editor-empty:first-child::before { |
|
@apply text-gray-500; |
|
content: attr(data-placeholder); |
|
white-space: pre-line; /* Preserve line breaks */ |
|
float: left; |
|
height: 0; |
|
pointer-events: none; |
|
} |
|
|
|
p { |
|
@apply !mb-1; |
|
} |
|
} |
|
</style>
|
|
|