Markdown编辑器实现:原生与Web的完美结合 #跟着淼哥学鸿蒙
本文介绍如何在HarmonyOS应用中实现一个基于混合方案的日记编辑器。通过对比纯原生、纯Web和混合三种方案,混合方案在性能、功能和灵活性上取得最佳平衡。架构设计采用原生UI层与WebView编辑器结合的方式,利用双向通信机制实现数据交互。具体实现包括HTML编辑器页面搭建,提供标题/内容设置、获取及文本插入等JavaScript接口。该方案既保持了原生性能优势,又继承了Web生态的丰富功能,适
·
📝 文章概述
在日记应用中,编辑器是核心功能。本文将详细介绍如何利用HarmonyOS的WebView组件,结合JavaScript Markdown库,打造一个功能强大、体验流畅的日记编辑器。我们将探讨原生代码与WebView的双向通信、实时预览、快捷工具栏等实现细节。
🎯 为什么选择混合方案?
方案对比
详细对比
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| 纯原生 | 性能最佳、系统集成好 | 功能有限、开发复杂 | 简单文本编辑 |
| 纯Web | 功能丰富、生态成熟 | 性能一般、与原生隔离 | Web应用 |
| 混合方案 | 兼顾性能和功能 | 需要处理通信 | 复杂编辑器 ⭐ |
🚀 架构设计
整体架构
通信机制
💡 实现步骤
第一步:HTML编辑器页面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown编辑器</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #FFFFFF;
height: 100vh;
display: flex;
flex-direction: column;
}
/* 标题输入框 */
#titleInput {
width: 100%;
padding: 16px 20px;
font-size: 20px;
font-weight: bold;
border: none;
border-bottom: 1px solid #E8E8E8;
outline: none;
}
#titleInput::placeholder {
color: #BDC3C7;
}
/* 内容编辑区 */
#contentEditor {
flex: 1;
width: 100%;
padding: 16px 20px;
font-size: 16px;
line-height: 1.6;
border: none;
outline: none;
resize: none;
font-family: -apple-system, BlinkMacSystemFont, 'Courier New', monospace;
}
#contentEditor::placeholder {
color: #BDC3C7;
}
/* 滚动条样式 */
#contentEditor::-webkit-scrollbar {
width: 8px;
}
#contentEditor::-webkit-scrollbar-thumb {
background: #BDC3C7;
border-radius: 4px;
}
</style>
</head>
<body>
<!-- 标题输入框 -->
<input
type="text"
id="titleInput"
placeholder="请输入标题..."
maxlength="100"
/>
<!-- 内容编辑区 -->
<textarea
id="contentEditor"
placeholder="开始写日记... 支持Markdown语法: # 标题 **粗体** *斜体* - 列表项 > 引用 [链接](url)"
></textarea>
<script>
// 获取DOM元素
const titleInput = document.getElementById('titleInput');
const contentEditor = document.getElementById('contentEditor');
// 🔥 供原生调用:设置标题
window.setTitle = function(title) {
titleInput.value = title || '';
};
// 🔥 供原生调用:设置内容
window.setContent = function(content) {
contentEditor.value = content || '';
};
// 🔥 供原生调用:获取标题
window.getTitle = function() {
return titleInput.value;
};
// 🔥 供原生调用:获取内容
window.getContent = function() {
return contentEditor.value;
};
// 🔥 供原生调用:插入文本
window.insertText = function(text) {
const start = contentEditor.selectionStart;
const end = contentEditor.selectionEnd;
const value = contentEditor.value;
// 在光标位置插入文本
contentEditor.value =
value.substring(0, start) +
text +
value.substring(end);
// 设置新的光标位置
const newCursorPos = start + text.length;
contentEditor.setSelectionRange(newCursorPos, newCursorPos);
contentEditor.focus();
// 通知原生内容已变化
notifyContentChange();
};
// 🔥 监听标题变化
titleInput.addEventListener('input', () => {
if (window.DiaryApp && window.DiaryApp.onTitleChange) {
window.DiaryApp.onTitleChange(titleInput.value);
}
});
// 🔥 监听内容变化
contentEditor.addEventListener('input', () => {
notifyContentChange();
});
// 通知原生内容变化
function notifyContentChange() {
if (window.DiaryApp && window.DiaryApp.onContentChange) {
window.DiaryApp.onContentChange(contentEditor.value);
}
}
// 防止页面被拖动
document.addEventListener('touchmove', (e) => {
if (e.target !== contentEditor) {
e.preventDefault();
}
}, { passive: false });
// 页面加载完成
window.addEventListener('load', () => {
console.log('Markdown编辑器加载完成');
});
</script>
</body>
</html>
第二步:原生页面实现
import { promptAction, router } from '@kit.ArkUI'
import { DiaryRecord } from '../db/User'
import DiaryAPI from '../db/AccountAPI'
import { webview } from '@kit.ArkWeb'
@Entry
@ComponentV2
struct WriteDiaryPage {
// 数据状态
@Local title: string = ''
@Local content: string = ''
@Local isSaving: boolean = false
@Local isEditMode: boolean = false
@Local editingDiary: DiaryRecord | null = null
// WebView控制器
@Local webviewController: webview.WebviewController =
new webview.WebviewController()
// 数据API
private diaryApi = DiaryAPI
build() {
Column() {
// 🔥 顶部导航栏
this.TopNavigationBar()
// 🔥 WebView编辑区
Web({
src: $rawfile('markdown_editor.html'),
controller: this.webviewController
})
.layoutWeight(1)
.width('100%')
.margin({ top: 4 })
.onControllerAttached(() => {
this.initMarkdownEditor()
})
.onPageEnd(() => {
this.loadDataToEditor()
})
// 🔥 底部快捷工具栏
this.BottomToolbar()
}
.width('100%')
.height('100%')
.backgroundColor('#F5F6FA')
}
// 🔥 顶部导航栏
@Builder
TopNavigationBar() {
Row() {
// 返回按钮
Button() {
SymbolGlyph($r('sys.symbol.chevron_left'))
.fontSize(20)
}
.backgroundColor(Color.Transparent)
.height(40)
.width(40)
.onClick(() => {
this.showExitConfirm()
})
// 标题
Text(this.isEditMode ? '编辑日记' : '写日记')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#2C3E50')
.layoutWeight(1)
.textAlign(TextAlign.Center)
// 字数统计
Text(`${this.content.length}字`)
.fontSize(12)
.fontColor('#7F8C8D')
.margin({ right: 12 })
// 保存按钮
Button() {
if (this.isSaving) {
LoadingProgress()
.width(16)
.height(16)
.color('#3498DB')
} else {
Text('保存')
.fontSize(14)
.fontColor('#3498DB')
.fontWeight(FontWeight.Medium)
}
}
.backgroundColor(Color.Transparent)
.height(36)
.padding({ left: 12, right: 12 })
.borderRadius(18)
.border({ width: 1, color: '#3498DB' })
.enabled(!this.isSaving)
.onClick(() => {
this.saveDiary()
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor('#FFFFFF')
.shadow({ radius: 2, color: '#E8E8E8', offsetX: 0, offsetY: 1 })
}
// 🔥 底部快捷工具栏
@Builder
BottomToolbar() {
Row() {
// 粗体
Button('B')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#27AE60')
.backgroundColor('#E8F5E8')
.borderRadius(6)
.height(36)
.width(36)
.margin({ right: 6 })
.onClick(() => this.insertMarkdown('**粗体**'))
// 斜体
Button('I')
.fontSize(12)
.fontStyle(FontStyle.Italic)
.fontColor('#F39C12')
.backgroundColor('#FFF8E1')
.borderRadius(6)
.height(36)
.width(36)
.margin({ right: 6 })
.onClick(() => this.insertMarkdown('*斜体*'))
// 标题
Button('H')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#3498DB')
.backgroundColor('#EBF3FD')
.borderRadius(6)
.height(36)
.width(36)
.margin({ right: 6 })
.onClick(() => this.insertMarkdown('# 标题'))
// 链接
Button('🔗')
.fontSize(12)
.backgroundColor('#FDF2F2')
.borderRadius(6)
.height(36)
.width(36)
.margin({ right: 6 })
.onClick(() => this.insertMarkdown('[链接](url)'))
// 列表
Button('📝')
.fontSize(12)
.backgroundColor('#F8F9FA')
.borderRadius(6)
.height(36)
.width(36)
.margin({ right: 6 })
.onClick(() => this.insertMarkdown('- 列表项'))
// 引用
Button('💡')
.fontSize(12)
.backgroundColor('#FFF3CD')
.borderRadius(6)
.height(36)
.width(36)
.onClick(() => this.insertMarkdown('> 引用'))
Blank()
// 当前时间
Text(this.getCurrentTime())
.fontSize(10)
.fontColor('#95A5A6')
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor('#FFFFFF')
.shadow({ radius: 2, color: '#E8E8E8', offsetX: 0, offsetY: -1 })
}
// 🔥 初始化Markdown编辑器
async initMarkdownEditor(): Promise<void> {
try {
// 等待WebView绑定完成
const state = await this.webviewController.waitForAttached(3000);
if (state === webview.ControllerAttachState.ATTACHED) {
console.info('✅ Markdown编辑器WebView绑定成功');
// 注册JavaScript接口
this.webviewController.registerJavaScriptProxy(
{
// 获取内容回调
getContent: (content: string) => {
this.content = content;
},
// 内容变化回调
onContentChange: (content: string) => {
this.content = content;
},
// 获取标题回调
getTitle: (title: string) => {
this.title = title;
},
// 标题变化回调
onTitleChange: (title: string) => {
this.title = title;
}
},
'DiaryApp',
['getContent', 'onContentChange', 'getTitle', 'onTitleChange']
);
// WebView绑定完成后加载数据
this.loadDataToEditor();
} else {
console.warn('⚠️ Markdown编辑器WebView绑定超时');
}
} catch (error) {
console.error('❌ 初始化Markdown编辑器失败:', error);
}
}
// 🔥 加载数据到编辑器
loadDataToEditor() {
if (!this.webviewController) {
return;
}
try {
// 检查WebView绑定状态
const attachState = this.webviewController.getAttachState();
if (attachState !== webview.ControllerAttachState.ATTACHED) {
console.warn('⚠️ 编辑器WebView未绑定,延迟加载');
setTimeout(() => this.loadDataToEditor(), 100);
return;
}
// 加载标题
const titleScript = `
if (window.setTitle) {
window.setTitle('${this.title.replace(/'/g, "\\'")}');
}
`;
this.webviewController.runJavaScript(titleScript);
// 加载内容
const contentScript = `
if (window.setContent) {
window.setContent('${this.content.replace(/'/g, "\\'")}');
}
`;
this.webviewController.runJavaScript(contentScript);
console.info('✅ 编辑器数据加载完成');
} catch (error) {
console.error('❌ 加载数据到编辑器失败:', error);
}
}
// 🔥 从编辑器获取数据
getDataFromEditor() {
if (!this.webviewController) {
return;
}
try {
// 获取标题
const titleScript = `
if (window.getTitle) {
const title = window.getTitle();
DiaryApp.getTitle(title);
}
`;
this.webviewController.runJavaScript(titleScript);
// 获取内容
const contentScript = `
if (window.getContent) {
const content = window.getContent();
DiaryApp.getContent(content);
}
`;
this.webviewController.runJavaScript(contentScript);
} catch (error) {
console.error('❌ 从编辑器获取数据失败:', error);
}
}
// 🔥 插入Markdown语法
insertMarkdown(syntax: string) {
if (!this.webviewController) {
return;
}
try {
const script = `
if (window.insertText) {
window.insertText('${syntax}');
}
`;
this.webviewController.runJavaScript(script);
} catch (error) {
console.error('❌ 插入Markdown语法失败:', error);
}
}
// 🔥 保存日记
async saveDiary() {
// 从WebView获取最新数据
this.getDataFromEditor();
// 等待数据获取完成
await new Promise<void>(resolve => setTimeout(resolve, 200));
// 验证数据
if (!this.title.trim()) {
promptAction.showToast({
message: '请输入日记标题',
duration: 2000,
bottom: '40%'
});
return;
}
if (!this.content.trim()) {
promptAction.showToast({
message: '请输入日记内容',
duration: 2000,
bottom: '40%'
});
return;
}
this.isSaving = true;
try {
if (this.isEditMode && this.editingDiary) {
// 编辑模式:更新现有日记
this.editingDiary.title = this.title.trim();
this.editingDiary.content = this.content.trim();
await this.diaryApi.updateRecord(this.editingDiary);
console.info('✅ 日记更新成功');
promptAction.showToast({
message: '日记更新成功!',
duration: 2000,
bottom: '40%'
});
} else {
// 新建模式:创建新日记
const diary = new DiaryRecord(
this.title.trim(),
this.content.trim(),
this.getCurrentDateTime()
);
await this.diaryApi.insertRecord(diary);
console.info('✅ 新日记保存成功');
promptAction.showToast({
message: '日记保存成功!',
duration: 2000,
bottom: '40%'
});
}
// 返回首页
router.back();
} catch (error) {
console.error('❌ 保存日记失败:', error);
promptAction.showToast({
message: '保存失败,请重试',
duration: 2000,
bottom: '40%'
});
} finally {
this.isSaving = false;
}
}
// 🔥 显示退出确认
showExitConfirm() {
if (this.title.trim() || this.content.trim()) {
promptAction.showDialog({
title: '确认退出',
message: '当前内容尚未保存,确定要退出吗?',
buttons: [
{ text: '取消', color: '#7F8C8D' },
{ text: '退出', color: '#E74C3C' }
]
}).then((result) => {
if (result.index === 1) {
router.back();
}
});
} else {
router.back();
}
}
// 获取当前时间
getCurrentTime(): string {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
// 获取当前日期时间
getCurrentDateTime(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// 页面初始化
aboutToAppear() {
this.loadPageParams();
}
// 加载页面参数
loadPageParams() {
try {
const params = router.getParams() as {
diary?: DiaryRecord,
isEditMode?: boolean
};
if (params && params.diary && params.isEditMode) {
// 编辑模式
this.isEditMode = true;
this.editingDiary = params.diary;
this.title = params.diary.title;
this.content = params.diary.content;
} else {
// 新建模式
this.isEditMode = false;
this.editingDiary = null;
this.title = '';
this.content = '';
}
} catch (error) {
console.error('❌ 加载页面参数失败:', error);
}
}
}
🎨 功能增强
1. Markdown预览
<!-- 添加预览区域 -->
<div id="previewPanel" style="display: none;">
<div id="previewContent"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
// 🔥 切换编辑/预览模式
window.togglePreview = function() {
const editor = document.getElementById('contentEditor');
const preview = document.getElementById('previewPanel');
const previewContent = document.getElementById('previewContent');
if (preview.style.display === 'none') {
// 显示预览
const markdown = editor.value;
const html = marked.parse(markdown);
previewContent.innerHTML = html;
editor.style.display = 'none';
preview.style.display = 'block';
} else {
// 显示编辑
editor.style.display = 'block';
preview.style.display = 'none';
}
};
</script>
2. 自动保存草稿
class AutoSaveDraft {
private saveTimer: number | null = null
private saveInterval: number = 5000 // 5秒自动保存
// 启动自动保存
startAutoSave() {
this.saveTimer = setInterval(() => {
this.saveDraft();
}, this.saveInterval);
}
// 停止自动保存
stopAutoSave() {
if (this.saveTimer) {
clearInterval(this.saveTimer);
this.saveTimer = null;
}
}
// 保存草稿
async saveDraft() {
if (!this.title && !this.content) {
return; // 没有内容,不保存
}
try {
// 保存到本地存储
const draft = {
title: this.title,
content: this.content,
timestamp: Date.now()
};
const preferences = await dataPreferences.getPreferences(
getContext(),
'diary_draft'
);
await preferences.put('draft', JSON.stringify(draft));
await preferences.flush();
console.info('✅ 草稿自动保存成功');
} catch (error) {
console.error('❌ 自动保存草稿失败:', error);
}
}
// 加载草稿
async loadDraft(): Promise<{title: string, content: string} | null> {
try {
const preferences = await dataPreferences.getPreferences(
getContext(),
'diary_draft'
);
const draftJson = await preferences.get('draft', '');
if (!draftJson) {
return null;
}
const draft = JSON.parse(draftJson as string);
console.info('✅ 草稿加载成功');
return {
title: draft.title,
content: draft.content
};
} catch (error) {
console.error('❌ 加载草稿失败:', error);
return null;
}
}
// 清除草稿
async clearDraft() {
try {
const preferences = await dataPreferences.getPreferences(
getContext(),
'diary_draft'
);
await preferences.delete('draft');
await preferences.flush();
console.info('✅ 草稿已清除');
} catch (error) {
console.error('❌ 清除草稿失败:', error);
}
}
}
3. 图片插入支持
// 选择图片
async insertImage() {
try {
const photoSelectOptions: picker.PhotoSelectOptions = {
maxSelectNumber: 1,
MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE
};
const photoPicker = new picker.PhotoViewPicker();
const photoSelectResult = await photoPicker.select(photoSelectOptions);
if (photoSelectResult && photoSelectResult.photoUris.length > 0) {
const imageUri = photoSelectResult.photoUris[0];
// 插入Markdown图片语法
const imageMarkdown = ``;
this.insertMarkdown(imageMarkdown);
}
} catch (error) {
console.error('❌ 插入图片失败:', error);
}
}
📚 最佳实践
✅ 推荐做法
- 使用waitForAttached确保WebView就绪
- 双向数据同步:实时更新title和content
- 错误处理:try-catch包裹所有异步操作
- 自动保存:避免数据丢失
- 退出确认:提示用户保存未保存的内容
❌ 避免做法
- ❌ 不检查WebView状态就执行JavaScript
- ❌ 忘记转义特殊字符(单引号、换行符等)
- ❌ 不处理WebView通信失败的情况
- ❌ 频繁的DOM操作影响性能
更多推荐



所有评论(0)