|
|
@@ -1,766 +1,50 @@
|
|
|
<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>
|
|
|
+ <QuillEditor content-type='html' v-model:content="content" :options='editorOption' />
|
|
|
</template>
|
|
|
-
|
|
|
-<script setup>
|
|
|
-import { richtextPropsDesc } from './state'
|
|
|
-import { computed, nextTick, onMounted, onUnmounted, ref, useSlots, watchEffect } from 'vue'
|
|
|
+<script setup lang="ts">
|
|
|
+import { reactive, ref, watchEffect } from 'vue';
|
|
|
+import { QuillEditor } from '@vueup/vue-quill';
|
|
|
+import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
|
|
+
|
|
|
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 || '')
|
|
|
-}
|
|
|
-
|
|
|
+ // 默认值
|
|
|
+ value: {
|
|
|
+ type: String,
|
|
|
+ default: '',
|
|
|
+ },
|
|
|
+});
|
|
|
+
|
|
|
+const emit = defineEmits(['update:value']);
|
|
|
+
|
|
|
+const content = ref(props.value);
|
|
|
+const editorOption = reactive({
|
|
|
+ modules: {
|
|
|
+ toolbar: [ // 工具栏配置
|
|
|
+ [{ 'color': [] },'bold', 'italic', 'underline', 'strike'], // 粗体、斜体、下划线、删除线
|
|
|
+ [{ 'header': 1 }, { 'header': 2 }], // 标题1和标题2
|
|
|
+ [{ 'list': 'ordered' }, { 'list': 'bullet' }], // 有序列表和无序列表
|
|
|
+ [{ 'script': 'sub' }, { 'script': 'super' }], // 上标和下标
|
|
|
+ [{ 'indent': '-1' }, { 'indent': '+1' }], // 缩进
|
|
|
+ [{ 'direction': 'rtl' }], // 文字方向
|
|
|
+ // [{ 'size': ['small', false, 'large', 'huge'] }], // 字号
|
|
|
+ [{ 'size': [1, 2, 3, 4, 5, 6, false] }], // 标题等级
|
|
|
+ [{ 'color': [] }, { 'background': [] }], // 字体颜色和背景色
|
|
|
+ [{ 'font': [] }], // 字体
|
|
|
+ [{ 'align': [] }], // 对齐方式
|
|
|
+ ['clean'] // 清除格式
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ placeholder: '请输入内容...',
|
|
|
+ theme: 'snow'
|
|
|
+},
|
|
|
+);
|
|
|
+// 内容有变化,就更新内容,将值返回给父组件
|
|
|
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,
|
|
|
-})
|
|
|
+ emit('update:value', content.value);
|
|
|
+});
|
|
|
</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: 320px;
|
|
|
- height: 40px;
|
|
|
- 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 lang="scss" scoped>
|
|
|
+.ql-editor.ql-blank::before{
|
|
|
+ color: #fff;
|
|
|
}
|
|
|
-</style>
|
|
|
+</style>
|