601 lines
16 KiB
Vue
601 lines
16 KiB
Vue
<template>
|
||
<div class="video-editor-root">
|
||
<div v-if="showToolbar" class="toolbar">
|
||
<button class="tool-btn" type="button" @click="openImagePicker">插入参考素材</button>
|
||
<button class="tool-btn" type="button" @click="clear">清空所有参考素材和文字</button>
|
||
</div>
|
||
<input
|
||
ref="fileInputRef"
|
||
class="hidden-input"
|
||
type="file"
|
||
accept="image/*,video/*,audio/*"
|
||
multiple
|
||
@change="handleSelectFiles" />
|
||
<div class="editor-wrapper">
|
||
<div
|
||
ref="editorRef"
|
||
class="user-input video-rich-editor"
|
||
contenteditable="true"
|
||
:data-placeholder="placeholder || '请输入文本生成视频...'"
|
||
@input="handleInput"
|
||
@paste="handlePaste"
|
||
@keyup="handleKeyup"
|
||
@click="handleEditorClick"></div>
|
||
<div v-if="mentionVisible" class="mention-panel">
|
||
<div
|
||
v-for="item in mentionImageList"
|
||
:key="item.url"
|
||
class="mention-item"
|
||
@mousedown.prevent="insertMentionImage(item)">
|
||
<img v-if="item.mediaType === 'image'" :src="item.url" class="mention-thumb" alt="" />
|
||
<span v-else class="mention-type-badge">{{ item.mediaType === 'video' ? '视频' : '音频' }}</span>
|
||
<span class="mention-label">参考{{ item.refNo }}</span>
|
||
</div>
|
||
<div v-if="mentionImageList.length === 0" class="mention-empty">暂无可引用素材</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { nextTick, onMounted, ref } from 'vue'
|
||
import { Message } from '@arco-design/web-vue'
|
||
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
|
||
|
||
const props = defineProps({
|
||
placeholder: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
/** 文生视频等场景可隐藏工具栏,仅保留纯文本编辑 */
|
||
showToolbar: {
|
||
type: Boolean,
|
||
default: true
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['text-change'])
|
||
|
||
const editorRef = ref(null)
|
||
const fileInputRef = ref(null)
|
||
const savedSelectionRange = ref(null)
|
||
const MAX_IMAGE_COUNT = 9
|
||
const mentionVisible = ref(false)
|
||
const mentionImageList = ref([])
|
||
|
||
const detectMediaType = (file) => {
|
||
const mime = (file?.type || '').toLowerCase()
|
||
if (mime.startsWith('image/')) return 'image'
|
||
if (mime.startsWith('video/')) return 'video'
|
||
if (mime.startsWith('audio/')) return 'audio'
|
||
const lowerName = (file?.name || '').toLowerCase()
|
||
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lowerName)) return 'image'
|
||
if (/\.(mp4|mov|webm|m4v|avi|mkv)$/.test(lowerName)) return 'video'
|
||
if (/\.(mp3|wav|aac|m4a|ogg|flac)$/.test(lowerName)) return 'audio'
|
||
return 'image'
|
||
}
|
||
|
||
const getPlainText = () => (editorRef.value?.innerText || '').trim()
|
||
|
||
const focusEditorToEnd = () => {
|
||
if (!editorRef.value) return
|
||
editorRef.value.focus()
|
||
const selection = window.getSelection()
|
||
if (!selection) return
|
||
const range = document.createRange()
|
||
range.selectNodeContents(editorRef.value)
|
||
range.collapse(false)
|
||
selection.removeAllRanges()
|
||
selection.addRange(range)
|
||
savedSelectionRange.value = range.cloneRange()
|
||
}
|
||
|
||
const saveSelection = () => {
|
||
const selection = window.getSelection()
|
||
if (!selection || selection.rangeCount === 0) return
|
||
const range = selection.getRangeAt(0)
|
||
if (editorRef.value?.contains(range.commonAncestorContainer)) {
|
||
savedSelectionRange.value = range.cloneRange()
|
||
}
|
||
}
|
||
|
||
const restoreSelection = () => {
|
||
const selection = window.getSelection()
|
||
if (!selection || !editorRef.value) return
|
||
selection.removeAllRanges()
|
||
if (savedSelectionRange.value) {
|
||
selection.addRange(savedSelectionRange.value)
|
||
} else {
|
||
const range = document.createRange()
|
||
range.selectNodeContents(editorRef.value)
|
||
range.collapse(false)
|
||
selection.addRange(range)
|
||
}
|
||
}
|
||
|
||
const handleInput = () => {
|
||
emit('text-change', getPlainText())
|
||
saveSelection()
|
||
syncMentionPanelVisibility()
|
||
}
|
||
|
||
const insertPlainTextAtCursor = (text) => {
|
||
if (!editorRef.value) return
|
||
const normalizedText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||
editorRef.value.focus()
|
||
restoreSelection()
|
||
const selection = window.getSelection()
|
||
if (!selection || selection.rangeCount === 0) return
|
||
let range = selection.getRangeAt(0)
|
||
if (!editorRef.value.contains(range.commonAncestorContainer)) {
|
||
focusEditorToEnd()
|
||
const currentSelection = window.getSelection()
|
||
if (!currentSelection || currentSelection.rangeCount === 0) return
|
||
range = currentSelection.getRangeAt(0)
|
||
}
|
||
range.deleteContents()
|
||
const lines = normalizedText.split('\n')
|
||
const fragment = document.createDocumentFragment()
|
||
lines.forEach((line, index) => {
|
||
if (line) fragment.appendChild(document.createTextNode(line))
|
||
if (index < lines.length - 1) fragment.appendChild(document.createElement('br'))
|
||
})
|
||
range.insertNode(fragment)
|
||
range.collapse(false)
|
||
selection.removeAllRanges()
|
||
selection.addRange(range)
|
||
saveSelection()
|
||
}
|
||
|
||
const handlePaste = (event) => {
|
||
if (!editorRef.value) return
|
||
event.preventDefault()
|
||
const clipboardText = event.clipboardData?.getData('text/plain') || ''
|
||
if (!clipboardText) return
|
||
insertPlainTextAtCursor(clipboardText)
|
||
emit('text-change', getPlainText())
|
||
syncMentionPanelVisibility()
|
||
}
|
||
|
||
const updateMentionImageList = () => {
|
||
mentionImageList.value = mentionImageList.value.slice(0, MAX_IMAGE_COUNT)
|
||
}
|
||
|
||
const hideMentionPanel = () => {
|
||
mentionVisible.value = false
|
||
}
|
||
|
||
const hasActiveMentionTriggerBeforeCursor = () => {
|
||
const selection = window.getSelection()
|
||
if (!selection || selection.rangeCount === 0) return false
|
||
const range = selection.getRangeAt(0)
|
||
if (!range.collapsed) return false
|
||
if (!editorRef.value?.contains(range.commonAncestorContainer)) return false
|
||
const container = range.startContainer
|
||
if (container.nodeType !== Node.TEXT_NODE) return false
|
||
const before = container.data.slice(0, range.startOffset)
|
||
const atIndex = before.lastIndexOf('@')
|
||
if (atIndex < 0) return false
|
||
const afterAt = before.slice(atIndex + 1)
|
||
return !/\s/.test(afterAt)
|
||
}
|
||
|
||
const syncMentionPanelVisibility = () => {
|
||
const shouldShow = hasActiveMentionTriggerBeforeCursor()
|
||
if (!shouldShow) {
|
||
hideMentionPanel()
|
||
return
|
||
}
|
||
updateMentionImageList()
|
||
mentionVisible.value = true
|
||
}
|
||
|
||
const removeMentionKeywordBeforeCursor = () => {
|
||
const selection = window.getSelection()
|
||
if (!selection || selection.rangeCount === 0) return
|
||
const range = selection.getRangeAt(0)
|
||
if (!range.collapsed) return
|
||
const container = range.startContainer
|
||
if (container.nodeType !== Node.TEXT_NODE) return
|
||
const before = container.data.slice(0, range.startOffset)
|
||
const atIndex = before.lastIndexOf('@')
|
||
if (atIndex < 0) return
|
||
const afterAt = before.slice(atIndex + 1)
|
||
if (/\s/.test(afterAt)) return
|
||
const removeRange = document.createRange()
|
||
removeRange.setStart(container, atIndex)
|
||
removeRange.setEnd(container, range.startOffset)
|
||
removeRange.deleteContents()
|
||
}
|
||
|
||
const insertReference = (item) => {
|
||
if (!item?.url || !editorRef.value) return
|
||
editorRef.value.focus()
|
||
restoreSelection()
|
||
const selection = window.getSelection()
|
||
if (!selection || selection.rangeCount === 0) return
|
||
const range = selection.getRangeAt(0)
|
||
if (!editorRef.value.contains(range.commonAncestorContainer)) return
|
||
|
||
const refNoText = `[图${item.refNo}]`
|
||
let node
|
||
if (item.mediaType === 'image') {
|
||
const img = document.createElement('img')
|
||
img.src = item.url
|
||
img.alt = `reference-${item.refNo}`
|
||
img.className = 'inline-rich-image'
|
||
img.setAttribute('data-image-url', item.url)
|
||
img.setAttribute('data-mention-reference', '1')
|
||
img.setAttribute('data-reference-no', String(item.refNo))
|
||
node = img
|
||
} else {
|
||
const tag = document.createElement('span')
|
||
tag.className = 'inline-rich-reference'
|
||
tag.textContent = refNoText
|
||
tag.setAttribute('data-reference-url', item.url)
|
||
tag.setAttribute('data-mention-reference', '1')
|
||
tag.setAttribute('data-reference-no', String(item.refNo))
|
||
tag.setAttribute('data-reference-type', item.mediaType)
|
||
node = tag
|
||
}
|
||
|
||
range.insertNode(node)
|
||
const space = document.createTextNode(' ')
|
||
range.setStartAfter(node)
|
||
range.insertNode(space)
|
||
range.setStartAfter(space)
|
||
range.collapse(true)
|
||
selection.removeAllRanges()
|
||
selection.addRange(range)
|
||
saveSelection()
|
||
}
|
||
|
||
const insertMentionImage = (item) => {
|
||
if (!item?.url || !editorRef.value) return
|
||
editorRef.value.focus()
|
||
restoreSelection()
|
||
removeMentionKeywordBeforeCursor()
|
||
// 删除 @关键字 后必须重新记录选区,否则 insertReference 里 restoreSelection 仍用删除前的 Range,会插错位置甚至覆盖后文
|
||
saveSelection()
|
||
insertReference(item)
|
||
hideMentionPanel()
|
||
emit('text-change', getPlainText())
|
||
}
|
||
|
||
const handleKeyup = (event) => {
|
||
saveSelection()
|
||
if (event.key === '@') {
|
||
syncMentionPanelVisibility()
|
||
return
|
||
}
|
||
if (event.key === 'Escape') {
|
||
hideMentionPanel()
|
||
return
|
||
}
|
||
syncMentionPanelVisibility()
|
||
}
|
||
|
||
const handleEditorClick = () => {
|
||
const selection = window.getSelection()
|
||
if (!selection || selection.rangeCount === 0) {
|
||
focusEditorToEnd()
|
||
return
|
||
}
|
||
const range = selection.getRangeAt(0)
|
||
if (!editorRef.value?.contains(range.commonAncestorContainer)) {
|
||
focusEditorToEnd()
|
||
return
|
||
}
|
||
saveSelection()
|
||
syncMentionPanelVisibility()
|
||
}
|
||
|
||
const getInsertedUniqueImageCount = () => mentionImageList.value.length
|
||
|
||
const openImagePicker = () => {
|
||
const current = getInsertedUniqueImageCount()
|
||
if (current >= MAX_IMAGE_COUNT) {
|
||
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材`)
|
||
return
|
||
}
|
||
fileInputRef.value?.click()
|
||
}
|
||
|
||
const uploadToCos = async (file) => {
|
||
const res = await uploadFile({
|
||
url: PORTAL_TENCENT_COS_UPLOAD_URL,
|
||
file,
|
||
name: 'file'
|
||
})
|
||
const codeOk = res && (Number(res.code) === 200 || res.code === 200)
|
||
const url = extractUploadUrlFromResponse(res)
|
||
if (codeOk && url) return url
|
||
throw new Error(res?.msg || '上传失败:未返回文件地址')
|
||
}
|
||
|
||
const handleSelectFiles = async (event) => {
|
||
const input = event.target
|
||
const files = Array.from(input.files || [])
|
||
if (!files.length) return
|
||
const remain = MAX_IMAGE_COUNT - getInsertedUniqueImageCount()
|
||
if (remain <= 0) {
|
||
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材`)
|
||
input.value = ''
|
||
return
|
||
}
|
||
const selected = files.slice(0, remain)
|
||
if (files.length > remain) {
|
||
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材,本次仅插入 ${remain} 个`)
|
||
}
|
||
for (const file of selected) {
|
||
const mediaType = detectMediaType(file)
|
||
try {
|
||
const url = await uploadToCos(file)
|
||
if (url && !mentionImageList.value.some((item) => item.url === url)) {
|
||
const refNo = mentionImageList.value.length + 1
|
||
mentionImageList.value.push({ url, refNo, mediaType })
|
||
}
|
||
} catch (e) {
|
||
Message.error('参考素材上传失败')
|
||
console.error(e)
|
||
}
|
||
}
|
||
input.value = ''
|
||
emit('text-change', getPlainText())
|
||
}
|
||
|
||
const getContentItems = () => {
|
||
const editor = editorRef.value
|
||
const items = []
|
||
if (!editor) return items
|
||
let textBuffer = ''
|
||
const flushText = () => {
|
||
const normalized = textBuffer.replace(/\u00a0/g, ' ').replace(/\s+\n/g, '\n')
|
||
if (normalized.trim()) items.push({ type: 'text', text: normalized.trim() })
|
||
textBuffer = ''
|
||
}
|
||
const walk = (node) => {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
textBuffer += node.textContent || ''
|
||
return
|
||
}
|
||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||
const el = node
|
||
const referenceUrl = (
|
||
el.dataset?.referenceUrl ||
|
||
el.dataset?.imageUrl ||
|
||
el.getAttribute('src') ||
|
||
''
|
||
).trim()
|
||
if (el.dataset?.mentionReference === '1' && referenceUrl) {
|
||
flushText()
|
||
items.push({
|
||
type: 'image_url',
|
||
image_url: { url: referenceUrl },
|
||
role: 'reference_image'
|
||
})
|
||
return
|
||
}
|
||
if (el.tagName === 'IMG' && (el.dataset?.imageUrl || el.getAttribute('src'))) {
|
||
return
|
||
}
|
||
if (el.tagName === 'BR') {
|
||
textBuffer += '\n'
|
||
return
|
||
}
|
||
if (['DIV', 'P', 'LI'].includes(el.tagName) && textBuffer && !textBuffer.endsWith('\n')) textBuffer += '\n'
|
||
Array.from(el.childNodes).forEach(walk)
|
||
if (['DIV', 'P', 'LI'].includes(el.tagName) && textBuffer && !textBuffer.endsWith('\n')) textBuffer += '\n'
|
||
}
|
||
Array.from(editor.childNodes).forEach(walk)
|
||
flushText()
|
||
return items
|
||
}
|
||
|
||
const clear = () => {
|
||
if (!editorRef.value) return
|
||
editorRef.value.innerHTML = ''
|
||
emit('text-change', '')
|
||
savedSelectionRange.value = null
|
||
mentionImageList.value = []
|
||
hideMentionPanel()
|
||
}
|
||
|
||
/** 供父组件持久化:参考图模式的 HTML + 素材列表 */
|
||
const getDraftState = () => ({
|
||
html: editorRef.value ? editorRef.value.innerHTML : '',
|
||
mentionList: mentionImageList.value.map(({ url, refNo, mediaType }) => ({
|
||
url,
|
||
refNo,
|
||
mediaType
|
||
}))
|
||
})
|
||
|
||
const applyDraftState = (draft) => {
|
||
hideMentionPanel()
|
||
savedSelectionRange.value = null
|
||
if (!draft || typeof draft !== 'object') {
|
||
clear()
|
||
return
|
||
}
|
||
mentionImageList.value = Array.isArray(draft.mentionList)
|
||
? draft.mentionList.map((x) => ({
|
||
url: x.url,
|
||
refNo: Number(x.refNo) || 0,
|
||
mediaType: x.mediaType || 'image'
|
||
}))
|
||
: []
|
||
if (editorRef.value) {
|
||
editorRef.value.innerHTML = typeof draft.html === 'string' ? draft.html : ''
|
||
}
|
||
nextTick(() => {
|
||
emit('text-change', getPlainText())
|
||
})
|
||
}
|
||
|
||
defineExpose({
|
||
getContentItems,
|
||
getPlainText,
|
||
clear,
|
||
getDraftState,
|
||
applyDraftState
|
||
})
|
||
|
||
onMounted(() => {
|
||
nextTick(() => {
|
||
focusEditorToEnd()
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.video-editor-root {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
.toolbar {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.hidden-input {
|
||
display: none;
|
||
}
|
||
.editor-wrapper {
|
||
position: relative;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 200px;
|
||
}
|
||
.tool-btn {
|
||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
color: rgba(255, 255, 255, 0.88);
|
||
padding: 6px 12px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
transition: border-color 0.2s, background 0.2s;
|
||
}
|
||
.tool-btn:hover {
|
||
border-color: rgba(0, 202, 224, 0.4);
|
||
background: rgba(0, 202, 224, 0.08);
|
||
}
|
||
.video-rich-editor {
|
||
width: 100%;
|
||
flex: 1;
|
||
min-height: 220px;
|
||
font-size: 15px;
|
||
line-height: 1.9;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
overflow-y: auto;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
caret-color: #4d89ff;
|
||
|
||
:deep(*) {
|
||
color: rgba(255, 255, 255, 0.9) !important;
|
||
}
|
||
|
||
:deep(.inline-rich-reference) {
|
||
color: #5eebf5 !important;
|
||
background: rgba(0, 202, 224, 0.15);
|
||
}
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 14px;
|
||
padding: 16px 18px;
|
||
background: rgba(0, 0, 0, 0.25);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||
}
|
||
.video-rich-editor:focus {
|
||
border-color: rgba(0, 202, 224, 0.45);
|
||
box-shadow: 0 0 0 2px rgba(0, 202, 224, 0.15);
|
||
outline: none;
|
||
}
|
||
.video-rich-editor:empty::before {
|
||
content: attr(data-placeholder);
|
||
color: rgba(255, 255, 255, 0.35);
|
||
pointer-events: none;
|
||
display: block;
|
||
}
|
||
.video-rich-editor :deep(.inline-rich-image) {
|
||
display: inline-block;
|
||
width: auto;
|
||
height: auto;
|
||
max-width: min(260px, 100%);
|
||
max-height: 148px;
|
||
object-fit: contain;
|
||
vertical-align: middle;
|
||
margin: 6px 8px;
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||
}
|
||
.video-rich-editor :deep(.inline-rich-reference) {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 0 6px;
|
||
margin: 0 3px;
|
||
border-radius: 4px;
|
||
background: rgba(0, 202, 224, 0.15);
|
||
color: #5eebf5;
|
||
font-size: 0.85em;
|
||
line-height: 1.5;
|
||
}
|
||
.mention-panel {
|
||
position: absolute;
|
||
left: 8px;
|
||
right: 8px;
|
||
bottom: 8px;
|
||
max-height: 180px;
|
||
overflow-y: auto;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 10px;
|
||
background: rgba(22, 24, 30, 0.98);
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||
z-index: 20;
|
||
}
|
||
.mention-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
cursor: pointer;
|
||
}
|
||
.mention-item:hover {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
}
|
||
.mention-thumb {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 4px;
|
||
object-fit: cover;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
.mention-type-badge {
|
||
min-width: 32px;
|
||
height: 32px;
|
||
border-radius: 4px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.65);
|
||
background: rgba(0, 0, 0, 0.25);
|
||
}
|
||
.mention-label {
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
}
|
||
.mention-empty {
|
||
padding: 8px 10px;
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
}
|
||
</style>
|