OpenTiny tiny-editor 是一款基于 Quill.js 2.0 的富文本编辑器,它提供了强大的基础编辑功能。本文将详细介绍如何在此基础上进行扩展,实现一个自定义的浮动工具栏,并集成“AI 改写”(文本替换)功能以及自定义图片上传与展示逻辑,所有示例将优先参考官方文档推荐的实现方式。
文章目标:
- 创建并控制一个随文本选择而出现的自定义浮动工具栏。
- 在浮动工具栏中添加一个“AI 改写”按钮,用于调用外部 API 并替换选中内容。
- 根据官方文档实现自定义图片上传逻辑,将图片上传到指定服务器并回显到编辑器中。
技术栈前提:
- 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-editor 的 options.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 中实现以下自定义功能:
- 获取 Quill 实例:通过
tiny-editor的options.events.ready事件回调,这是与编辑器底层 API 交互的基础。 - 自定义浮动工具栏:监听 Quill 的
selection-change事件,动态计算并显示一个随选区移动的工具栏,提供了上下文相关的操作入口。 - 集成 AI 改写功能:在浮动工具栏上添加按钮,调用外部 API(模拟),并使用 Quill API 替换选中的文本内容。
- 自定义图片上传:通过配置
tiny-editor的options.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/await和Promise处理异步流程,确保代码逻辑清晰。 - 样式与主题:自定义 UI 元素(如浮动工具栏)的样式应与
tiny-editor的整体主题或项目的设计规范保持一致。 - 性能考虑:频繁的 DOM 操作或复杂的计算可能会影响性能,尤其是在处理长文档或快速选区变化时。注意优化相关逻辑。
- 测试:对自定义功能进行充分测试,包括不同浏览器、不同设备以及各种边界条件。
通过遵循这些建议并结合官方文档,你可以更有效地扩展 OpenTiny tiny-editor,为其增加强大的定制化功能,以满足复杂的业务需求。