@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,138 @@
1
+ import { Columns3, Rows3 } from "lucide-react";
2
+ import { useState, type MouseEvent } from "react";
3
+ import { cn } from "../lib/cn";
4
+
5
+ export const TABLE_GRID_SIZE = 10;
6
+ export const TABLE_ROWS_MAX = 20;
7
+ export const TABLE_COLS_MAX = 12;
8
+
9
+ type TableGridPickerProps = {
10
+ onInsert: (rows: number, cols: number, withHeaderRow: boolean) => void;
11
+ };
12
+
13
+ function clampDimension(value: number, max: number) {
14
+ return Math.max(1, Math.min(max, value));
15
+ }
16
+
17
+ function pickCell(event: MouseEvent<HTMLElement>) {
18
+ const cell = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-table-row]");
19
+ if (!cell) return null;
20
+ return {
21
+ rows: clampDimension(Number(cell.dataset.tableRow), TABLE_ROWS_MAX),
22
+ cols: clampDimension(Number(cell.dataset.tableCol), TABLE_COLS_MAX),
23
+ };
24
+ }
25
+
26
+ export function TableGridPicker({ onInsert }: TableGridPickerProps) {
27
+ const [rows, setRows] = useState(3);
28
+ const [cols, setCols] = useState(3);
29
+ const [withHeaderRow, setWithHeaderRow] = useState(true);
30
+ const [hover, setHover] = useState<{ rows: number; cols: number } | null>(null);
31
+
32
+ const previewRows = hover?.rows ?? rows;
33
+ const previewCols = hover?.cols ?? cols;
34
+
35
+ const handleGridMove = (event: MouseEvent<HTMLDivElement>) => {
36
+ const picked = pickCell(event);
37
+ if (picked) setHover(picked);
38
+ };
39
+
40
+ const handleGridClick = (event: MouseEvent<HTMLDivElement>) => {
41
+ const picked = pickCell(event);
42
+ if (picked) {
43
+ setRows(picked.rows);
44
+ setCols(picked.cols);
45
+ onInsert(picked.rows, picked.cols, withHeaderRow);
46
+ }
47
+ };
48
+
49
+ return (
50
+ <div className="spk-editor-table-picker">
51
+ <div className="spk-editor-table-picker-label" aria-live="polite">
52
+ {previewCols} × {previewRows}
53
+ </div>
54
+ <div
55
+ className="spk-editor-table-picker-grid"
56
+ role="grid"
57
+ aria-label="Choose table size"
58
+ onMouseMove={handleGridMove}
59
+ onMouseLeave={() => setHover(null)}
60
+ onClick={handleGridClick}
61
+ >
62
+ {Array.from({ length: TABLE_GRID_SIZE * TABLE_GRID_SIZE }, (_, index) => {
63
+ const rowIndex = Math.floor(index / TABLE_GRID_SIZE);
64
+ const colIndex = index % TABLE_GRID_SIZE;
65
+ const cellRows = rowIndex + 1;
66
+ const cellCols = colIndex + 1;
67
+ const active = cellRows <= previewRows && cellCols <= previewCols;
68
+
69
+ return (
70
+ <button
71
+ key={`${rowIndex}-${colIndex}`}
72
+ type="button"
73
+ role="gridcell"
74
+ data-table-row={cellRows}
75
+ data-table-col={cellCols}
76
+ className={cn(
77
+ "spk-editor-table-picker-cell",
78
+ active && "spk-editor-table-picker-cell--active",
79
+ )}
80
+ aria-label={`${cellCols} by ${cellRows} table`}
81
+ onMouseDown={(event) => event.preventDefault()}
82
+ />
83
+ );
84
+ })}
85
+ </div>
86
+ <div className="spk-editor-table-picker-size">
87
+ <label className="spk-editor-table-picker-size-field">
88
+ <Columns3 className="size-4" aria-hidden="true" />
89
+ <span className="sr-only">Columns</span>
90
+ <input
91
+ type="number"
92
+ min={1}
93
+ max={TABLE_COLS_MAX}
94
+ className="spk-editor-table-picker-size-input"
95
+ value={previewCols}
96
+ onChange={(event) => {
97
+ setHover(null);
98
+ setCols(clampDimension(Number.parseInt(event.target.value, 10) || 1, TABLE_COLS_MAX));
99
+ }}
100
+ />
101
+ </label>
102
+ <span className="spk-editor-table-picker-size-sep" aria-hidden="true">
103
+ ×
104
+ </span>
105
+ <label className="spk-editor-table-picker-size-field">
106
+ <Rows3 className="size-4" aria-hidden="true" />
107
+ <span className="sr-only">Rows</span>
108
+ <input
109
+ type="number"
110
+ min={1}
111
+ max={TABLE_ROWS_MAX}
112
+ className="spk-editor-table-picker-size-input"
113
+ value={previewRows}
114
+ onChange={(event) => {
115
+ setHover(null);
116
+ setRows(clampDimension(Number.parseInt(event.target.value, 10) || 1, TABLE_ROWS_MAX));
117
+ }}
118
+ />
119
+ </label>
120
+ </div>
121
+ <label className="spk-editor-toolbar-popover-check">
122
+ <input
123
+ type="checkbox"
124
+ checked={withHeaderRow}
125
+ onChange={(event) => setWithHeaderRow(event.target.checked)}
126
+ />
127
+ <span>Header row</span>
128
+ </label>
129
+ <button
130
+ type="button"
131
+ className="spk-editor-toolbar-popover-btn spk-editor-toolbar-popover-btn--primary spk-editor-table-picker-insert"
132
+ onClick={() => onInsert(previewRows, previewCols, withHeaderRow)}
133
+ >
134
+ Insert {previewCols} × {previewRows} table
135
+ </button>
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,21 @@
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 { ToolbarButton } from "./ToolbarButton";
5
+
6
+ describe("ToolbarButton", () => {
7
+ it("fires onClick and marks active state", async () => {
8
+ const user = userEvent.setup();
9
+ const onClick = vi.fn();
10
+ render(
11
+ <ToolbarButton aria-label="Bold" active onClick={onClick}>
12
+ B
13
+ </ToolbarButton>,
14
+ );
15
+ const button = screen.getByRole("button", { name: "Bold" });
16
+ expect(button).toHaveAttribute("data-active", "true");
17
+ expect(button).toHaveClass("spk-editor-toolbar-btn--active");
18
+ await user.click(button);
19
+ expect(onClick).toHaveBeenCalledTimes(1);
20
+ });
21
+ });
@@ -0,0 +1,24 @@
1
+ import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ /** Compact icon control for the editor toolbar. Avoids a @spatika/react dependency. */
5
+ export const ToolbarButton = forwardRef<
6
+ HTMLButtonElement,
7
+ ButtonHTMLAttributes<HTMLButtonElement> & {
8
+ active?: boolean;
9
+ children: ReactNode;
10
+ }
11
+ >(function ToolbarButton({ className, active, children, ...props }, ref) {
12
+ return (
13
+ <button
14
+ ref={ref}
15
+ type="button"
16
+ data-slot="editor-toolbar-button"
17
+ data-active={active ? "true" : undefined}
18
+ className={cn("spk-editor-toolbar-btn", active && "spk-editor-toolbar-btn--active", className)}
19
+ {...props}
20
+ >
21
+ {children}
22
+ </button>
23
+ );
24
+ });
@@ -0,0 +1,46 @@
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 { HighlightColorPanel, TextColorPanel } from "./ToolbarFormatPanels";
6
+
7
+ function createEditor() {
8
+ const run = vi.fn();
9
+ const chain = {
10
+ focus: vi.fn().mockReturnThis(),
11
+ setColor: vi.fn().mockReturnThis(),
12
+ unsetColor: vi.fn().mockReturnThis(),
13
+ setHighlight: vi.fn().mockReturnThis(),
14
+ unsetHighlight: vi.fn().mockReturnThis(),
15
+ run,
16
+ };
17
+ return {
18
+ getAttributes: vi.fn((mark: string) => {
19
+ if (mark === "textStyle") return { color: undefined };
20
+ if (mark === "highlight") return { color: undefined };
21
+ return {};
22
+ }),
23
+ chain: vi.fn(() => chain),
24
+ chainRef: chain,
25
+ } as unknown as Editor & { chainRef: typeof chain };
26
+ }
27
+
28
+ describe("ToolbarFormatPanels", () => {
29
+ it("applies a text color and closes", async () => {
30
+ const editor = createEditor();
31
+ const close = vi.fn();
32
+ render(<TextColorPanel editor={editor} close={close} />);
33
+ await userEvent.click(screen.getByRole("listitem", { name: "Red text" }));
34
+ expect(editor.chainRef.setColor).toHaveBeenCalledWith("#dc2626");
35
+ expect(close).toHaveBeenCalled();
36
+ });
37
+
38
+ it("clears highlight color and closes", async () => {
39
+ const editor = createEditor();
40
+ const close = vi.fn();
41
+ render(<HighlightColorPanel editor={editor} close={close} />);
42
+ await userEvent.click(screen.getByRole("listitem", { name: "Remove highlight" }));
43
+ expect(editor.chainRef.unsetHighlight).toHaveBeenCalled();
44
+ expect(close).toHaveBeenCalled();
45
+ });
46
+ });
@@ -0,0 +1,78 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import type { CSSProperties } from "react";
3
+ import { EDITOR_HIGHLIGHT_COLORS, EDITOR_TEXT_COLORS } from "../lib/editor-format-colors";
4
+ import { cn } from "../lib/cn";
5
+
6
+ type PanelProps = {
7
+ editor: Editor;
8
+ close: () => void;
9
+ };
10
+
11
+ function ColorSwatchGrid({
12
+ colors,
13
+ activeValue,
14
+ onSelect,
15
+ }: {
16
+ colors: typeof EDITOR_TEXT_COLORS;
17
+ activeValue: string | null | undefined;
18
+ onSelect: (value: string | null) => void;
19
+ }) {
20
+ return (
21
+ <div className="spk-editor-color-grid" role="list">
22
+ {colors.map((color) => {
23
+ const isActive =
24
+ color.value === null ? !activeValue : activeValue?.toLowerCase() === color.value.toLowerCase();
25
+ return (
26
+ <button
27
+ key={color.id}
28
+ type="button"
29
+ role="listitem"
30
+ aria-label={color.label}
31
+ aria-pressed={isActive}
32
+ className={cn(
33
+ "spk-editor-color-swatch",
34
+ color.value === null && "spk-editor-color-swatch--default",
35
+ isActive && "spk-editor-color-swatch--active",
36
+ )}
37
+ style={color.value ? ({ "--swatch-color": color.value } as CSSProperties) : undefined}
38
+ onClick={() => onSelect(color.value)}
39
+ />
40
+ );
41
+ })}
42
+ </div>
43
+ );
44
+ }
45
+
46
+ export function TextColorPanel({ editor, close }: PanelProps) {
47
+ const activeColor = (editor.getAttributes("textStyle").color as string | undefined) ?? null;
48
+
49
+ const apply = (value: string | null) => {
50
+ if (value === null) {
51
+ editor.chain().focus().unsetColor().run();
52
+ } else {
53
+ editor.chain().focus().setColor(value).run();
54
+ }
55
+ close();
56
+ };
57
+
58
+ return (
59
+ <ColorSwatchGrid colors={EDITOR_TEXT_COLORS} activeValue={activeColor} onSelect={apply} />
60
+ );
61
+ }
62
+
63
+ export function HighlightColorPanel({ editor, close }: PanelProps) {
64
+ const activeColor = (editor.getAttributes("highlight").color as string | undefined) ?? null;
65
+
66
+ const apply = (value: string | null) => {
67
+ if (value === null) {
68
+ editor.chain().focus().unsetHighlight().run();
69
+ } else {
70
+ editor.chain().focus().setHighlight({ color: value }).run();
71
+ }
72
+ close();
73
+ };
74
+
75
+ return (
76
+ <ColorSwatchGrid colors={EDITOR_HIGHLIGHT_COLORS} activeValue={activeColor} onSelect={apply} />
77
+ );
78
+ }
@@ -0,0 +1,60 @@
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 { ImageInsertPanel, LinkInsertPanel, TableInsertPanel } from "./ToolbarInsertPanels";
6
+
7
+ function createEditor() {
8
+ const run = vi.fn();
9
+ const chain = {
10
+ focus: vi.fn().mockReturnThis(),
11
+ extendMarkRange: vi.fn().mockReturnThis(),
12
+ setLink: vi.fn().mockReturnThis(),
13
+ unsetLink: vi.fn().mockReturnThis(),
14
+ setImage: vi.fn().mockReturnThis(),
15
+ insertTable: vi.fn().mockReturnThis(),
16
+ run,
17
+ };
18
+ return {
19
+ getAttributes: vi.fn().mockReturnValue({ href: undefined }),
20
+ chain: vi.fn(() => chain),
21
+ } as unknown as Editor;
22
+ }
23
+
24
+ describe("ToolbarInsertPanels", () => {
25
+ it("applies a link from the panel", async () => {
26
+ const editor = createEditor();
27
+ const close = vi.fn();
28
+ render(<LinkInsertPanel editor={editor} close={close} />);
29
+ await userEvent.clear(screen.getByLabelText("URL"));
30
+ await userEvent.type(screen.getByLabelText("URL"), "https://spatika.dev");
31
+ await userEvent.click(screen.getByRole("button", { name: "Apply" }));
32
+ expect(editor.chain).toHaveBeenCalled();
33
+ expect(close).toHaveBeenCalled();
34
+ });
35
+
36
+ it("inserts an image from the panel URL field", async () => {
37
+ const editor = createEditor();
38
+ const close = vi.fn();
39
+ render(<ImageInsertPanel editor={editor} close={close} />);
40
+ expect(screen.getByText("Click to upload or drag and drop")).toBeInTheDocument();
41
+ await userEvent.type(screen.getByLabelText("Image URL"), "https://example.com/a.png");
42
+ await userEvent.click(screen.getByRole("button", { name: "Insert" }));
43
+ expect(editor.chain).toHaveBeenCalled();
44
+ expect(close).toHaveBeenCalled();
45
+ });
46
+
47
+ it("inserts a table from the grid picker", async () => {
48
+ const editor = createEditor();
49
+ const close = vi.fn();
50
+ render(<TableInsertPanel editor={editor} close={close} />);
51
+ await userEvent.click(screen.getByRole("gridcell", { name: "5 by 4 table" }));
52
+ const chain = editor.chain();
53
+ expect(chain.insertTable).toHaveBeenCalledWith({
54
+ rows: 4,
55
+ cols: 5,
56
+ withHeaderRow: true,
57
+ });
58
+ expect(close).toHaveBeenCalled();
59
+ });
60
+ });
@@ -0,0 +1,200 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { ImagePlus, Upload } from "lucide-react";
3
+ import { useRef, useState } from "react";
4
+ import type { ImageInsertConfig } from "../lib/types";
5
+ import { cn } from "../lib/cn";
6
+ import { insertImageFilesIntoEditor } from "../lib/insert-editor-images";
7
+ import { TableGridPicker } from "./TableGridPicker";
8
+
9
+ type PanelProps = {
10
+ editor: Editor;
11
+ close: () => void;
12
+ };
13
+
14
+ type ImagePanelProps = PanelProps & {
15
+ config?: ImageInsertConfig;
16
+ };
17
+
18
+ export function LinkInsertPanel({ editor, close }: PanelProps) {
19
+ const previous = editor.getAttributes("link").href as string | undefined;
20
+ const [url, setUrl] = useState(previous ?? "https://");
21
+
22
+ const apply = () => {
23
+ const trimmed = url.trim();
24
+ if (!trimmed) {
25
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
26
+ } else {
27
+ editor.chain().focus().extendMarkRange("link").setLink({ href: trimmed }).run();
28
+ }
29
+ close();
30
+ };
31
+
32
+ const remove = () => {
33
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
34
+ close();
35
+ };
36
+
37
+ return (
38
+ <div className="spk-editor-toolbar-popover-form">
39
+ <label className="spk-editor-toolbar-popover-field">
40
+ <span>URL</span>
41
+ <input
42
+ type="url"
43
+ className="spk-editor-toolbar-popover-input"
44
+ value={url}
45
+ placeholder="https://"
46
+ onChange={(event) => setUrl(event.target.value)}
47
+ onKeyDown={(event) => {
48
+ if (event.key === "Enter") apply();
49
+ if (event.key === "Escape") close();
50
+ }}
51
+ autoFocus
52
+ />
53
+ </label>
54
+ <div className="spk-editor-toolbar-popover-actions">
55
+ {previous ? (
56
+ <button type="button" className="spk-editor-toolbar-popover-btn spk-editor-toolbar-popover-btn--ghost" onClick={remove}>
57
+ Remove
58
+ </button>
59
+ ) : null}
60
+ <button type="button" className="spk-editor-toolbar-popover-btn spk-editor-toolbar-popover-btn--ghost" onClick={close}>
61
+ Cancel
62
+ </button>
63
+ <button type="button" className="spk-editor-toolbar-popover-btn spk-editor-toolbar-popover-btn--primary" onClick={apply}>
64
+ Apply
65
+ </button>
66
+ </div>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ function insertImageSrc(editor: Editor, src: string) {
72
+ editor.chain().focus().setImage({ src }).run();
73
+ }
74
+
75
+ export function ImageInsertPanel({ editor, close, config }: ImagePanelProps) {
76
+ const inputRef = useRef<HTMLInputElement>(null);
77
+ const [url, setUrl] = useState("");
78
+ const [error, setError] = useState<string | null>(null);
79
+ const [dragging, setDragging] = useState(false);
80
+
81
+ const maxFiles = config?.maxFiles ?? 3;
82
+ const maxSizeBytes = config?.maxSizeBytes ?? 5 * 1024 * 1024;
83
+ const accept = config?.accept ?? "image/*";
84
+ const limitMb = Math.round(maxSizeBytes / (1024 * 1024));
85
+
86
+ const handleFiles = async (files: FileList | File[]) => {
87
+ setError(null);
88
+ const inserted = await insertImageFilesIntoEditor(editor, files, config);
89
+ if (!inserted) {
90
+ setError("Could not insert image.");
91
+ return;
92
+ }
93
+ close();
94
+ };
95
+
96
+ const insertFromUrl = () => {
97
+ const trimmed = url.trim();
98
+ if (!trimmed) return;
99
+ insertImageSrc(editor, trimmed);
100
+ close();
101
+ };
102
+
103
+ return (
104
+ <div className="spk-editor-toolbar-popover-form spk-editor-image-insert">
105
+ <button
106
+ type="button"
107
+ className={cn(
108
+ "spk-editor-image-dropzone",
109
+ dragging && "spk-editor-image-dropzone--active",
110
+ )}
111
+ onClick={() => inputRef.current?.click()}
112
+ onDragEnter={(event) => {
113
+ event.preventDefault();
114
+ setDragging(true);
115
+ }}
116
+ onDragOver={(event) => {
117
+ event.preventDefault();
118
+ setDragging(true);
119
+ }}
120
+ onDragLeave={() => setDragging(false)}
121
+ onDrop={(event) => {
122
+ event.preventDefault();
123
+ setDragging(false);
124
+ if (event.dataTransfer.files.length) {
125
+ void handleFiles(event.dataTransfer.files);
126
+ }
127
+ }}
128
+ >
129
+ <span className="spk-editor-image-dropzone-icon" aria-hidden="true">
130
+ <Upload className="size-5" />
131
+ </span>
132
+ <span className="spk-editor-image-dropzone-title">Click to upload or drag and drop</span>
133
+ <span className="spk-editor-image-dropzone-hint">
134
+ Maximum {maxFiles} files, {limitMb}MB each
135
+ </span>
136
+ </button>
137
+ <input
138
+ ref={inputRef}
139
+ type="file"
140
+ accept={accept}
141
+ multiple={maxFiles > 1}
142
+ className="sr-only"
143
+ onChange={(event) => {
144
+ if (event.target.files?.length) {
145
+ void handleFiles(event.target.files);
146
+ }
147
+ }}
148
+ />
149
+ {error ? <p className="spk-editor-image-insert-error">{error}</p> : null}
150
+ <div className="spk-editor-image-insert-divider">
151
+ <span>or paste a URL</span>
152
+ </div>
153
+ <label className="spk-editor-toolbar-popover-field">
154
+ <span>Image URL</span>
155
+ <input
156
+ type="url"
157
+ className="spk-editor-toolbar-popover-input"
158
+ value={url}
159
+ placeholder="https://example.com/photo.jpg"
160
+ onChange={(event) => setUrl(event.target.value)}
161
+ onKeyDown={(event) => {
162
+ if (event.key === "Enter") insertFromUrl();
163
+ if (event.key === "Escape") close();
164
+ }}
165
+ />
166
+ </label>
167
+ <div className="spk-editor-toolbar-popover-actions">
168
+ <button type="button" className="spk-editor-toolbar-popover-btn spk-editor-toolbar-popover-btn--ghost" onClick={close}>
169
+ Cancel
170
+ </button>
171
+ <button
172
+ type="button"
173
+ className="spk-editor-toolbar-popover-btn spk-editor-toolbar-popover-btn--primary"
174
+ onClick={insertFromUrl}
175
+ disabled={!url.trim()}
176
+ >
177
+ <ImagePlus className="size-4" aria-hidden="true" />
178
+ Insert
179
+ </button>
180
+ </div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ export function TableInsertPanel({ editor, close }: PanelProps) {
186
+ const insert = (nextRows: number, nextCols: number, withHeaderRow: boolean) => {
187
+ editor
188
+ .chain()
189
+ .focus()
190
+ .insertTable({
191
+ rows: nextRows,
192
+ cols: nextCols,
193
+ withHeaderRow,
194
+ })
195
+ .run();
196
+ close();
197
+ };
198
+
199
+ return <TableGridPicker onInsert={insert} />;
200
+ }
@@ -0,0 +1,24 @@
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 { Link2 } from "lucide-react";
5
+ import { ToolbarPopover } from "./ToolbarPopover";
6
+
7
+ describe("ToolbarPopover", () => {
8
+ it("opens a glass panel and closes on cancel", async () => {
9
+ const close = vi.fn();
10
+ render(
11
+ <ToolbarPopover label="Insert link" title="Insert link" icon={<Link2 className="size-4" />}>
12
+ {({ close: closePanel }) => (
13
+ <button type="button" onClick={() => { close(); closePanel(); }}>
14
+ Done
15
+ </button>
16
+ )}
17
+ </ToolbarPopover>,
18
+ );
19
+ await userEvent.click(screen.getByRole("button", { name: "Insert link" }));
20
+ expect(screen.getByRole("dialog", { name: "Insert link" })).toBeInTheDocument();
21
+ await userEvent.click(screen.getByRole("button", { name: "Done" }));
22
+ expect(close).toHaveBeenCalled();
23
+ });
24
+ });
@@ -0,0 +1,95 @@
1
+ import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { cn } from "../lib/cn";
4
+ import { ToolbarButton } from "./ToolbarButton";
5
+
6
+ type ToolbarPopoverProps = {
7
+ label: string;
8
+ icon: ReactNode;
9
+ title: string;
10
+ className?: string;
11
+ active?: boolean;
12
+ children: (api: { close: () => void }) => ReactNode;
13
+ };
14
+
15
+ export function ToolbarPopover({
16
+ label,
17
+ icon,
18
+ title,
19
+ className,
20
+ active = false,
21
+ children,
22
+ }: ToolbarPopoverProps) {
23
+ const [open, setOpen] = useState(false);
24
+ const [panelStyle, setPanelStyle] = useState<{ top: number; left: number } | null>(null);
25
+ const rootRef = useRef<HTMLDivElement>(null);
26
+ const triggerRef = useRef<HTMLButtonElement>(null);
27
+ const panelRef = useRef<HTMLDivElement>(null);
28
+ const titleId = useId();
29
+
30
+ const close = () => setOpen(false);
31
+
32
+ useLayoutEffect(() => {
33
+ if (!open || !triggerRef.current) return;
34
+ const updatePosition = () => {
35
+ const rect = triggerRef.current?.getBoundingClientRect();
36
+ if (!rect) return;
37
+ setPanelStyle({
38
+ top: rect.bottom + 6,
39
+ left: rect.left,
40
+ });
41
+ };
42
+ updatePosition();
43
+ window.addEventListener("resize", updatePosition);
44
+ window.addEventListener("scroll", updatePosition, true);
45
+ return () => {
46
+ window.removeEventListener("resize", updatePosition);
47
+ window.removeEventListener("scroll", updatePosition, true);
48
+ };
49
+ }, [open]);
50
+
51
+ useEffect(() => {
52
+ if (!open) return;
53
+ const onPointerDown = (event: MouseEvent) => {
54
+ const target = event.target as Node;
55
+ if (rootRef.current?.contains(target) || panelRef.current?.contains(target)) return;
56
+ setOpen(false);
57
+ };
58
+ document.addEventListener("mousedown", onPointerDown);
59
+ return () => document.removeEventListener("mousedown", onPointerDown);
60
+ }, [open]);
61
+
62
+ const panel =
63
+ open && panelStyle ? (
64
+ <div
65
+ ref={panelRef}
66
+ id={titleId}
67
+ className="spk-editor-toolbar-popover-panel spk-editor-toolbar-popover-panel--portal"
68
+ role="dialog"
69
+ aria-label={title}
70
+ style={{ top: panelStyle.top, left: panelStyle.left }}
71
+ onMouseDown={(event) => event.stopPropagation()}
72
+ onPointerDown={(event) => event.stopPropagation()}
73
+ >
74
+ <div className="spk-editor-toolbar-popover-title">{title}</div>
75
+ {children({ close })}
76
+ </div>
77
+ ) : null;
78
+
79
+ return (
80
+ <div ref={rootRef} className={cn("spk-editor-toolbar-popover", className)}>
81
+ <ToolbarButton
82
+ ref={triggerRef}
83
+ aria-label={label}
84
+ aria-expanded={open}
85
+ aria-haspopup="dialog"
86
+ aria-controls={open ? titleId : undefined}
87
+ active={open || active}
88
+ onClick={() => setOpen((value) => !value)}
89
+ >
90
+ {icon}
91
+ </ToolbarButton>
92
+ {typeof document !== "undefined" && panel ? createPortal(panel, document.body) : null}
93
+ </div>
94
+ );
95
+ }