@groupher/rich-editor 0.0.8 → 0.0.9

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.
Files changed (36) hide show
  1. package/dist/rich-editor.es.js +31268 -20037
  2. package/dist/rich-editor.umd.js +77 -96
  3. package/package.json +17 -2
  4. package/src/RichEditor.tsx +204 -92
  5. package/src/components/editor/editor-kit.tsx +27 -0
  6. package/src/components/editor/plugins/autoformat-kit.tsx +99 -0
  7. package/src/components/editor/plugins/callout-kit.tsx +7 -0
  8. package/src/components/editor/plugins/emoji-kit.tsx +14 -0
  9. package/src/components/editor/plugins/indent-kit.tsx +12 -0
  10. package/src/components/editor/plugins/link-kit.tsx +13 -0
  11. package/src/components/editor/plugins/list-kit.tsx +17 -0
  12. package/src/components/editor/plugins/mention-kit.tsx +17 -0
  13. package/src/components/editor/plugins/slash-kit.tsx +15 -0
  14. package/src/components/editor/plugins/toggle-kit.tsx +7 -0
  15. package/src/components/ui/action-bar.tsx +208 -0
  16. package/src/components/ui/block-list.tsx +94 -0
  17. package/src/components/ui/button.tsx +49 -50
  18. package/src/components/ui/callout-node.tsx +65 -0
  19. package/src/components/ui/editor-static.tsx +44 -44
  20. package/src/components/ui/editor.tsx +107 -107
  21. package/src/components/ui/emoji-node.tsx +71 -0
  22. package/src/components/ui/emoji-toolbar-button.tsx +618 -0
  23. package/src/components/ui/floating-toolbar.tsx +86 -0
  24. package/src/components/ui/inline-combobox.tsx +414 -0
  25. package/src/components/ui/link-node.tsx +31 -0
  26. package/src/components/ui/link-toolbar-button.tsx +33 -0
  27. package/src/components/ui/mention-node.tsx +126 -0
  28. package/src/components/ui/slash-node.tsx +191 -0
  29. package/src/components/ui/toggle-node.tsx +36 -0
  30. package/src/components/ui/toolbar.tsx +10 -10
  31. package/src/hooks/use-debounce.ts +15 -0
  32. package/src/hooks/use-mounted.ts +11 -0
  33. package/src/i18n.tsx +155 -0
  34. package/src/main.tsx +35 -14
  35. package/src/mention-context.tsx +32 -0
  36. package/src/vite-env.d.ts +7 -0
@@ -0,0 +1,191 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import type { PlateEditor, PlateElementProps } from 'platejs/react';
6
+
7
+ import {
8
+ ChevronRightIcon,
9
+ Heading1Icon,
10
+ Heading2Icon,
11
+ Heading3Icon,
12
+ LightbulbIcon,
13
+ ListIcon,
14
+ ListOrdered,
15
+ PilcrowIcon,
16
+ Quote,
17
+ Square,
18
+ } from 'lucide-react';
19
+ import { type TComboboxInputElement, type TElement, KEYS } from 'platejs';
20
+ import { ListStyleType } from '@platejs/list';
21
+ import { PlateElement } from 'platejs/react';
22
+
23
+ import { useI18n } from '@/i18n';
24
+
25
+ import {
26
+ InlineCombobox,
27
+ InlineComboboxContent,
28
+ InlineComboboxEmpty,
29
+ InlineComboboxGroup,
30
+ InlineComboboxGroupLabel,
31
+ InlineComboboxInput,
32
+ InlineComboboxItem,
33
+ } from './inline-combobox';
34
+
35
+ type TGroupItem = {
36
+ icon: React.ReactNode;
37
+ value: string;
38
+ keywords?: string[];
39
+ label: string;
40
+ };
41
+
42
+ const listStyleMap: Record<string, ListStyleType> = {
43
+ [KEYS.ul]: ListStyleType.Disc,
44
+ [KEYS.ol]: ListStyleType.Decimal,
45
+ [KEYS.listTodo]: 'todo' as ListStyleType,
46
+ };
47
+
48
+ const listTypes = new Set(Object.keys(listStyleMap));
49
+
50
+ const setBlockType = (editor: PlateEditor, type: string) => {
51
+ editor.tf.withoutNormalizing(() => {
52
+ const entries = editor.api.blocks({ mode: 'lowest' });
53
+
54
+ for (const [node, path] of entries) {
55
+ if (listTypes.has(type)) {
56
+ editor.tf.setNodes(
57
+ {
58
+ indent: 1,
59
+ listStyleType: listStyleMap[type],
60
+ },
61
+ { at: path }
62
+ );
63
+ continue;
64
+ }
65
+
66
+ if ((node as TElement)[KEYS.listType]) {
67
+ editor.tf.unsetNodes([KEYS.listType, 'indent'], { at: path });
68
+ }
69
+
70
+ editor.tf.setNodes({ type }, { at: path });
71
+ }
72
+ });
73
+ };
74
+
75
+ export function SlashInputElement(
76
+ props: PlateElementProps<TComboboxInputElement>
77
+ ) {
78
+ const { editor, element } = props;
79
+ const i18n = useI18n();
80
+
81
+ const groups = React.useMemo(
82
+ () =>
83
+ [
84
+ {
85
+ group: i18n.slash.groups.blocks,
86
+ items: [
87
+ {
88
+ icon: <PilcrowIcon />,
89
+ keywords: ['paragraph'],
90
+ label: i18n.slash.items.paragraph,
91
+ value: KEYS.p,
92
+ },
93
+ {
94
+ icon: <Heading1Icon />,
95
+ keywords: ['h1'],
96
+ label: i18n.slash.items.heading1,
97
+ value: KEYS.h1,
98
+ },
99
+ {
100
+ icon: <Heading2Icon />,
101
+ keywords: ['h2'],
102
+ label: i18n.slash.items.heading2,
103
+ value: KEYS.h2,
104
+ },
105
+ {
106
+ icon: <Heading3Icon />,
107
+ keywords: ['h3'],
108
+ label: i18n.slash.items.heading3,
109
+ value: KEYS.h3,
110
+ },
111
+ {
112
+ icon: <ChevronRightIcon />,
113
+ keywords: ['toggle'],
114
+ label: i18n.slash.items.toggle,
115
+ value: KEYS.toggle,
116
+ },
117
+ {
118
+ icon: <LightbulbIcon />,
119
+ keywords: ['callout'],
120
+ label: i18n.slash.items.callout,
121
+ value: KEYS.callout,
122
+ },
123
+ {
124
+ icon: <Quote />,
125
+ keywords: ['quote', 'blockquote'],
126
+ label: i18n.slash.items.blockquote,
127
+ value: KEYS.blockquote,
128
+ },
129
+ ],
130
+ },
131
+ {
132
+ group: i18n.slash.groups.lists,
133
+ items: [
134
+ {
135
+ icon: <ListIcon />,
136
+ keywords: ['unordered', 'ul', '-'],
137
+ label: i18n.slash.items.bulletedList,
138
+ value: KEYS.ul,
139
+ },
140
+ {
141
+ icon: <ListOrdered />,
142
+ keywords: ['ordered', 'ol', '1'],
143
+ label: i18n.slash.items.numberedList,
144
+ value: KEYS.ol,
145
+ },
146
+ {
147
+ icon: <Square />,
148
+ keywords: ['todo', 'task', 'checkbox'],
149
+ label: i18n.slash.items.todoList,
150
+ value: KEYS.listTodo,
151
+ },
152
+ ],
153
+ },
154
+ ] as Array<{ group: string; items: TGroupItem[] }>,
155
+ [i18n]
156
+ );
157
+
158
+ return (
159
+ <PlateElement {...props} as="span">
160
+ <InlineCombobox element={element} trigger="/">
161
+ <InlineComboboxInput />
162
+
163
+ <InlineComboboxContent>
164
+ <InlineComboboxEmpty>{i18n.slash.empty}</InlineComboboxEmpty>
165
+
166
+ {groups.map(({ group, items }) => (
167
+ <InlineComboboxGroup key={group}>
168
+ <InlineComboboxGroupLabel>{group}</InlineComboboxGroupLabel>
169
+
170
+ {items.map(({ icon, keywords, label, value }) => (
171
+ <InlineComboboxItem
172
+ key={value}
173
+ value={value}
174
+ onClick={() => setBlockType(editor, value)}
175
+ label={label}
176
+ group={group}
177
+ keywords={keywords}
178
+ >
179
+ <div className="mr-2 text-muted-foreground">{icon}</div>
180
+ {label}
181
+ </InlineComboboxItem>
182
+ ))}
183
+ </InlineComboboxGroup>
184
+ ))}
185
+ </InlineComboboxContent>
186
+ </InlineCombobox>
187
+
188
+ {props.children}
189
+ </PlateElement>
190
+ );
191
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import type { PlateElementProps } from 'platejs/react';
4
+
5
+ import { useToggleButton, useToggleButtonState } from '@platejs/toggle/react';
6
+ import { ChevronRight } from 'lucide-react';
7
+ import { PlateElement } from 'platejs/react';
8
+
9
+ import { Button } from '@/components/ui/button';
10
+
11
+ export function ToggleElement(props: PlateElementProps) {
12
+ const element = props.element;
13
+ const state = useToggleButtonState(element.id as string);
14
+ const { buttonProps, open } = useToggleButton(state);
15
+
16
+ return (
17
+ <PlateElement {...props} className="pl-6">
18
+ <Button
19
+ size="icon"
20
+ variant="ghost"
21
+ className="-left-0.5 absolute top-0 size-6 cursor-pointer select-none items-center justify-center rounded-md p-px text-muted-foreground transition-colors hover:bg-accent [&_svg]:size-4"
22
+ contentEditable={false}
23
+ {...buttonProps}
24
+ >
25
+ <ChevronRight
26
+ className={
27
+ open
28
+ ? 'rotate-90 transition-transform duration-75'
29
+ : 'rotate-0 transition-transform duration-75'
30
+ }
31
+ />
32
+ </Button>
33
+ {props.children}
34
+ </PlateElement>
35
+ );
36
+ }
@@ -112,7 +112,7 @@ const dropdownArrowVariants = cva(
112
112
  }
113
113
  );
114
114
 
115
- type ToolbarButtonProps = {
115
+ type TToolbarButtonProps = {
116
116
  isDropdown?: boolean;
117
117
  pressed?: boolean;
118
118
  } & Omit<
@@ -129,7 +129,7 @@ export const ToolbarButton = withTooltip(function ToolbarButton({
129
129
  size = 'sm',
130
130
  variant,
131
131
  ...props
132
- }: ToolbarButtonProps) {
132
+ }: TToolbarButtonProps) {
133
133
  return typeof pressed === 'boolean' ? (
134
134
  <ToolbarToggleGroup disabled={props.disabled} value="single" type="single">
135
135
  <ToolbarToggleItem
@@ -190,7 +190,7 @@ export function ToolbarSplitButton({
190
190
  );
191
191
  }
192
192
 
193
- type ToolbarSplitButtonPrimaryProps = Omit<
193
+ type TToolbarSplitButtonPrimaryProps = Omit<
194
194
  React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>,
195
195
  'value'
196
196
  > &
@@ -202,7 +202,7 @@ export function ToolbarSplitButtonPrimary({
202
202
  size = 'sm',
203
203
  variant,
204
204
  ...props
205
- }: ToolbarSplitButtonPrimaryProps) {
205
+ }: TToolbarSplitButtonPrimaryProps) {
206
206
  return (
207
207
  <span
208
208
  className={cn(
@@ -226,10 +226,11 @@ export function ToolbarSplitButtonSecondary({
226
226
  size,
227
227
  variant,
228
228
  ...props
229
- }: React.ComponentPropsWithoutRef<'span'> &
229
+ }: React.ComponentPropsWithoutRef<'button'> &
230
230
  VariantProps<typeof dropdownArrowVariants>) {
231
231
  return (
232
- <span
232
+ <button
233
+ type="button"
233
234
  className={cn(
234
235
  dropdownArrowVariants({
235
236
  size,
@@ -239,11 +240,10 @@ export function ToolbarSplitButtonSecondary({
239
240
  className
240
241
  )}
241
242
  onClick={(e) => e.stopPropagation()}
242
- role="button"
243
243
  {...props}
244
244
  >
245
245
  <ChevronDown className="size-3.5 text-muted-foreground" data-icon />
246
- </span>
246
+ </button>
247
247
  );
248
248
  }
249
249
 
@@ -283,7 +283,7 @@ export function ToolbarGroup({
283
283
  );
284
284
  }
285
285
 
286
- type TooltipProps<T extends React.ElementType> = {
286
+ type TTooltipProps<T extends React.ElementType> = {
287
287
  tooltip?: React.ReactNode;
288
288
  tooltipContentProps?: Omit<
289
289
  React.ComponentPropsWithoutRef<typeof TooltipContent>,
@@ -303,7 +303,7 @@ function withTooltip<T extends React.ElementType>(Component: T) {
303
303
  tooltipProps,
304
304
  tooltipTriggerProps,
305
305
  ...props
306
- }: TooltipProps<T>) {
306
+ }: TTooltipProps<T>) {
307
307
  const [mounted, setMounted] = React.useState(false);
308
308
 
309
309
  React.useEffect(() => {
@@ -0,0 +1,15 @@
1
+ import * as React from 'react';
2
+
3
+ export function useDebounce<T>(value: T, delay = 200) {
4
+ const [debouncedValue, setDebouncedValue] = React.useState(value);
5
+
6
+ React.useEffect(() => {
7
+ const timer = setTimeout(() => {
8
+ setDebouncedValue(value);
9
+ }, delay);
10
+
11
+ return () => clearTimeout(timer);
12
+ }, [delay, value]);
13
+
14
+ return debouncedValue;
15
+ }
@@ -0,0 +1,11 @@
1
+ import * as React from 'react';
2
+
3
+ export function useMounted() {
4
+ const [mounted, setMounted] = React.useState(false);
5
+
6
+ React.useEffect(() => {
7
+ setMounted(true);
8
+ }, []);
9
+
10
+ return mounted;
11
+ }
package/src/i18n.tsx ADDED
@@ -0,0 +1,155 @@
1
+ import * as React from 'react';
2
+
3
+ export type TLocale = 'en' | 'zh-CN';
4
+
5
+ type TI18nStrings = {
6
+ locale: TLocale;
7
+ placeholder: string;
8
+ toolbar: {
9
+ bold: string;
10
+ italic: string;
11
+ underline: string;
12
+ strikethrough: string;
13
+ link: string;
14
+ };
15
+ slash: {
16
+ empty: string;
17
+ groups: {
18
+ blocks: string;
19
+ lists: string;
20
+ };
21
+ items: {
22
+ paragraph: string;
23
+ heading1: string;
24
+ heading2: string;
25
+ heading3: string;
26
+ bulletedList: string;
27
+ numberedList: string;
28
+ todoList: string;
29
+ toggle: string;
30
+ callout: string;
31
+ blockquote: string;
32
+ };
33
+ };
34
+ mention: {
35
+ empty: string;
36
+ label: string;
37
+ };
38
+ export: {
39
+ title: string;
40
+ button: string;
41
+ placeholder: string;
42
+ loadButton: string;
43
+ readonlyTitle: string;
44
+ invalidJson: string;
45
+ };
46
+ };
47
+
48
+ const translations: Record<TLocale, TI18nStrings> = {
49
+ en: {
50
+ locale: 'en',
51
+ placeholder: 'Type your content here...',
52
+ toolbar: {
53
+ bold: 'Bold',
54
+ italic: 'Italic',
55
+ underline: 'Underline',
56
+ strikethrough: 'Strikethrough',
57
+ link: 'Link',
58
+ },
59
+ slash: {
60
+ empty: 'No results',
61
+ groups: {
62
+ blocks: 'Blocks',
63
+ lists: 'Lists',
64
+ },
65
+ items: {
66
+ paragraph: 'Text',
67
+ heading1: 'Heading 1',
68
+ heading2: 'Heading 2',
69
+ heading3: 'Heading 3',
70
+ bulletedList: 'Bulleted list',
71
+ numberedList: 'Numbered list',
72
+ todoList: 'To-do list',
73
+ toggle: 'Toggle',
74
+ callout: 'Callout',
75
+ blockquote: 'Blockquote',
76
+ },
77
+ },
78
+ mention: {
79
+ empty: 'No matches',
80
+ label: 'Mention',
81
+ },
82
+ export: {
83
+ title: 'Export JSON',
84
+ button: 'Export',
85
+ placeholder: 'Paste exported JSON here to preview read-only.',
86
+ loadButton: 'Render Read-only',
87
+ readonlyTitle: 'Read-only Preview',
88
+ invalidJson: 'Invalid JSON, please check formatting.',
89
+ },
90
+ },
91
+ 'zh-CN': {
92
+ locale: 'zh-CN',
93
+ placeholder: '输入内容…',
94
+ toolbar: {
95
+ bold: '加粗',
96
+ italic: '斜体',
97
+ underline: '下划线',
98
+ strikethrough: '删除线',
99
+ link: '链接',
100
+ },
101
+ slash: {
102
+ empty: '没有匹配结果',
103
+ groups: {
104
+ blocks: '块级',
105
+ lists: '列表',
106
+ },
107
+ items: {
108
+ paragraph: '文本',
109
+ heading1: '标题 1',
110
+ heading2: '标题 2',
111
+ heading3: '标题 3',
112
+ bulletedList: '无序列表',
113
+ numberedList: '有序列表',
114
+ todoList: '待办列表',
115
+ toggle: '折叠',
116
+ callout: '提示块',
117
+ blockquote: '引用',
118
+ },
119
+ },
120
+ mention: {
121
+ empty: '没有匹配用户',
122
+ label: '提及',
123
+ },
124
+ export: {
125
+ title: '导出 JSON',
126
+ button: '导出',
127
+ placeholder: '粘贴导出的 JSON 以预览只读内容。',
128
+ loadButton: '渲染只读',
129
+ readonlyTitle: '只读预览',
130
+ invalidJson: 'JSON 格式错误,请检查。',
131
+ },
132
+ },
133
+ };
134
+
135
+ export const defaultLocale: TLocale = 'zh-CN';
136
+
137
+ const I18nContext = React.createContext<TI18nStrings>(
138
+ translations[defaultLocale]
139
+ );
140
+
141
+ export function I18nProvider({
142
+ children,
143
+ locale = defaultLocale,
144
+ }: {
145
+ children: React.ReactNode;
146
+ locale?: TLocale;
147
+ }) {
148
+ const value = translations[locale] ?? translations[defaultLocale];
149
+
150
+ return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
151
+ }
152
+
153
+ export function useI18n() {
154
+ return React.useContext(I18nContext);
155
+ }
package/src/main.tsx CHANGED
@@ -1,14 +1,35 @@
1
- import { StrictMode } from 'react'
2
- import { createRoot } from 'react-dom/client'
3
-
4
- import './global.css'
5
- import RE from '@groupher/rich-editor'
6
- import RichEditor from './RichEditor.tsx'
7
-
8
- createRoot(document.getElementById('root')!).render(
9
- <StrictMode>
10
- <RichEditor />
11
- <hr/>
12
- <RE />
13
- </StrictMode>,
14
- )
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+
4
+ import "./global.css";
5
+ import RE from "@groupher/rich-editor";
6
+ import RichEditor from "./RichEditor.tsx";
7
+
8
+ const MENTION_OPTIONS = [
9
+ {
10
+ key: "0",
11
+ text: "Alice",
12
+ },
13
+ {
14
+ key: "1",
15
+ text: "Bob",
16
+ },
17
+ {
18
+ key: "2",
19
+ text: "Simon",
20
+ },
21
+ ];
22
+
23
+ const rootElement = document.getElementById("root");
24
+
25
+ if (!rootElement) {
26
+ throw new Error("Root element not found");
27
+ }
28
+
29
+ createRoot(rootElement).render(
30
+ <StrictMode>
31
+ <RichEditor mentionOptions={MENTION_OPTIONS} />
32
+ <hr />
33
+ <RE />
34
+ </StrictMode>,
35
+ );
@@ -0,0 +1,32 @@
1
+ import * as React from "react";
2
+
3
+ export type TMentionOption = {
4
+ key: string;
5
+ text: string;
6
+ };
7
+
8
+ type TMentionContextValue = {
9
+ mentionOptions?: TMentionOption[];
10
+ onMentionSearch?: (query: string) => void;
11
+ };
12
+
13
+ const MentionContext = React.createContext<TMentionContextValue>({});
14
+
15
+ export function MentionProvider({
16
+ children,
17
+ mentionOptions,
18
+ onMentionSearch,
19
+ }: React.PropsWithChildren<TMentionContextValue>) {
20
+ const value = React.useMemo(
21
+ () => ({ mentionOptions, onMentionSearch }),
22
+ [mentionOptions, onMentionSearch],
23
+ );
24
+
25
+ return (
26
+ <MentionContext.Provider value={value}>{children}</MentionContext.Provider>
27
+ );
28
+ }
29
+
30
+ export function useMentionContext() {
31
+ return React.useContext(MentionContext);
32
+ }
package/src/vite-env.d.ts CHANGED
@@ -1 +1,8 @@
1
1
  /// <reference types="vite/client" />
2
+
3
+ declare module "@groupher/rich-editor" {
4
+ import type * as React from "react";
5
+
6
+ const RichEditor: React.ComponentType<Record<string, never>>;
7
+ export default RichEditor;
8
+ }