Files

660 lines
17 KiB
Vue
Raw Permalink 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>
<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>