@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.
Files changed (142) hide show
  1. package/README.md +43 -0
  2. package/dist/composites/AiCommandDock.d.ts +13 -0
  3. package/dist/composites/AiCommandDock.d.ts.map +1 -0
  4. package/dist/composites/AiCommandDock.js +63 -0
  5. package/dist/composites/AiCommandMenu.d.ts +16 -0
  6. package/dist/composites/AiCommandMenu.d.ts.map +1 -0
  7. package/dist/composites/AiCommandMenu.js +51 -0
  8. package/dist/composites/AiCommandPanel.d.ts +12 -0
  9. package/dist/composites/AiCommandPanel.d.ts.map +1 -0
  10. package/dist/composites/AiCommandPanel.js +12 -0
  11. package/dist/composites/AiOverlay.d.ts +7 -0
  12. package/dist/composites/AiOverlay.d.ts.map +1 -0
  13. package/dist/composites/AiOverlay.js +8 -0
  14. package/dist/composites/EditorBubbleMenu.d.ts +10 -0
  15. package/dist/composites/EditorBubbleMenu.d.ts.map +1 -0
  16. package/dist/composites/EditorBubbleMenu.js +16 -0
  17. package/dist/composites/EditorToolbar.d.ts +20 -0
  18. package/dist/composites/EditorToolbar.d.ts.map +1 -0
  19. package/dist/composites/EditorToolbar.js +45 -0
  20. package/dist/composites/ImageBubbleMenu.d.ts +7 -0
  21. package/dist/composites/ImageBubbleMenu.d.ts.map +1 -0
  22. package/dist/composites/ImageBubbleMenu.js +15 -0
  23. package/dist/composites/MentionMenu.d.ts +11 -0
  24. package/dist/composites/MentionMenu.d.ts.map +1 -0
  25. package/dist/composites/MentionMenu.js +10 -0
  26. package/dist/composites/SlashCommandMenu.d.ts +13 -0
  27. package/dist/composites/SlashCommandMenu.d.ts.map +1 -0
  28. package/dist/composites/SlashCommandMenu.js +10 -0
  29. package/dist/composites/SpatikaEditor.d.ts +14 -0
  30. package/dist/composites/SpatikaEditor.d.ts.map +1 -0
  31. package/dist/composites/SpatikaEditor.js +159 -0
  32. package/dist/composites/TableGridPicker.d.ts +9 -0
  33. package/dist/composites/TableGridPicker.d.ts.map +1 -0
  34. package/dist/composites/TableGridPicker.js +54 -0
  35. package/dist/composites/ToolbarButton.d.ts +7 -0
  36. package/dist/composites/ToolbarButton.d.ts.map +1 -0
  37. package/dist/composites/ToolbarButton.js +7 -0
  38. package/dist/composites/ToolbarFormatPanels.d.ts +9 -0
  39. package/dist/composites/ToolbarFormatPanels.d.ts.map +1 -0
  40. package/dist/composites/ToolbarFormatPanels.js +35 -0
  41. package/dist/composites/ToolbarInsertPanels.d.ts +14 -0
  42. package/dist/composites/ToolbarInsertPanels.d.ts.map +1 -0
  43. package/dist/composites/ToolbarInsertPanels.js +96 -0
  44. package/dist/composites/ToolbarPopover.d.ts +14 -0
  45. package/dist/composites/ToolbarPopover.d.ts.map +1 -0
  46. package/dist/composites/ToolbarPopover.js +48 -0
  47. package/dist/extensions/ai-action.d.ts +24 -0
  48. package/dist/extensions/ai-action.d.ts.map +1 -0
  49. package/dist/extensions/ai-action.js +53 -0
  50. package/dist/extensions/mention.d.ts +17 -0
  51. package/dist/extensions/mention.d.ts.map +1 -0
  52. package/dist/extensions/mention.js +129 -0
  53. package/dist/extensions/resizable-image-view.d.ts +18 -0
  54. package/dist/extensions/resizable-image-view.d.ts.map +1 -0
  55. package/dist/extensions/resizable-image-view.js +70 -0
  56. package/dist/extensions/resizable-image.d.ts +4 -0
  57. package/dist/extensions/resizable-image.d.ts.map +1 -0
  58. package/dist/extensions/resizable-image.js +68 -0
  59. package/dist/extensions/slash-command.d.ts +18 -0
  60. package/dist/extensions/slash-command.d.ts.map +1 -0
  61. package/dist/extensions/slash-command.js +178 -0
  62. package/dist/index.d.ts +24 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +18 -0
  65. package/dist/lib/ai-command-menu.d.ts +9 -0
  66. package/dist/lib/ai-command-menu.d.ts.map +1 -0
  67. package/dist/lib/ai-command-menu.js +43 -0
  68. package/dist/lib/cn.d.ts +4 -0
  69. package/dist/lib/cn.d.ts.map +1 -0
  70. package/dist/lib/cn.js +6 -0
  71. package/dist/lib/editor-format-colors.d.ts +10 -0
  72. package/dist/lib/editor-format-colors.d.ts.map +1 -0
  73. package/dist/lib/editor-format-colors.js +24 -0
  74. package/dist/lib/image-upload.d.ts +14 -0
  75. package/dist/lib/image-upload.d.ts.map +1 -0
  76. package/dist/lib/image-upload.js +29 -0
  77. package/dist/lib/insert-editor-images.d.ts +5 -0
  78. package/dist/lib/insert-editor-images.d.ts.map +1 -0
  79. package/dist/lib/insert-editor-images.js +32 -0
  80. package/dist/lib/types.d.ts +131 -0
  81. package/dist/lib/types.d.ts.map +1 -0
  82. package/dist/lib/types.js +12 -0
  83. package/dist/lib/use-spatika-editor.d.ts +13 -0
  84. package/dist/lib/use-spatika-editor.d.ts.map +1 -0
  85. package/dist/lib/use-spatika-editor.js +171 -0
  86. package/dist/styles.css +1056 -0
  87. package/package.json +101 -0
  88. package/src/composites/AiCommandDock.test.tsx +48 -0
  89. package/src/composites/AiCommandDock.tsx +137 -0
  90. package/src/composites/AiCommandMenu.test.tsx +67 -0
  91. package/src/composites/AiCommandMenu.tsx +120 -0
  92. package/src/composites/AiCommandPanel.test.tsx +42 -0
  93. package/src/composites/AiCommandPanel.tsx +56 -0
  94. package/src/composites/AiOverlay.test.tsx +18 -0
  95. package/src/composites/AiOverlay.tsx +18 -0
  96. package/src/composites/EditorBubbleMenu.test.tsx +49 -0
  97. package/src/composites/EditorBubbleMenu.tsx +73 -0
  98. package/src/composites/EditorToolbar.test.tsx +146 -0
  99. package/src/composites/EditorToolbar.tsx +362 -0
  100. package/src/composites/ImageBubbleMenu.test.tsx +43 -0
  101. package/src/composites/ImageBubbleMenu.tsx +51 -0
  102. package/src/composites/MentionMenu.test.tsx +25 -0
  103. package/src/composites/MentionMenu.tsx +46 -0
  104. package/src/composites/SlashCommandMenu.test.tsx +33 -0
  105. package/src/composites/SlashCommandMenu.tsx +54 -0
  106. package/src/composites/SpatikaEditor.test.tsx +167 -0
  107. package/src/composites/SpatikaEditor.tsx +302 -0
  108. package/src/composites/TableGridPicker.test.tsx +26 -0
  109. package/src/composites/TableGridPicker.tsx +138 -0
  110. package/src/composites/ToolbarButton.test.tsx +21 -0
  111. package/src/composites/ToolbarButton.tsx +24 -0
  112. package/src/composites/ToolbarFormatPanels.test.tsx +46 -0
  113. package/src/composites/ToolbarFormatPanels.tsx +78 -0
  114. package/src/composites/ToolbarInsertPanels.test.tsx +60 -0
  115. package/src/composites/ToolbarInsertPanels.tsx +200 -0
  116. package/src/composites/ToolbarPopover.test.tsx +24 -0
  117. package/src/composites/ToolbarPopover.tsx +95 -0
  118. package/src/extensions/ai-action.test.ts +26 -0
  119. package/src/extensions/ai-action.ts +73 -0
  120. package/src/extensions/mention.test.ts +31 -0
  121. package/src/extensions/mention.ts +158 -0
  122. package/src/extensions/resizable-image-view.test.ts +26 -0
  123. package/src/extensions/resizable-image-view.ts +96 -0
  124. package/src/extensions/resizable-image.test.ts +28 -0
  125. package/src/extensions/resizable-image.ts +75 -0
  126. package/src/extensions/slash-command.test.ts +45 -0
  127. package/src/extensions/slash-command.ts +197 -0
  128. package/src/index.ts +49 -0
  129. package/src/lib/ai-command-menu.test.ts +48 -0
  130. package/src/lib/ai-command-menu.ts +57 -0
  131. package/src/lib/cn.ts +7 -0
  132. package/src/lib/editor-format-colors.test.ts +15 -0
  133. package/src/lib/editor-format-colors.ts +31 -0
  134. package/src/lib/image-upload.test.ts +19 -0
  135. package/src/lib/image-upload.ts +49 -0
  136. package/src/lib/insert-editor-images.test.ts +76 -0
  137. package/src/lib/insert-editor-images.ts +44 -0
  138. package/src/lib/types.ts +149 -0
  139. package/src/lib/use-spatika-editor.test.tsx +56 -0
  140. package/src/lib/use-spatika-editor.ts +191 -0
  141. package/src/styles/editor.css +1056 -0
  142. 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
+ }