Files
yunzerwebsiteallinone/uniapp/components/NoteRichEditor.vue
T
2026-08-15 22:16:58 +08:00

532 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>