@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,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
|
+
});
|