@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
package/package.json
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spatika/editor",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Spatika UI — Tiptap-powered rich text editor with glass theming, AI hooks, and extensible toolbar",
|
|
6
|
+
"homepage": "https://spatika.sweyam.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/SreelalChalil/spatika.git",
|
|
10
|
+
"directory": "packages/editor"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/SreelalChalil/spatika/issues"
|
|
14
|
+
},
|
|
15
|
+
"author": "Sreelal Chalil",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"spatika",
|
|
18
|
+
"editor",
|
|
19
|
+
"tiptap",
|
|
20
|
+
"rich-text",
|
|
21
|
+
"react",
|
|
22
|
+
"glass",
|
|
23
|
+
"design-system"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": [
|
|
27
|
+
"*.css"
|
|
28
|
+
],
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"module": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/index.js",
|
|
36
|
+
"default": "./dist/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./styles.css": "./dist/styles.css",
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"src",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.build.json && cp src/styles/editor.css dist/styles.css",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest",
|
|
51
|
+
"test:coverage": "vitest run --coverage"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"react": "^18.2.0 || ^19.0.0",
|
|
58
|
+
"react-dom": "^18.2.0 || ^19.0.0"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@spatika/tokens": "1.5.0",
|
|
62
|
+
"@tiptap/core": "^3.28.0",
|
|
63
|
+
"@tiptap/extension-bubble-menu": "^3.28.0",
|
|
64
|
+
"@tiptap/extension-color": "^3.30.5",
|
|
65
|
+
"@tiptap/extension-highlight": "^3.30.5",
|
|
66
|
+
"@tiptap/extension-image": "^3.28.0",
|
|
67
|
+
"@tiptap/extension-link": "^3.28.0",
|
|
68
|
+
"@tiptap/extension-placeholder": "^3.28.0",
|
|
69
|
+
"@tiptap/extension-subscript": "^3.30.5",
|
|
70
|
+
"@tiptap/extension-superscript": "^3.30.5",
|
|
71
|
+
"@tiptap/extension-table": "^3.28.0",
|
|
72
|
+
"@tiptap/extension-table-cell": "^3.28.0",
|
|
73
|
+
"@tiptap/extension-table-header": "^3.28.0",
|
|
74
|
+
"@tiptap/extension-table-row": "^3.28.0",
|
|
75
|
+
"@tiptap/extension-task-item": "^3.30.5",
|
|
76
|
+
"@tiptap/extension-task-list": "^3.30.5",
|
|
77
|
+
"@tiptap/extension-text-align": "^3.28.0",
|
|
78
|
+
"@tiptap/extension-text-style": "^3.30.5",
|
|
79
|
+
"@tiptap/extension-underline": "^3.28.0",
|
|
80
|
+
"@tiptap/pm": "^3.28.0",
|
|
81
|
+
"@tiptap/react": "^3.28.0",
|
|
82
|
+
"@tiptap/starter-kit": "^3.28.0",
|
|
83
|
+
"@tiptap/suggestion": "^3.28.0",
|
|
84
|
+
"clsx": "^2.1.1",
|
|
85
|
+
"lucide-react": "^0.487.0",
|
|
86
|
+
"tailwind-merge": "^3.2.0"
|
|
87
|
+
},
|
|
88
|
+
"devDependencies": {
|
|
89
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
90
|
+
"@testing-library/react": "^16.3.2",
|
|
91
|
+
"@testing-library/user-event": "^14.6.1",
|
|
92
|
+
"@types/react": "^19.2.14",
|
|
93
|
+
"@types/react-dom": "^19.2.3",
|
|
94
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
95
|
+
"jsdom": "^30.0.1",
|
|
96
|
+
"react": "^18.2.0",
|
|
97
|
+
"react-dom": "^18.2.0",
|
|
98
|
+
"typescript": "~5.8.3",
|
|
99
|
+
"vitest": "^4.1.10"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { AI_PROMPT_ACTION_ID } from "../lib/types";
|
|
6
|
+
import { AiCommandDock } from "./AiCommandDock";
|
|
7
|
+
|
|
8
|
+
function createEditor(hasSelection = false) {
|
|
9
|
+
return {
|
|
10
|
+
state: {
|
|
11
|
+
selection: { empty: !hasSelection },
|
|
12
|
+
},
|
|
13
|
+
} as unknown as Editor;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("AiCommandDock", () => {
|
|
17
|
+
it("opens the Tiptap-style action panel from the dock", async () => {
|
|
18
|
+
render(
|
|
19
|
+
<AiCommandDock
|
|
20
|
+
editor={createEditor()}
|
|
21
|
+
config={{ title: "AI Toolkit examples" }}
|
|
22
|
+
onRun={vi.fn()}
|
|
23
|
+
/>,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
await userEvent.click(screen.getByRole("button", { name: "AI commands" }));
|
|
27
|
+
expect(screen.getByRole("dialog", { name: "AI Toolkit examples" })).toBeInTheDocument();
|
|
28
|
+
expect(
|
|
29
|
+
screen.getByPlaceholderText("Ask about this document or request a change…"),
|
|
30
|
+
).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("submits a prompt from the dock bar", async () => {
|
|
34
|
+
const onRun = vi.fn();
|
|
35
|
+
render(<AiCommandDock editor={createEditor()} onRun={onRun} />);
|
|
36
|
+
|
|
37
|
+
await userEvent.type(
|
|
38
|
+
screen.getByPlaceholderText("Ask about this document or request a change…"),
|
|
39
|
+
"Summarize this doc",
|
|
40
|
+
);
|
|
41
|
+
await userEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
42
|
+
|
|
43
|
+
expect(onRun).toHaveBeenCalledWith({
|
|
44
|
+
actionId: AI_PROMPT_ACTION_ID,
|
|
45
|
+
prompt: "Summarize this doc",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import {
|
|
3
|
+
ArrowLeft,
|
|
4
|
+
ArrowUp,
|
|
5
|
+
MessageSquarePlus,
|
|
6
|
+
PenLine,
|
|
7
|
+
Plus,
|
|
8
|
+
Search,
|
|
9
|
+
Sparkles,
|
|
10
|
+
Table2,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
13
|
+
import {
|
|
14
|
+
resolveAiCommandActions,
|
|
15
|
+
resolveAiPromptConfig,
|
|
16
|
+
} from "../lib/ai-command-menu";
|
|
17
|
+
import type { AiActionDefinition, AiCommandMenuConfig } from "../lib/types";
|
|
18
|
+
import { AI_PROMPT_ACTION_ID } from "../lib/types";
|
|
19
|
+
import { cn } from "../lib/cn";
|
|
20
|
+
import { AiCommandPanel } from "./AiCommandPanel";
|
|
21
|
+
import type { AiCommandRunRequest } from "./AiCommandMenu";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_ACTION_ICONS: Record<string, AiActionDefinition["icon"]> = {
|
|
24
|
+
proofread: <Search className="size-4" />,
|
|
25
|
+
rewrite: <PenLine className="size-4" />,
|
|
26
|
+
shorten: <ArrowLeft className="size-4" />,
|
|
27
|
+
improve: <Sparkles className="size-4" />,
|
|
28
|
+
expand: <Plus className="size-4" />,
|
|
29
|
+
continue: <Plus className="size-4" />,
|
|
30
|
+
table: <Table2 className="size-4" />,
|
|
31
|
+
comment: <MessageSquarePlus className="size-4" />,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function withDefaultIcons(actions: AiActionDefinition[]) {
|
|
35
|
+
return actions.map((action) => ({
|
|
36
|
+
...action,
|
|
37
|
+
icon: action.icon ?? DEFAULT_ACTION_ICONS[action.id],
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type AiCommandDockProps = {
|
|
42
|
+
editor: Editor | null;
|
|
43
|
+
config?: AiCommandMenuConfig;
|
|
44
|
+
actions?: AiActionDefinition[];
|
|
45
|
+
onRun: (request: AiCommandRunRequest) => void;
|
|
46
|
+
className?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function AiCommandDock({ editor, config, actions, onRun, className }: AiCommandDockProps) {
|
|
50
|
+
const [open, setOpen] = useState(false);
|
|
51
|
+
const [prompt, setPrompt] = useState("");
|
|
52
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
const panelId = useId();
|
|
54
|
+
|
|
55
|
+
const title = config?.title ?? "AI Toolkit examples";
|
|
56
|
+
const commandActions = withDefaultIcons(resolveAiCommandActions(config, actions));
|
|
57
|
+
const promptConfig = resolveAiPromptConfig(config?.prompt);
|
|
58
|
+
const hasSelection = Boolean(editor?.state?.selection && !editor.state.selection.empty);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!open) return;
|
|
62
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
63
|
+
if (!rootRef.current?.contains(event.target as Node)) {
|
|
64
|
+
setOpen(false);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
68
|
+
return () => document.removeEventListener("mousedown", onPointerDown);
|
|
69
|
+
}, [open]);
|
|
70
|
+
|
|
71
|
+
const runAction = (actionId: string, userPrompt?: string) => {
|
|
72
|
+
onRun({ actionId, prompt: userPrompt });
|
|
73
|
+
setOpen(false);
|
|
74
|
+
setPrompt("");
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const submitPrompt = () => {
|
|
78
|
+
const trimmed = prompt.trim();
|
|
79
|
+
if (!trimmed) return;
|
|
80
|
+
runAction(AI_PROMPT_ACTION_ID, trimmed);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (!editor) return null;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div ref={rootRef} className={cn("spk-editor-ai-dock", className)}>
|
|
87
|
+
{open ? (
|
|
88
|
+
<AiCommandPanel
|
|
89
|
+
id={panelId}
|
|
90
|
+
title={title}
|
|
91
|
+
actions={commandActions}
|
|
92
|
+
hasSelection={hasSelection}
|
|
93
|
+
onSelect={(actionId) => runAction(actionId)}
|
|
94
|
+
className="spk-editor-ai-dock-panel"
|
|
95
|
+
/>
|
|
96
|
+
) : null}
|
|
97
|
+
{promptConfig.enabled ? (
|
|
98
|
+
<div className="spk-editor-ai-dock-bar" role="search">
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
className={cn("spk-editor-ai-dock-trigger", open && "spk-editor-ai-dock-trigger--active")}
|
|
102
|
+
aria-label="AI commands"
|
|
103
|
+
aria-expanded={open}
|
|
104
|
+
aria-controls={open ? panelId : undefined}
|
|
105
|
+
onClick={() => setOpen((value) => !value)}
|
|
106
|
+
>
|
|
107
|
+
<Sparkles className="size-4" />
|
|
108
|
+
</button>
|
|
109
|
+
<label className="spk-editor-ai-dock-field">
|
|
110
|
+
<span className="sr-only">{promptConfig.placeholder}</span>
|
|
111
|
+
<input
|
|
112
|
+
type="text"
|
|
113
|
+
className="spk-editor-ai-dock-input"
|
|
114
|
+
value={prompt}
|
|
115
|
+
placeholder={promptConfig.placeholder}
|
|
116
|
+
onFocus={() => setOpen(true)}
|
|
117
|
+
onChange={(event) => setPrompt(event.target.value)}
|
|
118
|
+
onKeyDown={(event) => {
|
|
119
|
+
if (event.key === "Enter") submitPrompt();
|
|
120
|
+
if (event.key === "Escape") setOpen(false);
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
</label>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
className="spk-editor-ai-dock-submit"
|
|
127
|
+
aria-label={promptConfig.submitLabel}
|
|
128
|
+
disabled={!prompt.trim()}
|
|
129
|
+
onClick={submitPrompt}
|
|
130
|
+
>
|
|
131
|
+
<ArrowUp className="size-4" />
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
) : null}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { AI_PROMPT_ACTION_ID } from "../lib/types";
|
|
6
|
+
import { AiCommandMenu } from "./AiCommandMenu";
|
|
7
|
+
|
|
8
|
+
function createEditor(hasSelection = false) {
|
|
9
|
+
return {
|
|
10
|
+
state: {
|
|
11
|
+
selection: { empty: !hasSelection },
|
|
12
|
+
},
|
|
13
|
+
} as unknown as Editor;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("AiCommandMenu", () => {
|
|
17
|
+
it("opens the customizable command menu", async () => {
|
|
18
|
+
const onRun = vi.fn();
|
|
19
|
+
render(
|
|
20
|
+
<AiCommandMenu
|
|
21
|
+
editor={createEditor()}
|
|
22
|
+
config={{ title: "Journal AI" }}
|
|
23
|
+
onRun={onRun}
|
|
24
|
+
/>,
|
|
25
|
+
);
|
|
26
|
+
await userEvent.click(screen.getByRole("button", { name: "AI commands" }));
|
|
27
|
+
expect(screen.getByRole("dialog", { name: "Journal AI" })).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByRole("button", { name: "Proofread selection" })).toBeDisabled();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("runs preset actions and prompt submissions", async () => {
|
|
32
|
+
const onRun = vi.fn();
|
|
33
|
+
render(
|
|
34
|
+
<AiCommandMenu
|
|
35
|
+
editor={createEditor(true)}
|
|
36
|
+
config={{
|
|
37
|
+
actions: [{ id: "rewrite", label: "Rewrite selection", requiresSelection: true }],
|
|
38
|
+
prompt: { placeholder: "Ask the doc…", submitLabel: "Run" },
|
|
39
|
+
}}
|
|
40
|
+
onRun={onRun}
|
|
41
|
+
/>,
|
|
42
|
+
);
|
|
43
|
+
await userEvent.click(screen.getByRole("button", { name: "AI commands" }));
|
|
44
|
+
await userEvent.click(screen.getByRole("button", { name: "Rewrite selection" }));
|
|
45
|
+
expect(onRun).toHaveBeenCalledWith({ actionId: "rewrite", prompt: undefined });
|
|
46
|
+
|
|
47
|
+
await userEvent.click(screen.getByRole("button", { name: "AI commands" }));
|
|
48
|
+
await userEvent.type(screen.getByPlaceholderText("Ask the doc…"), "Add a summary");
|
|
49
|
+
await userEvent.click(screen.getByRole("button", { name: "Run" }));
|
|
50
|
+
expect(onRun).toHaveBeenLastCalledWith({
|
|
51
|
+
actionId: AI_PROMPT_ACTION_ID,
|
|
52
|
+
prompt: "Add a summary",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("hides the prompt field when disabled", async () => {
|
|
57
|
+
render(
|
|
58
|
+
<AiCommandMenu
|
|
59
|
+
editor={createEditor()}
|
|
60
|
+
config={{ prompt: false }}
|
|
61
|
+
onRun={vi.fn()}
|
|
62
|
+
/>,
|
|
63
|
+
);
|
|
64
|
+
await userEvent.click(screen.getByRole("button", { name: "AI commands" }));
|
|
65
|
+
expect(screen.queryByRole("button", { name: "Send" })).not.toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import { ArrowUp, Sparkles } from "lucide-react";
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
resolveAiCommandActions,
|
|
6
|
+
resolveAiPromptConfig,
|
|
7
|
+
} from "../lib/ai-command-menu";
|
|
8
|
+
import type { AiActionDefinition, AiCommandMenuConfig } from "../lib/types";
|
|
9
|
+
import { AI_PROMPT_ACTION_ID } from "../lib/types";
|
|
10
|
+
import { cn } from "../lib/cn";
|
|
11
|
+
import { AiCommandPanel } from "./AiCommandPanel";
|
|
12
|
+
import { ToolbarButton } from "./ToolbarButton";
|
|
13
|
+
|
|
14
|
+
export type AiCommandRunRequest = {
|
|
15
|
+
actionId: string;
|
|
16
|
+
prompt?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type AiCommandMenuProps = {
|
|
20
|
+
editor: Editor | null;
|
|
21
|
+
config?: AiCommandMenuConfig;
|
|
22
|
+
actions?: AiActionDefinition[];
|
|
23
|
+
onRun: (request: AiCommandRunRequest) => void;
|
|
24
|
+
className?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function AiCommandMenu({ editor, config, actions, onRun, className }: AiCommandMenuProps) {
|
|
28
|
+
const [open, setOpen] = useState(false);
|
|
29
|
+
const [prompt, setPrompt] = useState("");
|
|
30
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const titleId = useId();
|
|
32
|
+
|
|
33
|
+
const title = config?.title ?? "AI Toolkit examples";
|
|
34
|
+
const commandActions = resolveAiCommandActions(config, actions);
|
|
35
|
+
const promptConfig = resolveAiPromptConfig(config?.prompt);
|
|
36
|
+
const hasSelection = Boolean(editor?.state?.selection && !editor.state.selection.empty);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!open) return;
|
|
40
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
41
|
+
if (!rootRef.current?.contains(event.target as Node)) {
|
|
42
|
+
setOpen(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
46
|
+
return () => document.removeEventListener("mousedown", onPointerDown);
|
|
47
|
+
}, [open]);
|
|
48
|
+
|
|
49
|
+
const close = () => {
|
|
50
|
+
setOpen(false);
|
|
51
|
+
setPrompt("");
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const runAction = (actionId: string, userPrompt?: string) => {
|
|
55
|
+
onRun({ actionId, prompt: userPrompt });
|
|
56
|
+
close();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const submitPrompt = () => {
|
|
60
|
+
const trimmed = prompt.trim();
|
|
61
|
+
if (!trimmed) return;
|
|
62
|
+
runAction(AI_PROMPT_ACTION_ID, trimmed);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (!editor) return null;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div ref={rootRef} className={cn("spk-editor-ai-menu", className)}>
|
|
69
|
+
<ToolbarButton
|
|
70
|
+
aria-label="AI commands"
|
|
71
|
+
aria-expanded={open}
|
|
72
|
+
aria-haspopup="dialog"
|
|
73
|
+
aria-controls={open ? titleId : undefined}
|
|
74
|
+
active={open}
|
|
75
|
+
onClick={() => setOpen((value) => !value)}
|
|
76
|
+
>
|
|
77
|
+
<Sparkles className="size-4" />
|
|
78
|
+
</ToolbarButton>
|
|
79
|
+
{open ? (
|
|
80
|
+
<div className="spk-editor-ai-menu-shell" onMouseDown={(event) => event.stopPropagation()}>
|
|
81
|
+
<AiCommandPanel
|
|
82
|
+
id={titleId}
|
|
83
|
+
title={title}
|
|
84
|
+
actions={commandActions}
|
|
85
|
+
hasSelection={hasSelection}
|
|
86
|
+
onSelect={(actionId) => runAction(actionId)}
|
|
87
|
+
/>
|
|
88
|
+
{promptConfig.enabled ? (
|
|
89
|
+
<div className="spk-editor-ai-menu-prompt">
|
|
90
|
+
<label className="spk-editor-ai-menu-prompt-field">
|
|
91
|
+
<span className="sr-only">{promptConfig.placeholder}</span>
|
|
92
|
+
<input
|
|
93
|
+
type="text"
|
|
94
|
+
className="spk-editor-ai-menu-prompt-input"
|
|
95
|
+
value={prompt}
|
|
96
|
+
placeholder={promptConfig.placeholder}
|
|
97
|
+
onChange={(event) => setPrompt(event.target.value)}
|
|
98
|
+
onKeyDown={(event) => {
|
|
99
|
+
if (event.key === "Enter") submitPrompt();
|
|
100
|
+
if (event.key === "Escape") close();
|
|
101
|
+
}}
|
|
102
|
+
autoFocus
|
|
103
|
+
/>
|
|
104
|
+
</label>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
className="spk-editor-ai-menu-prompt-submit"
|
|
108
|
+
aria-label={promptConfig.submitLabel}
|
|
109
|
+
disabled={!prompt.trim()}
|
|
110
|
+
onClick={submitPrompt}
|
|
111
|
+
>
|
|
112
|
+
<ArrowUp className="size-4" />
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
116
|
+
</div>
|
|
117
|
+
) : null}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { AiCommandPanel } from "./AiCommandPanel";
|
|
5
|
+
|
|
6
|
+
const actions = [
|
|
7
|
+
{ id: "rewrite", label: "Rewrite selection", requiresSelection: true },
|
|
8
|
+
{ id: "continue", label: "Write a new paragraph", description: "Draft below the cursor" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe("AiCommandPanel", () => {
|
|
12
|
+
it("renders the action list in a dialog", () => {
|
|
13
|
+
render(
|
|
14
|
+
<AiCommandPanel
|
|
15
|
+
title="AI Toolkit examples"
|
|
16
|
+
actions={actions}
|
|
17
|
+
hasSelection={false}
|
|
18
|
+
onSelect={vi.fn()}
|
|
19
|
+
/>,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(screen.getByRole("dialog", { name: "AI Toolkit examples" })).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByRole("button", { name: "Rewrite selection" })).toBeDisabled();
|
|
24
|
+
expect(screen.getByRole("button", { name: /Write a new paragraph/i })).toBeEnabled();
|
|
25
|
+
expect(screen.getByText("Draft below the cursor")).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("calls onSelect for enabled actions", async () => {
|
|
29
|
+
const onSelect = vi.fn();
|
|
30
|
+
render(
|
|
31
|
+
<AiCommandPanel
|
|
32
|
+
title="AI Toolkit examples"
|
|
33
|
+
actions={actions}
|
|
34
|
+
hasSelection
|
|
35
|
+
onSelect={onSelect}
|
|
36
|
+
/>,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
await userEvent.click(screen.getByRole("button", { name: "Rewrite selection" }));
|
|
40
|
+
expect(onSelect).toHaveBeenCalledWith("rewrite");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AiActionDefinition } from "../lib/types";
|
|
2
|
+
import { Sparkles } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
type AiCommandPanelProps = {
|
|
6
|
+
title: string;
|
|
7
|
+
actions: AiActionDefinition[];
|
|
8
|
+
hasSelection: boolean;
|
|
9
|
+
onSelect: (actionId: string) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
id?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function actionDisabled(action: AiActionDefinition, hasSelection: boolean) {
|
|
15
|
+
return Boolean(action.requiresSelection && !hasSelection);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function AiCommandPanel({
|
|
19
|
+
title,
|
|
20
|
+
actions,
|
|
21
|
+
hasSelection,
|
|
22
|
+
onSelect,
|
|
23
|
+
className,
|
|
24
|
+
id,
|
|
25
|
+
}: AiCommandPanelProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div id={id} className={cn("spk-editor-ai-panel", className)} role="dialog" aria-label={title}>
|
|
28
|
+
<div className="spk-editor-ai-panel-title">{title}</div>
|
|
29
|
+
<ul className="spk-editor-ai-panel-list" aria-label={title}>
|
|
30
|
+
{actions.map((action) => {
|
|
31
|
+
const disabled = actionDisabled(action, hasSelection);
|
|
32
|
+
return (
|
|
33
|
+
<li key={action.id}>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
className="spk-editor-ai-panel-item"
|
|
37
|
+
disabled={disabled}
|
|
38
|
+
onClick={() => onSelect(action.id)}
|
|
39
|
+
>
|
|
40
|
+
<span className="spk-editor-ai-panel-item-icon" aria-hidden="true">
|
|
41
|
+
{action.icon ?? <Sparkles className="size-4" />}
|
|
42
|
+
</span>
|
|
43
|
+
<span className="spk-editor-ai-panel-item-copy">
|
|
44
|
+
<span className="spk-editor-ai-panel-item-label">{action.label}</span>
|
|
45
|
+
{action.description ? (
|
|
46
|
+
<span className="spk-editor-ai-panel-item-desc">{action.description}</span>
|
|
47
|
+
) : null}
|
|
48
|
+
</span>
|
|
49
|
+
</button>
|
|
50
|
+
</li>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
</ul>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { AiOverlay } from "./AiOverlay";
|
|
4
|
+
|
|
5
|
+
describe("AiOverlay", () => {
|
|
6
|
+
it("renders nothing when not visible", () => {
|
|
7
|
+
const { container } = render(<AiOverlay visible={false} />);
|
|
8
|
+
expect(container).toBeEmptyDOMElement();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("shows the AI working message when visible", () => {
|
|
12
|
+
render(<AiOverlay visible className="custom-overlay" />);
|
|
13
|
+
expect(screen.getByText("AI is working…")).toBeInTheDocument();
|
|
14
|
+
expect(screen.getByText("AI is working…").closest(".spk-editor-ai-overlay")).toHaveClass(
|
|
15
|
+
"custom-overlay",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Sparkles } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
type AiOverlayProps = {
|
|
5
|
+
visible: boolean;
|
|
6
|
+
className?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function AiOverlay({ visible, className }: AiOverlayProps) {
|
|
10
|
+
if (!visible) return null;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className={cn("spk-editor-ai-overlay", className)} aria-hidden="true">
|
|
14
|
+
<Sparkles className="spk-editor-ai-overlay-icon size-5" />
|
|
15
|
+
<span>AI is working…</span>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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 { EditorBubbleMenu } from "./EditorBubbleMenu";
|
|
6
|
+
|
|
7
|
+
vi.mock("@tiptap/react/menus", () => ({
|
|
8
|
+
BubbleMenu: ({ children }: { children: React.ReactNode }) => (
|
|
9
|
+
<div role="group" aria-label="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
|
+
toggleBold: vi.fn().mockReturnThis(),
|
|
20
|
+
toggleItalic: vi.fn().mockReturnThis(),
|
|
21
|
+
toggleUnderline: vi.fn().mockReturnThis(),
|
|
22
|
+
run,
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
isActive: vi.fn().mockReturnValue(false),
|
|
26
|
+
chain: vi.fn(() => chain),
|
|
27
|
+
} as unknown as Editor;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("EditorBubbleMenu", () => {
|
|
31
|
+
it("returns null without an editor", () => {
|
|
32
|
+
const { container } = render(<EditorBubbleMenu editor={null} />);
|
|
33
|
+
expect(container).toBeEmptyDOMElement();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("toggles bold from the bubble menu", async () => {
|
|
37
|
+
const editor = createEditor();
|
|
38
|
+
render(
|
|
39
|
+
<EditorBubbleMenu
|
|
40
|
+
editor={editor}
|
|
41
|
+
aiActions={[{ id: "improve", label: "Improve writing" }]}
|
|
42
|
+
onAiAction={vi.fn()}
|
|
43
|
+
/>,
|
|
44
|
+
);
|
|
45
|
+
await userEvent.click(screen.getByRole("button", { name: "Bold" }));
|
|
46
|
+
expect(editor.chain).toHaveBeenCalled();
|
|
47
|
+
expect(screen.getByRole("button", { name: "Improve writing" })).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
});
|