version-2.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. <template>
  2. <template v-if="!readonly">
  3. <div
  4. class="relative flex size-full flex-col rounded-xl bg-[#f2f4f7] px-3 py-2 shadow"
  5. >
  6. <div
  7. class="flex items-center gap-2 py-1 text-xs font-semibold text-gray-700"
  8. >
  9. <div class="flex items-center">
  10. <slot name="title">
  11. {{ title }}
  12. </slot>
  13. </div>
  14. <div class="ml-auto">{{ state.textCount }}</div>
  15. <varsPopover :node="node" @setVars="setVars">
  16. <el-tooltip content="变量" placement="top">
  17. <SvgIcon name="vars" color="#364153" size="16" />
  18. </el-tooltip>
  19. </varsPopover>
  20. <el-tooltip content="复制" placement="top">
  21. <SvgIcon
  22. class="__hover"
  23. color="#364153"
  24. name="copy"
  25. size="16"
  26. @click="onCopy(ref_textarea.innerText)"
  27. />
  28. </el-tooltip>
  29. <template v-if="delFunc">
  30. <el-tooltip content="删除" placement="top">
  31. <SvgIcon
  32. class="__hover"
  33. color="#364153"
  34. name="czr_del"
  35. size="16"
  36. @click="delFunc?.()"
  37. />
  38. </el-tooltip>
  39. </template>
  40. </div>
  41. <div class="text-text-secondary flex-1 overflow-y-auto py-1 text-[13px]">
  42. <div
  43. class="break-all"
  44. style="line-height: 1.5"
  45. ref="ref_textarea"
  46. contenteditable="true"
  47. @input="handleInput"
  48. @paste="handlePaste"
  49. @focus="state.isFocus = true"
  50. @blur="state.isFocus = false"
  51. ></div>
  52. </div>
  53. <Teleport to="body">
  54. <div
  55. class="absolute w-[300px] bg-white shadow"
  56. :variable-list="true"
  57. :style="state.variableListStyle"
  58. v-if="state.showVariableList"
  59. >
  60. <div class="filter">
  61. <el-input
  62. v-model="state.text"
  63. :prefix-icon="Search"
  64. placeholder="搜索变量"
  65. clearable
  66. />
  67. </div>
  68. <div class="flex flex-col gap-1.5 p-1">
  69. <template v-for="item in optionsCpt">
  70. <div class="flex flex-col" v-if="item.options?.length > 0">
  71. <div class="mb-1 px-2 text-sm text-gray-400">
  72. {{ item.label }}
  73. </div>
  74. <template v-for="son in item.options">
  75. <div class="__hover-bg px-2 py-1" @click="setVars(son)">
  76. <varsItem :item="son" />
  77. </div>
  78. </template>
  79. </div>
  80. </template>
  81. </div>
  82. </div>
  83. </Teleport>
  84. </div>
  85. </template>
  86. <template v-else>
  87. <div
  88. class="flex size-full flex-col overflow-hidden rounded-sm bg-[#f2f4f7] px-1 shadow"
  89. >
  90. <div
  91. class="flex items-center gap-2 py-1 text-xs font-semibold text-gray-700"
  92. >
  93. <div>{{ title }}</div>
  94. </div>
  95. <div class="text-text-secondary flex-1 py-0 text-xs">
  96. <div
  97. class="pb-1 break-all"
  98. style="line-height: 1.2"
  99. ref="ref_textarea"
  100. @input="handleInput"
  101. />
  102. </div>
  103. </div>
  104. </template>
  105. </template>
  106. <script setup lang="ts">
  107. import {
  108. ref,
  109. computed,
  110. onMounted,
  111. reactive,
  112. h,
  113. render,
  114. createApp,
  115. watch,
  116. nextTick,
  117. onUnmounted,
  118. } from 'vue'
  119. import { copy, domRootHasAttr } from '@/utils/czr-util'
  120. import { ElMessage } from 'element-plus'
  121. import SvgIcon from '@/components/SvgIcon/index.vue'
  122. import varsPopover from '@/views/workflow/instance/component/vars/vars-popover.vue'
  123. import paramValue from '@/views/workflow/instance/component/params-textarea/param-value.vue'
  124. import { useWorkflowStore } from '@/stores'
  125. import { Search } from '@element-plus/icons-vue'
  126. import varsItem from '@/views/workflow/instance/component/vars/vars-item.vue'
  127. const WorkflowStore = useWorkflowStore()
  128. const emit = defineEmits(['update:modelValue'])
  129. const props = defineProps({
  130. modelValue: {
  131. type: String,
  132. default: '',
  133. },
  134. node: {},
  135. title: {},
  136. readonly: {
  137. default: false,
  138. },
  139. delFunc: { default: undefined },
  140. })
  141. const state = reactive({
  142. textCount: 0,
  143. optionsMap: new Map(),
  144. options: [],
  145. lastSelection: null,
  146. isFocus: false,
  147. lastSlashPosition: null,
  148. variableListStyle: {},
  149. showVariableList: false,
  150. text: '',
  151. })
  152. const ref_textarea = ref()
  153. watch(
  154. () => props.modelValue,
  155. (n) => {
  156. if (props.readonly) {
  157. ref_textarea.value.innerHTML = n.replace(
  158. /\{\{#([^#]+)#\}\}/g,
  159. (match, p1) => {
  160. const k = [
  161. p1.substring(0, p1.indexOf('.')),
  162. p1.substring(p1.indexOf('.') + 1),
  163. ]
  164. const vars = state.optionsMap.get(`${k[0]}.${k[1]}`)
  165. const dom = document.createElement('div')
  166. dom.appendChild(initVarsDom(vars))
  167. return dom.innerHTML
  168. },
  169. )
  170. }
  171. },
  172. )
  173. watch(
  174. () => props.node,
  175. (n) => {
  176. if (n) {
  177. const all = WorkflowStore.getInVars(n)
  178. const map = new Map()
  179. all.forEach((p) => {
  180. p.options.forEach((v) => {
  181. map.set(`${v.nodeId}.${v.key}`, v)
  182. })
  183. })
  184. state.optionsMap = map
  185. state.options = all
  186. }
  187. },
  188. { immediate: true },
  189. )
  190. const handleInput = (e) => {
  191. if (e.target.innerText) {
  192. const brTags = e.target.querySelectorAll('br')
  193. brTags.forEach((br) => {
  194. br.parentNode.removeChild(br)
  195. })
  196. }
  197. let lastReplacedSpan: any = null
  198. const regex = /\{\{#([^#]+)#\}\}/g
  199. const walker = document.createTreeWalker(e.target, NodeFilter.SHOW_TEXT, null)
  200. while (walker.nextNode()) {
  201. const node: any = walker.currentNode
  202. const text: any = node.nodeValue
  203. if (regex.test(text)) {
  204. regex.lastIndex = 0
  205. const fragment = document.createDocumentFragment()
  206. let lastIndex = 0
  207. let match
  208. while ((match = regex.exec(text)) !== null) {
  209. if (match.index > lastIndex) {
  210. fragment.appendChild(
  211. document.createTextNode(text.substring(lastIndex, match.index)),
  212. )
  213. }
  214. const key = match[1]
  215. const varsValue: any = initVarsDom(state.optionsMap.get(key))
  216. fragment.appendChild(varsValue)
  217. lastReplacedSpan = varsValue
  218. lastIndex = regex.lastIndex
  219. }
  220. if (lastIndex < text.length) {
  221. fragment.appendChild(document.createTextNode(text.substring(lastIndex)))
  222. }
  223. node.parentNode.replaceChild(fragment, node)
  224. }
  225. }
  226. if (lastReplacedSpan) {
  227. const newRange = document.createRange()
  228. newRange.setStartAfter(lastReplacedSpan)
  229. newRange.collapse(true)
  230. const newSelection: any = window.getSelection()
  231. newSelection.removeAllRanges()
  232. newSelection.addRange(newRange)
  233. }
  234. state.textCount = e.target.innerText?.trim().length || 0
  235. // isSlashBeforeCursorEnhanced()
  236. emitValue()
  237. }
  238. function isSlashBeforeCursorEnhanced() {
  239. const selection = window.getSelection()
  240. if (selection.rangeCount === 0) return false
  241. const range = selection.getRangeAt(0)
  242. let node = range.startContainer
  243. let offset = range.startOffset
  244. // 如果当前节点不是文本节点,尝试找到前一个文本节点
  245. if (node.nodeType !== Node.TEXT_NODE) {
  246. // 如果光标在元素开始处,需要查找前一个兄弟节点
  247. if (offset === 0) {
  248. let prevNode = getPreviousTextNode(node)
  249. if (!prevNode) return false
  250. node = prevNode
  251. offset = node.textContent.length
  252. } else {
  253. // 光标在元素中间,需要检查子节点
  254. const child = node.childNodes[offset - 1]
  255. const lastTextNode = getLastTextNode(child)
  256. if (!lastTextNode) return false
  257. node = lastTextNode
  258. offset = node.textContent.length
  259. }
  260. }
  261. // 检查前一个字符是否是/
  262. if (offset > 0) {
  263. if (node.textContent[offset - 1] === '/') {
  264. showVariablePopup(range)
  265. return
  266. }
  267. }
  268. // 如果当前文本节点开头,需要检查前一个文本节点
  269. const prevTextNode = getPreviousTextNode(node)
  270. if (prevTextNode && prevTextNode.textContent.length > 0) {
  271. if (prevTextNode.textContent[prevTextNode.textContent.length - 1] === '/') {
  272. showVariablePopup(range)
  273. return
  274. }
  275. }
  276. state.showVariableList = false
  277. }
  278. // 辅助函数:获取前一个文本节点
  279. function getPreviousTextNode(node) {
  280. let sibling = node.previousSibling
  281. while (sibling) {
  282. if (
  283. sibling.nodeType === Node.TEXT_NODE &&
  284. sibling.textContent.trim() !== ''
  285. ) {
  286. return sibling
  287. }
  288. if (
  289. sibling.nodeType === Node.ELEMENT_NODE &&
  290. sibling.childNodes.length > 0
  291. ) {
  292. const lastChild = getLastTextNode(sibling)
  293. if (lastChild) return lastChild
  294. }
  295. sibling = sibling.previousSibling
  296. }
  297. return node.parentNode ? getPreviousTextNode(node.parentNode) : null
  298. }
  299. // 辅助函数:获取最后一个文本节点
  300. function getLastTextNode(node) {
  301. if (node.nodeType === Node.TEXT_NODE) return node
  302. if (node.nodeType === Node.ELEMENT_NODE && node.childNodes.length > 0) {
  303. for (let i = node.childNodes.length - 1; i >= 0; i--) {
  304. const result = getLastTextNode(node.childNodes[i])
  305. if (result) return result
  306. }
  307. }
  308. return null
  309. }
  310. const handlePaste = (e) => {
  311. e.preventDefault()
  312. const text = (e.clipboardData || window.clipboardData).getData('text')
  313. document.execCommand('insertText', false, text)
  314. }
  315. const onCopy = (text) => {
  316. copy(text)
  317. ElMessage.success('复制成功!')
  318. }
  319. const initVarsDom = (vars) => {
  320. const dom = document.createElement('div')
  321. if (vars) {
  322. dom.setAttribute('contenteditable', 'false')
  323. dom.setAttribute('sign', `{{#${vars.nodeId}.${vars.key}#}}`)
  324. dom.className = 'vars-dom inline-block'
  325. const app = createApp(paramValue, {
  326. vars,
  327. })
  328. app.component('SvgIcon', SvgIcon)
  329. app.mount(dom)
  330. }
  331. return dom
  332. }
  333. const setVars = (vars) => {
  334. const nodeToInsert: any = initVarsDom(vars)
  335. // 检查是否有有效的选择范围且在当前容器内
  336. if (state.lastSelection) {
  337. if (state.showVariableList) {
  338. const range = state.lastSelection.cloneRange()
  339. const startContainer = range.startContainer
  340. const startOffset = range.startOffset
  341. if (startContainer.nodeType === Node.TEXT_NODE && startOffset > 0) {
  342. const textBefore = startContainer.textContent.slice(0, startOffset)
  343. if (textBefore.endsWith('/')) {
  344. // Create a new range covering just the '/' character
  345. const deleteRange = document.createRange()
  346. deleteRange.setStart(startContainer, startOffset - 1)
  347. deleteRange.setEnd(startContainer, startOffset)
  348. deleteRange.deleteContents()
  349. // Adjust the original selection position
  350. range.setStart(startContainer, startOffset - 1)
  351. }
  352. }
  353. }
  354. // 恢复选区
  355. const sel: any = window.getSelection()
  356. sel.removeAllRanges()
  357. sel.addRange(state.lastSelection)
  358. // 插入内容
  359. state.lastSelection.deleteContents()
  360. state.lastSelection.insertNode(nodeToInsert)
  361. state.lastSelection.setStartAfter(state.lastSelection.endContainer)
  362. state.lastSelection.collapse(true)
  363. state.lastSelection = null
  364. } else {
  365. ref_textarea.value.appendChild(nodeToInsert)
  366. }
  367. // 可选:滚动到插入的元素
  368. nodeToInsert.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
  369. emitValue()
  370. state.text = ''
  371. state.showVariableList = false
  372. }
  373. const handleSelectionchange = () => {
  374. if (state.isFocus) {
  375. const sel: any = window.getSelection()
  376. if (sel.rangeCount > 0) {
  377. state.lastSelection = sel.getRangeAt(0).cloneRange()
  378. }
  379. }
  380. }
  381. const emitValue = () => {
  382. if (!props.readonly) {
  383. const newDom = document.createElement('div')
  384. newDom.innerHTML = ref_textarea.value.innerHTML
  385. const varsDomDivs = newDom.querySelectorAll('div[class*="vars-dom"]')
  386. // 遍历每个匹配的元素
  387. varsDomDivs.forEach((div: any) => {
  388. // 获取sign属性的值
  389. const signValue = div.getAttribute('sign')
  390. // 如果sign值存在,就用它替换整个div
  391. if (signValue) {
  392. // 创建一个文本节点来替换原div
  393. const textNode = document.createTextNode(signValue)
  394. // 用文本节点替换原div
  395. div.parentNode.replaceChild(textNode, div)
  396. }
  397. })
  398. emit('update:modelValue', newDom.innerHTML)
  399. }
  400. }
  401. // 显示变量弹窗
  402. const showVariablePopup = (range) => {
  403. const rect = range.getBoundingClientRect()
  404. state.variableListStyle = {
  405. position: 'absolute',
  406. left: `${rect.left + window.scrollX}px`,
  407. top: `${rect.bottom + window.scrollY}px`,
  408. zIndex: 1000,
  409. }
  410. state.showVariableList = true
  411. }
  412. const onMouseDown = (e) => {
  413. if (!domRootHasAttr(e.target, 'variable-list')) {
  414. state.showVariableList = false
  415. }
  416. }
  417. const optionsCpt = computed(() => {
  418. if (!state.text) {
  419. return state.options
  420. }
  421. return state.options
  422. .map((v: any) => {
  423. const obj = { ...v }
  424. obj.options = obj.options.filter(
  425. (s) => s.label.includes(state.text) || s.key.includes(state.text),
  426. )
  427. return obj
  428. })
  429. .filter((v) => v.options.length > 0)
  430. })
  431. onMounted(() => {
  432. if (props.modelValue) {
  433. // ref_textarea.value.innerHTML = props.modelValue
  434. // ref_textarea.value.dispatchEvent(
  435. // new Event('input', {
  436. // bubbles: true,
  437. // cancelable: true,
  438. // }),
  439. // )
  440. ref_textarea.value.innerHTML = props.modelValue.replace(
  441. /\{\{#([^#]+)#\}\}/g,
  442. (match, p1) => {
  443. const k = [
  444. p1.substring(0, p1.indexOf('.')),
  445. p1.substring(p1.indexOf('.') + 1),
  446. ]
  447. const vars = state.optionsMap.get(`${k[0]}.${k[1]}`)
  448. const dom = document.createElement('div')
  449. dom.appendChild(initVarsDom(vars))
  450. return dom.innerHTML
  451. },
  452. )
  453. }
  454. if (!props.readonly) {
  455. document.addEventListener('selectionchange', handleSelectionchange)
  456. document.addEventListener('mousedown', onMouseDown)
  457. }
  458. })
  459. onUnmounted(() => {
  460. if (!props.readonly) {
  461. document.removeEventListener('selectionchange', handleSelectionchange)
  462. document.removeEventListener('mousedown', onMouseDown)
  463. }
  464. })
  465. </script>
  466. <style scoped lang="scss">
  467. @use '@/views/workflow/instance/component/style';
  468. .filter {
  469. padding: 10px;
  470. border-bottom: style.$borderStyle;
  471. }
  472. </style>