📝 文章概述

在日记应用中,编辑器是核心功能。本文将详细介绍如何利用HarmonyOS的WebView组件,结合JavaScript Markdown库,打造一个功能强大、体验流畅的日记编辑器。我们将探讨原生代码与WebView的双向通信、实时预览、快捷工具栏等实现细节。

🎯 为什么选择混合方案?

方案对比

编辑器实现方案
纯原生方案
纯Web方案
混合方案
性能好
开发难度高
功能受限
功能丰富
性能一般
与原生隔离
性能平衡
功能丰富
灵活性高
最佳选择

详细对比

方案 优势 劣势 适用场景
纯原生 性能最佳、系统集成好 功能有限、开发复杂 简单文本编辑
纯Web 功能丰富、生态成熟 性能一般、与原生隔离 Web应用
混合方案 兼顾性能和功能 需要处理通信 复杂编辑器 ⭐

🚀 架构设计

整体架构

编辑内容
快捷操作
保存数据
插入语法
内容变化
保存
数据库
用户操作
原生UI层
操作类型
WebView编辑器
原生工具栏
原生数据层
JavaScript
双向通信
RelationalStore

通信机制

用户 原生层 WebView JavaScript 打开编辑页面 加载markdown_editor.html 初始化编辑器 waitForAttached() ATTACHED registerJavaScriptProxy() setContent(content) 显示内容 编辑内容 onContentChange() DiaryApp.onContentChange(content) 点击快捷按钮 insertText(syntax) 插入Markdown语法 点击保存 getContent() 返回内容 保存到数据库 用户 原生层 WebView JavaScript

💡 实现步骤

第一步: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="开始写日记...&#10;&#10;支持Markdown语法:&#10;# 标题&#10;**粗体** *斜体*&#10;- 列表项&#10;> 引用&#10;[链接](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 = `![图片](${imageUri})`;
      this.insertMarkdown(imageMarkdown);
    }
  } catch (error) {
    console.error('❌ 插入图片失败:', error);
  }
}

📚 最佳实践

✅ 推荐做法

  1. 使用waitForAttached确保WebView就绪
  2. 双向数据同步:实时更新title和content
  3. 错误处理:try-catch包裹所有异步操作
  4. 自动保存:避免数据丢失
  5. 退出确认:提示用户保存未保存的内容

❌ 避免做法

  1. ❌ 不检查WebView状态就执行JavaScript
  2. ❌ 忘记转义特殊字符(单引号、换行符等)
  3. ❌ 不处理WebView通信失败的情况
  4. ❌ 频繁的DOM操作影响性能
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐