660 lines
17 KiB
Vue
660 lines
17 KiB
Vue
<template>
|
||
<view class="note-editor-wrapper">
|
||
<!-- 通用富文本工具栏(H5 与 Android/iOS App 手机端均完整支持) -->
|
||
<view class="editor-toolbar">
|
||
<view class="toolbar-group">
|
||
<view class="tb-btn" title="加粗" @tap="handleFormat('bold')">
|
||
<FaIcon name="bold" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="斜体" @tap="handleFormat('italic')">
|
||
<FaIcon name="italic" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="下划线" @tap="handleFormat('underline')">
|
||
<FaIcon name="underline" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="删除线" @tap="handleFormat('strikeThrough')">
|
||
<FaIcon name="strikethrough" :size="13" color="#606266" />
|
||
</view>
|
||
</view>
|
||
|
||
<view class="toolbar-divider" />
|
||
|
||
<view class="toolbar-group">
|
||
<view class="tb-btn" title="左对齐" @tap="handleFormat('justifyLeft')">
|
||
<FaIcon name="align-left" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="居中" @tap="handleFormat('justifyCenter')">
|
||
<FaIcon name="align-center" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="右对齐" @tap="handleFormat('justifyRight')">
|
||
<FaIcon name="align-right" :size="13" color="#606266" />
|
||
</view>
|
||
</view>
|
||
|
||
<view class="toolbar-divider" />
|
||
|
||
<view class="toolbar-group">
|
||
<view class="tb-btn" title="大标题" @tap="handleFormat('formatBlock', '<h2>')">
|
||
<FaIcon name="heading" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="无序列表" @tap="handleFormat('insertUnorderedList')">
|
||
<FaIcon name="list-ul" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="有序列表" @tap="handleFormat('insertOrderedList')">
|
||
<FaIcon name="list-ol" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="插入分割线" @tap="handleInsertHtml('<hr/>')">
|
||
<FaIcon name="minus" :size="13" color="#606266" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 表格操作组(全端支持插入与行列增删) -->
|
||
<view class="toolbar-divider" />
|
||
<view class="toolbar-group">
|
||
<view class="tb-btn" title="插入表格" @tap="onInsertTable">
|
||
<FaIcon name="table" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="上方插入行" @tap="handleTableOp('addRowBefore')">
|
||
<FaIcon name="arrow-up" :size="11" color="#606266" />
|
||
<text class="tb-label">行</text>
|
||
</view>
|
||
<view class="tb-btn" title="下方插入行" @tap="handleTableOp('addRowAfter')">
|
||
<FaIcon name="arrow-down" :size="11" color="#606266" />
|
||
<text class="tb-label">行</text>
|
||
</view>
|
||
<view class="tb-btn" title="删除行" @tap="handleTableOp('deleteRow')">
|
||
<FaIcon name="minus" :size="11" color="#606266" />
|
||
<text class="tb-label">行</text>
|
||
</view>
|
||
<view class="tb-btn" title="左侧插入列" @tap="handleTableOp('addColBefore')">
|
||
<FaIcon name="arrow-left" :size="11" color="#606266" />
|
||
<text class="tb-label">列</text>
|
||
</view>
|
||
<view class="tb-btn" title="右侧插入列" @tap="handleTableOp('addColAfter')">
|
||
<FaIcon name="arrow-right" :size="11" color="#606266" />
|
||
<text class="tb-label">列</text>
|
||
</view>
|
||
<view class="tb-btn" title="删除列" @tap="handleTableOp('deleteCol')">
|
||
<FaIcon name="minus" :size="11" color="#606266" />
|
||
<text class="tb-label">列</text>
|
||
</view>
|
||
<view class="tb-btn tb-btn-danger" title="删除表格" @tap="handleTableOp('deleteTable')">
|
||
<FaIcon name="trash-can" :size="13" color="#f56c6c" />
|
||
</view>
|
||
</view>
|
||
|
||
<view class="toolbar-divider" />
|
||
<view class="toolbar-group">
|
||
<view class="tb-btn" title="撤销" @tap="handleFormat('undo')">
|
||
<FaIcon name="rotate-left" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="重做" @tap="handleFormat('redo')">
|
||
<FaIcon name="rotate-right" :size="13" color="#606266" />
|
||
</view>
|
||
<view class="tb-btn" title="清除格式" @tap="handleFormat('removeFormat')">
|
||
<FaIcon name="eraser" :size="13" color="#606266" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 编辑区(H5 与 App-plus 均使用真正的 HTML5 Webview DOM 渲染与编辑表格) -->
|
||
<!-- #ifdef H5 -->
|
||
<div
|
||
ref="h5EditorRef"
|
||
class="note-rich-editor rich-content"
|
||
contenteditable="true"
|
||
:data-placeholder="placeholder"
|
||
@mousedown="onH5MouseDown"
|
||
@input="onH5Input"
|
||
@blur="onH5Input"
|
||
/>
|
||
<!-- #endif -->
|
||
|
||
<!-- #ifdef APP-PLUS -->
|
||
<view
|
||
id="richEditorDiv"
|
||
class="note-rich-editor rich-content"
|
||
:prop="modelValue"
|
||
:action="renderAction"
|
||
:change:prop="renderEditor.onPropChange"
|
||
:change:action="renderEditor.onActionChange"
|
||
/>
|
||
<!-- #endif -->
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch, nextTick, onMounted } from 'vue'
|
||
import FaIcon from '@/components/FaIcon.vue'
|
||
import {
|
||
buildTableHtml,
|
||
injectTableInlineStyles,
|
||
getCurrentCell,
|
||
addRowBefore,
|
||
addRowAfter,
|
||
deleteRow,
|
||
addColBefore,
|
||
addColAfter,
|
||
deleteCol,
|
||
deleteTable
|
||
} from '@/utils/table-editor.js'
|
||
|
||
const props = defineProps({
|
||
modelValue: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
placeholder: {
|
||
type: String,
|
||
default: '记录你的想法...'
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['update:modelValue'])
|
||
|
||
const h5EditorRef = ref(null)
|
||
const isInternalUpdate = ref(false)
|
||
const lastAppliedHtml = ref('')
|
||
const renderAction = ref(null)
|
||
|
||
// 响应 RenderJS 传递上来的内容更新
|
||
function onContentChange(html) {
|
||
lastAppliedHtml.value = html
|
||
isInternalUpdate.value = true
|
||
emit('update:modelValue', html)
|
||
nextTick(() => {
|
||
isInternalUpdate.value = false
|
||
})
|
||
}
|
||
|
||
// 供 template 绑定的 Renderjs 桥接方法
|
||
function onRenderChange(html) {
|
||
onContentChange(html)
|
||
}
|
||
|
||
function handleFormat(cmd, value = null) {
|
||
// #ifdef H5
|
||
execH5Format(cmd, value)
|
||
// #endif
|
||
// #ifdef APP-PLUS
|
||
renderAction.value = {
|
||
type: 'format',
|
||
cmd,
|
||
value,
|
||
t: Date.now()
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
function handleInsertHtml(html) {
|
||
// #ifdef H5
|
||
insertH5Html(html)
|
||
// #endif
|
||
// #ifdef APP-PLUS
|
||
renderAction.value = {
|
||
type: 'insertHtml',
|
||
html: injectTableInlineStyles(html),
|
||
t: Date.now()
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
function handleTableOp(opName) {
|
||
// #ifdef H5
|
||
execH5TableOp(opName)
|
||
// #endif
|
||
// #ifdef APP-PLUS
|
||
renderAction.value = {
|
||
type: 'tableOp',
|
||
op: opName,
|
||
t: Date.now()
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
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]
|
||
const tableHtml = buildTableHtml(rows, cols)
|
||
handleInsertHtml(tableHtml)
|
||
}
|
||
})
|
||
}
|
||
|
||
// ================= H5 专属 DOM 操作 =================
|
||
function execH5Format(cmd, value = null) {
|
||
const el = h5EditorRef.value
|
||
if (!el) return
|
||
el.focus()
|
||
document.execCommand(cmd, false, value)
|
||
syncH5Content()
|
||
}
|
||
|
||
function insertH5Html(html) {
|
||
const el = h5EditorRef.value
|
||
if (!el) return
|
||
el.focus()
|
||
const formatted = injectTableInlineStyles(html)
|
||
if (document.queryCommandSupported('insertHTML')) {
|
||
document.execCommand('insertHTML', false, formatted)
|
||
} else {
|
||
el.insertAdjacentHTML('beforeend', formatted)
|
||
}
|
||
syncH5Content()
|
||
}
|
||
|
||
function syncH5Content() {
|
||
const el = h5EditorRef.value
|
||
if (!el) return
|
||
patchH5Tables(el)
|
||
const html = el.innerHTML || ''
|
||
lastAppliedHtml.value = html
|
||
isInternalUpdate.value = true
|
||
emit('update:modelValue', html)
|
||
nextTick(() => {
|
||
isInternalUpdate.value = false
|
||
})
|
||
}
|
||
|
||
function execH5TableOp(opName) {
|
||
const cell = getCurrentCell(h5EditorRef.value)
|
||
if (!cell) {
|
||
uni.showToast({ title: '请先在表格单元格中点击光标', icon: 'none' })
|
||
return
|
||
}
|
||
let target = null
|
||
if (opName === 'addRowBefore') target = addRowBefore(cell)
|
||
else if (opName === 'addRowAfter') target = addRowAfter(cell)
|
||
else if (opName === 'deleteRow') target = deleteRow(cell)
|
||
else if (opName === 'addColBefore') target = addColBefore(cell)
|
||
else if (opName === 'addColAfter') target = addColAfter(cell)
|
||
else if (opName === 'deleteCol') target = deleteCol(cell)
|
||
else if (opName === 'deleteTable') {
|
||
deleteTable(cell)
|
||
syncH5Content()
|
||
return
|
||
}
|
||
|
||
syncH5Content()
|
||
if (target && target.focus) target.focus()
|
||
}
|
||
|
||
function patchH5Tables(root) {
|
||
if (!root) return
|
||
root.querySelectorAll('table').forEach((table) => {
|
||
table.style.borderCollapse = 'collapse'
|
||
table.style.border = '1px solid #dcdfe6'
|
||
table.style.width = '100%'
|
||
table.style.margin = '12px 0'
|
||
table.setAttribute('border', '1')
|
||
table.querySelectorAll('th, td').forEach((cell) => {
|
||
cell.style.border = '1px solid #dcdfe6'
|
||
cell.style.padding = '8px 12px'
|
||
cell.style.verticalAlign = 'top'
|
||
cell.style.wordBreak = 'break-word'
|
||
cell.setAttribute('border', '1')
|
||
})
|
||
table.querySelectorAll('th').forEach((cell) => {
|
||
cell.style.backgroundColor = '#f5f7fa'
|
||
cell.style.fontWeight = '600'
|
||
})
|
||
})
|
||
}
|
||
|
||
function onH5MouseDown(e) {
|
||
const el = h5EditorRef.value
|
||
if (!el) return
|
||
const cell = e.target.closest('td, th')
|
||
if (!cell || !el.contains(cell)) return
|
||
requestAnimationFrame(() => {
|
||
el.focus()
|
||
})
|
||
}
|
||
|
||
function onH5Input(e) {
|
||
const html = e.target.innerHTML || ''
|
||
lastAppliedHtml.value = html
|
||
isInternalUpdate.value = true
|
||
emit('update:modelValue', html)
|
||
nextTick(() => {
|
||
isInternalUpdate.value = false
|
||
})
|
||
}
|
||
|
||
function setH5Html(html) {
|
||
// #ifdef H5
|
||
nextTick(() => {
|
||
const el = h5EditorRef.value
|
||
if (!el) return
|
||
const formatted = injectTableInlineStyles(html || '')
|
||
if (formatted === lastAppliedHtml.value && el.innerHTML === formatted) {
|
||
patchH5Tables(el)
|
||
return
|
||
}
|
||
if (el.innerHTML !== formatted) {
|
||
el.innerHTML = formatted
|
||
}
|
||
patchH5Tables(el)
|
||
lastAppliedHtml.value = formatted
|
||
})
|
||
// #endif
|
||
}
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(val) => {
|
||
if (isInternalUpdate.value) return
|
||
// #ifdef H5
|
||
setH5Html(val)
|
||
// #endif
|
||
}
|
||
)
|
||
|
||
onMounted(() => {
|
||
// #ifdef H5
|
||
setH5Html(props.modelValue)
|
||
// #endif
|
||
})
|
||
|
||
// 将 onRenderChange 挂载到组件实例供 Renderjs 调用
|
||
defineExpose({
|
||
onRenderChange
|
||
})
|
||
</script>
|
||
|
||
<!-- #ifdef APP-PLUS -->
|
||
<script module="renderEditor" lang="renderjs">
|
||
export default {
|
||
mounted() {
|
||
this.initRenderEditor()
|
||
},
|
||
methods: {
|
||
initRenderEditor() {
|
||
const el = document.getElementById('richEditorDiv')
|
||
if (!el) {
|
||
setTimeout(() => this.initRenderEditor(), 50)
|
||
return
|
||
}
|
||
el.contentEditable = 'true'
|
||
el.setAttribute('data-placeholder', '记录你的想法...')
|
||
el.style.outline = 'none'
|
||
el.style.minHeight = '320rpx'
|
||
el.style.userSelect = 'text'
|
||
el.style.webkitUserSelect = 'text'
|
||
|
||
if (this.prop) {
|
||
el.innerHTML = this.formatHtml(this.prop)
|
||
}
|
||
|
||
el.addEventListener('input', () => {
|
||
this.notifyOwner(el.innerHTML || '')
|
||
})
|
||
el.addEventListener('blur', () => {
|
||
this.notifyOwner(el.innerHTML || '')
|
||
})
|
||
},
|
||
formatHtml(html) {
|
||
if (!html) return ''
|
||
return html
|
||
.replace(/<table(\s+[^>]*)?>/gi, '<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;border:1px solid #dcdfe6;margin:12px 0;"$1>')
|
||
.replace(/<th(\s+[^>]*)?>/gi, '<th border="1" style="border:1px solid #dcdfe6;background-color:#f5f7fa;font-weight:600;padding:8px 12px;color:#303133;"$1>')
|
||
.replace(/<td(\s+[^>]*)?>/gi, '<td border="1" style="border:1px solid #dcdfe6;background-color:#ffffff;padding:8px 12px;color:#303133;"$1>')
|
||
},
|
||
onPropChange(newVal) {
|
||
const el = document.getElementById('richEditorDiv')
|
||
if (!el) return
|
||
const formatted = this.formatHtml(newVal || '')
|
||
if (formatted !== el.innerHTML) {
|
||
el.innerHTML = formatted
|
||
}
|
||
},
|
||
onActionChange(act) {
|
||
if (!act || !act.type) return
|
||
const el = document.getElementById('richEditorDiv')
|
||
if (!el) return
|
||
el.focus()
|
||
|
||
if (act.type === 'format') {
|
||
document.execCommand(act.cmd, false, act.value || null)
|
||
this.notifyOwner(el.innerHTML || '')
|
||
} else if (act.type === 'insertHtml') {
|
||
const formatted = this.formatHtml(act.html || '')
|
||
if (document.queryCommandSupported('insertHTML')) {
|
||
document.execCommand('insertHTML', false, formatted)
|
||
} else {
|
||
el.insertAdjacentHTML('beforeend', formatted)
|
||
}
|
||
this.notifyOwner(el.innerHTML || '')
|
||
} else if (act.type === 'tableOp') {
|
||
this.handleRenderTableOp(act.op)
|
||
}
|
||
},
|
||
handleRenderTableOp(op) {
|
||
const el = document.getElementById('richEditorDiv')
|
||
if (!el) return
|
||
const sel = window.getSelection()
|
||
if (!sel || sel.rangeCount === 0) return
|
||
let node = sel.anchorNode
|
||
if (node && node.nodeType === 3) node = node.parentElement
|
||
const cell = node ? node.closest('td, th') : null
|
||
if (!cell || !el.contains(cell)) return
|
||
|
||
if (op === 'addRowBefore' || op === 'addRowAfter') {
|
||
const row = cell.closest('tr')
|
||
if (!row) return
|
||
const newRow = row.cloneNode(true)
|
||
newRow.querySelectorAll('td, th').forEach(c => {
|
||
c.innerHTML = '<p><br></p>'
|
||
c.style.border = '1px solid #dcdfe6'
|
||
c.style.padding = '8px 12px'
|
||
c.setAttribute('border', '1')
|
||
})
|
||
if (op === 'addRowBefore') row.before(newRow)
|
||
else row.after(newRow)
|
||
} else if (op === 'deleteRow') {
|
||
const row = cell.closest('tr')
|
||
const section = row ? row.parentElement : null
|
||
if (row && section && section.querySelectorAll('tr').length > 1) {
|
||
row.remove()
|
||
}
|
||
} else if (op === 'addColBefore' || op === 'addColAfter') {
|
||
const idx = cell.cellIndex
|
||
const table = cell.closest('table')
|
||
if (!table) return
|
||
table.querySelectorAll('tr').forEach(r => {
|
||
const refCell = r.cells[idx]
|
||
if (!refCell) return
|
||
const newCell = document.createElement(refCell.tagName.toLowerCase())
|
||
newCell.innerHTML = '<p><br></p>'
|
||
newCell.style.border = '1px solid #dcdfe6'
|
||
newCell.style.padding = '8px 12px'
|
||
newCell.setAttribute('border', '1')
|
||
if (newCell.tagName === 'TH') {
|
||
newCell.style.backgroundColor = '#f5f7fa'
|
||
newCell.style.fontWeight = '600'
|
||
}
|
||
if (op === 'addColBefore') refCell.before(newCell)
|
||
else refCell.after(newCell)
|
||
})
|
||
} else if (op === 'deleteCol') {
|
||
const idx = cell.cellIndex
|
||
const table = cell.closest('table')
|
||
if (table && cell.parentElement.cells.length > 1) {
|
||
table.querySelectorAll('tr').forEach(r => {
|
||
const c = r.cells[idx]
|
||
if (c) c.remove()
|
||
})
|
||
}
|
||
} else if (op === 'deleteTable') {
|
||
const table = cell.closest('table')
|
||
if (table) table.remove()
|
||
}
|
||
this.notifyOwner(el.innerHTML || '')
|
||
},
|
||
notifyOwner(html) {
|
||
this.$ownerInstance.callMethod('onRenderChange', html)
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<!-- #endif -->
|
||
|
||
<style lang="scss" scoped>
|
||
.note-editor-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 100%;
|
||
height: 100%;
|
||
flex: 1;
|
||
min-height: 400rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.editor-toolbar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6rpx;
|
||
padding-bottom: 12rpx;
|
||
margin-bottom: 12rpx;
|
||
border-bottom: 1rpx solid #ebeef5;
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
|
||
&::-webkit-scrollbar {
|
||
display: none;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
}
|
||
|
||
.toolbar-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toolbar-divider {
|
||
width: 1rpx;
|
||
height: 32rpx;
|
||
background: #ebeef5;
|
||
flex-shrink: 0;
|
||
margin: 0 4rpx;
|
||
}
|
||
|
||
.tb-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 2rpx;
|
||
min-width: 54rpx;
|
||
height: 54rpx;
|
||
padding: 0 8rpx;
|
||
border: 1rpx solid #ebeef5;
|
||
border-radius: 8rpx;
|
||
background: #fff;
|
||
color: #606266;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
box-sizing: border-box;
|
||
|
||
&:active {
|
||
background: #f5f7fa;
|
||
}
|
||
}
|
||
|
||
.tb-btn-danger {
|
||
border-color: #fde2e2;
|
||
background: #fef0f0;
|
||
}
|
||
|
||
.tb-label {
|
||
font-size: 18rpx;
|
||
color: #909399;
|
||
margin-left: 2rpx;
|
||
}
|
||
|
||
.note-rich-editor {
|
||
width: 100%;
|
||
flex: 1;
|
||
min-height: 320rpx;
|
||
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;
|
||
}
|
||
|
||
.note-rich-editor:empty::before {
|
||
content: attr(data-placeholder);
|
||
color: #909399;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.note-rich-editor:focus:empty::before {
|
||
content: '';
|
||
}
|
||
</style>
|
||
|
||
<style lang="scss">
|
||
@import '@/src/styles/rich-content.scss';
|
||
|
||
/* 全局表格规范:强制在所有终端与渲染层呈现清晰的表格边框与单元格背景 */
|
||
.note-rich-editor {
|
||
table {
|
||
display: table !important;
|
||
border-collapse: collapse !important;
|
||
border-spacing: 0 !important;
|
||
border: 1px solid #dcdfe6 !important;
|
||
width: 100% !important;
|
||
margin: 12px 0 !important;
|
||
|
||
tbody {
|
||
display: table-row-group !important;
|
||
}
|
||
|
||
tr {
|
||
display: table-row !important;
|
||
border-bottom: 1px solid #ebeef5 !important;
|
||
}
|
||
|
||
th,
|
||
td {
|
||
display: table-cell !important;
|
||
border: 1px solid #dcdfe6 !important;
|
||
background-color: #ffffff;
|
||
padding: 8px 12px !important;
|
||
font-size: 26rpx !important;
|
||
min-width: 44px !important;
|
||
min-height: 36px !important;
|
||
vertical-align: top !important;
|
||
word-break: break-word !important;
|
||
box-sizing: border-box !important;
|
||
user-select: text !important;
|
||
-webkit-user-select: text !important;
|
||
cursor: text !important;
|
||
}
|
||
|
||
th {
|
||
background-color: #f5f7fa !important;
|
||
font-weight: 600 !important;
|
||
color: #303133 !important;
|
||
}
|
||
|
||
p {
|
||
margin: 0 !important;
|
||
min-height: 1.4em !important;
|
||
}
|
||
}
|
||
}
|
||
</style>
|