感谢 TinyMCE中文文档中文手册 提供的文档支持。

常见功能

代码地址

功能演示

使用本地方式引入(跳过API Key)

  • 社区版 下载对应版本的压缩包

  • 解压后将 tinymce 文件夹放到项目的 public 文件夹下

  • src/components/CustomEditor/index.tsx 中找到 tinymceScriptSrc, 然后引入本地的 tinymce 文件

    1
    2
    3
    4
    5
    6
    7
    <Editor 
    ...,
    init={{
    ...,
    tinymceScriptSrc={'/tinymce/tinymce.min.js'}
    }}
    />
  • 注意: 一定要放在 public 文件下, 否则会被打包, 导致找不到文件

下载语言包

  • 语言包下载地址 找到需要的语言包并下载

  • 解压后将 langs 文件夹放到项目的 public 文件夹下

  • src/components/CustomEditor/index.tsx 中找到 language, 填上对应的语言包名, 如 zh-Hans

    1
    2
    3
    4
    5
    6
    7
    <Editor 
    ...,
    init={{
    ...,
    language: 'zh-Hans',
    }}
    />

引入自定义字体

  • /public/tinymce 下新建 fonts 文件夹(也可以是其他文件夹, 但一定要在 tinymce 下)

  • 下载字体文件, 如 custom_font.ttf, 然后放到 /public/tinymce/fonts 文件夹下

  • 新建 font.css 文件, 然后引入字体文件

    1
    2
    3
    4
    @font-face {
    font-family: 'custom_font';
    src: url('./custom_font.ttf');
    }
  • 接着在 src/components/CustomEditor/index.tsx 中找到 content_css, 引入 font.css 文件, 以及定义字体 自定义字体=custom_font;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <Editor 
    ...,
    init={{
    ...,
    font_css: '/tinymce/fonts/font.css',
    font_family_formats:
    '微软雅黑=微软雅黑,宋体,sans-serif;' + // 官方提供
    '自定义字体=custom_font;', // 自定义
    }}
    />

图片上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const Index = () => {

/** 图片上传 */
const handleUpload = async (blobInfo: BlobInfo): any => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', blobInfo.blob());
axios.post('localhost:3000/api/upload', formData).then((data: any) => {
if (data.url) resolve(data.url);
else reject('上传失败');
});
});
};

return (
<Editor
...,
init={{
...,
plugins: ['image'], // 图片插件
images_upload_handler: handleUpload, // 图片上传方法
convert_urls: false, // TinyMCE 默认会将图片的 url 转换为相对路径, 这里禁用 (取得是 img data-mce-src 的值)
}}
/>
)
}

自定义保存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const Index = () => {

/** 自定义保存 */
const handleSave = (editor: TinyMCEEditor) => {
alert(`保存成功 ${editor.getContent()}`);
};

return (
<Editor
...,
init={{
...,
plugins: ['save'], // 保存插件
add_form_submit_trigger: true, // 添加表单提交触发器 ctrl+s
save_onsavecallback: handleSave, // 保存回调
}}
/>
)
}

换行间距过大问题

1
2
3
4
5
6
7
<Editor 
...,
init={{
...,
forced_root_block: 'div', // 强制根节点从 p 变为 div (回车符号, 间距过大问题)
}}
/>

复制粘贴功能

  • 高级插件中有更好的粘贴功能, 不过收费
1
2
3
4
5
6
7
<Editor 
...,
init={{
...,
paste_webkit_styles: true, // 保留所有 webkit 粘贴样式 eg: https://www.tiny.cloud/docs/tinymce/6/copy-and-paste/#paste_webkit_styles
}}
/>

快捷工具栏

1
2
3
4
5
6
7
8
9
<Editor 
...,
init={{
...,
plugins: ["quickbars"], // 快捷工具栏插件
quickbars_insert_toolbar: false, // 禁用快捷插入
quickbars_selection_toolbar: "bold italic underline translation translationMenu", // 快捷选择工具栏
}}
/>
  • 快捷插入

  • 快捷选中

自定义插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
const Index: React.FC<Props> = (props) => {

/**
* 初始化富文本
*
* 自定义组件参考:
* https://blog.csdn.net/byebukesi/article/details/132062864
* https://blog.csdn.net/snans/article/details/100862639
* https://blog.csdn.net/chengcheng9876/article/details/135666372
* 官方文档: https://www.tiny.cloud/docs/tinymce/latest/apis/tinymce.editor.ui.registry/
* 官方文档示例: https://www.tiny.cloud/docs/tinymce/6/custom-toolbarbuttons/
*/
const handleInit = (editor: TinyMCEEditor) => {
let toggleState = ""; // 切换状态

// 注册自定义图标
editor.ui.registry.addIcon(
"translationIcon",
`<svg t="1709617096310" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4825" width="20" height="20">
<path d="M608 416h288c35.36 0 64 28.48 64 64v416c0 35.36-28.48 64-64 64H480c-35.36 0-64-28.48-64-64v-288H128c-35.36 0-64-28.48-64-64V128c0-35.36 28.48-64 64-64h416c35.36 0 64 28.48 64 64v288z m0 64v64c0 35.36-28.48 64-64 64h-64v256.032c0 17.664 14.304 31.968 31.968 31.968H864a31.968 31.968 0 0 0 31.968-31.968V512a31.968 31.968 0 0 0-31.968-31.968H608zM128 159.968V512c0 17.664 14.304 31.968 31.968 31.968H512a31.968 31.968 0 0 0 31.968-31.968V160A31.968 31.968 0 0 0 512.032 128H160A31.968 31.968 0 0 0 128 159.968z m64 244.288V243.36h112.736V176h46.752c6.4 0.928 9.632 1.824 9.632 2.752a10.56 10.56 0 0 1-1.376 4.128c-2.752 7.328-4.128 16.032-4.128 26.112v34.368h119.648v156.768h-50.88v-20.64h-68.768v118.272H306.112v-118.272H238.752v24.768H192z m46.72-122.368v60.48h67.392V281.92H238.752z m185.664 60.48V281.92h-68.768v60.48h68.768z m203.84 488H576L668.128 576h64.64l89.344 254.4h-54.976l-19.264-53.664h-100.384l-19.232 53.632z m33.024-96.256h72.864l-34.368-108.608h-1.376l-37.12 108.608zM896 320h-64a128 128 0 0 0-128-128V128a192 192 0 0 1 192 192zM128 704h64a128 128 0 0 0 128 128v64a192 192 0 0 1-192-192z" fill="#333333" p-id="4826"></path>
</svg>`
);

// 添加自定义按钮 - 单个按钮
editor.ui.registry.addButton("translation", {
text: "翻译", // 按钮文字
// icon: "translationIcon", // 按钮图标
tooltip: "翻译", // 按钮提示
onAction: () => {
editor.insertContent("翻译");
// 如果需要官方的弹框, 可以使用 editor.windowManager.open() 示例: https://www.tiny.cloud/docs/tinymce/6/creating-a-plugin/
// 如果是自定义弹框, 可以使用使用 React 组件 - createPortal
},
});

// 添加自定义按钮 - 按钮组
editor.ui.registry.addMenuButton("translationMenu", {
// text: '翻译', // 按钮文字
icon: "translationIcon", // 按钮图标
tooltip: "翻译", // 按钮提示
fetch: (callback) => {
const items = [
{
type: "togglemenuitem", // ['menuitem' - 普通菜单, 'nestedmenuitem' - 嵌套菜单, 'togglemenuitem' - 切换菜单]
text: "zh",
onAction: () => {
toggleState = "zh";
editor.insertContent("你好!");
},
onSetup: (api) => {
api.setActive("zh" == toggleState);
return () => {};
},
},
{
type: "togglemenuitem",
text: "en",
onAction: () => {
toggleState = "en";
editor.insertContent("Hello!");
},
onSetup: (api) => {
api.setActive("en" == toggleState);
return () => {};
},
},
];
callback(items);
},
});
};

return (
<Editor
...,
init={{
...,
plugins: ["translation", "translationMenu"], // 自定义插件
quickbars_insert_toolbar: false, // 禁用快捷插入
quickbars_selection_toolbar: "bold italic underline translation translationMenu", // 快捷选择工具栏
setup: handleInit,
}}
/>
);
};

React使用Tinymce富文本的两种方式(官方提供)

非受控模式 (ref)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function MyComponent({initialValue}) {
const editorRef = useRef(null);

const [dirty, setDirty] = useState(false);

useEffect(() => setDirty(false), [initialValue]);

const save = () => {
if (editorRef.current) {
const content = editorRef.current.getContent();
setDirty(false);
editorRef.current.setDirty(false);
// an application would save the editor content to the server here
console.log(content);
}
};

return (
<>
<Editor
initialValue={initialValue}
onInit={(evt, editor) => editorRef.current = editor}
onDirty={() => setDirty(true)}
/>
<button onClick={save} disabled={!dirty}>Save</button>
{dirty && <p>You have unsaved content!</p>}
</>
);
}

受控模式 (state)

  • 函数组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function MyComponent({initialValue}) {
    const [value, setValue] = useState(initialValue ?? '');
    useEffect(() => setValue(initialValue ?? ''), [initialValue]);
    return (
    <Editor
    initialValue={initialValue}
    value={value}
    onEditorChange={(newValue, editor) => setValue(newValue)}
    />
    );
    }
  • 类组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    class MyComponent extends React.Component {
    constructor(props) {
    super(props);

    this.state = { value: props.initialValue ?? '' };
    this.handleEditorChange = this.handleEditorChange.bind(this);
    }

    componentDidUpdate(prevProps) {
    if (this.props.initialValue !== prevProps.initialValue) {
    this.setState({ value: this.props.initialValue ?? '' })
    }
    }

    handleEditorChange(value, editor) {
    this.setState({ value });
    }

    render() {
    return (
    <Editor
    initialValue={this.props.initialValue}
    value={this.state.value}
    onEditorChange={this.handleEditorChange}
    />
    )
    }
    }

受控模式下大文本处理方案

问题: 当文本过大时, 会导致页面卡顿, 甚至卡死

思路: 将过长的文本放到 iframe 中, 以减轻页面的压力 使用 ref 的方式

代码地址

功能演示