|
|
@@ -0,0 +1,765 @@
|
|
|
+<template>
|
|
|
+ <div
|
|
|
+ class="input textarea"
|
|
|
+ :class="{ suffix: showBar, disabled, right, 'has-toolbar': enableToolbar }"
|
|
|
+ ref="textRef"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ :contenteditable="canEdit"
|
|
|
+ class="ui-text input-div"
|
|
|
+ @input="inputHandler"
|
|
|
+ :placeholder="props.placeholder"
|
|
|
+ @click="emit('click')"
|
|
|
+ @focus="focusHandler"
|
|
|
+ @blur="blurHandler"
|
|
|
+ @paste="pasteHandler"
|
|
|
+ @compositionstart="compositionstartHandler"
|
|
|
+ @compositionend="compositionendHandler"
|
|
|
+ @mouseup="selectionUpdate"
|
|
|
+ @keyup="selectionUpdate"
|
|
|
+ ref="inputRef"
|
|
|
+ v-bind="other"
|
|
|
+ />
|
|
|
+ <span class="replace"></span>
|
|
|
+ <span v-if="showBar" class="retouch">
|
|
|
+ <div v-if="enableToolbar" class="toolbar" @mousedown.prevent>
|
|
|
+ <div class="toolbar-item dropdown font-size" :class="{ open: showFontSizeMenu }" @mousedown.prevent="toggleFontSizeMenu">
|
|
|
+ <span class="dropdown-value">{{ currentFontSize }}</span>
|
|
|
+ <span class="dropdown-arrow">▾</span>
|
|
|
+ <div v-if="showFontSizeMenu" class="dropdown-menu" @mousedown.prevent>
|
|
|
+ <div
|
|
|
+ v-for="size in fontSizeOptions"
|
|
|
+ :key="size"
|
|
|
+ class="dropdown-option"
|
|
|
+ :class="{ active: size === currentFontSize }"
|
|
|
+ @mousedown.prevent="applyFontSize(size)"
|
|
|
+ >
|
|
|
+ {{ size }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <button
|
|
|
+ class="toolbar-item btn"
|
|
|
+ type="button"
|
|
|
+ :class="{ active: isBold }"
|
|
|
+ :disabled="!canEdit"
|
|
|
+ @mousedown.prevent="toggleBold"
|
|
|
+ >
|
|
|
+ B
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <button
|
|
|
+ class="toolbar-item btn"
|
|
|
+ type="button"
|
|
|
+ :disabled="!canEdit"
|
|
|
+ @mousedown.prevent="triggerTextColor"
|
|
|
+ >
|
|
|
+ <span class="a-letter" :style="{ '--a-color': textColor }">A</span>
|
|
|
+ </button>
|
|
|
+ <input ref="textColorInputRef" class="color-input" type="color" :value="textColor" @input="applyTextColor" />
|
|
|
+
|
|
|
+ <button
|
|
|
+ class="toolbar-item btn"
|
|
|
+ type="button"
|
|
|
+ :disabled="!canEdit"
|
|
|
+ @mousedown.prevent="triggerHighlightColor"
|
|
|
+ >
|
|
|
+ <span class="hl-icon" :style="{ '--hl-color': highlightColor }"></span>
|
|
|
+ </button>
|
|
|
+ <input ref="highlightColorInputRef" class="color-input" type="color" :value="highlightColor" @input="applyHighlightColor" />
|
|
|
+
|
|
|
+ <div class="toolbar-item dropdown align-menu" :class="{ open: showAlignMenu }" @mousedown.prevent="toggleAlignMenu">
|
|
|
+ <span class="align-icon">{{ alignLabel }}</span>
|
|
|
+ <span class="dropdown-arrow">▾</span>
|
|
|
+ <div v-if="showAlignMenu" class="dropdown-menu" @mousedown.prevent>
|
|
|
+ <div class="dropdown-option" :class="{ active: align === 'left' }" @mousedown.prevent="applyAlign('left')">左对齐</div>
|
|
|
+ <div class="dropdown-option" :class="{ active: align === 'center' }" @mousedown.prevent="applyAlign('center')">居中</div>
|
|
|
+ <div class="dropdown-option" :class="{ active: align === 'right' }" @mousedown.prevent="applyAlign('right')">右对齐</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="toolbar-item link">
|
|
|
+ <button class="btn link-btn-trigger" type="button" :disabled="!canEdit" @mousedown.prevent="openLinkInput">
|
|
|
+ 链接
|
|
|
+ </button>
|
|
|
+ <div v-if="showLinkInput" class="link-pop" @mousedown.prevent>
|
|
|
+ <input class="link-input" v-model="linkValue" placeholder="请输入链接" />
|
|
|
+ <button class="link-btn" type="button" @mousedown.prevent="confirmLink">确认</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <slot v-else name="icon" />
|
|
|
+ <span v-if="props.maxlength" class="len">
|
|
|
+ <span>{{ length }}</span> / {{ maxlength }}
|
|
|
+ </span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { richtextPropsDesc } from './state'
|
|
|
+import { computed, nextTick, onMounted, onUnmounted, ref, useSlots, watchEffect } from 'vue'
|
|
|
+const props = defineProps({
|
|
|
+ ...richtextPropsDesc,
|
|
|
+})
|
|
|
+
|
|
|
+const emit = defineEmits(['update:modelValue', 'focus', 'blur', 'click', 'updatePos'])
|
|
|
+const slots = useSlots()
|
|
|
+const textRef = ref(null)
|
|
|
+const inputRef = ref(null)
|
|
|
+const length = ref(0)
|
|
|
+const textColor = ref('#ffffff')
|
|
|
+const highlightColor = ref('#ffe58f')
|
|
|
+const textColorInputRef = ref(null)
|
|
|
+const highlightColorInputRef = ref(null)
|
|
|
+const showFontSizeMenu = ref(false)
|
|
|
+const showAlignMenu = ref(false)
|
|
|
+const showLinkInput = ref(false)
|
|
|
+const linkValue = ref('')
|
|
|
+const savedRange = ref(null)
|
|
|
+const isBold = ref(false)
|
|
|
+const align = ref('left')
|
|
|
+const currentFontSize = ref(12)
|
|
|
+
|
|
|
+const enableToolbar = computed(() => !!props.rich)
|
|
|
+const showBar = computed(() => enableToolbar.value || !!props.maxlength || !!slots.icon)
|
|
|
+const canEdit = computed(() => !props.disabled && !props.readonly)
|
|
|
+
|
|
|
+const fontSizeOptions = computed(() => Array.from({ length: 37 }, (_, idx) => idx + 12))
|
|
|
+const alignLabel = computed(() => (align.value === 'center' ? '居中' : align.value === 'right' ? '右' : '左'))
|
|
|
+
|
|
|
+const getTextLen = (text = '') => text.replace(/[\u200B\uFEFF]/g, '').length
|
|
|
+const normalizeUrl = raw => {
|
|
|
+ const val = String(raw || '').trim()
|
|
|
+ if (!val) return ''
|
|
|
+ const low = val.toLowerCase()
|
|
|
+ if (low.startsWith('javascript:') || low.startsWith('data:')) return ''
|
|
|
+ if (/^(https?:\/\/|mailto:|tel:)/i.test(val)) return val
|
|
|
+ return 'https://' + val
|
|
|
+}
|
|
|
+
|
|
|
+const syncFromDom = () => {
|
|
|
+ if (!inputRef.value) return
|
|
|
+ length.value = getTextLen(inputRef.value.textContent || '')
|
|
|
+ emit('update:modelValue', inputRef.value.innerHTML)
|
|
|
+}
|
|
|
+
|
|
|
+const saveSelection = () => {
|
|
|
+ const sel = window.getSelection?.()
|
|
|
+ if (!sel || sel.rangeCount === 0) return
|
|
|
+ const range = sel.getRangeAt(0)
|
|
|
+ if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
|
|
|
+ savedRange.value = range.cloneRange()
|
|
|
+}
|
|
|
+
|
|
|
+const restoreSelection = () => {
|
|
|
+ if (!savedRange.value || !window.getSelection || !inputRef.value) return false
|
|
|
+ const range = savedRange.value
|
|
|
+ if (range?.startContainer?.isConnected === false || range?.endContainer?.isConnected === false) {
|
|
|
+ savedRange.value = null
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if (!inputRef.value.contains(range.commonAncestorContainer)) return false
|
|
|
+ const sel = window.getSelection()
|
|
|
+ if (!sel) return false
|
|
|
+ try {
|
|
|
+ sel.removeAllRanges()
|
|
|
+ sel.addRange(range)
|
|
|
+ return true
|
|
|
+ } catch (e) {
|
|
|
+ savedRange.value = null
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const getAlignFromRange = range => {
|
|
|
+ if (!inputRef.value || !window.getComputedStyle) return null
|
|
|
+ let node = range?.startContainer
|
|
|
+ if (!node) return null
|
|
|
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentElement
|
|
|
+ if (!(node instanceof Element)) return null
|
|
|
+
|
|
|
+ let cur = node
|
|
|
+ while (cur && cur !== inputRef.value) {
|
|
|
+ const style = window.getComputedStyle(cur)
|
|
|
+ const display = style.display
|
|
|
+ if (display === 'block' || display === 'list-item') {
|
|
|
+ const v = style.textAlign
|
|
|
+ if (v === 'center') return 'center'
|
|
|
+ if (v === 'right' || v === 'end') return 'right'
|
|
|
+ if (v === 'left' || v === 'start' || v === 'justify') return 'left'
|
|
|
+ }
|
|
|
+ cur = cur.parentElement
|
|
|
+ }
|
|
|
+
|
|
|
+ const v = window.getComputedStyle(inputRef.value).textAlign
|
|
|
+ if (v === 'center') return 'center'
|
|
|
+ if (v === 'right' || v === 'end') return 'right'
|
|
|
+ if (v === 'left' || v === 'start' || v === 'justify') return 'left'
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
+const selectionUpdate = () => {
|
|
|
+ const sel = window.getSelection?.()
|
|
|
+ if (!sel || sel.rangeCount === 0) return
|
|
|
+ const range = sel.getRangeAt(0)
|
|
|
+ if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
|
|
|
+ savedRange.value = range.cloneRange()
|
|
|
+ if (!document.queryCommandState) return
|
|
|
+ try {
|
|
|
+ isBold.value = document.queryCommandState('bold')
|
|
|
+ if (document.queryCommandState('justifyCenter')) align.value = 'center'
|
|
|
+ else if (document.queryCommandState('justifyRight')) align.value = 'right'
|
|
|
+ else if (document.queryCommandState('justifyLeft')) align.value = 'left'
|
|
|
+ } catch (e) {}
|
|
|
+ const nextAlign = getAlignFromRange(range)
|
|
|
+ if (nextAlign) align.value = nextAlign
|
|
|
+}
|
|
|
+
|
|
|
+const focusEditor = () => {
|
|
|
+ if (!inputRef.value) return
|
|
|
+ inputRef.value.focus()
|
|
|
+}
|
|
|
+
|
|
|
+const ensureEditorSelection = () => {
|
|
|
+ if (!inputRef.value) return false
|
|
|
+ focusEditor()
|
|
|
+ const sel = window.getSelection?.()
|
|
|
+ if (!sel) return false
|
|
|
+ if (sel.rangeCount > 0) {
|
|
|
+ const range = sel.getRangeAt(0)
|
|
|
+ if (inputRef.value.contains(range.commonAncestorContainer)) {
|
|
|
+ savedRange.value = range.cloneRange()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (restoreSelection()) return true
|
|
|
+ const range = document.createRange()
|
|
|
+ range.selectNodeContents(inputRef.value)
|
|
|
+ range.collapse(false)
|
|
|
+ try {
|
|
|
+ sel.removeAllRanges()
|
|
|
+ sel.addRange(range)
|
|
|
+ savedRange.value = range.cloneRange()
|
|
|
+ return true
|
|
|
+ } catch (e) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const wrapSelectionWithStyle = style => {
|
|
|
+ if (!canEdit.value) return
|
|
|
+ ensureEditorSelection()
|
|
|
+ const sel = window.getSelection?.()
|
|
|
+ if (!sel || sel.rangeCount === 0) return
|
|
|
+ const range = sel.getRangeAt(0)
|
|
|
+ if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
|
|
|
+
|
|
|
+ const span = document.createElement('span')
|
|
|
+ Object.keys(style).forEach(key => {
|
|
|
+ span.style[key] = style[key]
|
|
|
+ })
|
|
|
+
|
|
|
+ if (range.collapsed) {
|
|
|
+ span.appendChild(document.createTextNode('\u200B'))
|
|
|
+ range.insertNode(span)
|
|
|
+ const nextRange = document.createRange()
|
|
|
+ nextRange.setStart(span.firstChild, 1)
|
|
|
+ nextRange.collapse(true)
|
|
|
+ sel.removeAllRanges()
|
|
|
+ sel.addRange(nextRange)
|
|
|
+ savedRange.value = nextRange.cloneRange()
|
|
|
+ } else {
|
|
|
+ const content = range.extractContents()
|
|
|
+ span.appendChild(content)
|
|
|
+ range.insertNode(span)
|
|
|
+ const nextRange = document.createRange()
|
|
|
+ nextRange.selectNodeContents(span)
|
|
|
+ nextRange.collapse(false)
|
|
|
+ sel.removeAllRanges()
|
|
|
+ sel.addRange(nextRange)
|
|
|
+ savedRange.value = nextRange.cloneRange()
|
|
|
+ }
|
|
|
+
|
|
|
+ syncFromDom()
|
|
|
+ selectionUpdate()
|
|
|
+}
|
|
|
+
|
|
|
+const toggleFontSizeMenu = () => {
|
|
|
+ if (!enableToolbar.value) return
|
|
|
+ saveSelection()
|
|
|
+ showFontSizeMenu.value = !showFontSizeMenu.value
|
|
|
+ showAlignMenu.value = false
|
|
|
+ showLinkInput.value = false
|
|
|
+}
|
|
|
+const applyFontSize = size => {
|
|
|
+ currentFontSize.value = Number(size)
|
|
|
+ showFontSizeMenu.value = false
|
|
|
+ wrapSelectionWithStyle({ fontSize: currentFontSize.value + 'px' })
|
|
|
+}
|
|
|
+
|
|
|
+const toggleBold = () => {
|
|
|
+ if (!canEdit.value) return
|
|
|
+ ensureEditorSelection()
|
|
|
+ document.execCommand?.('bold')
|
|
|
+ nextTick(() => {
|
|
|
+ syncFromDom()
|
|
|
+ selectionUpdate()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const triggerTextColor = () => {
|
|
|
+ if (!canEdit.value) return
|
|
|
+ saveSelection()
|
|
|
+ textColorInputRef.value?.click?.()
|
|
|
+}
|
|
|
+const applyTextColor = ev => {
|
|
|
+ if (!canEdit.value) return
|
|
|
+ const val = ev?.target?.value
|
|
|
+ if (!val) return
|
|
|
+ textColor.value = val
|
|
|
+ ensureEditorSelection()
|
|
|
+ document.execCommand?.('styleWithCSS', false, true)
|
|
|
+ document.execCommand?.('foreColor', false, val)
|
|
|
+ nextTick(() => {
|
|
|
+ syncFromDom()
|
|
|
+ selectionUpdate()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const triggerHighlightColor = () => {
|
|
|
+ if (!canEdit.value) return
|
|
|
+ saveSelection()
|
|
|
+ highlightColorInputRef.value?.click?.()
|
|
|
+}
|
|
|
+const applyHighlightColor = ev => {
|
|
|
+ if (!canEdit.value) return
|
|
|
+ const val = ev?.target?.value
|
|
|
+ if (!val) return
|
|
|
+ highlightColor.value = val
|
|
|
+ ensureEditorSelection()
|
|
|
+ document.execCommand?.('styleWithCSS', false, true)
|
|
|
+ document.execCommand?.('hiliteColor', false, val) || document.execCommand?.('backColor', false, val)
|
|
|
+ nextTick(() => {
|
|
|
+ syncFromDom()
|
|
|
+ selectionUpdate()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const toggleAlignMenu = () => {
|
|
|
+ if (!enableToolbar.value) return
|
|
|
+ saveSelection()
|
|
|
+ showAlignMenu.value = !showAlignMenu.value
|
|
|
+ showFontSizeMenu.value = false
|
|
|
+ showLinkInput.value = false
|
|
|
+}
|
|
|
+const applyAlign = mode => {
|
|
|
+ if (!canEdit.value) return
|
|
|
+ align.value = mode
|
|
|
+ showAlignMenu.value = false
|
|
|
+ ensureEditorSelection()
|
|
|
+ const cmd = mode === 'center' ? 'justifyCenter' : mode === 'right' ? 'justifyRight' : 'justifyLeft'
|
|
|
+ document.execCommand?.(cmd)
|
|
|
+ nextTick(() => {
|
|
|
+ syncFromDom()
|
|
|
+ selectionUpdate()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const openLinkInput = () => {
|
|
|
+ if (!canEdit.value) return
|
|
|
+ saveSelection()
|
|
|
+ showLinkInput.value = true
|
|
|
+ showFontSizeMenu.value = false
|
|
|
+ showAlignMenu.value = false
|
|
|
+ nextTick(() => {
|
|
|
+ const el = textRef.value?.querySelector?.('.link-input')
|
|
|
+ el?.focus?.()
|
|
|
+ })
|
|
|
+}
|
|
|
+const confirmLink = () => {
|
|
|
+ if (!canEdit.value) return
|
|
|
+ const url = normalizeUrl(linkValue.value)
|
|
|
+ showLinkInput.value = false
|
|
|
+ linkValue.value = ''
|
|
|
+ if (!url) return
|
|
|
+ ensureEditorSelection()
|
|
|
+ const sel = window.getSelection?.()
|
|
|
+ if (!sel || sel.rangeCount === 0) return
|
|
|
+ const range = sel.getRangeAt(0)
|
|
|
+ if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
|
|
|
+
|
|
|
+ if (range.collapsed) {
|
|
|
+ const a = document.createElement('a')
|
|
|
+ a.href = url
|
|
|
+ a.target = '_blank'
|
|
|
+ a.rel = 'noopener noreferrer'
|
|
|
+ a.textContent = url
|
|
|
+ range.insertNode(a)
|
|
|
+ const nextRange = document.createRange()
|
|
|
+ nextRange.setStartAfter(a)
|
|
|
+ nextRange.collapse(true)
|
|
|
+ sel.removeAllRanges()
|
|
|
+ sel.addRange(nextRange)
|
|
|
+ savedRange.value = nextRange.cloneRange()
|
|
|
+ syncFromDom()
|
|
|
+ selectionUpdate()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ document.execCommand?.('createLink', false, url)
|
|
|
+ inputRef.value.querySelectorAll('a').forEach(a => {
|
|
|
+ a.target = '_blank'
|
|
|
+ a.rel = 'noopener noreferrer'
|
|
|
+ })
|
|
|
+ nextTick(() => {
|
|
|
+ syncFromDom()
|
|
|
+ selectionUpdate()
|
|
|
+ })
|
|
|
+ } catch (e) {}
|
|
|
+}
|
|
|
+
|
|
|
+const updateContent = html => {
|
|
|
+ if (!inputRef.value) return
|
|
|
+ inputRef.value.innerHTML = html || ''
|
|
|
+ length.value = getTextLen(inputRef.value.textContent || '')
|
|
|
+}
|
|
|
+
|
|
|
+watchEffect(() => {
|
|
|
+ if (inputRef.value && props.modelValue !== inputRef.value.innerHTML) {
|
|
|
+ updateContent(props.modelValue)
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+let inComposition = false
|
|
|
+const compositionstartHandler = () => {
|
|
|
+ inComposition = true
|
|
|
+}
|
|
|
+const compositionendHandler = ev => {
|
|
|
+ inComposition = false
|
|
|
+ inputHandler(ev)
|
|
|
+}
|
|
|
+
|
|
|
+const inputHandler = ev => {
|
|
|
+ if (inComposition) return
|
|
|
+ const nextLen = getTextLen(ev.target.textContent || '')
|
|
|
+ if (!props.maxlength || nextLen <= Number(props.maxlength)) {
|
|
|
+ length.value = nextLen
|
|
|
+ emit('update:modelValue', ev.target.innerHTML || '')
|
|
|
+ } else {
|
|
|
+ nextTick(() => {
|
|
|
+ if (ev.target.innerHTML !== props.modelValue.toString()) {
|
|
|
+ updateContent(props.modelValue.toString())
|
|
|
+ inputFocus()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ nextTick(selectionUpdate)
|
|
|
+}
|
|
|
+//获取当前光标位置
|
|
|
+const getCursortPosition = function (element = inputRef.value) {
|
|
|
+ var caretOffset = 0
|
|
|
+ var doc = element.ownerDocument || element.document
|
|
|
+ var win = doc.defaultView || doc.parentWindow
|
|
|
+ var sel
|
|
|
+ if (typeof win.getSelection != 'undefined') {
|
|
|
+ //谷歌、火狐
|
|
|
+ sel = win.getSelection()
|
|
|
+ if (sel.rangeCount > 0) {
|
|
|
+ //选中的区域
|
|
|
+ var range = win.getSelection().getRangeAt(0)
|
|
|
+ var preCaretRange = range.cloneRange() //克隆一个选中区域
|
|
|
+ preCaretRange.selectNodeContents(element) //设置选中区域的节点内容为当前节点
|
|
|
+ preCaretRange.setEnd(range.endContainer, range.endOffset) //重置选中区域的结束位置
|
|
|
+ caretOffset = preCaretRange.toString().length
|
|
|
+ }
|
|
|
+ } else if ((sel = doc.selection) && sel.type != 'Control') {
|
|
|
+ //IE
|
|
|
+ var textRange = sel.createRange()
|
|
|
+ var preCaretTextRange = doc.body.createTextRange()
|
|
|
+ preCaretTextRange.moveToElementText(element)
|
|
|
+ preCaretTextRange.setEndPoint('EndToEnd', textRange)
|
|
|
+ caretOffset = preCaretTextRange.text.length
|
|
|
+ }
|
|
|
+ return caretOffset
|
|
|
+}
|
|
|
+
|
|
|
+let interval
|
|
|
+const focusHandler = ev => {
|
|
|
+ clearInterval(interval)
|
|
|
+ interval = setInterval(() => {
|
|
|
+ emit('updatePos', getCursortPosition())
|
|
|
+ }, 100)
|
|
|
+ emit('focus')
|
|
|
+ nextTick(selectionUpdate)
|
|
|
+}
|
|
|
+const blurHandler = () => {
|
|
|
+ clearInterval(interval)
|
|
|
+ emit('blur')
|
|
|
+}
|
|
|
+
|
|
|
+const inputFocus = () => {
|
|
|
+ inputRef.value.focus()
|
|
|
+ const range = window.getSelection()
|
|
|
+ range.selectAllChildren(inputRef.value)
|
|
|
+ range.collapseToEnd()
|
|
|
+}
|
|
|
+
|
|
|
+const getPasteText = text => {
|
|
|
+ if (!props.maxlength) {
|
|
|
+ return text
|
|
|
+ }
|
|
|
+
|
|
|
+ const $el = document.createElement('div')
|
|
|
+ $el.innerHTML = text
|
|
|
+ const allowLen = Number(props.maxlength) - length.value
|
|
|
+ const rawText = $el.textContent || ''
|
|
|
+ if (getTextLen(rawText) > allowLen) {
|
|
|
+ return rawText.replace(/[\u200B\uFEFF]/g, '').substring(0, allowLen)
|
|
|
+ } else {
|
|
|
+ return text
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const pasteHandler = event => {
|
|
|
+ event.preventDefault()
|
|
|
+ var text
|
|
|
+ var clp = (event.originalEvent || event).clipboardData
|
|
|
+ // 兼容针对于opera ie等浏览器
|
|
|
+ if (clp === undefined || clp === null) {
|
|
|
+ text = window.clipboardData.getData('text') || ''
|
|
|
+ if (text !== '') {
|
|
|
+ if (window.getSelection) {
|
|
|
+ // 针对于ie11 10 9 safari
|
|
|
+ var newNode = document.createElement('span')
|
|
|
+ newNode.innerHTML = getPasteText(text)
|
|
|
+ window.getSelection().getRangeAt(0).insertNode(newNode)
|
|
|
+ } else {
|
|
|
+ document.selection.createRange().pasteHTML(text)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 兼容chorme或hotfire
|
|
|
+ text = clp.getData('text/plain') || ''
|
|
|
+ if (text !== '') {
|
|
|
+ document.execCommand('insertText', false, getPasteText(text))
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const onDocMousedown = ev => {
|
|
|
+ const root = textRef.value
|
|
|
+ if (!root) return
|
|
|
+ const target = ev.target
|
|
|
+ if (showFontSizeMenu.value) {
|
|
|
+ const el = root.querySelector('.font-size')
|
|
|
+ if (!el || !el.contains(target)) showFontSizeMenu.value = false
|
|
|
+ }
|
|
|
+ if (showAlignMenu.value) {
|
|
|
+ const el = root.querySelector('.align-menu')
|
|
|
+ if (!el || !el.contains(target)) showAlignMenu.value = false
|
|
|
+ }
|
|
|
+ if (showLinkInput.value) {
|
|
|
+ const pop = root.querySelector('.link-pop')
|
|
|
+ const btn = root.querySelector('.link-btn-trigger')
|
|
|
+ if ((!pop || !pop.contains(target)) && (!btn || !btn.contains(target))) showLinkInput.value = false
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!root.contains(target)) {
|
|
|
+ savedRange.value = null
|
|
|
+ window.getSelection?.()?.removeAllRanges?.()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ document.addEventListener('mousedown', onDocMousedown)
|
|
|
+})
|
|
|
+onUnmounted(() => {
|
|
|
+ document.removeEventListener('mousedown', onDocMousedown)
|
|
|
+})
|
|
|
+
|
|
|
+defineExpose({
|
|
|
+ root: textRef,
|
|
|
+ input: inputRef,
|
|
|
+ getCursortPosition: getCursortPosition,
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.btn {
|
|
|
+ border: none;
|
|
|
+ background: transparent;
|
|
|
+ color: rgba(255, 255, 255, 0.8);
|
|
|
+ padding: 0 6px;
|
|
|
+ height: 22px;
|
|
|
+ line-height: 22px;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-weight: 600;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+
|
|
|
+.btn:disabled {
|
|
|
+ opacity: 0.4;
|
|
|
+ cursor: not-allowed;
|
|
|
+}
|
|
|
+
|
|
|
+.btn.active {
|
|
|
+ color: var(--colors-primary-base);
|
|
|
+ background-color: rgba(var(--colors-primary-fill), 0.18);
|
|
|
+}
|
|
|
+
|
|
|
+.dropdown {
|
|
|
+ position: relative;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 0 6px;
|
|
|
+ height: 22px;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+ color: rgba(255, 255, 255, 0.8);
|
|
|
+ background: rgba(255, 255, 255, 0.06);
|
|
|
+ border: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+
|
|
|
+.dropdown-value {
|
|
|
+ font-size: 12px;
|
|
|
+ width: 24px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.dropdown-arrow {
|
|
|
+ font-size: 10px;
|
|
|
+ opacity: 0.7;
|
|
|
+}
|
|
|
+
|
|
|
+.dropdown-menu {
|
|
|
+ position: absolute;
|
|
|
+ bottom: calc(100% + 6px);
|
|
|
+ left: 0;
|
|
|
+ min-width: 92px;
|
|
|
+ max-height: 220px;
|
|
|
+ overflow: auto;
|
|
|
+ background: rgba(27, 27, 28, 0.95);
|
|
|
+ border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 6px 0;
|
|
|
+ z-index: 2;
|
|
|
+ backdrop-filter: blur(6px);
|
|
|
+}
|
|
|
+
|
|
|
+.dropdown-option {
|
|
|
+ height: 28px;
|
|
|
+ line-height: 28px;
|
|
|
+ padding: 0 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: rgba(255, 255, 255, 0.85);
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.dropdown-option:hover {
|
|
|
+ background: rgba(255, 255, 255, 0.06);
|
|
|
+}
|
|
|
+
|
|
|
+.dropdown-option.active {
|
|
|
+ color: var(--colors-primary-base);
|
|
|
+}
|
|
|
+
|
|
|
+.a-letter {
|
|
|
+ position: relative;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 14px;
|
|
|
+ height: 14px;
|
|
|
+ color: rgba(255, 255, 255, 0.95);
|
|
|
+}
|
|
|
+.a-letter::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ left: 1px;
|
|
|
+ right: 1px;
|
|
|
+ bottom: -3px;
|
|
|
+ height: 2px;
|
|
|
+ border-radius: 2px;
|
|
|
+ background: var(--a-color, #ffffff);
|
|
|
+}
|
|
|
+
|
|
|
+.hl-icon {
|
|
|
+ width: 14px;
|
|
|
+ height: 14px;
|
|
|
+ border-radius: 3px;
|
|
|
+ background: var(--hl-color, #ffe58f);
|
|
|
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18) inset;
|
|
|
+}
|
|
|
+
|
|
|
+.color-input {
|
|
|
+ position: absolute;
|
|
|
+ width: 1px;
|
|
|
+ height: 1px;
|
|
|
+ opacity: 0;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+
|
|
|
+.link {
|
|
|
+ position: relative;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.link-pop {
|
|
|
+ position: absolute;
|
|
|
+ bottom: calc(100% + 6px);
|
|
|
+ left: 0;
|
|
|
+ padding: 10px;
|
|
|
+ width: 220px;
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ background: rgba(27, 27, 28, 0.95);
|
|
|
+ border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
+ border-radius: 6px;
|
|
|
+ z-index: 3;
|
|
|
+ backdrop-filter: blur(6px);
|
|
|
+}
|
|
|
+
|
|
|
+.link-input {
|
|
|
+ flex: 1;
|
|
|
+ height: 28px;
|
|
|
+ border-radius: 4px;
|
|
|
+ border: 1px solid rgba(255, 255, 255, 0.12);
|
|
|
+ background: rgba(255, 255, 255, 0.06);
|
|
|
+ outline: none;
|
|
|
+ color: rgba(255, 255, 255, 0.9);
|
|
|
+ padding: 0 8px;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.link-btn {
|
|
|
+ height: 28px;
|
|
|
+ padding: 0 10px;
|
|
|
+ border-radius: 4px;
|
|
|
+ border: 1px solid rgba(255, 255, 255, 0.12);
|
|
|
+ background: rgba(var(--colors-primary-fill), 0.2);
|
|
|
+ color: rgba(255, 255, 255, 0.9);
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.has-toolbar > .retouch {
|
|
|
+ justify-content: space-between !important;
|
|
|
+}
|
|
|
+</style>
|