@meta-1/editor 0.0.27

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 (130) hide show
  1. package/README.md +458 -0
  2. package/package.json +100 -0
  3. package/src/editor/constants.tsx +66 -0
  4. package/src/editor/container.css +46 -0
  5. package/src/editor/control/character-count/index.tsx +39 -0
  6. package/src/editor/control/drag-handle/index.tsx +85 -0
  7. package/src/editor/control/drag-handle/use.content.actions.ts +71 -0
  8. package/src/editor/control/drag-handle/use.data.ts +29 -0
  9. package/src/editor/control/drag-handle/use.handle.id.ts +6 -0
  10. package/src/editor/control/index.tsx +35 -0
  11. package/src/editor/editor.css +626 -0
  12. package/src/editor/extension/block-quote-figure/BlockquoteFigure.ts +73 -0
  13. package/src/editor/extension/block-quote-figure/Quote/Quote.ts +31 -0
  14. package/src/editor/extension/block-quote-figure/Quote/index.ts +1 -0
  15. package/src/editor/extension/block-quote-figure/QuoteCaption/QuoteCaption.ts +54 -0
  16. package/src/editor/extension/block-quote-figure/QuoteCaption/index.ts +1 -0
  17. package/src/editor/extension/block-quote-figure/index.ts +1 -0
  18. package/src/editor/extension/document/index.ts +5 -0
  19. package/src/editor/extension/figcaption/Figcaption.ts +90 -0
  20. package/src/editor/extension/figcaption/index.ts +1 -0
  21. package/src/editor/extension/figure/Figure.ts +62 -0
  22. package/src/editor/extension/figure/index.ts +1 -0
  23. package/src/editor/extension/font-size/FontSize.ts +64 -0
  24. package/src/editor/extension/font-size/index.ts +1 -0
  25. package/src/editor/extension/global-drag-handle/clipboard-serializer.ts +28 -0
  26. package/src/editor/extension/global-drag-handle/index.ts +377 -0
  27. package/src/editor/extension/heading/index.ts +13 -0
  28. package/src/editor/extension/horizontal-rule/HorizontalRule.ts +10 -0
  29. package/src/editor/extension/horizontal-rule/index.ts +1 -0
  30. package/src/editor/extension/image/index.ts +5 -0
  31. package/src/editor/extension/image-block/ImageBlock.ts +103 -0
  32. package/src/editor/extension/image-block/components/ImageBlockMenu.tsx +100 -0
  33. package/src/editor/extension/image-block/components/ImageBlockView.tsx +47 -0
  34. package/src/editor/extension/image-block/components/ImageBlockWidth.tsx +40 -0
  35. package/src/editor/extension/image-block/index.ts +1 -0
  36. package/src/editor/extension/image-upload/ImageUpload.ts +58 -0
  37. package/src/editor/extension/image-upload/index.ts +1 -0
  38. package/src/editor/extension/image-upload/view/ImageUpload.tsx +27 -0
  39. package/src/editor/extension/image-upload/view/ImageUploader.tsx +64 -0
  40. package/src/editor/extension/image-upload/view/hooks.ts +109 -0
  41. package/src/editor/extension/image-upload/view/index.tsx +1 -0
  42. package/src/editor/extension/index.ts +30 -0
  43. package/src/editor/extension/link/Link.ts +39 -0
  44. package/src/editor/extension/link/index.ts +1 -0
  45. package/src/editor/extension/multi-column/Column.ts +33 -0
  46. package/src/editor/extension/multi-column/Columns.ts +65 -0
  47. package/src/editor/extension/multi-column/index.ts +2 -0
  48. package/src/editor/extension/multi-column/menus/ColumnsMenu.tsx +82 -0
  49. package/src/editor/extension/multi-column/menus/index.ts +1 -0
  50. package/src/editor/extension/selection/Selection.ts +36 -0
  51. package/src/editor/extension/selection/index.ts +1 -0
  52. package/src/editor/extension/slash-command/MenuList.tsx +145 -0
  53. package/src/editor/extension/slash-command/groups.ts +153 -0
  54. package/src/editor/extension/slash-command/index.ts +277 -0
  55. package/src/editor/extension/slash-command/types.ts +25 -0
  56. package/src/editor/extension/table/Cell.ts +126 -0
  57. package/src/editor/extension/table/Header.ts +89 -0
  58. package/src/editor/extension/table/Row.ts +8 -0
  59. package/src/editor/extension/table/Table.ts +9 -0
  60. package/src/editor/extension/table/index.ts +4 -0
  61. package/src/editor/extension/table/menus/TableColumn/index.tsx +73 -0
  62. package/src/editor/extension/table/menus/TableColumn/utils.ts +38 -0
  63. package/src/editor/extension/table/menus/TableRow/index.tsx +74 -0
  64. package/src/editor/extension/table/menus/TableRow/utils.ts +38 -0
  65. package/src/editor/extension/table/menus/index.tsx +2 -0
  66. package/src/editor/extension/table/utils.ts +258 -0
  67. package/src/editor/extension/task-item/index.ts +1 -0
  68. package/src/editor/extension/task-item/task-item.ts +225 -0
  69. package/src/editor/extension/task-list/index.ts +1 -0
  70. package/src/editor/extension/task-list/task-list.ts +81 -0
  71. package/src/editor/extension/trailing-node/index.ts +1 -0
  72. package/src/editor/extension/trailing-node/trailing-node.ts +70 -0
  73. package/src/editor/extension/unique-id/index.ts +1 -0
  74. package/src/editor/extension/unique-id/uniqueId.ts +123 -0
  75. package/src/editor/hooks.ts +264 -0
  76. package/src/editor/index.tsx +53 -0
  77. package/src/editor/menus/LinkMenu/LinkMenu.tsx +75 -0
  78. package/src/editor/menus/LinkMenu/index.tsx +1 -0
  79. package/src/editor/menus/TextMenu/TextMenu.tsx +193 -0
  80. package/src/editor/menus/TextMenu/components/AIDropdown.tsx +140 -0
  81. package/src/editor/menus/TextMenu/components/ContentTypePicker.tsx +76 -0
  82. package/src/editor/menus/TextMenu/components/EditLinkPopover.tsx +25 -0
  83. package/src/editor/menus/TextMenu/components/FontFamilyPicker.tsx +84 -0
  84. package/src/editor/menus/TextMenu/components/FontSizePicker.tsx +56 -0
  85. package/src/editor/menus/TextMenu/hooks/useTextmenuCommands.ts +96 -0
  86. package/src/editor/menus/TextMenu/hooks/useTextmenuContentTypes.ts +86 -0
  87. package/src/editor/menus/TextMenu/hooks/useTextmenuStates.ts +50 -0
  88. package/src/editor/menus/TextMenu/index.tsx +2 -0
  89. package/src/editor/menus/types.ts +21 -0
  90. package/src/editor/panels/Colorpicker/ColorButton.tsx +35 -0
  91. package/src/editor/panels/Colorpicker/Colorpicker.tsx +67 -0
  92. package/src/editor/panels/Colorpicker/index.tsx +2 -0
  93. package/src/editor/panels/LinkEditorPanel/LinkEditorPanel.tsx +76 -0
  94. package/src/editor/panels/LinkEditorPanel/index.tsx +1 -0
  95. package/src/editor/panels/LinkPreviewPanel/LinkPreviewPanel.tsx +32 -0
  96. package/src/editor/panels/LinkPreviewPanel/index.tsx +1 -0
  97. package/src/editor/panels/index.tsx +3 -0
  98. package/src/editor/types.tsx +38 -0
  99. package/src/editor/ui/Button/Button.tsx +70 -0
  100. package/src/editor/ui/Button/index.tsx +2 -0
  101. package/src/editor/ui/Dropdown/Dropdown.tsx +39 -0
  102. package/src/editor/ui/Dropdown/index.tsx +1 -0
  103. package/src/editor/ui/Icon.tsx +21 -0
  104. package/src/editor/ui/Loader/Loader.tsx +39 -0
  105. package/src/editor/ui/Loader/index.ts +1 -0
  106. package/src/editor/ui/Loader/types.ts +7 -0
  107. package/src/editor/ui/Panel/index.tsx +109 -0
  108. package/src/editor/ui/PopoverMenu.tsx +127 -0
  109. package/src/editor/ui/Spinner/Spinner.tsx +10 -0
  110. package/src/editor/ui/Spinner/index.tsx +1 -0
  111. package/src/editor/ui/Surface.tsx +27 -0
  112. package/src/editor/ui/Textarea/Textarea.tsx +20 -0
  113. package/src/editor/ui/Textarea/index.tsx +1 -0
  114. package/src/editor/ui/Toggle/Toggle.tsx +39 -0
  115. package/src/editor/ui/Toggle/index.tsx +1 -0
  116. package/src/editor/ui/Toolbar.tsx +107 -0
  117. package/src/editor/ui/Tooltip/index.tsx +77 -0
  118. package/src/editor/ui/Tooltip/types.ts +17 -0
  119. package/src/editor/utils/cssVar.ts +14 -0
  120. package/src/editor/utils/getRenderContainer.ts +39 -0
  121. package/src/editor/utils/index.ts +16 -0
  122. package/src/editor/utils/isCustomNodeSelected.ts +47 -0
  123. package/src/editor/utils/isTextSelected.ts +25 -0
  124. package/src/editor/utils/locale.ts +5 -0
  125. package/src/editor/viewer/index.tsx +26 -0
  126. package/src/globals.css +1 -0
  127. package/src/index.ts +7 -0
  128. package/src/locales/en-us.ts +133 -0
  129. package/src/locales/zh-cn.ts +133 -0
  130. package/src/locales/zh-tw.ts +133 -0
@@ -0,0 +1,13 @@
1
+ import { mergeAttributes } from "@tiptap/core";
2
+ import type { Level } from "@tiptap/extension-heading";
3
+ import TiptapHeading from "@tiptap/extension-heading";
4
+
5
+ export const Heading = TiptapHeading.extend({
6
+ renderHTML({ node, HTMLAttributes }) {
7
+ const nodeLevel = Number.parseInt(node.attrs.level, 10) as Level;
8
+ const hasLevel = this.options.levels.includes(nodeLevel);
9
+ const level = hasLevel ? nodeLevel : this.options.levels[0];
10
+
11
+ return [`h${level}`, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
12
+ },
13
+ });
@@ -0,0 +1,10 @@
1
+ import { mergeAttributes } from "@tiptap/core";
2
+ import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule";
3
+
4
+ export const HorizontalRule = TiptapHorizontalRule.extend({
5
+ renderHTML(props) {
6
+ return ["div", mergeAttributes(props.HTMLAttributes, { "data-type": this.name }), ["hr"]];
7
+ },
8
+ });
9
+
10
+ export default HorizontalRule;
@@ -0,0 +1 @@
1
+ export { HorizontalRule } from "./HorizontalRule";
@@ -0,0 +1,5 @@
1
+ import { Image as BaseImage } from "@tiptap/extension-image";
2
+
3
+ export const Image = BaseImage.extend({
4
+ group: "block",
5
+ });
@@ -0,0 +1,103 @@
1
+ import { mergeAttributes, type Range } from "@tiptap/core";
2
+ import { ReactNodeViewRenderer } from "@tiptap/react";
3
+
4
+ import { Image } from "../image";
5
+ import { ImageBlockView } from "./components/ImageBlockView";
6
+
7
+ declare module "@tiptap/core" {
8
+ interface Commands<ReturnType> {
9
+ imageBlock: {
10
+ setImageBlock: (attributes: { src: string }) => ReturnType;
11
+ setImageBlockAt: (attributes: { src: string; pos: number | Range }) => ReturnType;
12
+ setImageBlockAlign: (align: "left" | "center" | "right") => ReturnType;
13
+ setImageBlockWidth: (width: number) => ReturnType;
14
+ };
15
+ }
16
+ }
17
+
18
+ export const ImageBlock = Image.extend({
19
+ name: "imageBlock",
20
+
21
+ group: "block",
22
+
23
+ defining: true,
24
+
25
+ isolating: true,
26
+
27
+ addAttributes() {
28
+ return {
29
+ src: {
30
+ default: "",
31
+ parseHTML: (element) => element.getAttribute("src"),
32
+ renderHTML: (attributes) => ({
33
+ src: attributes.src,
34
+ }),
35
+ },
36
+ width: {
37
+ default: "100%",
38
+ parseHTML: (element) => element.getAttribute("data-width"),
39
+ renderHTML: (attributes) => ({
40
+ "data-width": attributes.width,
41
+ }),
42
+ },
43
+ align: {
44
+ default: "center",
45
+ parseHTML: (element) => element.getAttribute("data-align"),
46
+ renderHTML: (attributes) => ({
47
+ "data-align": attributes.align,
48
+ }),
49
+ },
50
+ alt: {
51
+ default: undefined,
52
+ parseHTML: (element) => element.getAttribute("alt"),
53
+ renderHTML: (attributes) => ({
54
+ alt: attributes.alt,
55
+ }),
56
+ },
57
+ };
58
+ },
59
+
60
+ parseHTML() {
61
+ return [
62
+ {
63
+ tag: "img",
64
+ },
65
+ ];
66
+ },
67
+
68
+ renderHTML({ HTMLAttributes }) {
69
+ return ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
70
+ },
71
+
72
+ addCommands() {
73
+ return {
74
+ setImageBlock:
75
+ (attrs) =>
76
+ ({ commands }) => {
77
+ return commands.insertContent({ type: "imageBlock", attrs: { src: attrs.src } });
78
+ },
79
+
80
+ setImageBlockAt:
81
+ (attrs) =>
82
+ ({ commands }) => {
83
+ return commands.insertContentAt(attrs.pos, { type: "imageBlock", attrs: { src: attrs.src } });
84
+ },
85
+
86
+ setImageBlockAlign:
87
+ (align) =>
88
+ ({ commands }) =>
89
+ commands.updateAttributes("imageBlock", { align }),
90
+
91
+ setImageBlockWidth:
92
+ (width) =>
93
+ ({ commands }) =>
94
+ commands.updateAttributes("imageBlock", { width: `${Math.max(0, Math.min(100, width))}%` }),
95
+ };
96
+ },
97
+
98
+ addNodeView() {
99
+ return ReactNodeViewRenderer(ImageBlockView);
100
+ },
101
+ });
102
+
103
+ export default ImageBlock;
@@ -0,0 +1,100 @@
1
+ import { type ReactElement, useCallback, useRef } from "react";
2
+ import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
3
+ import { type Instance, sticky } from "tippy.js";
4
+ import { v4 as uuid } from "uuid";
5
+
6
+ import type { MenuProps } from "../../../menus/types";
7
+ import { Icon } from "../../../ui/Icon";
8
+ import { Toolbar } from "../../../ui/Toolbar";
9
+ import { getRenderContainer } from "../../../utils";
10
+ import { i18n } from "../../../utils/locale";
11
+ import { ImageBlockWidth } from "./ImageBlockWidth";
12
+
13
+ export const ImageBlockMenu = ({ editor, appendTo }: MenuProps): ReactElement => {
14
+ const menuRef = useRef<HTMLDivElement>(null);
15
+ const tippyInstance = useRef<Instance | null>(null);
16
+
17
+ const getReferenceClientRect = useCallback(() => {
18
+ const renderContainer = getRenderContainer(editor, "node-imageBlock");
19
+ const rect = renderContainer?.getBoundingClientRect() || new DOMRect(-1000, -1000, 0, 0);
20
+
21
+ return rect;
22
+ }, [editor]);
23
+
24
+ const shouldShow = useCallback(() => {
25
+ const isActive = editor.isActive("imageBlock");
26
+
27
+ return isActive;
28
+ }, [editor]);
29
+
30
+ const onAlignImageLeft = useCallback(() => {
31
+ editor.chain().focus(undefined, { scrollIntoView: false }).setImageBlockAlign("left").run();
32
+ }, [editor]);
33
+
34
+ const onAlignImageCenter = useCallback(() => {
35
+ editor.chain().focus(undefined, { scrollIntoView: false }).setImageBlockAlign("center").run();
36
+ }, [editor]);
37
+
38
+ const onAlignImageRight = useCallback(() => {
39
+ editor.chain().focus(undefined, { scrollIntoView: false }).setImageBlockAlign("right").run();
40
+ }, [editor]);
41
+
42
+ const onWidthChange = useCallback(
43
+ (value: number) => {
44
+ editor.chain().focus(undefined, { scrollIntoView: false }).setImageBlockWidth(value).run();
45
+ },
46
+ [editor],
47
+ );
48
+ const { isImageCenter, isImageLeft, isImageRight, width } = useEditorState({
49
+ editor,
50
+ selector: (ctx) => {
51
+ return {
52
+ isImageLeft: ctx.editor.isActive("imageBlock", { align: "left" }),
53
+ isImageCenter: ctx.editor.isActive("imageBlock", { align: "center" }),
54
+ isImageRight: ctx.editor.isActive("imageBlock", { align: "right" }),
55
+ width: Number.parseInt(ctx.editor.getAttributes("imageBlock")?.width || 0, 10),
56
+ };
57
+ },
58
+ });
59
+
60
+ return (
61
+ <BaseBubbleMenu
62
+ editor={editor}
63
+ pluginKey={`imageBlockMenu-${uuid()}`}
64
+ shouldShow={shouldShow}
65
+ tippyOptions={{
66
+ offset: [0, 8],
67
+ zIndex: 40,
68
+ popperOptions: {
69
+ modifiers: [{ name: "flip", enabled: false }],
70
+ },
71
+ getReferenceClientRect,
72
+ onCreate: (instance: Instance) => {
73
+ tippyInstance.current = instance;
74
+ },
75
+ appendTo: () => {
76
+ return appendTo?.current;
77
+ },
78
+ plugins: [sticky],
79
+ sticky: "popper",
80
+ }}
81
+ updateDelay={0}
82
+ >
83
+ <Toolbar.Wrapper ref={menuRef} shouldShowContent={shouldShow()}>
84
+ <Toolbar.Button active={isImageLeft} onClick={onAlignImageLeft} tooltip={i18n("imageBlock.align.left")}>
85
+ <Icon name="AlignHorizontalDistributeStart" />
86
+ </Toolbar.Button>
87
+ <Toolbar.Button active={isImageCenter} onClick={onAlignImageCenter} tooltip={i18n("imageBlock.align.center")}>
88
+ <Icon name="AlignHorizontalDistributeCenter" />
89
+ </Toolbar.Button>
90
+ <Toolbar.Button active={isImageRight} onClick={onAlignImageRight} tooltip={i18n("imageBlock.align.right")}>
91
+ <Icon name="AlignHorizontalDistributeEnd" />
92
+ </Toolbar.Button>
93
+ <Toolbar.Divider />
94
+ <ImageBlockWidth onChange={onWidthChange} value={width} />
95
+ </Toolbar.Wrapper>
96
+ </BaseBubbleMenu>
97
+ );
98
+ };
99
+
100
+ export default ImageBlockMenu;
@@ -0,0 +1,47 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { Node } from "@tiptap/pm/model";
3
+ import { type Editor, NodeViewWrapper } from "@tiptap/react";
4
+ import classNames from "classnames";
5
+
6
+ interface ImageBlockViewProps {
7
+ editor: Editor;
8
+ getPos: () => number;
9
+ node: Node;
10
+ updateAttributes: (attrs: Record<string, string>) => void;
11
+ // biome-ignore lint/suspicious/noExplicitAny: <HTMLAttributes>
12
+ HTMLAttributes: Record<string, any>;
13
+ }
14
+
15
+ export const ImageBlockView = (props: ImageBlockViewProps) => {
16
+ const { editor, getPos, node } = props as ImageBlockViewProps & {
17
+ node: Node & {
18
+ attrs: {
19
+ src: string;
20
+ };
21
+ };
22
+ };
23
+ const imageWrapperRef = useRef<HTMLDivElement>(null);
24
+ const { src } = node.attrs;
25
+
26
+ const wrapperClassName = classNames(
27
+ node.attrs.align === "left" ? "ml-0" : "ml-auto",
28
+ node.attrs.align === "right" ? "mr-0" : "mr-auto",
29
+ node.attrs.align === "center" && "mx-auto",
30
+ );
31
+
32
+ const onClick = useCallback(() => {
33
+ editor.commands.setNodeSelection(getPos());
34
+ }, [getPos, editor.commands]);
35
+
36
+ return (
37
+ <NodeViewWrapper {...props.HTMLAttributes}>
38
+ <div className={wrapperClassName} style={{ width: node.attrs.width }}>
39
+ <div contentEditable={false} ref={imageWrapperRef}>
40
+ <img alt="" className="block" onClick={onClick} src={src} />
41
+ </div>
42
+ </div>
43
+ </NodeViewWrapper>
44
+ );
45
+ };
46
+
47
+ export default ImageBlockView;
@@ -0,0 +1,40 @@
1
+ import { type ChangeEvent, memo, useCallback, useEffect, useState } from "react";
2
+
3
+ export type ImageBlockWidthProps = {
4
+ onChange: (value: number) => void;
5
+ value: number;
6
+ };
7
+
8
+ export const ImageBlockWidth = memo(({ onChange, value }: ImageBlockWidthProps) => {
9
+ const [currentValue, setCurrentValue] = useState(value);
10
+
11
+ useEffect(() => {
12
+ setCurrentValue(value);
13
+ }, [value]);
14
+
15
+ const handleChange = useCallback(
16
+ (e: ChangeEvent<HTMLInputElement>) => {
17
+ const nextValue = Number.parseInt(e.target.value, 10);
18
+ onChange(nextValue);
19
+ setCurrentValue(nextValue);
20
+ },
21
+ [onChange],
22
+ );
23
+
24
+ return (
25
+ <div className="flex items-center gap-2">
26
+ <input
27
+ className="h-2 appearance-none rounded border-0 bg-neutral-200 fill-neutral-300"
28
+ max="100"
29
+ min="25"
30
+ onChange={handleChange}
31
+ step="25"
32
+ type="range"
33
+ value={currentValue}
34
+ />
35
+ <span className="select-none font-semibold text-neutral-500 text-xs">{value}%</span>
36
+ </div>
37
+ );
38
+ });
39
+
40
+ ImageBlockWidth.displayName = "ImageBlockWidth";
@@ -0,0 +1 @@
1
+ export { ImageBlock } from "./ImageBlock";
@@ -0,0 +1,58 @@
1
+ import { Node, ReactNodeViewRenderer } from "@tiptap/react";
2
+
3
+ import { ImageUpload as ImageUploadComponent } from "./view/ImageUpload";
4
+
5
+ declare module "@tiptap/core" {
6
+ interface Commands<ReturnType> {
7
+ imageUpload: {
8
+ setImageUpload: () => ReturnType;
9
+ };
10
+ }
11
+ }
12
+
13
+ export type UploadImageFunction = (file: File) => Promise<string>;
14
+
15
+ export const ImageUpload = Node.create<{
16
+ uploadImage?: UploadImageFunction;
17
+ }>({
18
+ name: "imageUpload",
19
+
20
+ isolating: true,
21
+
22
+ defining: true,
23
+
24
+ group: "block",
25
+
26
+ draggable: true,
27
+
28
+ selectable: true,
29
+
30
+ inline: false,
31
+
32
+ parseHTML() {
33
+ return [
34
+ {
35
+ tag: `div[data-type="${this.name}"]`,
36
+ },
37
+ ];
38
+ },
39
+
40
+ renderHTML() {
41
+ return ["div", { "data-type": this.name }];
42
+ },
43
+
44
+ addCommands() {
45
+ return {
46
+ setImageUpload:
47
+ () =>
48
+ ({ commands }) =>
49
+ commands.insertContent(`<div data-type="${this.name}"></div>`),
50
+ };
51
+ },
52
+
53
+ addNodeView() {
54
+ return ReactNodeViewRenderer(ImageUploadComponent);
55
+ },
56
+ });
57
+
58
+ export default ImageUpload;
@@ -0,0 +1 @@
1
+ export { ImageUpload } from "./ImageUpload";
@@ -0,0 +1,27 @@
1
+ import { type ComponentType, useCallback } from "react";
2
+ import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
3
+
4
+ import { ImageUploader } from "./ImageUploader";
5
+
6
+ export const ImageUpload: ComponentType<NodeViewProps> = (props) => {
7
+ const { getPos, editor, extension } = props;
8
+ const { uploadImage } = extension?.options || {};
9
+ const onUpload = useCallback(
10
+ (url: string) => {
11
+ if (url) {
12
+ editor.chain().setImageBlock({ src: url }).deleteRange({ from: getPos(), to: getPos() }).focus().run();
13
+ }
14
+ },
15
+ [getPos, editor],
16
+ );
17
+
18
+ return (
19
+ <NodeViewWrapper {...props.HTMLAttributes}>
20
+ <div className="m-0 p-0" data-drag-handle>
21
+ <ImageUploader onUpload={onUpload} uploadImage={uploadImage} />
22
+ </div>
23
+ </NodeViewWrapper>
24
+ );
25
+ };
26
+
27
+ export default ImageUpload;
@@ -0,0 +1,64 @@
1
+ import { type ChangeEvent, useCallback } from "react";
2
+ import cn from "classnames";
3
+
4
+ import { Button } from "../../../ui/Button";
5
+ import { Icon } from "../../../ui/Icon";
6
+ import { Spinner } from "../../../ui/Spinner";
7
+ import { i18n } from "../../../utils/locale";
8
+ import { type UseUploaderProps, useDropZone, useFileUpload, useUploader } from "./hooks";
9
+
10
+ export const ImageUploader = ({ onUpload, uploadImage }: UseUploaderProps) => {
11
+ const { loading, uploadFile } = useUploader({ onUpload, uploadImage });
12
+ const { handleUploadClick, ref } = useFileUpload();
13
+ const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
14
+
15
+ const onFileChange = useCallback(
16
+ (e: ChangeEvent<HTMLInputElement>) => (e.target.files ? uploadFile(e.target.files[0]) : null),
17
+ [uploadFile],
18
+ );
19
+
20
+ if (loading) {
21
+ return (
22
+ <div className="flex min-h-[10rem] items-center justify-center rounded-lg bg-opacity-80 p-8">
23
+ <Spinner className="text-neutral-500" size={1.5} />
24
+ </div>
25
+ );
26
+ }
27
+
28
+ const wrapperClass = cn(
29
+ "flex flex-col items-center justify-center rounded-lg bg-opacity-80 px-8 py-10",
30
+ draggedInside && "bg-neutral-100",
31
+ );
32
+
33
+ return (
34
+ <div
35
+ className={wrapperClass}
36
+ contentEditable={false}
37
+ onDragLeave={onDragLeave}
38
+ onDragOver={onDragEnter}
39
+ onDrop={onDrop}
40
+ >
41
+ <Icon className="mb-4 h-12 w-12 text-black opacity-20 dark:text-white" name="Image" />
42
+ <div className="flex flex-col items-center justify-center gap-2">
43
+ <div className="text-center font-medium text-neutral-400 text-sm dark:text-neutral-500">
44
+ {draggedInside ? i18n("imageUpload.draggedInside") : i18n("imageUpload.draggedInsideDefault")}
45
+ </div>
46
+ <div>
47
+ <Button buttonSize="small" disabled={draggedInside} onClick={handleUploadClick} variant="primary">
48
+ <Icon name="Upload" />
49
+ {i18n("imageUpload.button")}
50
+ </Button>
51
+ </div>
52
+ </div>
53
+ <input
54
+ accept=".jpg,.jpeg,.png,.webp,.gif"
55
+ className="h-0 w-0 overflow-hidden opacity-0"
56
+ onChange={onFileChange}
57
+ ref={ref}
58
+ type="file"
59
+ />
60
+ </div>
61
+ );
62
+ };
63
+
64
+ export default ImageUploader;
@@ -0,0 +1,109 @@
1
+ import { type DragEvent, useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ import { useMessage } from "@meta-1/design";
4
+
5
+ export type UseUploaderProps = {
6
+ onUpload: (url: string) => void;
7
+ uploadImage?: (file: File) => Promise<string>;
8
+ };
9
+
10
+ export const useUploader = ({ onUpload, uploadImage }: UseUploaderProps) => {
11
+ const [loading, setLoading] = useState(false);
12
+ const msg = useMessage();
13
+
14
+ const uploadFile = useCallback(
15
+ async (file: File) => {
16
+ setLoading(true);
17
+ try {
18
+ const url = await uploadImage?.(file);
19
+ onUpload(url!);
20
+ // biome-ignore lint/suspicious/noExplicitAny: <errPayload>
21
+ } catch (errPayload: any) {
22
+ const error = errPayload?.response?.data?.error || "Something went wrong";
23
+ msg.error(error);
24
+ }
25
+ setLoading(false);
26
+ },
27
+ [onUpload, msg, uploadImage],
28
+ );
29
+
30
+ return { loading, uploadFile };
31
+ };
32
+
33
+ export const useFileUpload = () => {
34
+ const fileInput = useRef<HTMLInputElement>(null);
35
+
36
+ const handleUploadClick = useCallback(() => {
37
+ fileInput.current?.click();
38
+ }, []);
39
+
40
+ return { ref: fileInput, handleUploadClick };
41
+ };
42
+
43
+ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
44
+ const [isDragging, setIsDragging] = useState<boolean>(false);
45
+ const [draggedInside, setDraggedInside] = useState<boolean>(false);
46
+
47
+ useEffect(() => {
48
+ const dragStartHandler = () => {
49
+ setIsDragging(true);
50
+ };
51
+
52
+ const dragEndHandler = () => {
53
+ setIsDragging(false);
54
+ };
55
+
56
+ document.body.addEventListener("dragstart", dragStartHandler);
57
+ document.body.addEventListener("dragend", dragEndHandler);
58
+
59
+ return () => {
60
+ document.body.removeEventListener("dragstart", dragStartHandler);
61
+ document.body.removeEventListener("dragend", dragEndHandler);
62
+ };
63
+ }, []);
64
+
65
+ const onDrop = useCallback(
66
+ (e: DragEvent<HTMLDivElement>) => {
67
+ setDraggedInside(false);
68
+ if (e.dataTransfer.files.length === 0) {
69
+ return;
70
+ }
71
+
72
+ const fileList = e.dataTransfer.files;
73
+
74
+ const files: File[] = [];
75
+
76
+ for (let i = 0; i < fileList.length; i += 1) {
77
+ const item = fileList.item(i);
78
+ if (item) {
79
+ files.push(item);
80
+ }
81
+ }
82
+
83
+ if (files.some((file) => file.type.indexOf("image") === -1)) {
84
+ return;
85
+ }
86
+
87
+ e.preventDefault();
88
+
89
+ const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1);
90
+
91
+ const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
92
+
93
+ if (file) {
94
+ uploader(file);
95
+ }
96
+ },
97
+ [uploader],
98
+ );
99
+
100
+ const onDragEnter = () => {
101
+ setDraggedInside(true);
102
+ };
103
+
104
+ const onDragLeave = () => {
105
+ setDraggedInside(false);
106
+ };
107
+
108
+ return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
109
+ };
@@ -0,0 +1 @@
1
+ export { ImageUpload } from "./ImageUpload";
@@ -0,0 +1,30 @@
1
+ export { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight";
2
+ export { Color } from "@tiptap/extension-color";
3
+ export { Dropcursor } from "@tiptap/extension-dropcursor";
4
+ export { FocusClasses as Focus } from "@tiptap/extension-focus";
5
+ export { FontFamily } from "@tiptap/extension-font-family";
6
+ export { Highlight } from "@tiptap/extension-highlight";
7
+ export { History } from "@tiptap/extension-history";
8
+ export { Subscript } from "@tiptap/extension-subscript";
9
+ export { Superscript } from "@tiptap/extension-superscript";
10
+ export { TextAlign } from "@tiptap/extension-text-align";
11
+ export { TextStyle } from "@tiptap/extension-text-style";
12
+ export { Underline } from "@tiptap/extension-underline";
13
+
14
+ export { BlockquoteFigure } from "./block-quote-figure";
15
+ export { Document } from "./document";
16
+ export { Figcaption } from "./figcaption";
17
+ export { FontSize } from "./font-size";
18
+ export { Heading } from "./heading";
19
+ export { HorizontalRule } from "./horizontal-rule";
20
+ export { Image } from "./image";
21
+ export { ImageBlock } from "./image-block";
22
+ export { ImageUpload } from "./image-upload";
23
+ export { Link } from "./link";
24
+ export { Column, Columns } from "./multi-column";
25
+ export { Selection } from "./selection";
26
+ export { Table, TableCell, TableHeader, TableRow } from "./table";
27
+ export { TaskItem } from "./task-item";
28
+ export { TaskList } from "./task-list";
29
+ export { TrailingNode } from "./trailing-node";
30
+ export { UniqueId } from "./unique-id";
@@ -0,0 +1,39 @@
1
+ import { mergeAttributes } from "@tiptap/core";
2
+ import TiptapLink from "@tiptap/extension-link";
3
+ import { Plugin } from "@tiptap/pm/state";
4
+ import type { EditorView } from "@tiptap/pm/view";
5
+
6
+ export const Link = TiptapLink.extend({
7
+ inclusive: false,
8
+
9
+ parseHTML() {
10
+ return [{ tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])' }];
11
+ },
12
+
13
+ renderHTML({ HTMLAttributes }) {
14
+ return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: "link" }), 0];
15
+ },
16
+
17
+ addProseMirrorPlugins() {
18
+ const { editor } = this;
19
+
20
+ return [
21
+ ...(this.parent?.() || []),
22
+ new Plugin({
23
+ props: {
24
+ handleKeyDown: (_view: EditorView, event: KeyboardEvent) => {
25
+ const { selection } = editor.state;
26
+
27
+ if (event.key === "Escape" && selection.empty !== true) {
28
+ editor.commands.focus(selection.to, { scrollIntoView: false });
29
+ }
30
+
31
+ return false;
32
+ },
33
+ },
34
+ }),
35
+ ];
36
+ },
37
+ });
38
+
39
+ export default Link;
@@ -0,0 +1 @@
1
+ export { Link } from "./Link";