@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,25 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { MentionMenu } from "./MentionMenu";
5
+
6
+ const contacts = [{ id: "1", label: "Asha Verma", subtitle: "Design" }];
7
+
8
+ describe("MentionMenu", () => {
9
+ it("returns null when closed", () => {
10
+ const { container } = render(
11
+ <MentionMenu contacts={contacts} query="" activeIndex={0} open={false} onSelect={vi.fn()} />,
12
+ );
13
+ expect(container).toBeEmptyDOMElement();
14
+ });
15
+
16
+ it("selects a contact on option click", async () => {
17
+ const onSelect = vi.fn();
18
+ render(
19
+ <MentionMenu contacts={contacts} query="ash" activeIndex={0} open onSelect={onSelect} />,
20
+ );
21
+ expect(screen.getByRole("listbox", { name: "Mentions" })).toBeInTheDocument();
22
+ await userEvent.click(screen.getByRole("option", { name: /Asha Verma/i }));
23
+ expect(onSelect).toHaveBeenCalledWith(contacts[0]);
24
+ });
25
+ });
@@ -0,0 +1,46 @@
1
+ import type { MentionContact } from "../lib/types";
2
+ import { cn } from "../lib/cn";
3
+
4
+ type MentionMenuProps = {
5
+ contacts: MentionContact[];
6
+ query: string;
7
+ activeIndex: number;
8
+ open: boolean;
9
+ onSelect: (contact: MentionContact) => void;
10
+ };
11
+
12
+ export function MentionMenu({ contacts, query, activeIndex, open, onSelect }: MentionMenuProps) {
13
+ if (!open || contacts.length === 0) return null;
14
+
15
+ return (
16
+ <div className="spk-editor-suggestion" role="listbox" aria-label="Mentions">
17
+ <div className="spk-editor-suggestion-header">
18
+ {query ? `People matching “@${query}”` : "Mention someone"}
19
+ </div>
20
+ <ul className="spk-editor-suggestion-list">
21
+ {contacts.map((contact, index) => (
22
+ <li key={contact.id}>
23
+ <button
24
+ type="button"
25
+ role="option"
26
+ aria-selected={index === activeIndex}
27
+ className={cn(
28
+ "spk-editor-suggestion-item",
29
+ index === activeIndex && "spk-editor-suggestion-item--active",
30
+ )}
31
+ onMouseDown={(event) => {
32
+ event.preventDefault();
33
+ onSelect(contact);
34
+ }}
35
+ >
36
+ <span className="spk-editor-suggestion-title">{contact.label}</span>
37
+ {contact.subtitle ? (
38
+ <span className="spk-editor-suggestion-desc">{contact.subtitle}</span>
39
+ ) : null}
40
+ </button>
41
+ </li>
42
+ ))}
43
+ </ul>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,33 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { SlashCommandItem } from "../lib/types";
5
+ import { SlashCommandMenu } from "./SlashCommandMenu";
6
+
7
+ const items: SlashCommandItem[] = [
8
+ {
9
+ id: "h1",
10
+ title: "Heading 1",
11
+ description: "Large title",
12
+ command: vi.fn(),
13
+ },
14
+ ];
15
+
16
+ describe("SlashCommandMenu", () => {
17
+ it("renders filtered slash commands", async () => {
18
+ const onSelect = vi.fn();
19
+ render(
20
+ <SlashCommandMenu
21
+ editor={null}
22
+ items={items}
23
+ query="head"
24
+ activeIndex={0}
25
+ open
26
+ onSelect={onSelect}
27
+ />,
28
+ );
29
+ expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
30
+ await userEvent.click(screen.getByRole("option", { name: /Heading 1/i }));
31
+ expect(onSelect).toHaveBeenCalledWith(items[0]);
32
+ });
33
+ });
@@ -0,0 +1,54 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import type { SlashCommandItem } from "../lib/types";
3
+ import { cn } from "../lib/cn";
4
+
5
+ type SlashCommandMenuProps = {
6
+ editor: Editor | null;
7
+ items: SlashCommandItem[];
8
+ query: string;
9
+ activeIndex: number;
10
+ open: boolean;
11
+ onSelect: (item: SlashCommandItem) => void;
12
+ };
13
+
14
+ export function SlashCommandMenu({
15
+ items,
16
+ query,
17
+ activeIndex,
18
+ open,
19
+ onSelect,
20
+ }: SlashCommandMenuProps) {
21
+ if (!open || items.length === 0) return null;
22
+
23
+ return (
24
+ <div className="spk-editor-suggestion" role="listbox" aria-label="Slash commands">
25
+ <div className="spk-editor-suggestion-header">
26
+ {query ? `Results for “/${query}”` : "Insert block"}
27
+ </div>
28
+ <ul className="spk-editor-suggestion-list">
29
+ {items.map((item, index) => (
30
+ <li key={item.id}>
31
+ <button
32
+ type="button"
33
+ role="option"
34
+ aria-selected={index === activeIndex}
35
+ className={cn(
36
+ "spk-editor-suggestion-item",
37
+ index === activeIndex && "spk-editor-suggestion-item--active",
38
+ )}
39
+ onMouseDown={(event) => {
40
+ event.preventDefault();
41
+ onSelect(item);
42
+ }}
43
+ >
44
+ <span className="spk-editor-suggestion-title">{item.title}</span>
45
+ {item.description ? (
46
+ <span className="spk-editor-suggestion-desc">{item.description}</span>
47
+ ) : null}
48
+ </button>
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,167 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useState } from "react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { defaultSlashCommands } from "../extensions/slash-command";
6
+ import { buildSpatikaExtensions } from "../lib/use-spatika-editor";
7
+ import { SpatikaEditor } from "./SpatikaEditor";
8
+
9
+ function ControlledEditor(
10
+ props: Partial<React.ComponentProps<typeof SpatikaEditor>> & { initial?: string },
11
+ ) {
12
+ const [value, setValue] = useState(props.initial ?? "<p>Hello editor</p>");
13
+ return (
14
+ <SpatikaEditor
15
+ value={value}
16
+ onChange={setValue}
17
+ placeholder="Write here…"
18
+ toolbar={{ layout: "compact", ai: false }}
19
+ {...props}
20
+ />
21
+ );
22
+ }
23
+
24
+ describe("buildSpatikaExtensions", () => {
25
+ it("includes default slash commands and starter kit", () => {
26
+ const extensions = buildSpatikaExtensions({
27
+ value: "",
28
+ onChange: () => {},
29
+ });
30
+ expect(extensions.length).toBeGreaterThan(5);
31
+ expect(defaultSlashCommands().map((item) => item.id)).toContain("h1");
32
+ });
33
+ });
34
+
35
+ describe("SpatikaEditor", () => {
36
+ it("renders toolbar controls and editable surface", async () => {
37
+ render(<ControlledEditor />);
38
+ expect(await screen.findByRole("toolbar", { name: "Editor formatting" })).toBeInTheDocument();
39
+ expect(screen.getByRole("textbox", { name: "Rich text editor" })).toBeInTheDocument();
40
+ expect(screen.getByLabelText("Bold")).toBeInTheDocument();
41
+ });
42
+
43
+ it("calls onChange when block formatting is applied", async () => {
44
+ const user = userEvent.setup();
45
+ const onChange = vi.fn();
46
+ render(<ControlledEditor onChange={onChange} initial="<p>Hello</p>" />);
47
+ const surface = await screen.findByRole("textbox", { name: "Rich text editor" });
48
+ await user.click(surface);
49
+ await user.click(screen.getByLabelText("Heading 1"));
50
+ await waitFor(() => {
51
+ expect(onChange).toHaveBeenCalled();
52
+ const last = onChange.mock.calls.at(-1)?.[0] as string;
53
+ expect(last).toContain("<h1");
54
+ });
55
+ });
56
+
57
+ it("shows slash command menu when typing /", async () => {
58
+ const user = userEvent.setup();
59
+ render(<ControlledEditor initial="<p></p>" />);
60
+ const surface = await screen.findByRole("textbox", { name: "Rich text editor" });
61
+ await user.click(surface);
62
+ await user.type(surface, "/heading");
63
+ await waitFor(
64
+ () => {
65
+ expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
66
+ },
67
+ { timeout: 3000 },
68
+ );
69
+ expect(screen.getByRole("option", { name: /Heading 1/i })).toBeInTheDocument();
70
+ });
71
+
72
+ it("shows mention menu when mentions are provided", async () => {
73
+ const user = userEvent.setup();
74
+ render(
75
+ <ControlledEditor
76
+ initial="<p></p>"
77
+ mentions={[{ id: "1", label: "Asha Verma", subtitle: "Design" }]}
78
+ />,
79
+ );
80
+ const surface = await screen.findByRole("textbox", { name: "Rich text editor" });
81
+ await user.click(surface);
82
+ await user.keyboard("@ash");
83
+ expect(await screen.findByRole("listbox", { name: "Mentions" })).toBeInTheDocument();
84
+ expect(screen.getByText("Asha Verma")).toBeInTheDocument();
85
+ });
86
+
87
+ it("shows the AI dock when an AI handler is provided", async () => {
88
+ const user = userEvent.setup();
89
+ const { container } = render(
90
+ <ControlledEditor
91
+ onAiCommand={async () => "Rewritten text"}
92
+ aiCommandMenu={{ title: "AI Toolkit examples", placement: "dock" }}
93
+ toolbar={{ ai: true, layout: "compact", insert: false, alignment: false, lists: false, textStyle: false }}
94
+ />,
95
+ );
96
+ await screen.findByRole("textbox", { name: "Rich text editor" });
97
+ expect(
98
+ screen.getByPlaceholderText("Ask about this document or request a change…"),
99
+ ).toBeInTheDocument();
100
+ expect(screen.queryByLabelText("Proofread selection")).not.toBeInTheDocument();
101
+ expect(container.querySelector(".spk-editor-ai-dock-footer")).toBeInTheDocument();
102
+ expect(container.querySelector(".spk-editor-shell--with-ai-dock")).toBeInTheDocument();
103
+ await user.click(screen.getByRole("button", { name: "AI commands" }));
104
+ expect(screen.getByRole("dialog", { name: "AI Toolkit examples" })).toBeInTheDocument();
105
+ });
106
+
107
+ it("shows the toolbar AI menu when placement is toolbar", async () => {
108
+ const user = userEvent.setup();
109
+ render(
110
+ <ControlledEditor
111
+ onAiCommand={async () => "Rewritten text"}
112
+ aiCommandMenu={{ title: "AI Toolkit examples", placement: "toolbar" }}
113
+ toolbar={{ ai: true, layout: "compact", insert: false, alignment: false, lists: false, textStyle: false }}
114
+ />,
115
+ );
116
+ await screen.findByRole("textbox", { name: "Rich text editor" });
117
+ await user.click(screen.getByRole("button", { name: "AI commands" }));
118
+ expect(screen.getByRole("dialog", { name: "AI Toolkit examples" })).toBeInTheDocument();
119
+ });
120
+
121
+ it("expands to fullscreen on focus when mobileFullscreen is enabled", async () => {
122
+ vi.stubGlobal(
123
+ "matchMedia",
124
+ vi.fn().mockImplementation((query: string) => ({
125
+ matches: query.includes("max-width: 767px"),
126
+ media: query,
127
+ addEventListener: vi.fn(),
128
+ removeEventListener: vi.fn(),
129
+ addListener: vi.fn(),
130
+ removeListener: vi.fn(),
131
+ dispatchEvent: vi.fn(),
132
+ })),
133
+ );
134
+
135
+ const user = userEvent.setup();
136
+ render(
137
+ <ControlledEditor
138
+ mobileFullscreen
139
+ initial="<p>Mobile edit</p>"
140
+ toolbar={{ ai: false, layout: "compact" }}
141
+ />,
142
+ );
143
+ const surface = await screen.findByRole("textbox", { name: "Rich text editor" });
144
+ await user.click(surface);
145
+ await waitFor(() => {
146
+ expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
147
+ });
148
+
149
+ vi.unstubAllGlobals();
150
+ });
151
+
152
+ it("invokes onAiSelectionAction when text is selected", async () => {
153
+ const onAiSelectionAction = vi.fn(async () => "Rewritten text");
154
+ render(
155
+ <ControlledEditor
156
+ initial="<p>Selected text</p>"
157
+ onAiSelectionAction={onAiSelectionAction}
158
+ aiCommandMenu={false}
159
+ aiActions={[{ id: "improve", label: "Improve writing" }]}
160
+ toolbar={{ ai: true, layout: "compact", insert: false, alignment: false, lists: false, textStyle: false }}
161
+ />,
162
+ );
163
+ await screen.findByRole("textbox", { name: "Rich text editor" });
164
+ await screen.getByLabelText("Improve writing").click();
165
+ expect(onAiSelectionAction).not.toHaveBeenCalled();
166
+ });
167
+ });
@@ -0,0 +1,302 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { createPortal } from "react-dom";
11
+ import { EditorContent } from "@tiptap/react";
12
+ import type { MentionStorage } from "../extensions/mention";
13
+ import type { SlashCommandStorage } from "../extensions/slash-command";
14
+ import { defaultSlashCommands } from "../extensions/slash-command";
15
+ import { cn } from "../lib/cn";
16
+ import type { SpatikaEditorHandle, SpatikaEditorProps } from "../lib/types";
17
+ import { DEFAULT_TOOLBAR } from "../lib/types";
18
+ import {
19
+ DEFAULT_AI_ACTIONS,
20
+ resolveAiCommandActions,
21
+ resolveAiCommandPlacement,
22
+ usesAiCommandMenu,
23
+ } from "../lib/ai-command-menu";
24
+ import { useSpatikaEditor } from "../lib/use-spatika-editor";
25
+ import { AiCommandDock } from "./AiCommandDock";
26
+ import { AiOverlay } from "./AiOverlay";
27
+ import { EditorBubbleMenu } from "./EditorBubbleMenu";
28
+ import { EditorToolbar } from "./EditorToolbar";
29
+ import { ImageBubbleMenu } from "./ImageBubbleMenu";
30
+ import { MentionMenu } from "./MentionMenu";
31
+ import { SlashCommandMenu } from "./SlashCommandMenu";
32
+
33
+ const DEFAULT_AI_ACTIONS_LEGACY = DEFAULT_AI_ACTIONS;
34
+
35
+ export const SpatikaEditor = forwardRef<SpatikaEditorHandle, SpatikaEditorProps>(
36
+ function SpatikaEditor(
37
+ {
38
+ value,
39
+ onChange,
40
+ placeholder,
41
+ disabled,
42
+ className,
43
+ heightClassName = "min-h-[400px]",
44
+ toolbar,
45
+ toolbarActions,
46
+ extensions,
47
+ starterKit,
48
+ slashCommands,
49
+ mentions,
50
+ onAiSelectionAction,
51
+ onAiCommand,
52
+ isAiProcessing,
53
+ aiActions = DEFAULT_AI_ACTIONS_LEGACY,
54
+ aiCommandMenu,
55
+ imageInsert,
56
+ onInsertImage,
57
+ resizableImages = true,
58
+ mobileFullscreen = false,
59
+ },
60
+ ref,
61
+ ) {
62
+ const shellRef = useRef<HTMLDivElement>(null);
63
+ const [fullscreen, setFullscreen] = useState(false);
64
+ const [slashState, setSlashState] = useState<SlashCommandStorage | null>(null);
65
+ const [mentionState, setMentionState] = useState<MentionStorage | null>(null);
66
+ const [aiBusyInternal, setAiBusyInternal] = useState(false);
67
+
68
+ const { editor, isReady } = useSpatikaEditor({
69
+ value,
70
+ onChange,
71
+ placeholder,
72
+ disabled,
73
+ extensions,
74
+ starterKit,
75
+ slashCommands,
76
+ mentions,
77
+ onAiSelectionAction,
78
+ onAiCommand,
79
+ resizableImages,
80
+ imageInsert,
81
+ onSlashUpdate: setSlashState,
82
+ onMentionUpdate: setMentionState,
83
+ });
84
+
85
+ useImperativeHandle(
86
+ ref,
87
+ () => ({
88
+ focus: () => editor?.commands.focus(),
89
+ setContent: (html: string) => editor?.commands.setContent(html),
90
+ getHTML: () => editor?.getHTML() ?? "",
91
+ getJSON: () => editor?.getJSON(),
92
+ expandFullscreen: () => setFullscreen(true),
93
+ }),
94
+ [editor],
95
+ );
96
+
97
+ const toolbarConfig = { ...DEFAULT_TOOLBAR, ...toolbar };
98
+ const allSlashCommands = useMemo(
99
+ () => [...defaultSlashCommands(), ...(slashCommands ?? [])],
100
+ [slashCommands],
101
+ );
102
+
103
+ const filteredSlashItems = useMemo(() => {
104
+ const query = slashState?.query?.toLowerCase() ?? "";
105
+ return allSlashCommands.filter((item) => {
106
+ if (!query) return true;
107
+ const haystack = [item.title, item.description, ...(item.keywords ?? [])]
108
+ .filter(Boolean)
109
+ .join(" ")
110
+ .toLowerCase();
111
+ return haystack.includes(query);
112
+ });
113
+ }, [allSlashCommands, slashState?.query]);
114
+
115
+ const filteredMentions = useMemo(() => {
116
+ const query = mentionState?.query?.toLowerCase() ?? "";
117
+ const contacts = mentions ?? [];
118
+ return contacts.filter((contact) => contact.label.toLowerCase().includes(query));
119
+ }, [mentions, mentionState?.query]);
120
+
121
+ const aiHandler = onAiCommand
122
+ ? onAiCommand
123
+ : onAiSelectionAction
124
+ ? async (context: import("../lib/types").AiCommandContext) =>
125
+ onAiSelectionAction(context.actionId, context.selectedText)
126
+ : undefined;
127
+
128
+ const commandMenuEnabled = usesAiCommandMenu(aiCommandMenu, Boolean(aiHandler));
129
+ const aiCommandPlacement = resolveAiCommandPlacement(aiCommandMenu);
130
+ const toolbarAiEnabled = commandMenuEnabled && aiCommandPlacement === "toolbar";
131
+ const dockAiEnabled = commandMenuEnabled && aiCommandPlacement === "dock";
132
+ const commandActions = resolveAiCommandActions(
133
+ aiCommandMenu === false ? undefined : aiCommandMenu,
134
+ aiActions,
135
+ );
136
+ const bubbleActionsEnabled =
137
+ Boolean(aiHandler) &&
138
+ (commandMenuEnabled
139
+ ? aiCommandMenu !== false && aiCommandMenu?.bubbleActions
140
+ : true);
141
+
142
+ const runAiCommand = useCallback(
143
+ async (request: { actionId: string; prompt?: string }) => {
144
+ if (!editor || !aiHandler) return;
145
+ const { from, to } = editor.state.selection;
146
+ const selectedText = from !== to ? editor.state.doc.textBetween(from, to, " ") : "";
147
+ editor.commands.setAiSelection?.(from !== to ? { from, to } : null);
148
+ editor.commands.setAiProcessing?.(true);
149
+ setAiBusyInternal(true);
150
+ try {
151
+ const result = await aiHandler({
152
+ actionId: request.actionId,
153
+ prompt: request.prompt,
154
+ selectedText,
155
+ documentText: editor.state.doc.textContent,
156
+ html: editor.getHTML(),
157
+ selection: from !== to ? { from, to } : null,
158
+ });
159
+ if (result) {
160
+ if (from !== to) {
161
+ editor.chain().focus().insertContentAt({ from, to }, result).run();
162
+ } else {
163
+ editor.chain().focus().insertContentAt(from, result).run();
164
+ }
165
+ }
166
+ } finally {
167
+ editor.commands.setAiProcessing?.(false);
168
+ editor.commands.setAiSelection?.(null);
169
+ setAiBusyInternal(false);
170
+ }
171
+ },
172
+ [editor, aiHandler],
173
+ );
174
+
175
+ const runLegacyAiAction = useCallback(
176
+ async (actionId: string) => {
177
+ if (!editor) return;
178
+ const { from, to } = editor.state.selection;
179
+ if (from === to) return;
180
+ await runAiCommand({ actionId });
181
+ },
182
+ [editor, runAiCommand],
183
+ );
184
+
185
+ useEffect(() => {
186
+ if (!mobileFullscreen || !editor) return;
187
+ const onFocus = () => {
188
+ if (window.matchMedia("(max-width: 767px)").matches) {
189
+ setFullscreen(true);
190
+ }
191
+ };
192
+ editor.on("focus", onFocus);
193
+ return () => {
194
+ editor.off("focus", onFocus);
195
+ };
196
+ }, [editor, mobileFullscreen]);
197
+
198
+ const shell = (
199
+ <div
200
+ ref={shellRef}
201
+ data-slot="spatika-editor"
202
+ className={cn(
203
+ "spk-editor-shell",
204
+ disabled && "spk-editor-shell--disabled",
205
+ fullscreen && "spk-editor-shell--fullscreen",
206
+ dockAiEnabled && "spk-editor-shell--with-ai-dock",
207
+ className,
208
+ )}
209
+ >
210
+ <EditorToolbar
211
+ editor={editor}
212
+ config={toolbarConfig}
213
+ toolbarActions={toolbarActions}
214
+ aiActions={
215
+ toolbarAiEnabled ? commandActions : !commandMenuEnabled ? aiActions : undefined
216
+ }
217
+ aiCommandMenu={aiCommandMenu === false ? false : aiCommandMenu}
218
+ useAiCommandMenu={toolbarAiEnabled}
219
+ onAiCommand={commandMenuEnabled ? runAiCommand : undefined}
220
+ onAiAction={!commandMenuEnabled ? runLegacyAiAction : undefined}
221
+ onInsertImage={onInsertImage}
222
+ imageInsert={imageInsert}
223
+ />
224
+ <div className="spk-editor-body">
225
+ <EditorContent
226
+ editor={editor}
227
+ className={cn("spk-editor-surface", heightClassName)}
228
+ />
229
+ <EditorBubbleMenu
230
+ editor={editor}
231
+ aiActions={bubbleActionsEnabled ? (commandMenuEnabled ? commandActions : aiActions) : undefined}
232
+ onAiAction={runLegacyAiAction}
233
+ />
234
+ <ImageBubbleMenu editor={editor} />
235
+ <SlashCommandMenu
236
+ editor={editor}
237
+ items={filteredSlashItems}
238
+ query={slashState?.query ?? ""}
239
+ activeIndex={slashState?.activeIndex ?? 0}
240
+ open={Boolean(slashState?.range)}
241
+ onSelect={(item) => {
242
+ if (!editor || !slashState?.range) return;
243
+ editor.chain().focus().deleteRange(slashState.range).run();
244
+ item.command(editor);
245
+ setSlashState(null);
246
+ }}
247
+ />
248
+ <MentionMenu
249
+ contacts={filteredMentions}
250
+ query={mentionState?.query ?? ""}
251
+ activeIndex={mentionState?.activeIndex ?? 0}
252
+ open={Boolean(mentionState?.range)}
253
+ onSelect={(contact) => {
254
+ if (!editor || !mentionState?.range) return;
255
+ editor
256
+ .chain()
257
+ .focus()
258
+ .insertContentAt(mentionState.range, [
259
+ { type: "mention", attrs: { id: contact.id, label: contact.label } },
260
+ { type: "text", text: " " },
261
+ ])
262
+ .run();
263
+ setMentionState(null);
264
+ }}
265
+ />
266
+ <AiOverlay visible={Boolean(isAiProcessing || aiBusyInternal)} />
267
+ </div>
268
+ {dockAiEnabled ? (
269
+ <div className="spk-editor-ai-dock-footer">
270
+ <AiCommandDock
271
+ editor={editor}
272
+ config={aiCommandMenu === false ? undefined : aiCommandMenu}
273
+ actions={commandActions}
274
+ onRun={runAiCommand}
275
+ />
276
+ </div>
277
+ ) : null}
278
+ {fullscreen ? (
279
+ <div className="spk-editor-fullscreen-bar">
280
+ <button type="button" className="spk-editor-fullscreen-close" onClick={() => setFullscreen(false)}>
281
+ Done
282
+ </button>
283
+ </div>
284
+ ) : null}
285
+ </div>
286
+ );
287
+
288
+ if (fullscreen && typeof document !== "undefined") {
289
+ return createPortal(shell, document.body);
290
+ }
291
+
292
+ if (!isReady) {
293
+ return (
294
+ <div className={cn("spk-editor-shell spk-editor-shell--loading", className)} aria-busy="true">
295
+ Loading editor…
296
+ </div>
297
+ );
298
+ }
299
+
300
+ return shell;
301
+ },
302
+ );
@@ -0,0 +1,26 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { TableGridPicker } from "./TableGridPicker";
5
+
6
+ describe("TableGridPicker", () => {
7
+ it("updates the preview label while hovering the grid", () => {
8
+ const onInsert = vi.fn();
9
+ render(<TableGridPicker onInsert={onInsert} />);
10
+
11
+ expect(screen.getByText("3 × 3")).toBeInTheDocument();
12
+
13
+ const cell = screen.getByRole("gridcell", { name: "5 by 4 table" });
14
+ fireEvent.mouseMove(cell);
15
+
16
+ expect(screen.getByText("5 × 4")).toBeInTheDocument();
17
+ });
18
+
19
+ it("inserts on grid click", async () => {
20
+ const onInsert = vi.fn();
21
+ render(<TableGridPicker onInsert={onInsert} />);
22
+
23
+ await userEvent.click(screen.getByRole("gridcell", { name: "5 by 4 table" }));
24
+ expect(onInsert).toHaveBeenCalledWith(4, 5, true);
25
+ });
26
+ });