123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497 |
- <template>
- <template v-if="!readonly">
- <div
- class="relative flex size-full flex-col rounded-xl bg-[#f2f4f7] px-3 py-2 shadow"
- >
- <div
- class="flex items-center gap-2 py-1 text-xs font-semibold text-gray-700"
- >
- <div class="flex items-center">
- <slot name="title">
- {{ title }}
- </slot>
- </div>
- <div class="ml-auto">{{ state.textCount }}</div>
- <varsPopover :node="node" @setVars="setVars">
- <el-tooltip content="变量" placement="top">
- <SvgIcon name="vars" color="#364153" size="16" />
- </el-tooltip>
- </varsPopover>
- <el-tooltip content="复制" placement="top">
- <SvgIcon
- class="__hover"
- color="#364153"
- name="copy"
- size="16"
- @click="onCopy(ref_textarea.innerText)"
- />
- </el-tooltip>
- <template v-if="delFunc">
- <el-tooltip content="删除" placement="top">
- <SvgIcon
- class="__hover"
- color="#364153"
- name="czr_del"
- size="16"
- @click="delFunc?.()"
- />
- </el-tooltip>
- </template>
- </div>
- <div class="text-text-secondary flex-1 overflow-y-auto py-1 text-[13px]">
- <div
- class="break-all"
- style="line-height: 1.5"
- ref="ref_textarea"
- contenteditable="true"
- @input="handleInput"
- @paste="handlePaste"
- @focus="state.isFocus = true"
- @blur="state.isFocus = false"
- ></div>
- </div>
- <Teleport to="body">
- <div
- class="absolute w-[300px] bg-white shadow"
- :variable-list="true"
- :style="state.variableListStyle"
- v-if="state.showVariableList"
- >
- <div class="filter">
- <el-input
- v-model="state.text"
- :prefix-icon="Search"
- placeholder="搜索变量"
- clearable
- />
- </div>
- <div class="flex flex-col gap-1.5 p-1">
- <template v-for="item in optionsCpt">
- <div class="flex flex-col" v-if="item.options?.length > 0">
- <div class="mb-1 px-2 text-sm text-gray-400">
- {{ item.label }}
- </div>
- <template v-for="son in item.options">
- <div class="__hover-bg px-2 py-1" @click="setVars(son)">
- <varsItem :item="son" />
- </div>
- </template>
- </div>
- </template>
- </div>
- </div>
- </Teleport>
- </div>
- </template>
- <template v-else>
- <div
- class="flex size-full flex-col overflow-hidden rounded-sm bg-[#f2f4f7] px-1 shadow"
- >
- <div
- class="flex items-center gap-2 py-1 text-xs font-semibold text-gray-700"
- >
- <div>{{ title }}</div>
- </div>
- <div class="text-text-secondary flex-1 py-0 text-xs">
- <div
- class="pb-1 break-all"
- style="line-height: 1.2"
- ref="ref_textarea"
- @input="handleInput"
- />
- </div>
- </div>
- </template>
- </template>
- <script setup lang="ts">
- import {
- ref,
- computed,
- onMounted,
- reactive,
- h,
- render,
- createApp,
- watch,
- nextTick,
- onUnmounted,
- } from 'vue'
- import { copy, domRootHasAttr } from '@/utils/czr-util'
- import { ElMessage } from 'element-plus'
- import SvgIcon from '@/components/SvgIcon/index.vue'
- import varsPopover from '@/views/workflow/instance/component/vars/vars-popover.vue'
- import paramValue from '@/views/workflow/instance/component/params-textarea/param-value.vue'
- import { useWorkflowStore } from '@/stores'
- import { Search } from '@element-plus/icons-vue'
- import varsItem from '@/views/workflow/instance/component/vars/vars-item.vue'
- const WorkflowStore = useWorkflowStore()
- const emit = defineEmits(['update:modelValue'])
- const props = defineProps({
- modelValue: {
- type: String,
- default: '',
- },
- node: {},
- title: {},
- readonly: {
- default: false,
- },
- delFunc: { default: undefined },
- })
- const state = reactive({
- textCount: 0,
- optionsMap: new Map(),
- options: [],
- lastSelection: null,
- isFocus: false,
- lastSlashPosition: null,
- variableListStyle: {},
- showVariableList: false,
- text: '',
- })
- const ref_textarea = ref()
- watch(
- () => props.modelValue,
- (n) => {
- if (props.readonly) {
- ref_textarea.value.innerHTML = n.replace(
- /\{\{#([^#]+)#\}\}/g,
- (match, p1) => {
- const k = [
- p1.substring(0, p1.indexOf('.')),
- p1.substring(p1.indexOf('.') + 1),
- ]
- const vars = state.optionsMap.get(`${k[0]}.${k[1]}`)
- const dom = document.createElement('div')
- dom.appendChild(initVarsDom(vars))
- return dom.innerHTML
- },
- )
- }
- },
- )
- watch(
- () => props.node,
- (n) => {
- if (n) {
- const all = WorkflowStore.getInVars(n)
- const map = new Map()
- all.forEach((p) => {
- p.options.forEach((v) => {
- map.set(`${v.nodeId}.${v.key}`, v)
- })
- })
- state.optionsMap = map
- state.options = all
- }
- },
- { immediate: true },
- )
- const handleInput = (e) => {
- if (e.target.innerText) {
- const brTags = e.target.querySelectorAll('br')
- brTags.forEach((br) => {
- br.parentNode.removeChild(br)
- })
- }
- let lastReplacedSpan: any = null
- const regex = /\{\{#([^#]+)#\}\}/g
- const walker = document.createTreeWalker(e.target, NodeFilter.SHOW_TEXT, null)
- while (walker.nextNode()) {
- const node: any = walker.currentNode
- const text: any = node.nodeValue
- if (regex.test(text)) {
- regex.lastIndex = 0
- const fragment = document.createDocumentFragment()
- let lastIndex = 0
- let match
- while ((match = regex.exec(text)) !== null) {
- if (match.index > lastIndex) {
- fragment.appendChild(
- document.createTextNode(text.substring(lastIndex, match.index)),
- )
- }
- const key = match[1]
- const varsValue: any = initVarsDom(state.optionsMap.get(key))
- fragment.appendChild(varsValue)
- lastReplacedSpan = varsValue
- lastIndex = regex.lastIndex
- }
- if (lastIndex < text.length) {
- fragment.appendChild(document.createTextNode(text.substring(lastIndex)))
- }
- node.parentNode.replaceChild(fragment, node)
- }
- }
- if (lastReplacedSpan) {
- const newRange = document.createRange()
- newRange.setStartAfter(lastReplacedSpan)
- newRange.collapse(true)
- const newSelection: any = window.getSelection()
- newSelection.removeAllRanges()
- newSelection.addRange(newRange)
- }
- state.textCount = e.target.innerText?.trim().length || 0
- // isSlashBeforeCursorEnhanced()
- emitValue()
- }
- function isSlashBeforeCursorEnhanced() {
- const selection = window.getSelection()
- if (selection.rangeCount === 0) return false
- const range = selection.getRangeAt(0)
- let node = range.startContainer
- let offset = range.startOffset
- // 如果当前节点不是文本节点,尝试找到前一个文本节点
- if (node.nodeType !== Node.TEXT_NODE) {
- // 如果光标在元素开始处,需要查找前一个兄弟节点
- if (offset === 0) {
- let prevNode = getPreviousTextNode(node)
- if (!prevNode) return false
- node = prevNode
- offset = node.textContent.length
- } else {
- // 光标在元素中间,需要检查子节点
- const child = node.childNodes[offset - 1]
- const lastTextNode = getLastTextNode(child)
- if (!lastTextNode) return false
- node = lastTextNode
- offset = node.textContent.length
- }
- }
- // 检查前一个字符是否是/
- if (offset > 0) {
- if (node.textContent[offset - 1] === '/') {
- showVariablePopup(range)
- return
- }
- }
- // 如果当前文本节点开头,需要检查前一个文本节点
- const prevTextNode = getPreviousTextNode(node)
- if (prevTextNode && prevTextNode.textContent.length > 0) {
- if (prevTextNode.textContent[prevTextNode.textContent.length - 1] === '/') {
- showVariablePopup(range)
- return
- }
- }
- state.showVariableList = false
- }
- // 辅助函数:获取前一个文本节点
- function getPreviousTextNode(node) {
- let sibling = node.previousSibling
- while (sibling) {
- if (
- sibling.nodeType === Node.TEXT_NODE &&
- sibling.textContent.trim() !== ''
- ) {
- return sibling
- }
- if (
- sibling.nodeType === Node.ELEMENT_NODE &&
- sibling.childNodes.length > 0
- ) {
- const lastChild = getLastTextNode(sibling)
- if (lastChild) return lastChild
- }
- sibling = sibling.previousSibling
- }
- return node.parentNode ? getPreviousTextNode(node.parentNode) : null
- }
- // 辅助函数:获取最后一个文本节点
- function getLastTextNode(node) {
- if (node.nodeType === Node.TEXT_NODE) return node
- if (node.nodeType === Node.ELEMENT_NODE && node.childNodes.length > 0) {
- for (let i = node.childNodes.length - 1; i >= 0; i--) {
- const result = getLastTextNode(node.childNodes[i])
- if (result) return result
- }
- }
- return null
- }
- const handlePaste = (e) => {
- e.preventDefault()
- const text = (e.clipboardData || window.clipboardData).getData('text')
- document.execCommand('insertText', false, text)
- }
- const onCopy = (text) => {
- copy(text)
- ElMessage.success('复制成功!')
- }
- const initVarsDom = (vars) => {
- const dom = document.createElement('div')
- if (vars) {
- dom.setAttribute('contenteditable', 'false')
- dom.setAttribute('sign', `{{#${vars.nodeId}.${vars.key}#}}`)
- dom.className = 'vars-dom inline-block'
- const app = createApp(paramValue, {
- vars,
- })
- app.component('SvgIcon', SvgIcon)
- app.mount(dom)
- }
- return dom
- }
- const setVars = (vars) => {
- const nodeToInsert: any = initVarsDom(vars)
- // 检查是否有有效的选择范围且在当前容器内
- if (state.lastSelection) {
- if (state.showVariableList) {
- const range = state.lastSelection.cloneRange()
- const startContainer = range.startContainer
- const startOffset = range.startOffset
- if (startContainer.nodeType === Node.TEXT_NODE && startOffset > 0) {
- const textBefore = startContainer.textContent.slice(0, startOffset)
- if (textBefore.endsWith('/')) {
- // Create a new range covering just the '/' character
- const deleteRange = document.createRange()
- deleteRange.setStart(startContainer, startOffset - 1)
- deleteRange.setEnd(startContainer, startOffset)
- deleteRange.deleteContents()
- // Adjust the original selection position
- range.setStart(startContainer, startOffset - 1)
- }
- }
- }
- // 恢复选区
- const sel: any = window.getSelection()
- sel.removeAllRanges()
- sel.addRange(state.lastSelection)
- // 插入内容
- state.lastSelection.deleteContents()
- state.lastSelection.insertNode(nodeToInsert)
- state.lastSelection.setStartAfter(state.lastSelection.endContainer)
- state.lastSelection.collapse(true)
- state.lastSelection = null
- } else {
- ref_textarea.value.appendChild(nodeToInsert)
- }
- // 可选:滚动到插入的元素
- nodeToInsert.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
- emitValue()
- state.text = ''
- state.showVariableList = false
- }
- const handleSelectionchange = () => {
- if (state.isFocus) {
- const sel: any = window.getSelection()
- if (sel.rangeCount > 0) {
- state.lastSelection = sel.getRangeAt(0).cloneRange()
- }
- }
- }
- const emitValue = () => {
- if (!props.readonly) {
- const newDom = document.createElement('div')
- newDom.innerHTML = ref_textarea.value.innerHTML
- const varsDomDivs = newDom.querySelectorAll('div[class*="vars-dom"]')
- // 遍历每个匹配的元素
- varsDomDivs.forEach((div: any) => {
- // 获取sign属性的值
- const signValue = div.getAttribute('sign')
- // 如果sign值存在,就用它替换整个div
- if (signValue) {
- // 创建一个文本节点来替换原div
- const textNode = document.createTextNode(signValue)
- // 用文本节点替换原div
- div.parentNode.replaceChild(textNode, div)
- }
- })
- emit('update:modelValue', newDom.innerHTML)
- }
- }
- // 显示变量弹窗
- const showVariablePopup = (range) => {
- const rect = range.getBoundingClientRect()
- state.variableListStyle = {
- position: 'absolute',
- left: `${rect.left + window.scrollX}px`,
- top: `${rect.bottom + window.scrollY}px`,
- zIndex: 1000,
- }
- state.showVariableList = true
- }
- const onMouseDown = (e) => {
- if (!domRootHasAttr(e.target, 'variable-list')) {
- state.showVariableList = false
- }
- }
- const optionsCpt = computed(() => {
- if (!state.text) {
- return state.options
- }
- return state.options
- .map((v: any) => {
- const obj = { ...v }
- obj.options = obj.options.filter(
- (s) => s.label.includes(state.text) || s.key.includes(state.text),
- )
- return obj
- })
- .filter((v) => v.options.length > 0)
- })
- onMounted(() => {
- if (props.modelValue) {
- // ref_textarea.value.innerHTML = props.modelValue
- // ref_textarea.value.dispatchEvent(
- // new Event('input', {
- // bubbles: true,
- // cancelable: true,
- // }),
- // )
- ref_textarea.value.innerHTML = props.modelValue.replace(
- /\{\{#([^#]+)#\}\}/g,
- (match, p1) => {
- const k = [
- p1.substring(0, p1.indexOf('.')),
- p1.substring(p1.indexOf('.') + 1),
- ]
- const vars = state.optionsMap.get(`${k[0]}.${k[1]}`)
- const dom = document.createElement('div')
- dom.appendChild(initVarsDom(vars))
- return dom.innerHTML
- },
- )
- }
- if (!props.readonly) {
- document.addEventListener('selectionchange', handleSelectionchange)
- document.addEventListener('mousedown', onMouseDown)
- }
- })
- onUnmounted(() => {
- if (!props.readonly) {
- document.removeEventListener('selectionchange', handleSelectionchange)
- document.removeEventListener('mousedown', onMouseDown)
- }
- })
- </script>
- <style scoped lang="scss">
- @use '@/views/workflow/instance/component/style';
- .filter {
- padding: 10px;
- border-bottom: style.$borderStyle;
- }
- </style>
|