@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,197 @@
1
+ import { Extension } from "@tiptap/core";
2
+ import { PluginKey } from "@tiptap/pm/state";
3
+ import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
4
+ import type { SlashCommandItem } from "../lib/types";
5
+
6
+ export type SlashCommandStorage = {
7
+ items: SlashCommandItem[];
8
+ activeIndex: number;
9
+ query: string;
10
+ range: { from: number; to: number } | null;
11
+ filteredItems: SlashCommandItem[];
12
+ runCommand: ((item: SlashCommandItem) => void) | null;
13
+ };
14
+
15
+ export const SlashCommandPluginKey = new PluginKey("spatikaSlashCommand");
16
+
17
+ export function createSlashCommandExtension(
18
+ items: SlashCommandItem[],
19
+ onUpdate?: (state: SlashCommandStorage) => void,
20
+ ) {
21
+ return Extension.create({
22
+ name: "slashCommand",
23
+
24
+ addStorage() {
25
+ return {
26
+ items,
27
+ activeIndex: 0,
28
+ query: "",
29
+ range: null,
30
+ filteredItems: items,
31
+ runCommand: null,
32
+ } satisfies SlashCommandStorage;
33
+ },
34
+
35
+ addProseMirrorPlugins() {
36
+ const editor = this.editor;
37
+ const storage = this.storage as SlashCommandStorage;
38
+
39
+ const suggestion: Partial<SuggestionOptions<SlashCommandItem>> = {
40
+ char: "/",
41
+ pluginKey: SlashCommandPluginKey,
42
+ allowSpaces: true,
43
+ items: ({ query }) => {
44
+ const needle = query.toLowerCase();
45
+ return items.filter((item) => {
46
+ if (!needle) return true;
47
+ const haystack = [item.title, item.description, ...(item.keywords ?? [])]
48
+ .filter(Boolean)
49
+ .join(" ")
50
+ .toLowerCase();
51
+ return haystack.includes(needle);
52
+ });
53
+ },
54
+ command: ({ editor: ed, range, props }) => {
55
+ ed.chain().focus().deleteRange(range).run();
56
+ props.command(editor);
57
+ },
58
+ render: () => ({
59
+ onStart: (props) => {
60
+ storage.query = props.query;
61
+ storage.activeIndex = 0;
62
+ storage.range = props.range;
63
+ storage.filteredItems = props.items;
64
+ storage.runCommand = (item) => props.command(item);
65
+ onUpdate?.({ ...storage });
66
+ },
67
+ onUpdate: (props) => {
68
+ storage.query = props.query;
69
+ storage.activeIndex = 0;
70
+ storage.range = props.range;
71
+ storage.filteredItems = props.items;
72
+ storage.runCommand = (item) => props.command(item);
73
+ onUpdate?.({ ...storage });
74
+ },
75
+ onKeyDown: (props) => {
76
+ if (props.event.key === "ArrowUp") {
77
+ storage.activeIndex = Math.max(0, storage.activeIndex - 1);
78
+ onUpdate?.({ ...storage });
79
+ return true;
80
+ }
81
+ if (props.event.key === "ArrowDown") {
82
+ const max = storage.filteredItems.length - 1;
83
+ storage.activeIndex = Math.min(max, storage.activeIndex + 1);
84
+ onUpdate?.({ ...storage });
85
+ return true;
86
+ }
87
+ if (props.event.key === "Enter") {
88
+ const item = storage.filteredItems[storage.activeIndex];
89
+ if (item) storage.runCommand?.(item);
90
+ return true;
91
+ }
92
+ if (props.event.key === "Escape") {
93
+ storage.query = "";
94
+ storage.range = null;
95
+ onUpdate?.({ ...storage });
96
+ return true;
97
+ }
98
+ return false;
99
+ },
100
+ onExit: () => {
101
+ storage.query = "";
102
+ storage.range = null;
103
+ storage.activeIndex = 0;
104
+ storage.filteredItems = items;
105
+ storage.runCommand = null;
106
+ onUpdate?.({ ...storage });
107
+ },
108
+ }),
109
+ };
110
+
111
+ return [Suggestion({ editor, ...suggestion })];
112
+ },
113
+ });
114
+ }
115
+
116
+ export function defaultSlashCommands(): SlashCommandItem[] {
117
+ return [
118
+ {
119
+ id: "paragraph",
120
+ title: "Text",
121
+ description: "Plain paragraph",
122
+ keywords: ["p", "text"],
123
+ command: (editor) => editor.chain().focus().setParagraph().run(),
124
+ },
125
+ {
126
+ id: "h1",
127
+ title: "Heading 1",
128
+ description: "Large section title",
129
+ keywords: ["heading", "title"],
130
+ command: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
131
+ },
132
+ {
133
+ id: "h2",
134
+ title: "Heading 2",
135
+ description: "Medium section title",
136
+ keywords: ["heading", "subtitle"],
137
+ command: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
138
+ },
139
+ {
140
+ id: "h3",
141
+ title: "Heading 3",
142
+ description: "Small section title",
143
+ keywords: ["heading"],
144
+ command: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
145
+ },
146
+ {
147
+ id: "bullet",
148
+ title: "Bullet list",
149
+ description: "Unordered list",
150
+ keywords: ["ul", "list"],
151
+ command: (editor) => editor.chain().focus().toggleBulletList().run(),
152
+ },
153
+ {
154
+ id: "ordered",
155
+ title: "Numbered list",
156
+ description: "Ordered list",
157
+ keywords: ["ol", "list"],
158
+ command: (editor) => editor.chain().focus().toggleOrderedList().run(),
159
+ },
160
+ {
161
+ id: "task",
162
+ title: "Task list",
163
+ description: "Checklist with todos",
164
+ keywords: ["todo", "checkbox", "task"],
165
+ command: (editor) => editor.chain().focus().toggleTaskList().run(),
166
+ },
167
+ {
168
+ id: "code-block",
169
+ title: "Code block",
170
+ description: "Monospace code snippet",
171
+ keywords: ["code", "pre", "snippet"],
172
+ command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
173
+ },
174
+ {
175
+ id: "quote",
176
+ title: "Quote",
177
+ description: "Block quotation",
178
+ keywords: ["blockquote"],
179
+ command: (editor) => editor.chain().focus().toggleBlockquote().run(),
180
+ },
181
+ {
182
+ id: "divider",
183
+ title: "Divider",
184
+ description: "Horizontal rule",
185
+ keywords: ["hr", "line"],
186
+ command: (editor) => editor.chain().focus().setHorizontalRule().run(),
187
+ },
188
+ {
189
+ id: "table",
190
+ title: "Table",
191
+ description: "3×3 table with header",
192
+ keywords: ["grid"],
193
+ command: (editor) =>
194
+ editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
195
+ },
196
+ ];
197
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ export { SpatikaEditor } from "./composites/SpatikaEditor";
2
+ export { EditorToolbar } from "./composites/EditorToolbar";
3
+ export { EditorBubbleMenu } from "./composites/EditorBubbleMenu";
4
+ export { ImageBubbleMenu } from "./composites/ImageBubbleMenu";
5
+ export { SlashCommandMenu } from "./composites/SlashCommandMenu";
6
+ export { MentionMenu } from "./composites/MentionMenu";
7
+ export { AiCommandMenu } from "./composites/AiCommandMenu";
8
+ export { AiCommandDock } from "./composites/AiCommandDock";
9
+ export { AiCommandPanel } from "./composites/AiCommandPanel";
10
+ export type { AiCommandRunRequest } from "./composites/AiCommandMenu";
11
+ export { AiOverlay } from "./composites/AiOverlay";
12
+ export { ToolbarButton } from "./composites/ToolbarButton";
13
+
14
+ export { useSpatikaEditor, buildSpatikaExtensions } from "./lib/use-spatika-editor";
15
+
16
+ export {
17
+ DEFAULT_AI_ACTIONS,
18
+ DEFAULT_AI_COMMANDS,
19
+ resolveAiCommandActions,
20
+ resolveAiCommandPlacement,
21
+ resolveAiPromptConfig,
22
+ usesAiCommandMenu,
23
+ } from "./lib/ai-command-menu";
24
+
25
+ export { createSlashCommandExtension, defaultSlashCommands } from "./extensions/slash-command";
26
+ export { createMentionExtension } from "./extensions/mention";
27
+ export { ResizableImage } from "./extensions/resizable-image";
28
+ export { createAiActionExtension } from "./extensions/ai-action";
29
+
30
+ export type {
31
+ SpatikaEditorProps,
32
+ SpatikaEditorHandle,
33
+ SpatikaEditorToolbarConfig,
34
+ SlashCommandItem,
35
+ MentionContact,
36
+ AiActionDefinition,
37
+ AiCommandContext,
38
+ AiCommandMenuConfig,
39
+ AiCommandPromptConfig,
40
+ ImageInsertConfig,
41
+ PlaceholderConfig,
42
+ UseSpatikaEditorOptions,
43
+ } from "./lib/types";
44
+
45
+ export { AI_PROMPT_ACTION_ID } from "./lib/types";
46
+
47
+ export type { SlashCommandStorage } from "./extensions/slash-command";
48
+ export type { MentionStorage } from "./extensions/mention";
49
+ export type { ImageAlign } from "./extensions/resizable-image";
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ DEFAULT_AI_COMMANDS,
4
+ resolveAiCommandActions,
5
+ resolveAiCommandPlacement,
6
+ resolveAiPromptConfig,
7
+ usesAiCommandMenu,
8
+ } from "./ai-command-menu";
9
+
10
+ describe("ai-command-menu helpers", () => {
11
+ it("uses menu actions when provided", () => {
12
+ const actions = resolveAiCommandActions(
13
+ { actions: [{ id: "tone", label: "Adjust tone" }] },
14
+ [{ id: "legacy", label: "Legacy" }],
15
+ );
16
+ expect(actions).toEqual([{ id: "tone", label: "Adjust tone" }]);
17
+ });
18
+
19
+ it("falls back to aiActions then defaults", () => {
20
+ expect(resolveAiCommandActions(undefined, [{ id: "legacy", label: "Legacy" }])).toEqual([
21
+ { id: "legacy", label: "Legacy" },
22
+ ]);
23
+ expect(resolveAiCommandActions(undefined, undefined)).toEqual(DEFAULT_AI_COMMANDS);
24
+ });
25
+
26
+ it("resolves prompt config", () => {
27
+ expect(resolveAiPromptConfig(false)).toEqual({
28
+ enabled: false,
29
+ placeholder: "",
30
+ submitLabel: "Send",
31
+ });
32
+ expect(resolveAiPromptConfig({ placeholder: "Ask AI…" })).toMatchObject({
33
+ enabled: true,
34
+ placeholder: "Ask AI…",
35
+ });
36
+ });
37
+
38
+ it("enables the command menu when a handler exists", () => {
39
+ expect(usesAiCommandMenu(undefined, true)).toBe(true);
40
+ expect(usesAiCommandMenu(false, true)).toBe(false);
41
+ expect(usesAiCommandMenu(undefined, false)).toBe(false);
42
+ });
43
+
44
+ it("defaults AI placement to dock", () => {
45
+ expect(resolveAiCommandPlacement(undefined)).toBe("dock");
46
+ expect(resolveAiCommandPlacement({ placement: "toolbar" })).toBe("toolbar");
47
+ });
48
+ });
@@ -0,0 +1,57 @@
1
+ import type { AiActionDefinition, AiCommandMenuConfig, AiCommandPromptConfig } from "./types";
2
+
3
+ export const DEFAULT_AI_COMMANDS: AiActionDefinition[] = [
4
+ { id: "proofread", label: "Proofread selection", requiresSelection: true },
5
+ { id: "rewrite", label: "Rewrite selection", requiresSelection: true },
6
+ { id: "shorten", label: "Make it shorter", requiresSelection: true },
7
+ { id: "improve", label: "Improve writing", requiresSelection: true },
8
+ { id: "expand", label: "Expand", requiresSelection: true },
9
+ { id: "continue", label: "Write a new paragraph", requiresSelection: false },
10
+ ];
11
+
12
+ /** Legacy three-action set for toolbar/bubble buttons when `aiCommandMenu` is false. */
13
+ export const DEFAULT_AI_ACTIONS: AiActionDefinition[] = [
14
+ { id: "improve", label: "Improve writing", requiresSelection: true },
15
+ { id: "shorten", label: "Make shorter", requiresSelection: true },
16
+ { id: "expand", label: "Expand", requiresSelection: true },
17
+ ];
18
+
19
+ export function resolveAiCommandActions(
20
+ menu?: AiCommandMenuConfig | false,
21
+ actions?: AiActionDefinition[],
22
+ ): AiActionDefinition[] {
23
+ if (menu && typeof menu === "object" && menu.actions?.length) {
24
+ return menu.actions;
25
+ }
26
+ if (actions?.length) return actions;
27
+ return DEFAULT_AI_COMMANDS;
28
+ }
29
+
30
+ export function resolveAiPromptConfig(
31
+ prompt: AiCommandMenuConfig["prompt"] | undefined,
32
+ ): Required<AiCommandPromptConfig> {
33
+ if (prompt === false) {
34
+ return { enabled: false, placeholder: "", submitLabel: "Send" };
35
+ }
36
+ const config = prompt === true || prompt === undefined ? {} : prompt;
37
+ return {
38
+ enabled: config.enabled ?? true,
39
+ placeholder: config.placeholder ?? "Ask about this document or request a change…",
40
+ submitLabel: config.submitLabel ?? "Send",
41
+ };
42
+ }
43
+
44
+ export function resolveAiCommandPlacement(
45
+ menu?: AiCommandMenuConfig | false,
46
+ ): "toolbar" | "dock" {
47
+ if (menu === false) return "toolbar";
48
+ return menu?.placement ?? "dock";
49
+ }
50
+
51
+ export function usesAiCommandMenu(
52
+ aiCommandMenu: AiCommandMenuConfig | false | undefined,
53
+ hasHandler: boolean,
54
+ ): boolean {
55
+ if (!hasHandler) return false;
56
+ return aiCommandMenu !== false;
57
+ }
package/src/lib/cn.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ /** Merge Tailwind class names without conflicts. */
5
+ export function cn(...inputs: ClassValue[]) {
6
+ return twMerge(clsx(inputs));
7
+ }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { EDITOR_HIGHLIGHT_COLORS, EDITOR_TEXT_COLORS } from "./editor-format-colors";
3
+
4
+ describe("editor-format-colors", () => {
5
+ it("includes a default/unset swatch for text and highlight", () => {
6
+ expect(EDITOR_TEXT_COLORS[0]?.value).toBeNull();
7
+ expect(EDITOR_HIGHLIGHT_COLORS[0]?.value).toBeNull();
8
+ });
9
+
10
+ it("defines readable preset palettes", () => {
11
+ expect(EDITOR_TEXT_COLORS.length).toBeGreaterThan(5);
12
+ expect(EDITOR_HIGHLIGHT_COLORS.length).toBeGreaterThan(4);
13
+ expect(EDITOR_TEXT_COLORS.every((color) => color.label.length > 0)).toBe(true);
14
+ });
15
+ });
@@ -0,0 +1,31 @@
1
+ export type EditorColorSwatch = {
2
+ id: string;
3
+ label: string;
4
+ value: string | null;
5
+ };
6
+
7
+ /** Preset text colors for the formatting toolbar. */
8
+ export const EDITOR_TEXT_COLORS: EditorColorSwatch[] = [
9
+ { id: "default", label: "Default text color", value: null },
10
+ { id: "red", label: "Red text", value: "#dc2626" },
11
+ { id: "orange", label: "Orange text", value: "#ea580c" },
12
+ { id: "amber", label: "Amber text", value: "#d97706" },
13
+ { id: "green", label: "Green text", value: "#16a34a" },
14
+ { id: "teal", label: "Teal text", value: "#0d9488" },
15
+ { id: "blue", label: "Blue text", value: "#2563eb" },
16
+ { id: "violet", label: "Violet text", value: "#7c3aed" },
17
+ { id: "pink", label: "Pink text", value: "#db2777" },
18
+ { id: "gray", label: "Gray text", value: "#64748b" },
19
+ ];
20
+
21
+ /** Preset highlight colors for the formatting toolbar. */
22
+ export const EDITOR_HIGHLIGHT_COLORS: EditorColorSwatch[] = [
23
+ { id: "none", label: "Remove highlight", value: null },
24
+ { id: "yellow", label: "Yellow highlight", value: "#fef08a" },
25
+ { id: "lime", label: "Lime highlight", value: "#d9f99d" },
26
+ { id: "cyan", label: "Cyan highlight", value: "#a5f3fc" },
27
+ { id: "blue", label: "Blue highlight", value: "#bfdbfe" },
28
+ { id: "violet", label: "Violet highlight", value: "#ddd6fe" },
29
+ { id: "pink", label: "Pink highlight", value: "#fbcfe8" },
30
+ { id: "orange", label: "Orange highlight", value: "#fed7aa" },
31
+ ];
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readImageFileAsDataUrl } from "./image-upload";
3
+
4
+ describe("readImageFileAsDataUrl", () => {
5
+ it("rejects files over the size limit", async () => {
6
+ const file = new File(["hello"], "big.png", { type: "image/png" });
7
+ Object.defineProperty(file, "size", { value: 6 * 1024 * 1024 });
8
+ const result = await readImageFileAsDataUrl(file, { maxSizeBytes: 5 * 1024 * 1024 });
9
+ expect(result.ok).toBe(false);
10
+ if (!result.ok) expect(result.error).toContain("5MB");
11
+ });
12
+
13
+ it("reads a small image as a data URL", async () => {
14
+ const file = new File(["hello"], "small.png", { type: "image/png" });
15
+ const result = await readImageFileAsDataUrl(file);
16
+ expect(result.ok).toBe(true);
17
+ if (result.ok) expect(result.src.startsWith("data:image/png;base64,")).toBe(true);
18
+ });
19
+ });
@@ -0,0 +1,49 @@
1
+ export type ImageUploadOptions = {
2
+ maxSizeBytes?: number;
3
+ accept?: string;
4
+ };
5
+
6
+ export type ImageUploadResult =
7
+ | { ok: true; src: string }
8
+ | { ok: false; error: string };
9
+
10
+ const DEFAULT_MAX_SIZE = 5 * 1024 * 1024;
11
+
12
+ export function readImageFileAsDataUrl(
13
+ file: File,
14
+ options: ImageUploadOptions = {},
15
+ ): Promise<ImageUploadResult> {
16
+ const maxSizeBytes = options.maxSizeBytes ?? DEFAULT_MAX_SIZE;
17
+ const accept = options.accept ?? "image/*";
18
+
19
+ if (!file.type.startsWith("image/") && !accept.includes("*")) {
20
+ return Promise.resolve({ ok: false, error: "Only image files are supported." });
21
+ }
22
+
23
+ if (file.size > maxSizeBytes) {
24
+ const limitMb = Math.round(maxSizeBytes / (1024 * 1024));
25
+ return Promise.resolve({ ok: false, error: `Maximum file size is ${limitMb}MB.` });
26
+ }
27
+
28
+ return new Promise((resolve) => {
29
+ const reader = new FileReader();
30
+ reader.onload = () => {
31
+ if (typeof reader.result === "string") {
32
+ resolve({ ok: true, src: reader.result });
33
+ } else {
34
+ resolve({ ok: false, error: "Could not read the image file." });
35
+ }
36
+ };
37
+ reader.onerror = () => resolve({ ok: false, error: "Could not read the image file." });
38
+ reader.readAsDataURL(file);
39
+ });
40
+ }
41
+
42
+ export function insertImagesFromFiles(
43
+ files: FileList | File[],
44
+ upload: (file: File) => Promise<ImageUploadResult>,
45
+ limit = 3,
46
+ ): Promise<ImageUploadResult[]> {
47
+ const list = Array.from(files).slice(0, limit);
48
+ return Promise.all(list.map((file) => upload(file)));
49
+ }
@@ -0,0 +1,76 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { extractImageFiles, insertImageFilesIntoEditor } from "./insert-editor-images";
4
+
5
+ function createEditor() {
6
+ const run = vi.fn();
7
+ const chain = {
8
+ focus: vi.fn().mockReturnThis(),
9
+ setImage: vi.fn().mockReturnThis(),
10
+ run,
11
+ };
12
+ return {
13
+ chain: vi.fn(() => chain),
14
+ } as unknown as Editor;
15
+ }
16
+
17
+ describe("extractImageFiles", () => {
18
+ it("returns an empty array when there is no data transfer", () => {
19
+ expect(extractImageFiles(null)).toEqual([]);
20
+ expect(extractImageFiles(undefined)).toEqual([]);
21
+ });
22
+
23
+ it("filters out non-image files", () => {
24
+ const dataTransfer = {
25
+ files: [
26
+ new File(["img"], "photo.png", { type: "image/png" }),
27
+ new File(["txt"], "notes.txt", { type: "text/plain" }),
28
+ ],
29
+ } as unknown as DataTransfer;
30
+
31
+ expect(extractImageFiles(dataTransfer)).toHaveLength(1);
32
+ expect(extractImageFiles(dataTransfer)[0]?.name).toBe("photo.png");
33
+ });
34
+ });
35
+
36
+ describe("insertImageFilesIntoEditor", () => {
37
+ it("inserts images through the editor chain", async () => {
38
+ const editor = createEditor();
39
+ const file = new File(["hello"], "photo.png", { type: "image/png" });
40
+
41
+ const inserted = await insertImageFilesIntoEditor(editor, [file]);
42
+
43
+ expect(inserted).toBe(true);
44
+ expect(editor.chain).toHaveBeenCalled();
45
+ const chain = editor.chain();
46
+ expect(chain.setImage).toHaveBeenCalledWith({
47
+ src: expect.stringContaining("data:image/png;base64,"),
48
+ });
49
+ expect(chain.run).toHaveBeenCalled();
50
+ });
51
+
52
+ it("uses the host upload handler when provided", async () => {
53
+ const editor = createEditor();
54
+ const onUpload = vi.fn(async () => "https://cdn.example.com/photo.png");
55
+ const file = new File(["hello"], "photo.png", { type: "image/png" });
56
+
57
+ const inserted = await insertImageFilesIntoEditor(editor, [file], { onUpload });
58
+
59
+ expect(inserted).toBe(true);
60
+ expect(onUpload).toHaveBeenCalledWith(file);
61
+ const chain = editor.chain();
62
+ expect(chain.setImage).toHaveBeenCalledWith({ src: "https://cdn.example.com/photo.png" });
63
+ });
64
+
65
+ it("returns false when every upload fails", async () => {
66
+ const editor = createEditor();
67
+ const file = new File(["hello"], "photo.png", { type: "image/png" });
68
+
69
+ const inserted = await insertImageFilesIntoEditor(editor, [file], {
70
+ onUpload: async () => "",
71
+ });
72
+
73
+ expect(inserted).toBe(false);
74
+ expect(editor.chain).not.toHaveBeenCalled();
75
+ });
76
+ });
@@ -0,0 +1,44 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import type { ImageInsertConfig } from "./types";
3
+ import { insertImagesFromFiles, readImageFileAsDataUrl, type ImageUploadResult } from "./image-upload";
4
+
5
+ async function uploadImageFile(file: File, config?: ImageInsertConfig): Promise<ImageUploadResult> {
6
+ if (config?.onUpload) {
7
+ try {
8
+ const src = await config.onUpload(file);
9
+ return src ? { ok: true, src } : { ok: false, error: "Upload failed." };
10
+ } catch {
11
+ return { ok: false, error: "Upload failed." };
12
+ }
13
+ }
14
+ return readImageFileAsDataUrl(file, {
15
+ maxSizeBytes: config?.maxSizeBytes,
16
+ accept: config?.accept,
17
+ });
18
+ }
19
+
20
+ export async function insertImageFilesIntoEditor(
21
+ editor: Editor,
22
+ files: FileList | File[],
23
+ config?: ImageInsertConfig,
24
+ ) {
25
+ const maxFiles = config?.maxFiles ?? 3;
26
+ const results = await insertImagesFromFiles(
27
+ files,
28
+ (file) => uploadImageFile(file, config),
29
+ maxFiles,
30
+ );
31
+
32
+ const sources = results.flatMap((result) => (result.ok ? [result.src] : []));
33
+ if (!sources.length) return false;
34
+
35
+ sources.forEach((src) => {
36
+ editor.chain().focus().setImage({ src }).run();
37
+ });
38
+ return true;
39
+ }
40
+
41
+ export function extractImageFiles(dataTransfer: DataTransfer | null | undefined) {
42
+ if (!dataTransfer?.files?.length) return [];
43
+ return Array.from(dataTransfer.files).filter((file) => file.type.startsWith("image/"));
44
+ }