@groupher/rich-editor 0.0.8 → 0.0.10

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,414 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import type { Point, TElement } from 'platejs';
6
+
7
+ import {
8
+ type ComboboxItemProps,
9
+ Combobox,
10
+ ComboboxGroup,
11
+ ComboboxGroupLabel,
12
+ ComboboxItem,
13
+ ComboboxPopover,
14
+ ComboboxProvider,
15
+ ComboboxRow,
16
+ Portal,
17
+ useComboboxContext,
18
+ useComboboxStore,
19
+ } from '@ariakit/react';
20
+ import { filterWords } from '@platejs/combobox';
21
+ import {
22
+ type UseComboboxInputResult,
23
+ useComboboxInput,
24
+ useHTMLInputCursorState,
25
+ } from '@platejs/combobox/react';
26
+ import { cva } from 'class-variance-authority';
27
+ import { useComposedRef, useEditorRef } from 'platejs/react';
28
+
29
+ import { cn } from '@/lib/utils';
30
+
31
+ type TFilterFn = (
32
+ item: { value: string; group?: string; keywords?: string[]; label?: string },
33
+ search: string
34
+ ) => boolean;
35
+
36
+ type TInlineComboboxContextValue = {
37
+ filter: TFilterFn | false;
38
+ inputProps: UseComboboxInputResult['props'];
39
+ inputRef: React.RefObject<HTMLInputElement | null>;
40
+ removeInput: UseComboboxInputResult['removeInput'];
41
+ showTrigger: boolean;
42
+ trigger: string;
43
+ setHasEmpty: (hasEmpty: boolean) => void;
44
+ };
45
+
46
+ const InlineComboboxContext = React.createContext<TInlineComboboxContextValue>(
47
+ null as unknown as TInlineComboboxContextValue
48
+ );
49
+
50
+ const defaultFilter: TFilterFn = (
51
+ { group, keywords = [], label, value },
52
+ search
53
+ ) => {
54
+ const uniqueTerms = new Set(
55
+ [value, ...keywords, group, label].filter(Boolean)
56
+ );
57
+
58
+ return Array.from(uniqueTerms).some((keyword) =>
59
+ keyword ? filterWords(keyword, search) : false
60
+ );
61
+ };
62
+
63
+ type TInlineComboboxProps = {
64
+ children: React.ReactNode;
65
+ element: TElement;
66
+ trigger: string;
67
+ filter?: TFilterFn | false;
68
+ hideWhenNoValue?: boolean;
69
+ showTrigger?: boolean;
70
+ value?: string;
71
+ setValue?: (value: string) => void;
72
+ };
73
+
74
+ const InlineCombobox = ({
75
+ children,
76
+ element,
77
+ filter = defaultFilter,
78
+ hideWhenNoValue = false,
79
+ setValue: setValueProp,
80
+ showTrigger = true,
81
+ trigger,
82
+ value: valueProp,
83
+ }: TInlineComboboxProps) => {
84
+ const editor = useEditorRef();
85
+ const inputRef = React.useRef<HTMLInputElement>(null);
86
+ const cursorState = useHTMLInputCursorState(inputRef);
87
+
88
+ const [valueState, setValueState] = React.useState('');
89
+ const hasValueProp = valueProp !== undefined;
90
+ const value = hasValueProp ? valueProp : valueState;
91
+
92
+ const setValue = React.useCallback(
93
+ (newValue: string) => {
94
+ setValueProp?.(newValue);
95
+
96
+ if (!hasValueProp) {
97
+ setValueState(newValue);
98
+ }
99
+ },
100
+ [setValueProp, hasValueProp]
101
+ );
102
+
103
+ const insertPoint = React.useRef<Point | null>(null);
104
+
105
+ React.useEffect(() => {
106
+ const path = editor.api.findPath(element);
107
+
108
+ if (!path) return;
109
+
110
+ const point = editor.api.before(path);
111
+
112
+ if (!point) return;
113
+
114
+ const pointRef = editor.api.pointRef(point);
115
+ insertPoint.current = pointRef.current;
116
+
117
+ return () => {
118
+ pointRef.unref();
119
+ };
120
+ }, [editor, element]);
121
+
122
+ const { props: inputProps, removeInput } = useComboboxInput({
123
+ cancelInputOnBlur: true,
124
+ cursorState,
125
+ autoFocus: true,
126
+ ref: inputRef,
127
+ onCancelInput: (cause: string) => {
128
+ if (cause !== 'backspace') {
129
+ editor.tf.insertText(trigger + value, {
130
+ at: insertPoint?.current ?? undefined,
131
+ });
132
+ }
133
+ if (cause === 'arrowLeft' || cause === 'arrowRight') {
134
+ editor.tf.move({
135
+ distance: 1,
136
+ reverse: cause === 'arrowLeft',
137
+ });
138
+ }
139
+ },
140
+ });
141
+
142
+ const [hasEmpty, setHasEmpty] = React.useState(false);
143
+
144
+ const contextValue: TInlineComboboxContextValue = React.useMemo(
145
+ () => ({
146
+ filter,
147
+ inputProps,
148
+ inputRef,
149
+ removeInput,
150
+ setHasEmpty,
151
+ showTrigger,
152
+ trigger,
153
+ }),
154
+ [trigger, showTrigger, filter, inputProps, removeInput]
155
+ );
156
+
157
+ const store = useComboboxStore({
158
+ setValue: (newValue: string) =>
159
+ React.startTransition(() => setValue(newValue)),
160
+ });
161
+
162
+ const items = store.useState('items') as Array<{ id: string }>;
163
+
164
+ React.useEffect(() => {
165
+ if (!items.length) return;
166
+
167
+ if (!store.getState().activeId) {
168
+ store.setActiveId(store.first());
169
+ }
170
+ }, [items, store]);
171
+
172
+ return (
173
+ <span contentEditable={false}>
174
+ <ComboboxProvider
175
+ open={
176
+ (items.length > 0 || hasEmpty) &&
177
+ (!hideWhenNoValue || value.length > 0)
178
+ }
179
+ store={store}
180
+ >
181
+ <InlineComboboxContext.Provider value={contextValue}>
182
+ {children}
183
+ </InlineComboboxContext.Provider>
184
+ </ComboboxProvider>
185
+ </span>
186
+ );
187
+ };
188
+
189
+ const InlineComboboxInput = ({
190
+ className,
191
+ ref: propRef,
192
+ ...props
193
+ }: React.HTMLAttributes<HTMLInputElement> & {
194
+ ref?: React.RefObject<HTMLInputElement | null>;
195
+ }) => {
196
+ const {
197
+ inputProps,
198
+ inputRef: contextRef,
199
+ showTrigger,
200
+ trigger,
201
+ } = React.useContext(InlineComboboxContext);
202
+
203
+ const store = useComboboxContext();
204
+
205
+ if (!store) return null;
206
+ const value = store.useState('value');
207
+
208
+ const ref = useComposedRef(propRef, contextRef);
209
+
210
+ return (
211
+ <>
212
+ {showTrigger && trigger}
213
+
214
+ <span className="relative min-h-[1lh]">
215
+ <span
216
+ className="invisible overflow-hidden text-nowrap"
217
+ aria-hidden="true"
218
+ >
219
+ {value || '\u200B'}
220
+ </span>
221
+
222
+ <Combobox
223
+ ref={ref}
224
+ className={cn(
225
+ 'absolute top-0 left-0 size-full bg-transparent outline-none',
226
+ className
227
+ )}
228
+ value={value}
229
+ autoSelect
230
+ {...inputProps}
231
+ {...props}
232
+ />
233
+ </span>
234
+ </>
235
+ );
236
+ };
237
+
238
+ InlineComboboxInput.displayName = 'InlineComboboxInput';
239
+
240
+ const InlineComboboxContent = ({
241
+ className,
242
+ ...props
243
+ }: React.ComponentProps<typeof ComboboxPopover>) => {
244
+ const store = useComboboxContext();
245
+
246
+ if (!store) return null;
247
+
248
+ function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
249
+ if (!store) return;
250
+
251
+ const state = store.getState();
252
+ const { items, activeId } = state as {
253
+ items: Array<{ id: string }>;
254
+ activeId?: string;
255
+ };
256
+
257
+ if (!items.length) return;
258
+
259
+ const currentIndex = items.findIndex((item) => item.id === activeId);
260
+
261
+ if (event.key === 'ArrowUp' && currentIndex <= 0) {
262
+ event.preventDefault();
263
+ store.setActiveId(store.last());
264
+ } else if (event.key === 'ArrowDown' && currentIndex >= items.length - 1) {
265
+ event.preventDefault();
266
+ store.setActiveId(store.first());
267
+ }
268
+ }
269
+
270
+ return (
271
+ <Portal>
272
+ <ComboboxPopover
273
+ className={cn(
274
+ 'z-50 max-h-[260px] w-[280px] overflow-y-auto rounded-md bg-popover shadow-md',
275
+ className
276
+ )}
277
+ onKeyDownCapture={handleKeyDown}
278
+ {...props}
279
+ />
280
+ </Portal>
281
+ );
282
+ };
283
+
284
+ const comboboxItemVariants = cva(
285
+ 'relative mx-1 flex h-[30px] select-none items-center rounded-sm px-2 text-foreground text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
286
+ {
287
+ defaultVariants: {
288
+ interactive: true,
289
+ },
290
+ variants: {
291
+ interactive: {
292
+ false: '',
293
+ true: 'cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground data-[active-item=true]:bg-accent data-[active-item=true]:text-accent-foreground',
294
+ },
295
+ },
296
+ }
297
+ );
298
+
299
+ const InlineComboboxItem = ({
300
+ className,
301
+ focusEditor = true,
302
+ group,
303
+ keywords,
304
+ label,
305
+ onClick,
306
+ ...props
307
+ }: {
308
+ focusEditor?: boolean;
309
+ group?: string;
310
+ keywords?: string[];
311
+ label?: string;
312
+ } & ComboboxItemProps &
313
+ Required<Pick<ComboboxItemProps, 'value'>>) => {
314
+ const { value } = props;
315
+
316
+ const { filter, removeInput } = React.useContext(InlineComboboxContext);
317
+
318
+ const store = useComboboxContext();
319
+
320
+ if (!store) return null;
321
+
322
+ const search = filter && store.useState('value');
323
+
324
+ const visible = React.useMemo(
325
+ () =>
326
+ !filter || filter({ group, keywords, label, value }, search as string),
327
+ [filter, group, keywords, label, value, search]
328
+ );
329
+
330
+ if (!visible) return null;
331
+
332
+ return (
333
+ <ComboboxItem
334
+ className={cn(comboboxItemVariants(), className)}
335
+ onClick={(event: React.MouseEvent<HTMLDivElement>) => {
336
+ removeInput(focusEditor);
337
+ onClick?.(event);
338
+ }}
339
+ {...props}
340
+ />
341
+ );
342
+ };
343
+
344
+ const InlineComboboxEmpty = ({
345
+ children,
346
+ className,
347
+ }: React.HTMLAttributes<HTMLDivElement>) => {
348
+ const { setHasEmpty } = React.useContext(InlineComboboxContext);
349
+ const store = useComboboxContext();
350
+
351
+ if (!store) return null;
352
+ const items = store.useState('items');
353
+
354
+ React.useEffect(() => {
355
+ setHasEmpty(true);
356
+
357
+ return () => {
358
+ setHasEmpty(false);
359
+ };
360
+ }, [setHasEmpty]);
361
+
362
+ if (items.length > 0) return null;
363
+
364
+ return (
365
+ <div
366
+ className={cn(comboboxItemVariants({ interactive: false }), className)}
367
+ >
368
+ {children}
369
+ </div>
370
+ );
371
+ };
372
+
373
+ const InlineComboboxRow = ComboboxRow;
374
+
375
+ function InlineComboboxGroup({
376
+ className,
377
+ ...props
378
+ }: React.ComponentProps<typeof ComboboxGroup>) {
379
+ return (
380
+ <ComboboxGroup
381
+ {...props}
382
+ className={cn(
383
+ 'hidden not-last:border-b py-1.5 [&:has([role=option])]:block',
384
+ className
385
+ )}
386
+ />
387
+ );
388
+ }
389
+
390
+ function InlineComboboxGroupLabel({
391
+ className,
392
+ ...props
393
+ }: React.ComponentProps<typeof ComboboxGroupLabel>) {
394
+ return (
395
+ <ComboboxGroupLabel
396
+ {...props}
397
+ className={cn(
398
+ 'mt-1.5 mb-2 px-3 font-medium text-muted-foreground text-xs',
399
+ className
400
+ )}
401
+ />
402
+ );
403
+ }
404
+
405
+ export {
406
+ InlineCombobox,
407
+ InlineComboboxContent,
408
+ InlineComboboxEmpty,
409
+ InlineComboboxGroup,
410
+ InlineComboboxGroupLabel,
411
+ InlineComboboxInput,
412
+ InlineComboboxItem,
413
+ InlineComboboxRow,
414
+ };
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import type { TLinkElement } from 'platejs';
4
+ import type { PlateElementProps } from 'platejs/react';
5
+
6
+ import { getLinkAttributes } from '@platejs/link';
7
+ import { PlateElement } from 'platejs/react';
8
+
9
+ import { cn } from '@/lib/utils';
10
+
11
+ export function LinkElement(props: PlateElementProps<TLinkElement>) {
12
+ return (
13
+ <PlateElement
14
+ {...props}
15
+ as="a"
16
+ className={cn(
17
+ 'font-medium text-primary underline decoration-primary underline-offset-4',
18
+ props.className
19
+ )}
20
+ attributes={{
21
+ ...props.attributes,
22
+ ...getLinkAttributes(props.editor, props.element),
23
+ onMouseOver: (event) => {
24
+ event.stopPropagation();
25
+ },
26
+ }}
27
+ >
28
+ {props.children}
29
+ </PlateElement>
30
+ );
31
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import type * as React from 'react';
4
+
5
+ import {
6
+ useLinkToolbarButton,
7
+ useLinkToolbarButtonState,
8
+ } from '@platejs/link/react';
9
+ import { Link } from 'lucide-react';
10
+
11
+ import { useI18n } from '@/i18n';
12
+
13
+ import { ToolbarButton } from './toolbar';
14
+
15
+ export function LinkToolbarButton({
16
+ tooltip,
17
+ ...props
18
+ }: React.ComponentProps<typeof ToolbarButton> & { tooltip?: string }) {
19
+ const state = useLinkToolbarButtonState();
20
+ const { props: buttonProps } = useLinkToolbarButton(state);
21
+ const i18n = useI18n();
22
+
23
+ return (
24
+ <ToolbarButton
25
+ {...props}
26
+ {...buttonProps}
27
+ data-plate-focus
28
+ tooltip={tooltip ?? i18n.toolbar.link}
29
+ >
30
+ <Link />
31
+ </ToolbarButton>
32
+ );
33
+ }
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+
5
+ import type { TComboboxInputElement, TMentionElement } from "platejs";
6
+ import type { PlateElementProps } from "platejs/react";
7
+
8
+ import { getMentionOnSelectItem } from "@platejs/mention";
9
+ import { KEYS } from "platejs";
10
+ import {
11
+ PlateElement,
12
+ useFocused,
13
+ useReadOnly,
14
+ useSelected,
15
+ } from "platejs/react";
16
+
17
+ import { useI18n } from "@/i18n";
18
+ import { cn } from "@/lib/utils";
19
+ import { useMentionContext } from "@/mention-context";
20
+
21
+ import {
22
+ InlineCombobox,
23
+ InlineComboboxContent,
24
+ InlineComboboxEmpty,
25
+ InlineComboboxGroup,
26
+ InlineComboboxInput,
27
+ InlineComboboxItem,
28
+ } from "./inline-combobox";
29
+
30
+ const onSelectItem = getMentionOnSelectItem();
31
+
32
+ export function MentionElement(
33
+ props: PlateElementProps<TMentionElement> & {
34
+ prefix?: string;
35
+ },
36
+ ) {
37
+ const element = props.element;
38
+
39
+ const selected = useSelected();
40
+ const focused = useFocused();
41
+ const readOnly = useReadOnly();
42
+
43
+ return (
44
+ <PlateElement
45
+ {...props}
46
+ className={cn(
47
+ "inline-block rounded-md bg-sky-100 px-1.5 py-0.5 align-baseline font-medium text-sky-900",
48
+ !readOnly && "cursor-pointer",
49
+ selected && focused && "ring-2 ring-ring",
50
+ element.children[0][KEYS.bold] === true && "font-bold",
51
+ element.children[0][KEYS.italic] === true && "italic",
52
+ element.children[0][KEYS.underline] === true && "underline",
53
+ )}
54
+ attributes={{
55
+ ...props.attributes,
56
+ contentEditable: false,
57
+ "data-slate-value": element.value,
58
+ draggable: true,
59
+ }}
60
+ >
61
+ <>
62
+ @{element.value}
63
+ {props.children}
64
+ </>
65
+ </PlateElement>
66
+ );
67
+ }
68
+
69
+ export function MentionInputElement(
70
+ props: PlateElementProps<TComboboxInputElement>,
71
+ ) {
72
+ const { editor, element } = props;
73
+ const [search, setSearch] = useState("");
74
+ const i18n = useI18n();
75
+ const { mentionOptions: mentionablesProp, onMentionSearch } =
76
+ useMentionContext();
77
+
78
+ const mentionOptions = useMemo(
79
+ () => mentionablesProp ?? [],
80
+ [mentionablesProp],
81
+ );
82
+
83
+ useEffect(() => {
84
+ onMentionSearch?.(search);
85
+ }, [onMentionSearch, search]);
86
+
87
+ return (
88
+ <PlateElement {...props} as="span">
89
+ <InlineCombobox
90
+ value={search}
91
+ element={element}
92
+ setValue={setSearch}
93
+ showTrigger={false}
94
+ trigger="@"
95
+ filter={({ value }, query) => {
96
+ if (query.length === 0) return true;
97
+
98
+ return value.toLowerCase().includes(query.toLowerCase());
99
+ }}
100
+ >
101
+ <span className="inline-block rounded-md bg-sky-100 px-1.5 py-0.5 align-baseline text-sm text-sky-900 ring-ring focus-within:ring-2">
102
+ @
103
+ <InlineComboboxInput />
104
+ </span>
105
+
106
+ <InlineComboboxContent className="my-1.5">
107
+ <InlineComboboxEmpty>{i18n.mention.empty}</InlineComboboxEmpty>
108
+
109
+ <InlineComboboxGroup>
110
+ {mentionOptions.map((item) => (
111
+ <InlineComboboxItem
112
+ key={item.key}
113
+ value={item.text}
114
+ onClick={() => onSelectItem(editor, item, search)}
115
+ >
116
+ {item.text}
117
+ </InlineComboboxItem>
118
+ ))}
119
+ </InlineComboboxGroup>
120
+ </InlineComboboxContent>
121
+ </InlineCombobox>
122
+
123
+ {props.children}
124
+ </PlateElement>
125
+ );
126
+ }