@meta-1/editor 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # @meta-1/editor
2
+
3
+ 一个基于 [Tiptap](https://tiptap.dev) 构建的现代化富文本编辑器,提供类似 Notion 的编辑体验。
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@meta-1/editor.svg)](https://www.npmjs.com/package/@meta-1/editor)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ## ✨ 特性
9
+
10
+ - 🎨 **类 Notion 编辑体验** - 支持斜杠命令、拖拽排序、浮动工具栏
11
+ - 📝 **丰富的内容格式** - 标题、列表、引用、代码块、表格等
12
+ - 🖼️ **图片上传** - 内置图片上传组件,支持自定义上传函数
13
+ - 📊 **表格编辑** - 完整的表格支持,包括合并单元格、调整大小
14
+ - 🔢 **数学公式** - 支持 LaTeX 数学公式渲染
15
+ - 🎯 **多种输出格式** - 支持 JSON、HTML、Markdown 输出
16
+ - 🌐 **国际化** - 内置中文简体、中文繁体、英文语言包
17
+ - 🔄 **协作编辑** - 基于 Yjs 的实时协作支持
18
+
19
+ ## 📦 安装
20
+
21
+ ```bash
22
+ pnpm add @meta-1/editor
23
+ # 或
24
+ npm install @meta-1/editor
25
+ # 或
26
+ yarn add @meta-1/editor
27
+ ```
28
+
29
+ ### Peer Dependencies
30
+
31
+ ```bash
32
+ pnpm add react-i18next i18next
33
+ ```
34
+
35
+ ## 🚀 快速开始
36
+
37
+ ### 基础用法
38
+
39
+ ```tsx
40
+ import { Meta1Editor } from "@meta-1/editor";
41
+ import { useState } from "react";
42
+
43
+ function App() {
44
+ const [content, setContent] = useState({});
45
+
46
+ return (
47
+ <Meta1Editor
48
+ value={content}
49
+ onChange={setContent}
50
+ placeholder="输入内容..."
51
+ />
52
+ );
53
+ }
54
+ ```
55
+
56
+ ### 使用 Editor 实例
57
+
58
+ ```tsx
59
+ import { Meta1Editor } from "@meta-1/editor";
60
+ import { useState } from "react";
61
+
62
+ function App() {
63
+ const editor = Meta1Editor.useEditor();
64
+ const [content, setContent] = useState({});
65
+
66
+ const handleSave = () => {
67
+ const json = editor.getJSON();
68
+ const html = editor.getHTML();
69
+ const markdown = editor.getMarkdown();
70
+ console.log({ json, html, markdown });
71
+ };
72
+
73
+ return (
74
+ <>
75
+ <Meta1Editor
76
+ editor={editor}
77
+ value={content}
78
+ onChange={setContent}
79
+ placeholder="输入内容..."
80
+ />
81
+ <button onClick={handleSave}>保存</button>
82
+ </>
83
+ );
84
+ }
85
+ ```
86
+
87
+ ### 配置图片上传
88
+
89
+ ```tsx
90
+ import { Meta1Editor } from "@meta-1/editor";
91
+
92
+ function App() {
93
+ return (
94
+ <Meta1Editor
95
+ imageUpload={{
96
+ upload: async (file) => {
97
+ const formData = new FormData();
98
+ formData.append("file", file);
99
+ const response = await fetch("/api/upload", {
100
+ method: "POST",
101
+ body: formData,
102
+ });
103
+ const { url } = await response.json();
104
+ return url;
105
+ },
106
+ accept: "image/*",
107
+ maxSize: 5 * 1024 * 1024, // 5MB
108
+ limit: 3,
109
+ onError: (error) => console.error("上传失败:", error),
110
+ onSuccess: (url) => console.log("上传成功:", url),
111
+ }}
112
+ />
113
+ );
114
+ }
115
+ ```
116
+
117
+ ### 设置内容类型
118
+
119
+ ```tsx
120
+ // JSON 格式 (默认)
121
+ <Meta1Editor contentType="json" />
122
+
123
+ // HTML 格式
124
+ <Meta1Editor contentType="html" />
125
+
126
+ // Markdown 格式
127
+ <Meta1Editor contentType="markdown" />
128
+ ```
129
+
130
+ ## 📖 API
131
+
132
+ ### Meta1Editor Props
133
+
134
+ | 属性 | 类型 | 默认值 | 说明 |
135
+ |------|------|--------|------|
136
+ | `value` | `string \| Record<string, unknown>` | - | 编辑器内容 |
137
+ | `onChange` | `(content: Meta1EditorValue) => void` | - | 内容变化回调 |
138
+ | `editor` | `Meta1EditorInstance` | - | 编辑器实例 |
139
+ | `placeholder` | `string` | - | 占位符文本 |
140
+ | `contentType` | `'json' \| 'html' \| 'markdown'` | `'json'` | 内容格式 |
141
+ | `editable` | `boolean` | `true` | 是否可编辑 |
142
+ | `imageUpload` | `ImageUploadOptions` | - | 图片上传配置 |
143
+ | `nodeExclude` | `string[]` | - | 输出时排除的节点类型 |
144
+ | `extensions` | `Extensions` | - | 额外的 Tiptap 扩展 |
145
+ | `className` | `string` | - | 自定义 CSS 类名 |
146
+
147
+ ### Meta1EditorInstance 方法
148
+
149
+ | 方法 | 返回类型 | 说明 |
150
+ |------|----------|------|
151
+ | `getEditor()` | `Editor \| null` | 获取底层 Tiptap Editor 实例 |
152
+ | `getJSON()` | `Record<string, unknown> \| null` | 获取 JSON 格式内容 |
153
+ | `getHTML()` | `string \| null` | 获取 HTML 格式内容 |
154
+ | `getMarkdown()` | `string \| null` | 获取 Markdown 格式内容 |
155
+ | `focus()` | `void` | 聚焦编辑器 |
156
+ | `isEmpty()` | `boolean` | 检查编辑器是否为空 |
157
+
158
+ ### ImageUploadOptions
159
+
160
+ | 属性 | 类型 | 默认值 | 说明 |
161
+ |------|------|--------|------|
162
+ | `upload` | `(file: File) => Promise<string>` | - | 上传函数,返回图片 URL |
163
+ | `accept` | `string` | `'image/*'` | 接受的文件类型 |
164
+ | `maxSize` | `number` | `10MB` | 最大文件大小 (bytes) |
165
+ | `limit` | `number` | `3` | 最大同时上传数量 |
166
+ | `onError` | `(error: Error) => void` | - | 上传错误回调 |
167
+ | `onSuccess` | `(url: string) => void` | - | 上传成功回调 |
168
+
169
+ ## 🌐 国际化
170
+
171
+ 编辑器内置多语言支持,可通过以下方式导入语言包:
172
+
173
+ ```tsx
174
+ import zhCN from "@meta-1/editor/locales/zh-CN";
175
+ import zhTW from "@meta-1/editor/locales/zh-TW";
176
+ import en from "@meta-1/editor/locales/en";
177
+
178
+ // 配置 i18next
179
+ i18n.init({
180
+ resources: {
181
+ "zh-CN": { editor: zhCN },
182
+ "zh-TW": { editor: zhTW },
183
+ en: { editor: en },
184
+ },
185
+ });
186
+ ```
187
+
188
+ ## 🎨 内置功能
189
+
190
+ ### 文本格式
191
+ - 粗体、斜体、下划线、删除线
192
+ - 文字颜色、背景高亮
193
+ - 上标、下标
194
+ - 行内代码
195
+
196
+ ### 块级元素
197
+ - 标题 (H1-H6)
198
+ - 段落
199
+ - 有序列表、无序列表、任务列表
200
+ - 引用块
201
+ - 代码块
202
+ - 表格
203
+ - 分割线
204
+ - 图片
205
+
206
+ ### 高级功能
207
+ - 斜杠命令菜单 (`/`)
208
+ - @ 提及功能
209
+ - 拖拽排序
210
+ - 浮动工具栏
211
+ - 数学公式 (LaTeX)
212
+ - 文本对齐
213
+ - 节点唯一 ID
214
+
215
+ ## 📄 License
216
+
217
+ [MIT](https://opensource.org/licenses/MIT)
218
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meta-1/editor",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "keywords": [
5
5
  "meta",
6
6
  "tailwindcss",
@@ -8,26 +8,21 @@
8
8
  @apply relative;
9
9
  color: inherit;
10
10
  font-style: inherit;
11
-
12
- &:first-child,
13
- &:first-of-type {
14
- @apply mt-0;
15
- }
16
11
  }
17
12
 
18
13
  h1 {
19
- @apply text-2xl font-bold mt-12;
14
+ @apply text-2xl font-bold;
20
15
  }
21
16
 
22
17
  h2 {
23
- @apply text-xl font-bold mt-10;
18
+ @apply text-xl font-bold;
24
19
  }
25
20
 
26
21
  h3 {
27
- @apply text-lg font-semibold mt-8;
22
+ @apply text-lg font-semibold;
28
23
  }
29
24
 
30
25
  h4 {
31
- @apply text-base font-semibold mt-8;
26
+ @apply text-base font-semibold;
32
27
  }
33
28
  }
@@ -69,34 +69,24 @@ export function EditorContentArea() {
69
69
  export type Meta1EditorProps = Meta1EditorOptions & {
70
70
  /** Editor instance created by Meta1Editor.useEditor() */
71
71
  editor?: Meta1EditorInstance;
72
- /** Controlled value - works with FormItem */
73
- value?: Meta1EditorValue;
74
- /** onChange callback - works with FormItem */
75
- onChange?: (content: Meta1EditorValue) => void;
76
72
  };
77
73
 
78
74
  const Meta1EditorInner: FC<Meta1EditorProps> = (props) => {
79
- const { editor: editorInstance, value, onChange, ...options } = props;
75
+ const { editor: editorInstance, ...options } = props;
80
76
 
81
77
  // Create the actual tiptap editor with value/onChange support
82
- const tiptapEditor = useMeta1Editor({
83
- ...options,
84
- value,
85
- onChange,
86
- });
78
+ const tiptapEditor = useMeta1Editor(options);
87
79
 
88
80
  // Update the instance reference when editor changes
89
81
  useEffect(() => {
90
82
  if (editorInstance?._setEditor) {
91
83
  editorInstance._setEditor(tiptapEditor);
92
- if (options.contentType) {
93
- editorInstance._contentType = options.contentType;
94
- }
84
+ editorInstance._nodeExclude = options.nodeExclude;
95
85
  }
96
86
  return () => {
97
87
  editorInstance?._setEditor?.(null);
98
88
  };
99
- }, [tiptapEditor, editorInstance, options.contentType]);
89
+ }, [tiptapEditor, editorInstance, options.nodeExclude]);
100
90
 
101
91
  if (!tiptapEditor) {
102
92
  return null;
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef } from "react";
1
+ import { useCallback, useRef } from "react";
2
2
  import { Highlight } from "@tiptap/extension-highlight";
3
3
  import { TaskItem, TaskList } from "@tiptap/extension-list";
4
4
  import { Mathematics } from "@tiptap/extension-mathematics";
@@ -46,6 +46,10 @@ export type Meta1EditorOptions = {
46
46
  contentType?: ContentType;
47
47
  editable?: boolean;
48
48
  imageUpload?: ImageUploadOptions;
49
+ value?: Meta1EditorValue;
50
+ onChange?: (content: Meta1EditorValue) => void;
51
+ /** Node types to exclude from output (e.g., ['imageUpload']) */
52
+ nodeExclude?: string[];
49
53
  };
50
54
 
51
55
  /**
@@ -54,13 +58,13 @@ export type Meta1EditorOptions = {
54
58
  export interface Meta1EditorInstance {
55
59
  /** Internal: set the actual editor reference */
56
60
  _setEditor?: (editor: Editor | null) => void;
57
- /** Internal: set contentType for value conversion */
58
- _contentType?: ContentType;
61
+ /** Internal: set nodeExclude for filtering */
62
+ _nodeExclude?: string[];
59
63
  /** Get the underlying tiptap Editor instance */
60
64
  getEditor: () => Editor | null;
61
- /** Get current content as JSON */
65
+ /** Get current content as JSON (with nodeExclude filtering applied) */
62
66
  getJSON: () => Record<string, unknown> | null;
63
- /** Get current content as HTML */
67
+ /** Get current content as HTML (with nodeExclude filtering applied) */
64
68
  getHTML: () => string | null;
65
69
  /** Get current content as Markdown */
66
70
  getMarkdown: () => string | null;
@@ -70,13 +74,63 @@ export interface Meta1EditorInstance {
70
74
  isEmpty: () => boolean;
71
75
  }
72
76
 
73
- const getValueFromEditor = (editor: Editor, contentType: ContentType): Meta1EditorValue => {
77
+ /**
78
+ * Filter out excluded nodes from JSON content recursively
79
+ */
80
+ const filterJsonNodes = (json: Record<string, unknown>, excludeTypes: string[]): Record<string, unknown> | null => {
81
+ if (excludeTypes.includes(json.type as string)) {
82
+ return null;
83
+ }
84
+
85
+ if (Array.isArray(json.content)) {
86
+ const filteredContent = json.content
87
+ .map((node) => filterJsonNodes(node as Record<string, unknown>, excludeTypes))
88
+ .filter((node): node is Record<string, unknown> => node !== null);
89
+
90
+ return {
91
+ ...json,
92
+ content: filteredContent,
93
+ };
94
+ }
95
+
96
+ return json;
97
+ };
98
+
99
+ /**
100
+ * Filter out excluded nodes from HTML content
101
+ */
102
+ const filterHtmlNodes = (html: string, excludeTypes: string[]): string => {
103
+ // Create patterns for each excluded type
104
+ // imageUpload renders as: <div data-type="image-upload" ...></div>
105
+ let result = html;
106
+ for (const type of excludeTypes) {
107
+ // Convert camelCase to kebab-case for data-type attribute
108
+ const kebabType = type.replace(/([A-Z])/g, "-$1").toLowerCase();
109
+ // Match self-closing or content divs with data-type
110
+ const pattern = new RegExp(`<div[^>]*data-type="${kebabType}"[^>]*>.*?</div>`, "gi");
111
+ result = result.replace(pattern, "");
112
+ }
113
+ return result;
114
+ };
115
+
116
+ const getValueFromEditor = (editor: Editor, contentType: ContentType, nodeExclude?: string[]): Meta1EditorValue => {
74
117
  switch (contentType) {
75
- case "json":
76
- return editor.getJSON();
77
- case "html":
78
- return editor.getHTML();
118
+ case "json": {
119
+ const json = editor.getJSON();
120
+ if (nodeExclude?.length) {
121
+ return filterJsonNodes(json, nodeExclude) ?? { type: "doc", content: [] };
122
+ }
123
+ return json;
124
+ }
125
+ case "html": {
126
+ const html = editor.getHTML();
127
+ if (nodeExclude?.length) {
128
+ return filterHtmlNodes(html, nodeExclude);
129
+ }
130
+ return html;
131
+ }
79
132
  case "markdown":
133
+ // Markdown doesn't output unsupported nodes, so no filtering needed
80
134
  return editor.getMarkdown();
81
135
  default:
82
136
  return JSON.stringify(editor.getJSON());
@@ -99,6 +153,7 @@ const getValueFromEditor = (editor: Editor, contentType: ContentType): Meta1Edit
99
153
  export const useEditorInstance = (): Meta1EditorInstance => {
100
154
  const editorRef = useRef<Editor | null>(null);
101
155
  const instanceRef = useRef<Meta1EditorInstance | null>(null);
156
+ const nodeExcludeRef = useRef<string[]>([]);
102
157
 
103
158
  const _setEditor = useCallback((editor: Editor | null) => {
104
159
  editorRef.current = editor;
@@ -107,10 +162,29 @@ export const useEditorInstance = (): Meta1EditorInstance => {
107
162
  if (!instanceRef.current) {
108
163
  instanceRef.current = {
109
164
  _setEditor,
110
- _contentType: "json",
165
+ get _nodeExclude() {
166
+ return nodeExcludeRef.current;
167
+ },
168
+ set _nodeExclude(value: string[] | undefined) {
169
+ nodeExcludeRef.current = value ?? [];
170
+ },
111
171
  getEditor: () => editorRef.current,
112
- getJSON: () => editorRef.current?.getJSON() ?? null,
113
- getHTML: () => editorRef.current?.getHTML() ?? null,
172
+ getJSON: () => {
173
+ if (!editorRef.current) return null;
174
+ const json = editorRef.current.getJSON();
175
+ if (nodeExcludeRef.current.length) {
176
+ return filterJsonNodes(json, nodeExcludeRef.current) ?? { type: "doc", content: [] };
177
+ }
178
+ return json;
179
+ },
180
+ getHTML: () => {
181
+ if (!editorRef.current) return null;
182
+ const html = editorRef.current.getHTML();
183
+ if (nodeExcludeRef.current.length) {
184
+ return filterHtmlNodes(html, nodeExcludeRef.current);
185
+ }
186
+ return html;
187
+ },
114
188
  getMarkdown: () => editorRef.current?.getMarkdown() ?? null,
115
189
  focus: () => editorRef.current?.commands.focus(),
116
190
  isEmpty: () => editorRef.current?.isEmpty ?? true,
@@ -120,10 +194,7 @@ export const useEditorInstance = (): Meta1EditorInstance => {
120
194
  return instanceRef.current;
121
195
  };
122
196
 
123
- export type UseMeta1EditorProps = Meta1EditorOptions & {
124
- value?: Meta1EditorValue;
125
- onChange?: (content: Meta1EditorValue) => void;
126
- };
197
+ export type UseMeta1EditorProps = Meta1EditorOptions;
127
198
 
128
199
  /**
129
200
  * Internal hook that creates the actual tiptap editor.
@@ -139,6 +210,7 @@ export const useMeta1Editor = (props: UseMeta1EditorProps) => {
139
210
  contentType = "json",
140
211
  editable = true,
141
212
  imageUpload,
213
+ nodeExclude,
142
214
  } = props;
143
215
  const isUpdatingFromPropsRef = useRef(false);
144
216
 
@@ -148,7 +220,7 @@ export const useMeta1Editor = (props: UseMeta1EditorProps) => {
148
220
  editable,
149
221
  onUpdate: ({ editor }) => {
150
222
  if (!isUpdatingFromPropsRef.current) {
151
- onChange?.(getValueFromEditor(editor, contentType));
223
+ onChange?.(getValueFromEditor(editor, contentType, nodeExclude));
152
224
  }
153
225
  },
154
226
  editorProps: {
@@ -210,28 +282,5 @@ export const useMeta1Editor = (props: UseMeta1EditorProps) => {
210
282
  immediatelyRender: false,
211
283
  });
212
284
 
213
- // Sync value from props to editor
214
- useEffect(() => {
215
- if (!editor || value === undefined) {
216
- return;
217
- }
218
-
219
- const currentContent = getValueFromEditor(editor, contentType);
220
- const isContentEqual =
221
- typeof value === "string" && typeof currentContent === "string"
222
- ? value === currentContent
223
- : JSON.stringify(value) === JSON.stringify(currentContent);
224
-
225
- if (!isContentEqual) {
226
- queueMicrotask(() => {
227
- isUpdatingFromPropsRef.current = true;
228
- editor.commands.setContent(value);
229
- requestAnimationFrame(() => {
230
- isUpdatingFromPropsRef.current = false;
231
- });
232
- });
233
- }
234
- }, [editor, value, contentType]);
235
-
236
285
  return editor;
237
286
  };