扩展 OpenTiny tiny-editor:自定义浮动工具栏、AI 改写与图片上传

316 阅读4分钟

OpenTiny tiny-editor 是一款基于 Quill.js 2.0 的富文本编辑器,它提供了强大的基础编辑功能。本文将详细介绍如何在此基础上进行扩展,实现一个自定义的浮动工具栏,并集成“AI 改写”(文本替换)功能以及自定义图片上传与展示逻辑,所有示例将优先参考官方文档推荐的实现方式

文章目标:

  1. 创建并控制一个随文本选择而出现的自定义浮动工具栏。
  2. 在浮动工具栏中添加一个“AI 改写”按钮,用于调用外部 API 并替换选中内容。
  3. 根据官方文档实现自定义图片上传逻辑,将图片上传到指定服务器并回显到编辑器中。

技术栈前提:

  • Vue.js (OpenTiny 主要面向 Vue)
  • OpenTiny tiny-editor 组件
  • 熟悉 Quill.js 的基本 API
  • 了解 JavaScript 异步操作 (Promise, async/await)

重要参考: 在进行任何自定义之前,请务必查阅 OpenTiny tiny-editor 的官方文档opentiny.github.io/tiny-editor…

准备工作:配置与获取 Quill 实例

根据官方文档,获取 Quill 实例的最佳方式是通过 tiny-editoroptions.events.ready 事件。图片上传也将通过 options.modules.toolbar.handlers 进行配置。

<template>
  <div>
    <tiny-editor
      ref="tinyEditorRef"
      v-model="editorContent"
      :options="editorOptions"
    />
    <!-- 我们的自定义浮动工具栏 -->
    <div
      v-if="showFloatingToolbar"
      :style="floatingToolbarStyle"
      class="custom-floating-toolbar"
      @mousedown.prevent="() => {}" <!-- Prevent editor blur when clicking toolbar -->
    >
      <button @click="handleAIRewrite">AI 改写</button>
      <!-- 更多按钮 -->
    </div>
  </div>
</template>

<script>
// 假设已安装并引入 tiny-editor
// import TinyEditor from '@opentiny/vue'; // 根据你的项目配置引入
// 或者 import { Editor } from '@opentiny/vue';

export default {
  // components: { TinyEditor }, // 或 Editor
  data() {
    return {
      editorContent: '<p>Hello Tiny Editor!</p>',
      quill: null, // 用于存储 Quill 实例
      showFloatingToolbar: false,
      floatingToolbarStyle: {
        position: 'absolute',
        top: '0px',
        left: '0px',
        zIndex: 1000,
        // 更多样式,见下方 CSS
      },
      editorOptions: { // **关键:编辑器配置**
        modules: {
          toolbar: {
            container: [ // 定义你的主工具栏按钮
              ['bold', 'italic', 'underline'],
              [{ 'list': 'ordered'}, { 'list': 'bullet' }],
              ['image'], // 确保图片按钮在工具栏中
              // ... 其他你需要的按钮
            ],
            handlers: {
              // **关键:自定义图片上传处理器**
              'image': this.customImageHandler // 必须在 methods 中定义此方法
            }
          }
        },
        events: {
          // **关键:获取 Quill 实例**
          'ready': this.onEditorReady,
          // 'text-change': this.onEditorTextChange, // 可选:监听文本变化
          // 'selection-change': this.onEditorSelectionChange, // 可选:监听选区变化 (我们也会用quill实例直接监听)
        }
        // theme: 'snow' // 默认是 snow,也可以是 'bubble'
      }
    };
  },
  methods: {
    onEditorReady(quillInstance) {
      console.log('Tiny Editor is ready. Quill instance:', quillInstance);
      this.quill = quillInstance;
      this.setupCustomFeatures();
    },
    // onEditorTextChange(delta, oldDelta, source) {
    //   console.log('Text changed:', delta, source);
    // },
    // onEditorSelectionChange(range, oldRange, source) {
    //    // 虽然编辑器options提供了selection-change,但为了浮动工具栏的即时性,我们仍建议直接在quill实例上监听
    //   console.log('Selection changed (via options):', range, source);
    // },
    setupCustomFeatures() {
      if (!this.quill) return;
      this.setupFloatingToolbarListener();
      // 图片处理器已在 editorOptions 中配置,Quill 会自动调用 customImageHandler
    },

    // --- Part 1: Custom Floating Toolbar Methods ---
    setupFloatingToolbarListener() {
      this.quill.on('selection-change', (range, oldRange, source) => {
        if (source === 'user' && range && range.length > 0) {
          const bounds = this.quill.getBounds(range.index, range.length);
          const editorWrapper = this.$refs.tinyEditorRef?.$el; // 获取 tiny-editor 组件的根 DOM 元素

          if (!editorWrapper) return;

          const editorRect = editorWrapper.getBoundingClientRect(); // 编辑器组件在视口中的位置
          const scrollTop = window.pageYOffset || document.documentElement.scrollTop; // 页面垂直滚动
          const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; // 页面水平滚动

          const toolbarHeight = 40; // 估算的工具栏高度
          const toolbarWidth = 80;  // 估算的工具栏宽度 (AI改写按钮)

          // 计算位置,使其在选区上方
          let topPosition = editorRect.top + scrollTop + bounds.top - toolbarHeight - 5; // 5px 间距
          let leftPosition = editorRect.left + scrollLeft + bounds.left + (bounds.width / 2) - (toolbarWidth / 2);

          // 边界检查 (简单示例,可根据需要完善)
          if (topPosition < (editorRect.top + scrollTop)) { // 防止超出编辑器顶部
              topPosition = editorRect.top + scrollTop + bounds.top + bounds.height + 5;
          }
          if (leftPosition < (editorRect.left + scrollLeft)) { // 防止超出编辑器左侧
              leftPosition = editorRect.left + scrollLeft;
          }
          if (leftPosition + toolbarWidth > (editorRect.left + scrollLeft + editorRect.width)) { // 防止超出编辑器右侧
              leftPosition = editorRect.left + scrollLeft + editorRect.width - toolbarWidth;
          }

          this.floatingToolbarStyle.top = `${topPosition}px`;
          this.floatingToolbarStyle.left = `${leftPosition}px`;

          this.showFloatingToolbar = true;
        } else {
          // 用户操作导致无选区或选区长度为0
          if (this.showFloatingToolbar && source === 'user') {
             this.showFloatingToolbar = false;
          }
        }
      });

      // 点击编辑器外部时隐藏工具栏
      document.addEventListener('click', this.hideToolbarOnClickOutside, true);
    },
    hideToolbarOnClickOutside(event) {
        const editorWrapper = this.$refs.tinyEditorRef?.$el;
        const toolbarElement = this.$el.querySelector('.custom-floating-toolbar');

        if (this.showFloatingToolbar &&
            editorWrapper && !editorWrapper.contains(event.target) &&
            toolbarElement && !toolbarElement.contains(event.target)) {
            this.showFloatingToolbar = false;
        }
    },
    beforeUnmount() { // Vue 3
        document.removeEventListener('click', this.hideToolbarOnClickOutside, true);
    },
    // destroyed() { // Vue 2
    //     document.removeEventListener('click', this.hideToolbarOnClickOutside, true);
    // },

    // --- Part 2: AI Rewrite Methods ---
    async handleAIRewrite() {
      if (!this.quill) return;
      const range = this.quill.getSelection();
      if (!range || range.length === 0) {
        // console.warn('No text selected for AI rewrite.');
        return;
      }
      const selectedText = this.quill.getText(range.index, range.length);
      try {
        // 可选:显示加载提示
        // this.showLoadingIndicatorForAIRewrite = true;
        const rewrittenText = await this.callAIRewriteAPI(selectedText);
        if (rewrittenText && rewrittenText !== selectedText) { // 只有当改写内容不同时才执行替换
          this.quill.deleteText(range.index, range.length, 'api');
          this.quill.insertText(range.index, rewrittenText, 'api');
          this.quill.setSelection(range.index + rewrittenText.length, 0, 'api');
        } else if (rewrittenText === selectedText) {
          console.log('AI 未对文本进行修改。');
          // 可选:给用户一个提示,如 "AI 认为当前文本已是最佳。"
        }
      } catch (error) {
        console.error('AI 改写失败:', error);
        alert('AI 改写服务暂时不可用。'); // 考虑使用更友好的提示组件
      } finally {
        // this.showLoadingIndicatorForAIRewrite = false;
        this.showFloatingToolbar = false;
      }
    },
    async callAIRewriteAPI(text) {
      // 模拟 API 调用
      console.log(`Calling AI to rewrite: "${text}"`);
      return new Promise(resolve => setTimeout(() => {
        resolve(`[AI] ${text.toUpperCase()}`); // 示例:转换为大写
      }, 1000));
      /*
      // 实际的 fetch 调用示例:
      try {
        const response = await fetch('/api/ai/rewrite', { // 你的后端AI接口
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            // 'Authorization': 'Bearer YOUR_API_TOKEN' // 如果需要认证
          },
          body: JSON.stringify({ text: text })
        });
        if (!response.ok) {
          // 可以根据 status 或后端返回的错误信息给出更具体的提示
          const errorBody = await response.text();
          throw new Error(`API Error ${response.status}: ${errorBody}`);
        }
        const data = await response.json();
        return data.rewrittenText; // 假设后端返回 { rewrittenText: "..." }
      } catch (error) {
        console.error("AI API call failed:", error);
        throw error; // 重新抛出错误,由调用者 (handleAIRewrite) 处理
      }
      */
    },

    // --- Part 3: Custom Image Upload Methods (Handler for toolbar) ---
    customImageHandler() {
      // 这个方法会由 Quill 的图片工具栏按钮触发 (因为我们在 options 中配置了 handler)
      console.log('Custom image handler triggered');
      const input = document.createElement('input');
      input.setAttribute('type', 'file');
      input.setAttribute('accept', 'image/*'); // 限制文件类型为图片
      input.style.display = 'none';
      document.body.appendChild(input); // 必须添加到DOM才能触发

      input.onchange = async () => {
        const file = input.files; // 获取单个文件
        if (file && file.length > 0) {
          try {
            // this.isUploadingImage = true; // 可选:设置上传状态的 loading
            const imageUrl = await this.uploadImageToServer(file); // 传递文件本身
            this.insertImageToEditor(imageUrl);
          } catch (error) {
            console.error('图片上传或插入失败:', error);
            alert('图片上传失败,请稍后再试。'); // 考虑使用更友好的提示组件
          } finally {
            // this.isUploadingImage = false;
            document.body.removeChild(input); // 清理DOM
          }
        } else {
            document.body.removeChild(input); // 如果用户未选择文件也清理DOM
        }
      };
      input.click(); // 程序化触发文件选择对话框
    },
    async uploadImageToServer(fileInstance) {
      // 模拟图片上传到服务器
      console.log(`Uploading image: ${fileInstance.name}`);
      const formData = new FormData();
      formData.append('imageFile', fileInstance); // 'imageFile' 是后端期望的字段名

      // 实际的 fetch 调用:
      /*
      try {
        const response = await fetch('/api/upload/image', { // 你的图片上传接口
          method: 'POST',
          // headers: { 'Authorization': 'Bearer YOUR_API_TOKEN' }, // 如果需要认证
          body: formData
        });
        if (!response.ok) {
          const errorText = await response.text();
          throw new Error(`Network response was not ok: ${response.status} ${errorText}`);
        }
        const data = await response.json(); // 假设后端返回 { imageUrl: "..." }
        return data.imageUrl;
      } catch (error) {
        console.error("Image upload API call failed:", error);
        throw error; // 重新抛出,让调用者处理UI提示
      }
      */

      // 模拟成功返回
      return new Promise(resolve => setTimeout(() => {
        const mockUrl = `https://via.placeholder.com/300x200.png?text=Uploaded:${encodeURIComponent(fileInstance.name)}`;
        console.log('Mock image URL:', mockUrl);
        resolve(mockUrl);
      }, 1500));
    },
    insertImageToEditor(imageUrl) {
      if (!this.quill || !imageUrl) return;
      const range = this.quill.getSelection(true); // true: focus editor if not focused
      // 在当前光标位置插入图片
      // Quill 会自动处理图片加载和显示
      this.quill.insertEmbed(range.index, 'image', imageUrl, 'user');
      // 将光标移到图片之后,并确保编辑器有焦点
      this.quill.setSelection(range.index + 1, 0, 'user');
      this.quill.focus();
    }
  }
};
</script>

<style>
/* 确保 tiny-editor 容器有相对定位,以便浮动工具栏绝对定位 */
/* 如果 tiny-editor 组件本身没有设置 position: relative, 你可能需要给它的父容器添加 */
/* .tiny-editor-wrapper {
/*  position: relative;
/* } */

.custom-floating-toolbar {
  position: absolute; /* 将由 JS 设置 top/left */
  background-color: #2c3e50;
  color: white;
  padding: 8px 12px;
  border-radius: 6px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  display: flex;
  align-items: center;
  gap: 10px;
  transform: translateX(-50%); /* 配合 JS 计算的 left 值,实现水平居中 */
  /* transition: opacity 0.1s ease-in-out, top 0.1s ease-in-out;  // 可选的过渡效果 */
}
.custom-floating-toolbar button {
  background: none;
  border: 1px solid #4a6c8c;
  color: white;
  cursor: pointer;
  padding: 5px 10px;
  border-radius: 4px;
  font-size: 14px;
}
.custom-floating-toolbar button:hover {
  background-color: #34495e;
}
</style>

总结与建议

通过本文的介绍,我们了解了如何在 OpenTiny tiny-editor 中实现以下自定义功能:

  1. 获取 Quill 实例:通过 tiny-editoroptions.events.ready 事件回调,这是与编辑器底层 API 交互的基础。
  2. 自定义浮动工具栏:监听 Quill 的 selection-change 事件,动态计算并显示一个随选区移动的工具栏,提供了上下文相关的操作入口。
  3. 集成 AI 改写功能:在浮动工具栏上添加按钮,调用外部 API(模拟),并使用 Quill API 替换选中的文本内容。
  4. 自定义图片上传:通过配置 tiny-editoroptions.modules.toolbar.handlers,覆盖默认的图片处理逻辑,实现将图片上传到自定义服务器并回显到编辑器中。

开发建议:

  • 优先查阅官方文档:OpenTiny tiny-editor 的官方文档 (opentiny.github.io/tiny-editor…) 是获取准确 API 信息和推荐做法的首要资源。组件库的封装可能会有其特定的配置方式。
  • 理解 Quill.js:由于 tiny-editor 基于 Quill.js,深入理解 Quill 的模块(Modules)、格式(Formats)、Blots、Deltas 以及 API 对于进行高级定制至关重要。
  • 用户体验 (UX)
    • 对于耗时操作(如 API 调用、图片上传),提供明确的加载状态指示(如 loading spinner、禁用按钮)。
    • 提供清晰的错误提示和成功反馈,使用户了解操作结果。
    • 浮动工具栏的定位和显隐逻辑应尽可能平滑和直观,避免遮挡重要内容或在不适宜的时候出现。
    • 确保自定义功能的键盘可访问性。
  • 代码健壮性与错误处理
    • 对 API 调用、文件操作等进行充分的错误捕获和处理。
    • 考虑网络问题、服务器错误、文件格式或大小限制等边界情况。
  • 组件封装与复用:如果自定义功能较为复杂,可以考虑将其封装成独立的 Vue 组件或服务,以提高代码的可维护性和复用性。
  • 异步操作管理:熟练使用 async/awaitPromise 处理异步流程,确保代码逻辑清晰。
  • 样式与主题:自定义 UI 元素(如浮动工具栏)的样式应与 tiny-editor 的整体主题或项目的设计规范保持一致。
  • 性能考虑:频繁的 DOM 操作或复杂的计算可能会影响性能,尤其是在处理长文档或快速选区变化时。注意优化相关逻辑。
  • 测试:对自定义功能进行充分测试,包括不同浏览器、不同设备以及各种边界条件。

通过遵循这些建议并结合官方文档,你可以更有效地扩展 OpenTiny tiny-editor,为其增加强大的定制化功能,以满足复杂的业务需求。