多维表格
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.
 
 
 
 
 
 

257 lines
7.1 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 tippy from 'tippy.js'
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
readOnly?: boolean
}>(),
{
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...',
readOnly: false,
},
)
const emits = defineEmits(['update:modelValue'])
const vModel = computed({
get: () => props.modelValue,
set: (v) => {
emits('update:modelValue', v)
},
})
const { autoFocus, readOnly } = toRefs(props)
const debouncedLoadMentionFieldTagTooltip = useDebounceFn(loadMentionFieldTagTooltip, 1000)
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()) || `${o.title?.toLowerCase()}}` === query.toLowerCase(),
) ?? []
)
},
char: '{',
allowSpaces: true,
},
renderHTML: ({ node }) => {
const matchedOption = props.options?.find((option) => option.title === node.attrs.id)
const isAttachment = matchedOption?.uidt === UITypes.Attachment
return [
'span',
{
'class': `prompt-field-tag ${isAttachment ? '!bg-green-200' : ''} ${props.promptFieldTagClassName}`,
'style': 'max-width: 100px; white-space: nowrap; overflow: hidden; display: inline-block; text-overflow: ellipsis;', // Enforces truncation
'data-tooltip': node.attrs.id, // Tooltip content
},
`${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
debouncedLoadMentionFieldTagTooltip()
},
editable: !readOnly.value,
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)
}
})
const tooltipInstances: any[] = []
function loadMentionFieldTagTooltip() {
document.querySelectorAll('.nc-ai-prompt-with-fields .prompt-field-tag').forEach((el) => {
const tooltip = Object.values(el.attributes).find((attr) => attr.name === 'data-tooltip')
if (!tooltip || el.scrollWidth <= el.clientWidth) return
// Show tooltip only on truncate
const instance = tippy(el, {
content: `<div class="tooltip text-xs">${tooltip.value}</div>`,
placement: 'top',
allowHTML: true,
arrow: true,
animation: 'fade',
duration: 0,
maxWidth: '200px',
})
tooltipInstances.push(instance)
})
}
onMounted(() => {
debouncedLoadMentionFieldTagTooltip()
})
onBeforeUnmount(() => {
tooltipInstances.forEach((instance) => instance?.destroy())
tooltipInstances.length = 0
})
</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"
:disabled="readOnly"
@click.stop="newFieldSuggestionNode"
>
<slot name="triggerIcon">
<GeneralIcon
icon="ncPlusSquareSolid"
class="text-nc-content-brand"
:class="[
`${suggestionIconClassName}`,
{
'opacity-75': readOnly,
},
]"
/>
</slot>
</NcButton>
</div>
</template>
<style lang="scss">
.nc-ai-prompt-with-fields {
@apply relative;
.nc-prompt-with-field-suggestion-btn {
@apply absolute top-[2px] right-[1px];
}
.prompt-field-tag {
@apply bg-gray-100 rounded-md px-1 align-middle;
}
.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;
& > p {
@apply mr-3;
}
}
.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>