uniapp 安卓/H5 使用 contentEditable 属性 富文本编辑器

复制代码
editor-memo.vue

<template>
	<view>
		<view id="title" class="title" data-placeholder="标题...">

		</view>

		<view id="editor" class="editor" data-placeholder="请输入内容..." :cmd="cmd" :change:cmd="editorModule.handleCmd">
			<!-- renderjs 会在这里操作 DOM -->
		</view>
		<!-- 工具栏在逻辑层 -->
		<view class="toolbar">
			<button @click="editorModule.formatBold">加粗</button>
			<button @click="editorModule.insertImg">插入图片</button>
			<button @click="editorModule.insertAudio">插入音频</button>
		</view>


	</view>
</template>

<script>
	export default {
		// 组件名称,建议写全,方便调试
		name: "EditorMemo",

		// 父组件传入的数据
		props: {
			titleIsShow: {
				type: Boolean,
				default: true
			},
			oldFileIds: {
				type: Array,
				default: []
			},
		},
		data() {
			return {
				cmd: null, //cmd 和数据
			}
		},
		methods: {
			selectFileUpload(map) {
				/* let vm = this;
				plus.io.chooseFile({
					title: '选择文件',
					filetypes: ['png', 'jpg', 'jpeg', 'mp3', 'mp4'], // 允许的文件类型
					multiple: false, // 是否允许多选
				}, (res) => {
					// console.log('res', res);
					let parts = res.files[0].split('/');
					let result = parts.pop();
					let listItem = [{
						imageOrVideo: '0',
						name: result,
						uri: res.files[0],
						url: res.files[0],
						model: 'testTableName',
					}]
					const userInfo = uni.getStorageSync('USERINFO');
					if (userInfo.user && userInfo.user.id) {
						const baseDirectory = `${userInfo.user.id}/mmwz_user_digital_treasury`;
						// 显示进度条
						vm.showProgressBar = true;
						vm.uploadProgress = 0;
				
						vm.$huaweiobssignature(
							listItem,
							'testTableName',
							baseDirectory,
							(percent, loaded, total) => {
								// 更新进度条
								//vm.uploadProgress = percent;
								console.log(`上传进度:${percent}%`);
							}
						).then(resa => {
							// 隐藏进度条
							//vm.showProgressBar = false;
							//vm.uploadProgress = 0;
				
							if (resa && resa.length > 0 && resa[0].id) {
								console.log(resa);
							} else {
								uni.hideLoading()
								//vm.showProgressBar = false;
								vm.$refs.uToast.show({
									title: '上传失败',
									type: 'error',
								})
							}
						}).catch(err => {
							uni.hideLoading()
							//vm.showProgressBar = false;
							vm.$refs.uToast.show({
								title: '上传失败',
								type: 'error',
							})
						})
					}
					setTimeout(() => {
						uni.hideLoading()
					}, 10000);
				})*/

				console.log(map.filetypes);
				console.log(map.funcName);


				this.cmd = {
					type: map.funcName,
					data: {
						url: 'https://adsmind.ugdtimg.com/ads_svp_video__0bc3faacqaaadiahjntkjjtbukaefauaakca.f1000_0.jpeg?dis_k=ee08351a67d705d7cf721e2ec8906107&amp;dis_t=1718246483&amp;sha256=581060cb59350df0178ad2ea29c8dca50992b5c531293c484dd66c432552a95a&amp;ck=db826460a21fce82c3d36c3776711db9&amp;m=db826460a21fce82c3d36c3776711db9',
						id: '1111666'
					},
					timestamp: Date.now() // 加时间戳确保每次都能触发(即使内容相同)
				}
			},
			contentChanges(html){
				this.$emit('content-changes', html);
				//console.log(html)
			},
			receiveNewFileIds(newFileIds){
				var that = this;
				this.$emit('fileIds-changes', {oldFileIds:that.oldFileIds,newFileIds:newFileIds});
			}
		}
	}
</script>
<script module="editorModule" lang="renderjs">
	export default {
		data() {
			return {
				savedRange: null
			}
		},

		mounted() {
			const title = document.getElementById('title')
			title.contentEditable = 'true'
			/*title.innerHTML = '标题...' */

			// 初始化编辑器
			const editor = document.getElementById('editor')
			editor.contentEditable = 'true'
			/*editor.innerHTML = '<p>请输入内容...</p>'*/

			// 监听输入变化
			editor.addEventListener('input', () => {
				// 通过 uni.postMessage 或 triggerEvent 通知逻辑层
				//this.$ownerInstance.callMethod('selectFileUpload', editor.innerHTML)
				
				this.$ownerInstance.callMethod('contentChanges', editor.innerHTML)
				
				this.getAllDataId();
			})

			// 统一保存选区的方法
			const saveSelection = () => {
				const sel = window.getSelection()
				if (sel.rangeCount === 0) return

				const range = sel.getRangeAt(0)
				// 检查选区是否在 editor 或 title 内
				if (editor.contains(range.commonAncestorContainer)) {
					this.savedRange = range.cloneRange()
					//this.currentEditor = 'editor'  // 标记当前操作的是哪个编辑器
				}
				/* else if (title.contains(range.commonAncestorContainer)) {
			      this.savedRange = range.cloneRange()
			      this.currentEditor = 'title'
			    } */
			}

			document.addEventListener('selectionchange', saveSelection)
		},
		methods: {
			handleCmd(newVal, oldVal, ownerInstance, instance) {
				if (!newVal) return

				const {
					type,
					data
				} = newVal

				if (type === 'insertImgToDom') {
					this.insertImgToDom(data, ownerInstance)
				} else if (type === 'insertAudioToDom') {
					this.insertAudioToDom(data, ownerInstance)
				}
			},
			insertImg(event, ownerInstance) {
				ownerInstance.callMethod('selectFileUpload', {
					filetypes: ['png', 'jpg', 'jpeg'],
					funcName: 'insertImgToDom'
				})
			},
			insertImgToDom(data, ownerInstance) {
				console.log(data);
				console.log(ownerInstance);

				const img = document.createElement('img')
				img.src = data.url
				img.style.maxWidth = '100%'
				img.style.display = 'block'
				img.style.margin = '8px 0'
				img.style.borderRadius = '4px'

				img.setAttribute('data-id', data.id)
				img.setAttribute('data-type', 'image')

				this.insertDom(img);
			},
			insertAudio(event, ownerInstance) {
				ownerInstance.callMethod('selectFileUpload', {
					filetypes: ['mp3'],
					funcName: 'insertAudioToDom'
				})
			},
			insertAudioToDom(data, ownerInstance) {
				console.log(data);
				console.log(ownerInstance);

				const audio = document.createElement('audio')
				audio.src = 'https://web-ext-storage.dcloud.net.cn/doc/uniapp/ForElise.mp3'
				audio.controls = true // 显示播放控件,必须
				audio.preload = 'metadata' // 只预加载元数据(时长等),不自动下载完整音频
				audio.style.width = '100%' // 音频控件宽度占满
				audio.style.display = 'block' // 块级显示
				audio.style.margin = '8px 0' // 上下间距

				audio.setAttribute('data-id', data.id)
				audio.setAttribute('data-type', 'audio')
				audio.setAttribute('contenteditable', 'false') // 关键:防止在编辑器内误编辑音频控件本身


				this.insertDom(audio);
			},
			insertDom(dom) {
				const editor = document.getElementById('editor')
				editor.focus()

				// 恢复选区
				const sel = window.getSelection()
				sel.removeAllRanges()
				if (this.savedRange) {
					sel.addRange(this.savedRange)
				} else {
					// 没有保存的选区,创建一个默认选区(放到编辑器末尾)
					const range = document.createRange()
					range.selectNodeContents(editor)
					range.collapse(false) // false = 折叠到末尾
					sel.addRange(range)
					this.savedRange = range.cloneRange()
				}

				// 3. 再次安全检查:确保 rangeCount > 0
				if (sel.rangeCount === 0) {
					console.warn('无法获取选区')
					return
				}

				// 创建图片元素
				/* const img = document.createElement('img')
				img.src =
					'https://adsmind.ugdtimg.com/ads_svp_video__0bc3faacqaaadiahjntkjjtbukaefauaakca.f1000_0.jpeg?dis_k=ee08351a67d705d7cf721e2ec8906107&amp;dis_t=1718246483&amp;sha256=581060cb59350df0178ad2ea29c8dca50992b5c531293c484dd66c432552a95a&amp;ck=db826460a21fce82c3d36c3776711db9&amp;m=db826460a21fce82c3d36c3776711db9'
				img.style.maxWidth = '100%'
				img.style.display = 'block'
				img.style.margin = '8px 0'
				img.style.borderRadius = '4px'
				img.setAttribute('data-type', 'image') */

				// 如果当前选区 collapsed(没有选中文字),直接插入图片
				// 如果选中了文字,先删除选中的文字,再插入图片
				const range = sel.getRangeAt(0)
				range.deleteContents()
				range.insertNode(dom)

				// 插入后,把光标移到图片后面
				range.setStartAfter(dom)
				range.setEndAfter(dom)
				range.collapse(true)
				sel.removeAllRanges()
				sel.addRange(range)

				// 保存新选区
				this.savedRange = range.cloneRange()
				
				this.getAllDataId();
			},
			getAllDataId(){

				const elements = document.querySelectorAll('[data-id]');

				const idList = Array.from(elements).map(el => el.dataset.id);

				
				this.$ownerInstance.callMethod('receiveNewFileIds', idList)
				
			}
		}
	}
</script>

<style>
	.title:empty::before {
		content: attr(data-placeholder);
		color: #999;
		pointer-events: none;
		/* 防止占位文字阻挡点击 */
	}

	.title:focus:empty::before {
		content: '';
		/* 聚焦时隐藏占位文字 */
	}

	.editor:empty::before {
		content: attr(data-placeholder);
		color: #999;
		pointer-events: none;
		/* 防止占位文字阻挡点击 */
	}

	.editor:focus:empty::before {
		content: '';
		/* 聚焦时隐藏占位文字 */
	}


	/* 方法二:如果你用了类名,更精确 */
	.title:focus-visible {
		outline: none;
		border-bottom: 2px solid #ccc;
		/* 灰色下边框 */
	}

	/* 方法三:兼容 :focus-visible(部分浏览器默认使用) */
	.editor:focus-visible {
		outline: none;
		/*border-bottom: 1px solid #ccc;  灰色下边框 */
	}

	.toolbar {
		padding: 10px 0;
		border-bottom: 1px solid #ccc;
		/* 灰色下边框 */
		border-top: 1px solid #ccc;
		/* 灰色下边框 */

		display: flex;
		gap: 16px;

	}




	.editor {
		width: 100%;
		min-height: 400px;
		height: auto;
	}
</style>

使用

复制代码
fwb3.vue

<template>
	<view>
		<EditorMemo :titleIsShow="true" :oldFileIds="['999999']" @content-changes="contentChanges" @fileIds-changes="fileIdsChanges"></EditorMemo>
	</view>
</template>

<script>
	import EditorMemo from '@/components/my-editor/editor-memo.vue';
	
	export default {
		// 2. 手动注册
		components: {
			EditorMemo
		},
		data() {
			return {
				
			}
		},
		methods: {
			contentChanges(html){
				console.log(html)
			},
			fileIdsChanges(fileIds){
				console.log(fileIds)
			}
		}
	}
</script>

<style>

</style>

效果示例

相关推荐
yuqifang1 小时前
var deferred = jQuery.Deferred(); 使用示例
前端·javascript·面试
阳火锅1 小时前
领导夸我日报越写越详细了,其实我只敲了 npm run commit
前端·javascript·人工智能
mONESY1 小时前
搞懂 Session 与 JWT 区别 + Axios 拦截器实战
javascript
xiaominlaopodaren1 小时前
three.js最小地图运行时(七): MapRuntime 与地图交互
javascript·gis·three.js
二流小码农1 小时前
鸿蒙开发:了解Context
android·ios·harmonyos
404_coder1 小时前
源码视角下的 Android 开机流程(三):从 Launcher Task 到 WindowState 挂载
android
又见情义1 小时前
RK3568 Android 13 移植 EC20-CE 4G 模块全记录
android·arm开发·驱动开发
网安蟹佬霸1 小时前
Android安全攻防实战:从APK逆向到Frida动态Hook全流程详解(附脚本)
android·前端·安全·web安全·逆向·csrf·网安
雨声不在2 小时前
mitmdump Android 抓包
android