@spatika/editor 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/dist/composites/AiCommandDock.d.ts +13 -0
- package/dist/composites/AiCommandDock.d.ts.map +1 -0
- package/dist/composites/AiCommandDock.js +63 -0
- package/dist/composites/AiCommandMenu.d.ts +16 -0
- package/dist/composites/AiCommandMenu.d.ts.map +1 -0
- package/dist/composites/AiCommandMenu.js +51 -0
- package/dist/composites/AiCommandPanel.d.ts +12 -0
- package/dist/composites/AiCommandPanel.d.ts.map +1 -0
- package/dist/composites/AiCommandPanel.js +12 -0
- package/dist/composites/AiOverlay.d.ts +7 -0
- package/dist/composites/AiOverlay.d.ts.map +1 -0
- package/dist/composites/AiOverlay.js +8 -0
- package/dist/composites/EditorBubbleMenu.d.ts +10 -0
- package/dist/composites/EditorBubbleMenu.d.ts.map +1 -0
- package/dist/composites/EditorBubbleMenu.js +16 -0
- package/dist/composites/EditorToolbar.d.ts +20 -0
- package/dist/composites/EditorToolbar.d.ts.map +1 -0
- package/dist/composites/EditorToolbar.js +45 -0
- package/dist/composites/ImageBubbleMenu.d.ts +7 -0
- package/dist/composites/ImageBubbleMenu.d.ts.map +1 -0
- package/dist/composites/ImageBubbleMenu.js +15 -0
- package/dist/composites/MentionMenu.d.ts +11 -0
- package/dist/composites/MentionMenu.d.ts.map +1 -0
- package/dist/composites/MentionMenu.js +10 -0
- package/dist/composites/SlashCommandMenu.d.ts +13 -0
- package/dist/composites/SlashCommandMenu.d.ts.map +1 -0
- package/dist/composites/SlashCommandMenu.js +10 -0
- package/dist/composites/SpatikaEditor.d.ts +14 -0
- package/dist/composites/SpatikaEditor.d.ts.map +1 -0
- package/dist/composites/SpatikaEditor.js +159 -0
- package/dist/composites/TableGridPicker.d.ts +9 -0
- package/dist/composites/TableGridPicker.d.ts.map +1 -0
- package/dist/composites/TableGridPicker.js +54 -0
- package/dist/composites/ToolbarButton.d.ts +7 -0
- package/dist/composites/ToolbarButton.d.ts.map +1 -0
- package/dist/composites/ToolbarButton.js +7 -0
- package/dist/composites/ToolbarFormatPanels.d.ts +9 -0
- package/dist/composites/ToolbarFormatPanels.d.ts.map +1 -0
- package/dist/composites/ToolbarFormatPanels.js +35 -0
- package/dist/composites/ToolbarInsertPanels.d.ts +14 -0
- package/dist/composites/ToolbarInsertPanels.d.ts.map +1 -0
- package/dist/composites/ToolbarInsertPanels.js +96 -0
- package/dist/composites/ToolbarPopover.d.ts +14 -0
- package/dist/composites/ToolbarPopover.d.ts.map +1 -0
- package/dist/composites/ToolbarPopover.js +48 -0
- package/dist/extensions/ai-action.d.ts +24 -0
- package/dist/extensions/ai-action.d.ts.map +1 -0
- package/dist/extensions/ai-action.js +53 -0
- package/dist/extensions/mention.d.ts +17 -0
- package/dist/extensions/mention.d.ts.map +1 -0
- package/dist/extensions/mention.js +129 -0
- package/dist/extensions/resizable-image-view.d.ts +18 -0
- package/dist/extensions/resizable-image-view.d.ts.map +1 -0
- package/dist/extensions/resizable-image-view.js +70 -0
- package/dist/extensions/resizable-image.d.ts +4 -0
- package/dist/extensions/resizable-image.d.ts.map +1 -0
- package/dist/extensions/resizable-image.js +68 -0
- package/dist/extensions/slash-command.d.ts +18 -0
- package/dist/extensions/slash-command.d.ts.map +1 -0
- package/dist/extensions/slash-command.js +178 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/lib/ai-command-menu.d.ts +9 -0
- package/dist/lib/ai-command-menu.d.ts.map +1 -0
- package/dist/lib/ai-command-menu.js +43 -0
- package/dist/lib/cn.d.ts +4 -0
- package/dist/lib/cn.d.ts.map +1 -0
- package/dist/lib/cn.js +6 -0
- package/dist/lib/editor-format-colors.d.ts +10 -0
- package/dist/lib/editor-format-colors.d.ts.map +1 -0
- package/dist/lib/editor-format-colors.js +24 -0
- package/dist/lib/image-upload.d.ts +14 -0
- package/dist/lib/image-upload.d.ts.map +1 -0
- package/dist/lib/image-upload.js +29 -0
- package/dist/lib/insert-editor-images.d.ts +5 -0
- package/dist/lib/insert-editor-images.d.ts.map +1 -0
- package/dist/lib/insert-editor-images.js +32 -0
- package/dist/lib/types.d.ts +131 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +12 -0
- package/dist/lib/use-spatika-editor.d.ts +13 -0
- package/dist/lib/use-spatika-editor.d.ts.map +1 -0
- package/dist/lib/use-spatika-editor.js +171 -0
- package/dist/styles.css +1056 -0
- package/package.json +101 -0
- package/src/composites/AiCommandDock.test.tsx +48 -0
- package/src/composites/AiCommandDock.tsx +137 -0
- package/src/composites/AiCommandMenu.test.tsx +67 -0
- package/src/composites/AiCommandMenu.tsx +120 -0
- package/src/composites/AiCommandPanel.test.tsx +42 -0
- package/src/composites/AiCommandPanel.tsx +56 -0
- package/src/composites/AiOverlay.test.tsx +18 -0
- package/src/composites/AiOverlay.tsx +18 -0
- package/src/composites/EditorBubbleMenu.test.tsx +49 -0
- package/src/composites/EditorBubbleMenu.tsx +73 -0
- package/src/composites/EditorToolbar.test.tsx +146 -0
- package/src/composites/EditorToolbar.tsx +362 -0
- package/src/composites/ImageBubbleMenu.test.tsx +43 -0
- package/src/composites/ImageBubbleMenu.tsx +51 -0
- package/src/composites/MentionMenu.test.tsx +25 -0
- package/src/composites/MentionMenu.tsx +46 -0
- package/src/composites/SlashCommandMenu.test.tsx +33 -0
- package/src/composites/SlashCommandMenu.tsx +54 -0
- package/src/composites/SpatikaEditor.test.tsx +167 -0
- package/src/composites/SpatikaEditor.tsx +302 -0
- package/src/composites/TableGridPicker.test.tsx +26 -0
- package/src/composites/TableGridPicker.tsx +138 -0
- package/src/composites/ToolbarButton.test.tsx +21 -0
- package/src/composites/ToolbarButton.tsx +24 -0
- package/src/composites/ToolbarFormatPanels.test.tsx +46 -0
- package/src/composites/ToolbarFormatPanels.tsx +78 -0
- package/src/composites/ToolbarInsertPanels.test.tsx +60 -0
- package/src/composites/ToolbarInsertPanels.tsx +200 -0
- package/src/composites/ToolbarPopover.test.tsx +24 -0
- package/src/composites/ToolbarPopover.tsx +95 -0
- package/src/extensions/ai-action.test.ts +26 -0
- package/src/extensions/ai-action.ts +73 -0
- package/src/extensions/mention.test.ts +31 -0
- package/src/extensions/mention.ts +158 -0
- package/src/extensions/resizable-image-view.test.ts +26 -0
- package/src/extensions/resizable-image-view.ts +96 -0
- package/src/extensions/resizable-image.test.ts +28 -0
- package/src/extensions/resizable-image.ts +75 -0
- package/src/extensions/slash-command.test.ts +45 -0
- package/src/extensions/slash-command.ts +197 -0
- package/src/index.ts +49 -0
- package/src/lib/ai-command-menu.test.ts +48 -0
- package/src/lib/ai-command-menu.ts +57 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/editor-format-colors.test.ts +15 -0
- package/src/lib/editor-format-colors.ts +31 -0
- package/src/lib/image-upload.test.ts +19 -0
- package/src/lib/image-upload.ts +49 -0
- package/src/lib/insert-editor-images.test.ts +76 -0
- package/src/lib/insert-editor-images.ts +44 -0
- package/src/lib/types.ts +149 -0
- package/src/lib/use-spatika-editor.test.tsx +56 -0
- package/src/lib/use-spatika-editor.ts +191 -0
- package/src/styles/editor.css +1056 -0
- package/src/test/setup.ts +46 -0
|
@@ -0,0 +1,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
|
+
}
|