532 lines
12 KiB
Vue
532 lines
12 KiB
Vue
<template>
|
||
<!-- #ifdef H5 -->
|
||
<div class="note-editor-h5">
|
||
<div class="editor-toolbar">
|
||
<div class="toolbar-group">
|
||
<button type="button" class="tb-btn" title="加粗" @mousedown.prevent="execFormat('bold')">
|
||
<FaIcon name="bold" :size="14" color="#606266" />
|
||
</button>
|
||
<button type="button" class="tb-btn" title="斜体" @mousedown.prevent="execFormat('italic')">
|
||
<FaIcon name="italic" :size="14" color="#606266" />
|
||
</button>
|
||
<button type="button" class="tb-btn" title="下划线" @mousedown.prevent="execFormat('underline')">
|
||
<FaIcon name="underline" :size="14" color="#606266" />
|
||
</button>
|
||
<button type="button" class="tb-btn" title="左对齐" @mousedown.prevent="execFormat('justifyLeft')">
|
||
<FaIcon name="align-left" :size="14" color="#606266" />
|
||
</button>
|
||
<button type="button" class="tb-btn" title="居中对齐" @mousedown.prevent="execFormat('justifyCenter')">
|
||
<FaIcon name="align-center" :size="14" color="#606266" />
|
||
</button>
|
||
<button type="button" class="tb-btn" title="右对齐" @mousedown.prevent="execFormat('justifyRight')">
|
||
<FaIcon name="align-right" :size="14" color="#606266" />
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-divider" />
|
||
<div class="toolbar-group">
|
||
<button type="button" class="tb-btn" title="插入表格" @mousedown.prevent="onInsertTable">
|
||
<FaIcon name="table" :size="14" color="#606266" />
|
||
</button>
|
||
<button type="button" class="tb-btn" title="上方插入行" @mousedown.prevent="onAddRowBefore">
|
||
<FaIcon name="arrow-up" :size="12" color="#606266" />
|
||
<span class="tb-label">行</span>
|
||
</button>
|
||
<button type="button" class="tb-btn" title="下方插入行" @mousedown.prevent="onAddRowAfter">
|
||
<FaIcon name="arrow-down" :size="12" color="#606266" />
|
||
<span class="tb-label">行</span>
|
||
</button>
|
||
<button type="button" class="tb-btn" title="删除行" @mousedown.prevent="onDeleteRow">
|
||
<FaIcon name="minus" :size="12" color="#606266" />
|
||
<span class="tb-label">行</span>
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-divider" />
|
||
<div class="toolbar-group">
|
||
<button type="button" class="tb-btn" title="左侧插入列" @mousedown.prevent="onAddColBefore">
|
||
<FaIcon name="arrow-left" :size="12" color="#606266" />
|
||
<span class="tb-label">列</span>
|
||
</button>
|
||
<button type="button" class="tb-btn" title="右侧插入列" @mousedown.prevent="onAddColAfter">
|
||
<FaIcon name="arrow-right" :size="12" color="#606266" />
|
||
<span class="tb-label">列</span>
|
||
</button>
|
||
<button type="button" class="tb-btn" title="删除列" @mousedown.prevent="onDeleteCol">
|
||
<FaIcon name="minus" :size="12" color="#606266" />
|
||
<span class="tb-label">列</span>
|
||
</button>
|
||
<button type="button" class="tb-btn tb-btn-danger" title="删除表格" @mousedown.prevent="onDeleteTable">
|
||
<FaIcon name="trash-can" :size="14" color="#f56c6c" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
ref="h5EditorRef"
|
||
class="note-rich-editor rich-content"
|
||
contenteditable="true"
|
||
:data-placeholder="placeholder"
|
||
@mousedown="onH5MouseDown"
|
||
@input="onH5Input"
|
||
@blur="onH5Input"
|
||
/>
|
||
</div>
|
||
<!-- #endif -->
|
||
<!-- #ifndef H5 -->
|
||
<editor
|
||
:id="editorId"
|
||
class="note-rich-editor"
|
||
:placeholder="placeholder"
|
||
@ready="onEditorReady"
|
||
@input="onEditorInput"
|
||
/>
|
||
<!-- #endif -->
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch, nextTick, getCurrentInstance, onMounted } from 'vue'
|
||
import FaIcon from '@/components/FaIcon.vue'
|
||
import {
|
||
buildTableHtml,
|
||
getCurrentCell,
|
||
addRowBefore,
|
||
addRowAfter,
|
||
deleteRow,
|
||
addColBefore,
|
||
addColAfter,
|
||
deleteCol
|
||
} from '@/utils/table-editor.js'
|
||
|
||
const props = defineProps({
|
||
modelValue: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
placeholder: {
|
||
type: String,
|
||
default: '请输入内容'
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['update:modelValue'])
|
||
|
||
const editorId = 'noteRichEditor'
|
||
const h5EditorRef = ref(null)
|
||
const editorCtx = ref(null)
|
||
const instance = getCurrentInstance()
|
||
const isInternalUpdate = ref(false)
|
||
const lastAppliedHtml = ref('')
|
||
|
||
function normalizeEditorHtml(html) {
|
||
if (!html) return ''
|
||
const template = document.createElement('template')
|
||
template.innerHTML = html.trim()
|
||
const root = template.content
|
||
|
||
root.querySelectorAll('[contenteditable]').forEach((node) => {
|
||
node.removeAttribute('contenteditable')
|
||
})
|
||
|
||
root.querySelectorAll('.column-resize-handle, .selectedCell, .grip-column, .grip-row').forEach((node) => {
|
||
node.remove()
|
||
})
|
||
|
||
root.querySelectorAll('td, th').forEach((cell) => {
|
||
const text = cell.textContent || ''
|
||
if (!text.trim() && !cell.querySelector('img, br')) {
|
||
cell.innerHTML = '<p><br></p>'
|
||
}
|
||
})
|
||
|
||
return template.innerHTML
|
||
}
|
||
|
||
function unlockEditableTree(root) {
|
||
if (!root) return
|
||
root.querySelectorAll('[contenteditable="false"]').forEach((node) => {
|
||
node.removeAttribute('contenteditable')
|
||
})
|
||
}
|
||
|
||
function patchTables(root) {
|
||
if (!root) return
|
||
root.querySelectorAll('table').forEach((table) => {
|
||
table.removeAttribute('contenteditable')
|
||
table.style.borderCollapse = 'collapse'
|
||
table.style.border = '1px solid #e4e7ed'
|
||
table.style.width = '100%'
|
||
table.style.margin = '12px 0'
|
||
table.querySelectorAll('th, td').forEach((cell) => {
|
||
cell.removeAttribute('contenteditable')
|
||
cell.style.border = '1px solid #e4e7ed'
|
||
cell.style.padding = '8px 12px'
|
||
cell.style.verticalAlign = 'top'
|
||
cell.style.wordBreak = 'break-word'
|
||
cell.style.userSelect = 'text'
|
||
cell.style.cursor = 'text'
|
||
})
|
||
table.querySelectorAll('th').forEach((cell) => {
|
||
cell.style.backgroundColor = '#f5f7fa'
|
||
cell.style.fontWeight = '600'
|
||
})
|
||
})
|
||
}
|
||
|
||
function placeCaretInCell(cell, event) {
|
||
const el = h5EditorRef.value
|
||
if (!el || !cell) return
|
||
el.focus()
|
||
|
||
let range = null
|
||
if (event && document.caretRangeFromPoint) {
|
||
range = document.caretRangeFromPoint(event.clientX, event.clientY)
|
||
} else if (event && document.caretPositionFromPoint) {
|
||
const pos = document.caretPositionFromPoint(event.clientX, event.clientY)
|
||
if (pos) {
|
||
range = document.createRange()
|
||
range.setStart(pos.offsetNode, pos.offset)
|
||
range.collapse(true)
|
||
}
|
||
}
|
||
|
||
if (range && cell.contains(range.startContainer)) {
|
||
const sel = window.getSelection()
|
||
sel.removeAllRanges()
|
||
sel.addRange(range)
|
||
return
|
||
}
|
||
|
||
const fallback = document.createRange()
|
||
const target = cell.querySelector('p, div, span') || cell
|
||
fallback.selectNodeContents(target)
|
||
fallback.collapse(false)
|
||
const sel = window.getSelection()
|
||
sel.removeAllRanges()
|
||
sel.addRange(fallback)
|
||
}
|
||
|
||
function syncContent() {
|
||
const el = h5EditorRef.value
|
||
if (!el) return
|
||
unlockEditableTree(el)
|
||
patchTables(el)
|
||
const html = el.innerHTML || ''
|
||
lastAppliedHtml.value = html
|
||
isInternalUpdate.value = true
|
||
emit('update:modelValue', html)
|
||
nextTick(() => {
|
||
isInternalUpdate.value = false
|
||
})
|
||
}
|
||
|
||
function insertHtmlAtCursor(html) {
|
||
const el = h5EditorRef.value
|
||
if (!el) return
|
||
el.focus()
|
||
if (document.queryCommandSupported('insertHTML')) {
|
||
document.execCommand('insertHTML', false, html)
|
||
} else {
|
||
el.insertAdjacentHTML('beforeend', html)
|
||
}
|
||
syncContent()
|
||
}
|
||
|
||
function execFormat(cmd) {
|
||
const el = h5EditorRef.value
|
||
if (!el) return
|
||
el.focus()
|
||
document.execCommand(cmd, false, null)
|
||
syncContent()
|
||
}
|
||
|
||
function requireCell(message = '请先在表格中选中单元格') {
|
||
const cell = getCurrentCell(h5EditorRef.value)
|
||
if (!cell) {
|
||
uni.showToast({ title: message, icon: 'none' })
|
||
}
|
||
return cell
|
||
}
|
||
|
||
function onInsertTable() {
|
||
uni.showActionSheet({
|
||
itemList: ['3×3 表格', '4×4 表格', '5×3 表格'],
|
||
success: (res) => {
|
||
const sizes = [[3, 3], [4, 4], [5, 3]]
|
||
const [rows, cols] = sizes[res.tapIndex] || [3, 3]
|
||
insertHtmlAtCursor(buildTableHtml(rows, cols))
|
||
}
|
||
})
|
||
}
|
||
|
||
function onAddRowBefore() {
|
||
const cell = requireCell()
|
||
if (!cell) return
|
||
const target = addRowBefore(cell)
|
||
syncContent()
|
||
if (target) placeCaretInCell(target)
|
||
}
|
||
|
||
function onAddRowAfter() {
|
||
const cell = requireCell()
|
||
if (!cell) return
|
||
const target = addRowAfter(cell)
|
||
syncContent()
|
||
if (target) placeCaretInCell(target)
|
||
}
|
||
|
||
function onDeleteRow() {
|
||
const cell = requireCell()
|
||
if (!cell) return
|
||
const target = deleteRow(cell)
|
||
if (!target) {
|
||
uni.showToast({ title: '至少保留一行', icon: 'none' })
|
||
return
|
||
}
|
||
syncContent()
|
||
placeCaretInCell(target)
|
||
}
|
||
|
||
function onAddColBefore() {
|
||
const cell = requireCell()
|
||
if (!cell) return
|
||
const target = addColBefore(cell)
|
||
syncContent()
|
||
if (target) placeCaretInCell(target)
|
||
}
|
||
|
||
function onAddColAfter() {
|
||
const cell = requireCell()
|
||
if (!cell) return
|
||
const target = addColAfter(cell)
|
||
syncContent()
|
||
if (target) placeCaretInCell(target)
|
||
}
|
||
|
||
function onDeleteCol() {
|
||
const cell = requireCell()
|
||
if (!cell) return
|
||
const target = deleteCol(cell)
|
||
if (!target) {
|
||
uni.showToast({ title: '至少保留一列', icon: 'none' })
|
||
return
|
||
}
|
||
syncContent()
|
||
placeCaretInCell(target)
|
||
}
|
||
|
||
function onDeleteTable() {
|
||
const cell = requireCell()
|
||
if (!cell) return
|
||
const table = cell.closest('table')
|
||
if (!table) return
|
||
uni.showModal({
|
||
title: '删除表格',
|
||
content: '确定删除当前表格吗?',
|
||
success: (res) => {
|
||
if (!res.confirm) return
|
||
table.remove()
|
||
syncContent()
|
||
}
|
||
})
|
||
}
|
||
|
||
function onH5MouseDown(e) {
|
||
const el = h5EditorRef.value
|
||
if (!el) return
|
||
const cell = e.target.closest('td, th')
|
||
if (!cell || !el.contains(cell)) return
|
||
|
||
unlockEditableTree(el)
|
||
const evt = e
|
||
requestAnimationFrame(() => {
|
||
placeCaretInCell(cell, evt)
|
||
})
|
||
}
|
||
|
||
function setH5Html(html) {
|
||
// #ifdef H5
|
||
nextTick(() => {
|
||
const el = h5EditorRef.value
|
||
if (!el) return
|
||
const normalized = normalizeEditorHtml(html || '')
|
||
if (normalized === lastAppliedHtml.value && el.innerHTML === normalized) {
|
||
patchTables(el)
|
||
return
|
||
}
|
||
if (el.innerHTML !== normalized) {
|
||
el.innerHTML = normalized
|
||
}
|
||
unlockEditableTree(el)
|
||
patchTables(el)
|
||
lastAppliedHtml.value = normalized
|
||
})
|
||
// #endif
|
||
}
|
||
|
||
function onH5Input(e) {
|
||
// #ifdef H5
|
||
const html = e.target.innerHTML || ''
|
||
lastAppliedHtml.value = html
|
||
isInternalUpdate.value = true
|
||
emit('update:modelValue', html)
|
||
nextTick(() => {
|
||
isInternalUpdate.value = false
|
||
})
|
||
// #endif
|
||
}
|
||
|
||
function onEditorInput(e) {
|
||
emit('update:modelValue', e.detail.html || '')
|
||
}
|
||
|
||
function onEditorReady() {
|
||
// #ifndef H5
|
||
uni.createSelectorQuery()
|
||
.in(instance)
|
||
.select(`#${editorId}`)
|
||
.context((res) => {
|
||
if (res && res.context) {
|
||
editorCtx.value = res.context
|
||
if (props.modelValue) {
|
||
editorCtx.value.setContents({ html: props.modelValue })
|
||
}
|
||
}
|
||
})
|
||
.exec()
|
||
// #endif
|
||
}
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(val) => {
|
||
if (isInternalUpdate.value) return
|
||
// #ifdef H5
|
||
setH5Html(val)
|
||
// #endif
|
||
// #ifndef H5
|
||
if (editorCtx.value) {
|
||
editorCtx.value.setContents({ html: val || '' })
|
||
}
|
||
// #endif
|
||
}
|
||
)
|
||
|
||
onMounted(() => {
|
||
// #ifdef H5
|
||
setH5Html(props.modelValue)
|
||
// #endif
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.note-editor-h5 {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 360rpx;
|
||
}
|
||
|
||
.editor-toolbar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding-bottom: 12rpx;
|
||
margin-bottom: 12rpx;
|
||
border-bottom: 1rpx solid #ebeef5;
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
|
||
.toolbar-group {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.toolbar-divider {
|
||
width: 1rpx;
|
||
height: 28rpx;
|
||
background: #ebeef5;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tb-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4rpx;
|
||
min-width: 56rpx;
|
||
height: 56rpx;
|
||
padding: 0 12rpx;
|
||
border: 1rpx solid #ebeef5;
|
||
border-radius: 8rpx;
|
||
background: #fff;
|
||
color: #606266;
|
||
font-size: 22rpx;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
|
||
&:active {
|
||
background: #f5f7fa;
|
||
}
|
||
}
|
||
|
||
.tb-btn-danger {
|
||
border-color: #fde2e2;
|
||
background: #fef0f0;
|
||
}
|
||
|
||
.tb-label {
|
||
font-size: 20rpx;
|
||
color: #909399;
|
||
}
|
||
|
||
.note-rich-editor {
|
||
width: 100%;
|
||
flex: 1;
|
||
min-height: 0;
|
||
box-sizing: border-box;
|
||
font-size: 28rpx;
|
||
line-height: 1.6;
|
||
color: #303133;
|
||
outline: none;
|
||
overflow-y: auto;
|
||
-webkit-user-select: text;
|
||
user-select: text;
|
||
}
|
||
|
||
/* #ifdef H5 */
|
||
.note-rich-editor:empty::before {
|
||
content: attr(data-placeholder);
|
||
color: #909399;
|
||
}
|
||
|
||
.note-rich-editor:focus:empty::before {
|
||
content: '';
|
||
}
|
||
/* #endif */
|
||
</style>
|
||
|
||
<style lang="scss">
|
||
@import '@/src/styles/rich-content.scss';
|
||
|
||
.note-rich-editor.rich-content {
|
||
table {
|
||
td,
|
||
th {
|
||
user-select: text !important;
|
||
-webkit-user-select: text !important;
|
||
cursor: text !important;
|
||
}
|
||
|
||
p {
|
||
margin: 0;
|
||
min-height: 1.4em;
|
||
}
|
||
}
|
||
}
|
||
</style>
|