@spatika/editor 1.5.0
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 +43 -0
- package/dist/composites/AiCommandDock.d.ts +13 -0
- package/dist/composites/AiCommandDock.d.ts.map +1 -0
- package/dist/composites/AiCommandDock.js +63 -0
- package/dist/composites/AiCommandMenu.d.ts +16 -0
- package/dist/composites/AiCommandMenu.d.ts.map +1 -0
- package/dist/composites/AiCommandMenu.js +51 -0
- package/dist/composites/AiCommandPanel.d.ts +12 -0
- package/dist/composites/AiCommandPanel.d.ts.map +1 -0
- package/dist/composites/AiCommandPanel.js +12 -0
- package/dist/composites/AiOverlay.d.ts +7 -0
- package/dist/composites/AiOverlay.d.ts.map +1 -0
- package/dist/composites/AiOverlay.js +8 -0
- package/dist/composites/EditorBubbleMenu.d.ts +10 -0
- package/dist/composites/EditorBubbleMenu.d.ts.map +1 -0
- package/dist/composites/EditorBubbleMenu.js +16 -0
- package/dist/composites/EditorToolbar.d.ts +20 -0
- package/dist/composites/EditorToolbar.d.ts.map +1 -0
- package/dist/composites/EditorToolbar.js +45 -0
- package/dist/composites/ImageBubbleMenu.d.ts +7 -0
- package/dist/composites/ImageBubbleMenu.d.ts.map +1 -0
- package/dist/composites/ImageBubbleMenu.js +15 -0
- package/dist/composites/MentionMenu.d.ts +11 -0
- package/dist/composites/MentionMenu.d.ts.map +1 -0
- package/dist/composites/MentionMenu.js +10 -0
- package/dist/composites/SlashCommandMenu.d.ts +13 -0
- package/dist/composites/SlashCommandMenu.d.ts.map +1 -0
- package/dist/composites/SlashCommandMenu.js +10 -0
- package/dist/composites/SpatikaEditor.d.ts +14 -0
- package/dist/composites/SpatikaEditor.d.ts.map +1 -0
- package/dist/composites/SpatikaEditor.js +159 -0
- package/dist/composites/TableGridPicker.d.ts +9 -0
- package/dist/composites/TableGridPicker.d.ts.map +1 -0
- package/dist/composites/TableGridPicker.js +54 -0
- package/dist/composites/ToolbarButton.d.ts +7 -0
- package/dist/composites/ToolbarButton.d.ts.map +1 -0
- package/dist/composites/ToolbarButton.js +7 -0
- package/dist/composites/ToolbarFormatPanels.d.ts +9 -0
- package/dist/composites/ToolbarFormatPanels.d.ts.map +1 -0
- package/dist/composites/ToolbarFormatPanels.js +35 -0
- package/dist/composites/ToolbarInsertPanels.d.ts +14 -0
- package/dist/composites/ToolbarInsertPanels.d.ts.map +1 -0
- package/dist/composites/ToolbarInsertPanels.js +96 -0
- package/dist/composites/ToolbarPopover.d.ts +14 -0
- package/dist/composites/ToolbarPopover.d.ts.map +1 -0
- package/dist/composites/ToolbarPopover.js +48 -0
- package/dist/extensions/ai-action.d.ts +24 -0
- package/dist/extensions/ai-action.d.ts.map +1 -0
- package/dist/extensions/ai-action.js +53 -0
- package/dist/extensions/mention.d.ts +17 -0
- package/dist/extensions/mention.d.ts.map +1 -0
- package/dist/extensions/mention.js +129 -0
- package/dist/extensions/resizable-image-view.d.ts +18 -0
- package/dist/extensions/resizable-image-view.d.ts.map +1 -0
- package/dist/extensions/resizable-image-view.js +70 -0
- package/dist/extensions/resizable-image.d.ts +4 -0
- package/dist/extensions/resizable-image.d.ts.map +1 -0
- package/dist/extensions/resizable-image.js +68 -0
- package/dist/extensions/slash-command.d.ts +18 -0
- package/dist/extensions/slash-command.d.ts.map +1 -0
- package/dist/extensions/slash-command.js +178 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/lib/ai-command-menu.d.ts +9 -0
- package/dist/lib/ai-command-menu.d.ts.map +1 -0
- package/dist/lib/ai-command-menu.js +43 -0
- package/dist/lib/cn.d.ts +4 -0
- package/dist/lib/cn.d.ts.map +1 -0
- package/dist/lib/cn.js +6 -0
- package/dist/lib/editor-format-colors.d.ts +10 -0
- package/dist/lib/editor-format-colors.d.ts.map +1 -0
- package/dist/lib/editor-format-colors.js +24 -0
- package/dist/lib/image-upload.d.ts +14 -0
- package/dist/lib/image-upload.d.ts.map +1 -0
- package/dist/lib/image-upload.js +29 -0
- package/dist/lib/insert-editor-images.d.ts +5 -0
- package/dist/lib/insert-editor-images.d.ts.map +1 -0
- package/dist/lib/insert-editor-images.js +32 -0
- package/dist/lib/types.d.ts +131 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +12 -0
- package/dist/lib/use-spatika-editor.d.ts +13 -0
- package/dist/lib/use-spatika-editor.d.ts.map +1 -0
- package/dist/lib/use-spatika-editor.js +171 -0
- package/dist/styles.css +1056 -0
- package/package.json +101 -0
- package/src/composites/AiCommandDock.test.tsx +48 -0
- package/src/composites/AiCommandDock.tsx +137 -0
- package/src/composites/AiCommandMenu.test.tsx +67 -0
- package/src/composites/AiCommandMenu.tsx +120 -0
- package/src/composites/AiCommandPanel.test.tsx +42 -0
- package/src/composites/AiCommandPanel.tsx +56 -0
- package/src/composites/AiOverlay.test.tsx +18 -0
- package/src/composites/AiOverlay.tsx +18 -0
- package/src/composites/EditorBubbleMenu.test.tsx +49 -0
- package/src/composites/EditorBubbleMenu.tsx +73 -0
- package/src/composites/EditorToolbar.test.tsx +146 -0
- package/src/composites/EditorToolbar.tsx +362 -0
- package/src/composites/ImageBubbleMenu.test.tsx +43 -0
- package/src/composites/ImageBubbleMenu.tsx +51 -0
- package/src/composites/MentionMenu.test.tsx +25 -0
- package/src/composites/MentionMenu.tsx +46 -0
- package/src/composites/SlashCommandMenu.test.tsx +33 -0
- package/src/composites/SlashCommandMenu.tsx +54 -0
- package/src/composites/SpatikaEditor.test.tsx +167 -0
- package/src/composites/SpatikaEditor.tsx +302 -0
- package/src/composites/TableGridPicker.test.tsx +26 -0
- package/src/composites/TableGridPicker.tsx +138 -0
- package/src/composites/ToolbarButton.test.tsx +21 -0
- package/src/composites/ToolbarButton.tsx +24 -0
- package/src/composites/ToolbarFormatPanels.test.tsx +46 -0
- package/src/composites/ToolbarFormatPanels.tsx +78 -0
- package/src/composites/ToolbarInsertPanels.test.tsx +60 -0
- package/src/composites/ToolbarInsertPanels.tsx +200 -0
- package/src/composites/ToolbarPopover.test.tsx +24 -0
- package/src/composites/ToolbarPopover.tsx +95 -0
- package/src/extensions/ai-action.test.ts +26 -0
- package/src/extensions/ai-action.ts +73 -0
- package/src/extensions/mention.test.ts +31 -0
- package/src/extensions/mention.ts +158 -0
- package/src/extensions/resizable-image-view.test.ts +26 -0
- package/src/extensions/resizable-image-view.ts +96 -0
- package/src/extensions/resizable-image.test.ts +28 -0
- package/src/extensions/resizable-image.ts +75 -0
- package/src/extensions/slash-command.test.ts +45 -0
- package/src/extensions/slash-command.ts +197 -0
- package/src/index.ts +49 -0
- package/src/lib/ai-command-menu.test.ts +48 -0
- package/src/lib/ai-command-menu.ts +57 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/editor-format-colors.test.ts +15 -0
- package/src/lib/editor-format-colors.ts +31 -0
- package/src/lib/image-upload.test.ts +19 -0
- package/src/lib/image-upload.ts +49 -0
- package/src/lib/insert-editor-images.test.ts +76 -0
- package/src/lib/insert-editor-images.ts +44 -0
- package/src/lib/types.ts +149 -0
- package/src/lib/use-spatika-editor.test.tsx +56 -0
- package/src/lib/use-spatika-editor.ts +191 -0
- package/src/styles/editor.css +1056 -0
- package/src/test/setup.ts +46 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import { BubbleMenu } from "@tiptap/react/menus";
|
|
3
|
+
import { Bold, Code, Italic, Sparkles, Strikethrough, Underline as UnderlineIcon } from "lucide-react";
|
|
4
|
+
import type { AiActionDefinition } from "../lib/types";
|
|
5
|
+
import { ToolbarButton } from "./ToolbarButton";
|
|
6
|
+
|
|
7
|
+
type EditorBubbleMenuProps = {
|
|
8
|
+
editor: Editor | null;
|
|
9
|
+
aiActions?: AiActionDefinition[];
|
|
10
|
+
onAiAction?: (actionId: string) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function EditorBubbleMenu({ editor, aiActions, onAiAction }: EditorBubbleMenuProps) {
|
|
14
|
+
if (!editor) return null;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<BubbleMenu
|
|
18
|
+
editor={editor}
|
|
19
|
+
className="spk-editor-bubble-menu"
|
|
20
|
+
shouldShow={({ editor: ed, state }) => {
|
|
21
|
+
const { selection } = state;
|
|
22
|
+
if (selection.empty) return false;
|
|
23
|
+
if (ed.isActive("image")) return false;
|
|
24
|
+
return true;
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
<ToolbarButton
|
|
28
|
+
aria-label="Bold"
|
|
29
|
+
active={editor.isActive("bold")}
|
|
30
|
+
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
31
|
+
>
|
|
32
|
+
<Bold className="size-4" />
|
|
33
|
+
</ToolbarButton>
|
|
34
|
+
<ToolbarButton
|
|
35
|
+
aria-label="Italic"
|
|
36
|
+
active={editor.isActive("italic")}
|
|
37
|
+
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
38
|
+
>
|
|
39
|
+
<Italic className="size-4" />
|
|
40
|
+
</ToolbarButton>
|
|
41
|
+
<ToolbarButton
|
|
42
|
+
aria-label="Underline"
|
|
43
|
+
active={editor.isActive("underline")}
|
|
44
|
+
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
45
|
+
>
|
|
46
|
+
<UnderlineIcon className="size-4" />
|
|
47
|
+
</ToolbarButton>
|
|
48
|
+
<ToolbarButton
|
|
49
|
+
aria-label="Strikethrough"
|
|
50
|
+
active={editor.isActive("strike")}
|
|
51
|
+
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
52
|
+
>
|
|
53
|
+
<Strikethrough className="size-4" />
|
|
54
|
+
</ToolbarButton>
|
|
55
|
+
<ToolbarButton
|
|
56
|
+
aria-label="Inline code"
|
|
57
|
+
active={editor.isActive("code")}
|
|
58
|
+
onClick={() => editor.chain().focus().toggleCode().run()}
|
|
59
|
+
>
|
|
60
|
+
<Code className="size-4" />
|
|
61
|
+
</ToolbarButton>
|
|
62
|
+
{aiActions?.map((action) => (
|
|
63
|
+
<ToolbarButton
|
|
64
|
+
key={action.id}
|
|
65
|
+
aria-label={action.label}
|
|
66
|
+
onClick={() => onAiAction?.(action.id)}
|
|
67
|
+
>
|
|
68
|
+
{action.icon ?? <Sparkles className="size-4" />}
|
|
69
|
+
</ToolbarButton>
|
|
70
|
+
))}
|
|
71
|
+
</BubbleMenu>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { EditorToolbar } from "./EditorToolbar";
|
|
6
|
+
|
|
7
|
+
function createEditor() {
|
|
8
|
+
const run = vi.fn();
|
|
9
|
+
const chain = {
|
|
10
|
+
focus: vi.fn().mockReturnThis(),
|
|
11
|
+
toggleBold: vi.fn().mockReturnThis(),
|
|
12
|
+
toggleHeading: vi.fn().mockReturnThis(),
|
|
13
|
+
toggleStrike: vi.fn().mockReturnThis(),
|
|
14
|
+
toggleCode: vi.fn().mockReturnThis(),
|
|
15
|
+
toggleCodeBlock: vi.fn().mockReturnThis(),
|
|
16
|
+
toggleTaskList: vi.fn().mockReturnThis(),
|
|
17
|
+
setTextAlign: vi.fn().mockReturnThis(),
|
|
18
|
+
setParagraph: vi.fn().mockReturnThis(),
|
|
19
|
+
extendMarkRange: vi.fn().mockReturnThis(),
|
|
20
|
+
setLink: vi.fn().mockReturnThis(),
|
|
21
|
+
insertTable: vi.fn().mockReturnThis(),
|
|
22
|
+
setImage: vi.fn().mockReturnThis(),
|
|
23
|
+
undo: vi.fn().mockReturnThis(),
|
|
24
|
+
redo: vi.fn().mockReturnThis(),
|
|
25
|
+
run,
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
isActive: vi.fn().mockReturnValue(false),
|
|
29
|
+
chain: vi.fn(() => chain),
|
|
30
|
+
can: vi.fn(() => ({
|
|
31
|
+
undo: vi.fn().mockReturnValue(true),
|
|
32
|
+
redo: vi.fn().mockReturnValue(false),
|
|
33
|
+
})),
|
|
34
|
+
on: vi.fn(),
|
|
35
|
+
off: vi.fn(),
|
|
36
|
+
getAttributes: vi.fn().mockReturnValue({ href: undefined, color: undefined }),
|
|
37
|
+
state: { selection: { empty: true, from: 0, to: 0 } },
|
|
38
|
+
} as unknown as Editor;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("EditorToolbar", () => {
|
|
42
|
+
it("returns null without an editor", () => {
|
|
43
|
+
const { container } = render(<EditorToolbar editor={null} />);
|
|
44
|
+
expect(container).toBeEmptyDOMElement();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders configured groups and toggles bold", async () => {
|
|
48
|
+
const editor = createEditor();
|
|
49
|
+
render(
|
|
50
|
+
<EditorToolbar
|
|
51
|
+
editor={editor}
|
|
52
|
+
config={{ layout: "compact", insert: false, ai: false, alignment: false }}
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByRole("toolbar", { name: "Editor formatting" })).toBeInTheDocument();
|
|
56
|
+
await userEvent.click(screen.getByRole("button", { name: "Bold" }));
|
|
57
|
+
expect(editor.chain).toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("calls onInsertImage instead of prompting", async () => {
|
|
61
|
+
const editor = createEditor();
|
|
62
|
+
const onInsertImage = vi.fn();
|
|
63
|
+
render(
|
|
64
|
+
<EditorToolbar
|
|
65
|
+
editor={editor}
|
|
66
|
+
config={{ textStyle: false, inlineFormat: false, lists: false, alignment: false, ai: false }}
|
|
67
|
+
onInsertImage={onInsertImage}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
await userEvent.click(screen.getByRole("button", { name: "Insert image" }));
|
|
71
|
+
expect(onInsertImage).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("opens insert panels instead of native prompts", async () => {
|
|
75
|
+
const editor = createEditor();
|
|
76
|
+
render(
|
|
77
|
+
<EditorToolbar
|
|
78
|
+
editor={editor}
|
|
79
|
+
config={{ textStyle: false, inlineFormat: false, lists: false, alignment: false, ai: false }}
|
|
80
|
+
/>,
|
|
81
|
+
);
|
|
82
|
+
await userEvent.click(screen.getByRole("button", { name: "Insert link" }));
|
|
83
|
+
expect(screen.getByRole("dialog", { name: "Insert link" })).toBeInTheDocument();
|
|
84
|
+
await userEvent.click(screen.getByRole("button", { name: "Insert table" }));
|
|
85
|
+
expect(screen.getByRole("dialog", { name: "Insert table" })).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders undo and redo controls", async () => {
|
|
89
|
+
const editor = createEditor();
|
|
90
|
+
render(
|
|
91
|
+
<EditorToolbar
|
|
92
|
+
editor={editor}
|
|
93
|
+
config={{ textStyle: false, inlineFormat: false, lists: false, alignment: false, insert: false, ai: false }}
|
|
94
|
+
/>,
|
|
95
|
+
);
|
|
96
|
+
await userEvent.click(screen.getByRole("button", { name: "Undo" }));
|
|
97
|
+
const chain = editor.chain();
|
|
98
|
+
expect(chain.undo).toHaveBeenCalled();
|
|
99
|
+
expect(screen.getByRole("button", { name: "Redo" })).toBeDisabled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("renders the AI command menu when enabled", async () => {
|
|
103
|
+
const editor = createEditor();
|
|
104
|
+
render(
|
|
105
|
+
<EditorToolbar
|
|
106
|
+
editor={editor}
|
|
107
|
+
config={{ textStyle: false, inlineFormat: false, lists: false, alignment: false, insert: false }}
|
|
108
|
+
useAiCommandMenu
|
|
109
|
+
onAiCommand={vi.fn()}
|
|
110
|
+
aiCommandMenu={{ title: "Doc AI", placement: "toolbar" }}
|
|
111
|
+
/>,
|
|
112
|
+
);
|
|
113
|
+
await userEvent.click(screen.getByRole("button", { name: "AI commands" }));
|
|
114
|
+
expect(screen.getByRole("dialog", { name: "Doc AI" })).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("renders extended formatting controls", async () => {
|
|
118
|
+
const editor = createEditor();
|
|
119
|
+
render(
|
|
120
|
+
<EditorToolbar
|
|
121
|
+
editor={editor}
|
|
122
|
+
config={{ textStyle: false, lists: false, alignment: false, insert: false, ai: false, history: false }}
|
|
123
|
+
/>,
|
|
124
|
+
);
|
|
125
|
+
expect(screen.getByRole("button", { name: "Strikethrough" })).toBeInTheDocument();
|
|
126
|
+
expect(screen.getByRole("button", { name: "Inline code" })).toBeInTheDocument();
|
|
127
|
+
expect(screen.getByRole("button", { name: "Subscript" })).toBeInTheDocument();
|
|
128
|
+
expect(screen.getByRole("button", { name: "Text color" })).toBeInTheDocument();
|
|
129
|
+
await userEvent.click(screen.getByRole("button", { name: "Strikethrough" }));
|
|
130
|
+
expect(editor.chain).toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders task list, justify, and code block controls", async () => {
|
|
134
|
+
const editor = createEditor();
|
|
135
|
+
render(
|
|
136
|
+
<EditorToolbar
|
|
137
|
+
editor={editor}
|
|
138
|
+
config={{ textStyle: false, inlineFormat: false, ai: false, history: false }}
|
|
139
|
+
/>,
|
|
140
|
+
);
|
|
141
|
+
expect(screen.getByRole("button", { name: "Task list" })).toBeInTheDocument();
|
|
142
|
+
expect(screen.getByRole("button", { name: "Justify" })).toBeInTheDocument();
|
|
143
|
+
await userEvent.click(screen.getByRole("button", { name: "Code block" }));
|
|
144
|
+
expect(editor.chain).toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import {
|
|
3
|
+
AlignCenter,
|
|
4
|
+
AlignJustify,
|
|
5
|
+
AlignLeft,
|
|
6
|
+
AlignRight,
|
|
7
|
+
Bold,
|
|
8
|
+
Code,
|
|
9
|
+
Heading1,
|
|
10
|
+
Heading2,
|
|
11
|
+
Heading3,
|
|
12
|
+
Highlighter,
|
|
13
|
+
ImageIcon,
|
|
14
|
+
Italic,
|
|
15
|
+
Link2,
|
|
16
|
+
List,
|
|
17
|
+
ListOrdered,
|
|
18
|
+
ListTodo,
|
|
19
|
+
Minus,
|
|
20
|
+
Palette,
|
|
21
|
+
Pilcrow,
|
|
22
|
+
Quote,
|
|
23
|
+
Redo2,
|
|
24
|
+
Sparkles,
|
|
25
|
+
SquareCode,
|
|
26
|
+
Strikethrough,
|
|
27
|
+
Subscript,
|
|
28
|
+
Superscript,
|
|
29
|
+
Table2,
|
|
30
|
+
Underline as UnderlineIcon,
|
|
31
|
+
Undo2,
|
|
32
|
+
} from "lucide-react";
|
|
33
|
+
import type { ReactNode } from "react";
|
|
34
|
+
import { useEffect, useState } from "react";
|
|
35
|
+
import type { AiActionDefinition, AiCommandMenuConfig, ImageInsertConfig, SpatikaEditorToolbarConfig } from "../lib/types";
|
|
36
|
+
import { DEFAULT_TOOLBAR } from "../lib/types";
|
|
37
|
+
import { cn } from "../lib/cn";
|
|
38
|
+
import { AiCommandMenu, type AiCommandRunRequest } from "./AiCommandMenu";
|
|
39
|
+
import { ToolbarButton } from "./ToolbarButton";
|
|
40
|
+
import { ImageInsertPanel, LinkInsertPanel, TableInsertPanel } from "./ToolbarInsertPanels";
|
|
41
|
+
import { HighlightColorPanel, TextColorPanel } from "./ToolbarFormatPanels";
|
|
42
|
+
import { ToolbarPopover } from "./ToolbarPopover";
|
|
43
|
+
|
|
44
|
+
type EditorToolbarProps = {
|
|
45
|
+
editor: Editor | null;
|
|
46
|
+
config?: SpatikaEditorToolbarConfig;
|
|
47
|
+
toolbarActions?: ReactNode;
|
|
48
|
+
aiActions?: AiActionDefinition[];
|
|
49
|
+
aiCommandMenu?: AiCommandMenuConfig | false;
|
|
50
|
+
useAiCommandMenu?: boolean;
|
|
51
|
+
onAiCommand?: (request: AiCommandRunRequest) => void;
|
|
52
|
+
onAiAction?: (actionId: string) => void;
|
|
53
|
+
onInsertImage?: () => void;
|
|
54
|
+
imageInsert?: ImageInsertConfig;
|
|
55
|
+
className?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function ToolbarGroup({ children, className }: { children: ReactNode; className?: string }) {
|
|
59
|
+
return <div className={cn("spk-editor-toolbar-group", className)}>{children}</div>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function EditorToolbar({
|
|
63
|
+
editor,
|
|
64
|
+
config,
|
|
65
|
+
toolbarActions,
|
|
66
|
+
aiActions,
|
|
67
|
+
aiCommandMenu,
|
|
68
|
+
useAiCommandMenu = false,
|
|
69
|
+
onAiCommand,
|
|
70
|
+
onAiAction,
|
|
71
|
+
onInsertImage,
|
|
72
|
+
imageInsert,
|
|
73
|
+
className,
|
|
74
|
+
}: EditorToolbarProps) {
|
|
75
|
+
const toolbar = { ...DEFAULT_TOOLBAR, ...config };
|
|
76
|
+
const [, setRevision] = useState(0);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!editor) return;
|
|
80
|
+
const refresh = () => setRevision((value) => value + 1);
|
|
81
|
+
editor.on("transaction", refresh);
|
|
82
|
+
return () => {
|
|
83
|
+
editor.off("transaction", refresh);
|
|
84
|
+
};
|
|
85
|
+
}, [editor]);
|
|
86
|
+
|
|
87
|
+
if (!editor) return null;
|
|
88
|
+
|
|
89
|
+
const historyGroup = toolbar.history ? (
|
|
90
|
+
<ToolbarGroup>
|
|
91
|
+
<ToolbarButton
|
|
92
|
+
aria-label="Undo"
|
|
93
|
+
disabled={!editor.can().undo()}
|
|
94
|
+
onClick={() => editor.chain().focus().undo().run()}
|
|
95
|
+
>
|
|
96
|
+
<Undo2 className="size-4" />
|
|
97
|
+
</ToolbarButton>
|
|
98
|
+
<ToolbarButton
|
|
99
|
+
aria-label="Redo"
|
|
100
|
+
disabled={!editor.can().redo()}
|
|
101
|
+
onClick={() => editor.chain().focus().redo().run()}
|
|
102
|
+
>
|
|
103
|
+
<Redo2 className="size-4" />
|
|
104
|
+
</ToolbarButton>
|
|
105
|
+
</ToolbarGroup>
|
|
106
|
+
) : null;
|
|
107
|
+
|
|
108
|
+
const textStyleGroup = toolbar.textStyle ? (
|
|
109
|
+
<ToolbarGroup>
|
|
110
|
+
<ToolbarButton
|
|
111
|
+
aria-label="Paragraph"
|
|
112
|
+
active={editor.isActive("paragraph")}
|
|
113
|
+
onClick={() => editor.chain().focus().setParagraph().run()}
|
|
114
|
+
>
|
|
115
|
+
<Pilcrow className="size-4" />
|
|
116
|
+
</ToolbarButton>
|
|
117
|
+
<ToolbarButton
|
|
118
|
+
aria-label="Heading 1"
|
|
119
|
+
active={editor.isActive("heading", { level: 1 })}
|
|
120
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
121
|
+
>
|
|
122
|
+
<Heading1 className="size-4" />
|
|
123
|
+
</ToolbarButton>
|
|
124
|
+
<ToolbarButton
|
|
125
|
+
aria-label="Heading 2"
|
|
126
|
+
active={editor.isActive("heading", { level: 2 })}
|
|
127
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
128
|
+
>
|
|
129
|
+
<Heading2 className="size-4" />
|
|
130
|
+
</ToolbarButton>
|
|
131
|
+
<ToolbarButton
|
|
132
|
+
aria-label="Heading 3"
|
|
133
|
+
active={editor.isActive("heading", { level: 3 })}
|
|
134
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
135
|
+
>
|
|
136
|
+
<Heading3 className="size-4" />
|
|
137
|
+
</ToolbarButton>
|
|
138
|
+
</ToolbarGroup>
|
|
139
|
+
) : null;
|
|
140
|
+
|
|
141
|
+
const inlineGroup = toolbar.inlineFormat ? (
|
|
142
|
+
<ToolbarGroup>
|
|
143
|
+
<ToolbarButton
|
|
144
|
+
aria-label="Bold"
|
|
145
|
+
active={editor.isActive("bold")}
|
|
146
|
+
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
147
|
+
>
|
|
148
|
+
<Bold className="size-4" />
|
|
149
|
+
</ToolbarButton>
|
|
150
|
+
<ToolbarButton
|
|
151
|
+
aria-label="Italic"
|
|
152
|
+
active={editor.isActive("italic")}
|
|
153
|
+
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
154
|
+
>
|
|
155
|
+
<Italic className="size-4" />
|
|
156
|
+
</ToolbarButton>
|
|
157
|
+
<ToolbarButton
|
|
158
|
+
aria-label="Underline"
|
|
159
|
+
active={editor.isActive("underline")}
|
|
160
|
+
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
161
|
+
>
|
|
162
|
+
<UnderlineIcon className="size-4" />
|
|
163
|
+
</ToolbarButton>
|
|
164
|
+
<ToolbarButton
|
|
165
|
+
aria-label="Strikethrough"
|
|
166
|
+
active={editor.isActive("strike")}
|
|
167
|
+
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
168
|
+
>
|
|
169
|
+
<Strikethrough className="size-4" />
|
|
170
|
+
</ToolbarButton>
|
|
171
|
+
<ToolbarButton
|
|
172
|
+
aria-label="Inline code"
|
|
173
|
+
active={editor.isActive("code")}
|
|
174
|
+
onClick={() => editor.chain().focus().toggleCode().run()}
|
|
175
|
+
>
|
|
176
|
+
<Code className="size-4" />
|
|
177
|
+
</ToolbarButton>
|
|
178
|
+
<ToolbarButton
|
|
179
|
+
aria-label="Subscript"
|
|
180
|
+
active={editor.isActive("subscript")}
|
|
181
|
+
onClick={() => editor.chain().focus().toggleSubscript().run()}
|
|
182
|
+
>
|
|
183
|
+
<Subscript className="size-4" />
|
|
184
|
+
</ToolbarButton>
|
|
185
|
+
<ToolbarButton
|
|
186
|
+
aria-label="Superscript"
|
|
187
|
+
active={editor.isActive("superscript")}
|
|
188
|
+
onClick={() => editor.chain().focus().toggleSuperscript().run()}
|
|
189
|
+
>
|
|
190
|
+
<Superscript className="size-4" />
|
|
191
|
+
</ToolbarButton>
|
|
192
|
+
<ToolbarPopover
|
|
193
|
+
label="Text color"
|
|
194
|
+
title="Text color"
|
|
195
|
+
icon={<Palette className="size-4" />}
|
|
196
|
+
active={Boolean(editor.getAttributes("textStyle").color)}
|
|
197
|
+
>
|
|
198
|
+
{({ close }) => <TextColorPanel editor={editor} close={close} />}
|
|
199
|
+
</ToolbarPopover>
|
|
200
|
+
<ToolbarPopover
|
|
201
|
+
label="Highlight"
|
|
202
|
+
title="Highlight color"
|
|
203
|
+
icon={<Highlighter className="size-4" />}
|
|
204
|
+
active={editor.isActive("highlight")}
|
|
205
|
+
>
|
|
206
|
+
{({ close }) => <HighlightColorPanel editor={editor} close={close} />}
|
|
207
|
+
</ToolbarPopover>
|
|
208
|
+
<ToolbarButton
|
|
209
|
+
aria-label="Blockquote"
|
|
210
|
+
active={editor.isActive("blockquote")}
|
|
211
|
+
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
212
|
+
>
|
|
213
|
+
<Quote className="size-4" />
|
|
214
|
+
</ToolbarButton>
|
|
215
|
+
</ToolbarGroup>
|
|
216
|
+
) : null;
|
|
217
|
+
|
|
218
|
+
const listGroup = toolbar.lists ? (
|
|
219
|
+
<ToolbarGroup>
|
|
220
|
+
<ToolbarButton
|
|
221
|
+
aria-label="Bullet list"
|
|
222
|
+
active={editor.isActive("bulletList")}
|
|
223
|
+
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
224
|
+
>
|
|
225
|
+
<List className="size-4" />
|
|
226
|
+
</ToolbarButton>
|
|
227
|
+
<ToolbarButton
|
|
228
|
+
aria-label="Numbered list"
|
|
229
|
+
active={editor.isActive("orderedList")}
|
|
230
|
+
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
231
|
+
>
|
|
232
|
+
<ListOrdered className="size-4" />
|
|
233
|
+
</ToolbarButton>
|
|
234
|
+
<ToolbarButton
|
|
235
|
+
aria-label="Task list"
|
|
236
|
+
active={editor.isActive("taskList")}
|
|
237
|
+
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
|
238
|
+
>
|
|
239
|
+
<ListTodo className="size-4" />
|
|
240
|
+
</ToolbarButton>
|
|
241
|
+
</ToolbarGroup>
|
|
242
|
+
) : null;
|
|
243
|
+
|
|
244
|
+
const alignGroup = toolbar.alignment ? (
|
|
245
|
+
<ToolbarGroup>
|
|
246
|
+
<ToolbarButton
|
|
247
|
+
aria-label="Align left"
|
|
248
|
+
active={editor.isActive({ textAlign: "left" })}
|
|
249
|
+
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
|
250
|
+
>
|
|
251
|
+
<AlignLeft className="size-4" />
|
|
252
|
+
</ToolbarButton>
|
|
253
|
+
<ToolbarButton
|
|
254
|
+
aria-label="Align center"
|
|
255
|
+
active={editor.isActive({ textAlign: "center" })}
|
|
256
|
+
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
|
257
|
+
>
|
|
258
|
+
<AlignCenter className="size-4" />
|
|
259
|
+
</ToolbarButton>
|
|
260
|
+
<ToolbarButton
|
|
261
|
+
aria-label="Align right"
|
|
262
|
+
active={editor.isActive({ textAlign: "right" })}
|
|
263
|
+
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
|
264
|
+
>
|
|
265
|
+
<AlignRight className="size-4" />
|
|
266
|
+
</ToolbarButton>
|
|
267
|
+
<ToolbarButton
|
|
268
|
+
aria-label="Justify"
|
|
269
|
+
active={editor.isActive({ textAlign: "justify" })}
|
|
270
|
+
onClick={() => editor.chain().focus().setTextAlign("justify").run()}
|
|
271
|
+
>
|
|
272
|
+
<AlignJustify className="size-4" />
|
|
273
|
+
</ToolbarButton>
|
|
274
|
+
</ToolbarGroup>
|
|
275
|
+
) : null;
|
|
276
|
+
|
|
277
|
+
const insertGroup = toolbar.insert ? (
|
|
278
|
+
<ToolbarGroup>
|
|
279
|
+
<ToolbarPopover label="Insert link" title="Insert link" icon={<Link2 className="size-4" />}>
|
|
280
|
+
{({ close }) => <LinkInsertPanel editor={editor} close={close} />}
|
|
281
|
+
</ToolbarPopover>
|
|
282
|
+
<ToolbarButton
|
|
283
|
+
aria-label="Insert divider"
|
|
284
|
+
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
|
285
|
+
>
|
|
286
|
+
<Minus className="size-4" />
|
|
287
|
+
</ToolbarButton>
|
|
288
|
+
<ToolbarButton
|
|
289
|
+
aria-label="Code block"
|
|
290
|
+
active={editor.isActive("codeBlock")}
|
|
291
|
+
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
292
|
+
>
|
|
293
|
+
<SquareCode className="size-4" />
|
|
294
|
+
</ToolbarButton>
|
|
295
|
+
<ToolbarPopover label="Insert table" title="Insert table" icon={<Table2 className="size-4" />}>
|
|
296
|
+
{({ close }) => <TableInsertPanel editor={editor} close={close} />}
|
|
297
|
+
</ToolbarPopover>
|
|
298
|
+
{onInsertImage ? (
|
|
299
|
+
<ToolbarButton aria-label="Insert image" onClick={onInsertImage}>
|
|
300
|
+
<ImageIcon className="size-4" />
|
|
301
|
+
</ToolbarButton>
|
|
302
|
+
) : (
|
|
303
|
+
<ToolbarPopover label="Insert image" title="Insert image" icon={<ImageIcon className="size-4" />}>
|
|
304
|
+
{({ close }) => <ImageInsertPanel editor={editor} close={close} config={imageInsert} />}
|
|
305
|
+
</ToolbarPopover>
|
|
306
|
+
)}
|
|
307
|
+
</ToolbarGroup>
|
|
308
|
+
) : null;
|
|
309
|
+
|
|
310
|
+
const aiGroup =
|
|
311
|
+
toolbar.ai && useAiCommandMenu && onAiCommand ? (
|
|
312
|
+
<ToolbarGroup>
|
|
313
|
+
<AiCommandMenu
|
|
314
|
+
editor={editor}
|
|
315
|
+
config={typeof aiCommandMenu === "object" ? aiCommandMenu : undefined}
|
|
316
|
+
actions={aiActions}
|
|
317
|
+
onRun={onAiCommand}
|
|
318
|
+
/>
|
|
319
|
+
</ToolbarGroup>
|
|
320
|
+
) : toolbar.ai && aiActions?.length ? (
|
|
321
|
+
<ToolbarGroup>
|
|
322
|
+
{aiActions.map((action) => (
|
|
323
|
+
<ToolbarButton
|
|
324
|
+
key={action.id}
|
|
325
|
+
aria-label={action.label}
|
|
326
|
+
onClick={() => onAiAction?.(action.id)}
|
|
327
|
+
>
|
|
328
|
+
{action.icon ?? <Sparkles className="size-4" />}
|
|
329
|
+
</ToolbarButton>
|
|
330
|
+
))}
|
|
331
|
+
</ToolbarGroup>
|
|
332
|
+
) : null;
|
|
333
|
+
|
|
334
|
+
const groupNodes = [
|
|
335
|
+
{ id: "history", node: historyGroup },
|
|
336
|
+
{ id: "text-style", node: textStyleGroup },
|
|
337
|
+
{ id: "inline", node: inlineGroup },
|
|
338
|
+
{ id: "lists", node: listGroup },
|
|
339
|
+
{ id: "align", node: alignGroup },
|
|
340
|
+
{ id: "insert", node: insertGroup },
|
|
341
|
+
{ id: "ai", node: aiGroup },
|
|
342
|
+
].filter((entry) => entry.node);
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<div
|
|
346
|
+
className={cn(
|
|
347
|
+
"spk-editor-toolbar",
|
|
348
|
+
toolbar.layout === "compact" && "spk-editor-toolbar--compact",
|
|
349
|
+
className,
|
|
350
|
+
)}
|
|
351
|
+
role="toolbar"
|
|
352
|
+
aria-label="Editor formatting"
|
|
353
|
+
>
|
|
354
|
+
<div className="spk-editor-toolbar-row">
|
|
355
|
+
{groupNodes.map((entry) => (
|
|
356
|
+
<div key={entry.id}>{entry.node}</div>
|
|
357
|
+
))}
|
|
358
|
+
</div>
|
|
359
|
+
{toolbarActions ? <div className="spk-editor-toolbar-actions">{toolbarActions}</div> : null}
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { ImageBubbleMenu } from "./ImageBubbleMenu";
|
|
6
|
+
|
|
7
|
+
vi.mock("@tiptap/react/menus", () => ({
|
|
8
|
+
BubbleMenu: ({ children }: { children: React.ReactNode }) => (
|
|
9
|
+
<div role="group" aria-label="Image bubble menu">
|
|
10
|
+
{children}
|
|
11
|
+
</div>
|
|
12
|
+
),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
function createEditor() {
|
|
16
|
+
const run = vi.fn();
|
|
17
|
+
const chain = {
|
|
18
|
+
focus: vi.fn().mockReturnThis(),
|
|
19
|
+
updateAttributes: vi.fn().mockReturnThis(),
|
|
20
|
+
deleteSelection: vi.fn().mockReturnThis(),
|
|
21
|
+
run,
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
isActive: vi.fn().mockReturnValue(true),
|
|
25
|
+
chain: vi.fn(() => chain),
|
|
26
|
+
} as unknown as Editor;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("ImageBubbleMenu", () => {
|
|
30
|
+
it("returns null without an editor", () => {
|
|
31
|
+
const { container } = render(<ImageBubbleMenu editor={null} />);
|
|
32
|
+
expect(container).toBeEmptyDOMElement();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("updates image width from bubble controls", async () => {
|
|
36
|
+
const editor = createEditor();
|
|
37
|
+
render(<ImageBubbleMenu editor={editor} />);
|
|
38
|
+
await userEvent.click(screen.getByRole("button", { name: "Image width 50%" }));
|
|
39
|
+
const chain = editor.chain();
|
|
40
|
+
expect(chain.updateAttributes).toHaveBeenCalledWith("image", { width: "50%" });
|
|
41
|
+
expect(chain.run).toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import { BubbleMenu } from "@tiptap/react/menus";
|
|
3
|
+
import { AlignCenter, AlignLeft, AlignRight, Trash2 } from "lucide-react";
|
|
4
|
+
import type { ImageAlign } from "../extensions/resizable-image";
|
|
5
|
+
import { ToolbarButton } from "./ToolbarButton";
|
|
6
|
+
|
|
7
|
+
type ImageBubbleMenuProps = {
|
|
8
|
+
editor: Editor | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function ImageBubbleMenu({ editor }: ImageBubbleMenuProps) {
|
|
12
|
+
if (!editor) return null;
|
|
13
|
+
|
|
14
|
+
const setAlign = (align: ImageAlign) => {
|
|
15
|
+
editor.chain().focus().updateAttributes("image", { align }).run();
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const setWidth = (width: string) => {
|
|
19
|
+
editor.chain().focus().updateAttributes("image", { width }).run();
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<BubbleMenu
|
|
24
|
+
editor={editor}
|
|
25
|
+
className="spk-editor-bubble-menu"
|
|
26
|
+
shouldShow={({ editor: ed }) => ed.isActive("image")}
|
|
27
|
+
>
|
|
28
|
+
<ToolbarButton aria-label="Align image left" onClick={() => setAlign("left")}>
|
|
29
|
+
<AlignLeft className="size-4" />
|
|
30
|
+
</ToolbarButton>
|
|
31
|
+
<ToolbarButton aria-label="Align image center" onClick={() => setAlign("center")}>
|
|
32
|
+
<AlignCenter className="size-4" />
|
|
33
|
+
</ToolbarButton>
|
|
34
|
+
<ToolbarButton aria-label="Align image right" onClick={() => setAlign("right")}>
|
|
35
|
+
<AlignRight className="size-4" />
|
|
36
|
+
</ToolbarButton>
|
|
37
|
+
<ToolbarButton aria-label="Image width 50%" onClick={() => setWidth("50%")}>
|
|
38
|
+
50%
|
|
39
|
+
</ToolbarButton>
|
|
40
|
+
<ToolbarButton aria-label="Image width 100%" onClick={() => setWidth("100%")}>
|
|
41
|
+
100%
|
|
42
|
+
</ToolbarButton>
|
|
43
|
+
<ToolbarButton
|
|
44
|
+
aria-label="Delete image"
|
|
45
|
+
onClick={() => editor.chain().focus().deleteSelection().run()}
|
|
46
|
+
>
|
|
47
|
+
<Trash2 className="size-4" />
|
|
48
|
+
</ToolbarButton>
|
|
49
|
+
</BubbleMenu>
|
|
50
|
+
);
|
|
51
|
+
}
|