Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
151 lines
5.4 KiB
Vue
151 lines
5.4 KiB
Vue
<script setup lang="ts">
|
|
import { activeMention } from '~~/shared/utils/mentions'
|
|
import type { MentionUser } from '~~/shared/utils/mentions'
|
|
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const model = defineModel<string>({ required: true })
|
|
const props = defineProps<{ placeholder?: string }>()
|
|
const emit = defineEmits<{ 'paste-files': [files: File[]] }>()
|
|
const { data: users } = useMentionUsers()
|
|
const listId = useId()
|
|
const caret = shallowRef(0)
|
|
const focused = shallowRef(false)
|
|
const dismissed = shallowRef(false)
|
|
const selected = shallowRef(0)
|
|
let textarea: HTMLTextAreaElement | null = null
|
|
const mention = computed(() => activeMention(model.value, caret.value))
|
|
const suggestions = computed(() => {
|
|
if (!mention.value) return []
|
|
const query = mention.value.query.toLowerCase()
|
|
return users.value.filter(u => u.name.toLowerCase().startsWith(query)).slice(0, 8)
|
|
})
|
|
const open = computed(() => focused.value && !dismissed.value && suggestions.value.length > 0)
|
|
|
|
function updateCaret(event: Event) {
|
|
textarea = event.target as HTMLTextAreaElement
|
|
caret.value = textarea.selectionStart
|
|
focused.value = true
|
|
dismissed.value = false
|
|
selected.value = 0
|
|
}
|
|
|
|
async function choose(user: MentionUser) {
|
|
if (!mention.value || !textarea) return
|
|
const { start, end } = mention.value
|
|
const suffix = model.value.slice(end).replace(/^[\p{L}\p{N}_-]*/u, '')
|
|
const insertion = `@${user.name} `
|
|
model.value = model.value.slice(0, start) + insertion + suffix
|
|
dismissed.value = true
|
|
await nextTick()
|
|
textarea.focus({ preventScroll: true })
|
|
textarea.setSelectionRange(start + insertion.length, start + insertion.length)
|
|
caret.value = start + insertion.length
|
|
}
|
|
|
|
// Text an der Cursorposition einfügen (z. B. Emoji-Shortcodes aus dem Picker)
|
|
async function insert(text: string) {
|
|
if (!textarea) {
|
|
model.value += text
|
|
return
|
|
}
|
|
const start = textarea.selectionStart ?? model.value.length
|
|
const end = textarea.selectionEnd ?? start
|
|
model.value = model.value.slice(0, start) + text + model.value.slice(end)
|
|
await nextTick()
|
|
textarea.focus({ preventScroll: true })
|
|
textarea.setSelectionRange(start + text.length, start + text.length)
|
|
caret.value = start + text.length
|
|
}
|
|
defineExpose({ insert })
|
|
|
|
// Dateien aus der Zwischenablage (z. B. Screenshots) gehen an den Parent -
|
|
// der landet sie als Pending-Anhänge; Text-Paste läuft wie gehabt weiter
|
|
function onPaste(event: ClipboardEvent) {
|
|
const files = Array.from(event.clipboardData?.files ?? [])
|
|
if (!files.length) return
|
|
event.preventDefault()
|
|
emit('paste-files', files)
|
|
}
|
|
|
|
function onTextareaKeyup(event: KeyboardEvent) {
|
|
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(event.key)) updateCaret(event)
|
|
}
|
|
|
|
// Klicks in der Textarea dürfen das Mention-Popover nicht schließen
|
|
function onInteractOutside(event: { target: EventTarget | null, preventDefault: () => void }) {
|
|
if (event.target === textarea) event.preventDefault()
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (!open.value || event.isComposing) return
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
selected.value = (selected.value + (event.key === 'ArrowDown' ? 1 : -1) + suggestions.value.length) % suggestions.value.length
|
|
}
|
|
else if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault()
|
|
const user = suggestions.value[selected.value]
|
|
if (user) choose(user)
|
|
}
|
|
else if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
dismissed.value = true
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Popover :open="open" @update:open="value => { if (!value) dismissed = true }">
|
|
<PopoverAnchor as-child>
|
|
<div class="relative">
|
|
<Textarea
|
|
v-model="model"
|
|
:placeholder="props.placeholder"
|
|
class="min-h-32 bg-card pb-9"
|
|
:aria-label="$t('comments.commentAria')"
|
|
aria-autocomplete="list"
|
|
:aria-expanded="open"
|
|
:aria-controls="open ? listId : undefined"
|
|
:aria-activedescendant="open ? `${listId}-${selected}` : undefined"
|
|
@input="updateCaret"
|
|
@click="updateCaret"
|
|
@keyup="onTextareaKeyup"
|
|
@focus="updateCaret"
|
|
@blur="focused = false"
|
|
@keydown="onKeydown"
|
|
@paste="onPaste"
|
|
/>
|
|
<slot />
|
|
</div>
|
|
</PopoverAnchor>
|
|
<PopoverContent
|
|
side="top"
|
|
align="start"
|
|
class="gap-1 rounded-md p-1"
|
|
@open-auto-focus.prevent
|
|
@close-auto-focus.prevent
|
|
@interact-outside="onInteractOutside"
|
|
>
|
|
<div :id="listId" role="listbox" :aria-label="$t('comments.mentionAria')">
|
|
<button
|
|
v-for="(user, index) in suggestions"
|
|
:id="`${listId}-${index}`"
|
|
:key="user.id"
|
|
type="button"
|
|
role="option"
|
|
:aria-selected="index === selected"
|
|
:class="cn('flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-left', index === selected && 'bg-accent text-accent-foreground')"
|
|
@pointerdown.prevent
|
|
@pointermove="selected = index"
|
|
@click="choose(user)"
|
|
>
|
|
<Icon name="ph:user-fill" class="size-4 shrink-0" :style="user.color ? { color: user.color } : undefined" />
|
|
<span class="truncate">@{{ user.name }}</span>
|
|
</button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</template>
|