@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,26 @@
1
+ import { Editor } from "@tiptap/core";
2
+ import Document from "@tiptap/extension-document";
3
+ import Paragraph from "@tiptap/extension-paragraph";
4
+ import Text from "@tiptap/extension-text";
5
+ import { describe, expect, it } from "vitest";
6
+ import { AiActionPluginKey, createAiActionExtension } from "./ai-action";
7
+
8
+ describe("createAiActionExtension", () => {
9
+ it("stores AI processing and selection range", () => {
10
+ const editor = new Editor({
11
+ extensions: [Document, Paragraph, Text, createAiActionExtension()],
12
+ content: "<p>Hello world</p>",
13
+ });
14
+
15
+ editor.commands.setAiSelection({ from: 1, to: 6 });
16
+ editor.commands.setAiProcessing(true);
17
+
18
+ expect(editor.storage.aiAction.lastSelection).toEqual({ from: 1, to: 6 });
19
+ expect(editor.storage.aiAction.processing).toBe(true);
20
+
21
+ const plugin = editor.state.plugins.find((item) => item.spec.key === AiActionPluginKey);
22
+ expect(plugin).toBeDefined();
23
+
24
+ editor.destroy();
25
+ });
26
+ });
@@ -0,0 +1,73 @@
1
+ import { Extension } from "@tiptap/core";
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
3
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
4
+
5
+ export const AiActionPluginKey = new PluginKey("spatikaAiAction");
6
+
7
+ export type AiActionStorage = {
8
+ processing: boolean;
9
+ lastSelection: { from: number; to: number } | null;
10
+ };
11
+
12
+ /** Highlights the active selection while an AI transform runs. */
13
+ export function createAiActionExtension() {
14
+ return Extension.create({
15
+ name: "aiAction",
16
+
17
+ addStorage() {
18
+ return {
19
+ processing: false,
20
+ lastSelection: null,
21
+ } satisfies AiActionStorage;
22
+ },
23
+
24
+ addCommands() {
25
+ return {
26
+ setAiProcessing:
27
+ (processing: boolean) =>
28
+ ({ tr, dispatch }) => {
29
+ this.storage.processing = processing;
30
+ if (dispatch) dispatch(tr);
31
+ return true;
32
+ },
33
+ setAiSelection:
34
+ (range: { from: number; to: number } | null) =>
35
+ ({ tr, dispatch }) => {
36
+ this.storage.lastSelection = range;
37
+ if (dispatch) dispatch(tr);
38
+ return true;
39
+ },
40
+ };
41
+ },
42
+
43
+ addProseMirrorPlugins() {
44
+ const storage = this.storage as AiActionStorage;
45
+ return [
46
+ new Plugin({
47
+ key: AiActionPluginKey,
48
+ props: {
49
+ decorations: (state) => {
50
+ if (!storage.processing || !storage.lastSelection) {
51
+ return DecorationSet.empty;
52
+ }
53
+ const { from, to } = storage.lastSelection;
54
+ if (from === to) return DecorationSet.empty;
55
+ return DecorationSet.create(state.doc, [
56
+ Decoration.inline(from, to, { class: "spk-editor-ai-selection" }),
57
+ ]);
58
+ },
59
+ },
60
+ }),
61
+ ];
62
+ },
63
+ });
64
+ }
65
+
66
+ declare module "@tiptap/core" {
67
+ interface Commands<ReturnType> {
68
+ aiAction: {
69
+ setAiProcessing: (processing: boolean) => ReturnType;
70
+ setAiSelection: (range: { from: number; to: number } | null) => ReturnType;
71
+ };
72
+ }
73
+ }
@@ -0,0 +1,31 @@
1
+ import { Editor } from "@tiptap/core";
2
+ import Document from "@tiptap/extension-document";
3
+ import Paragraph from "@tiptap/extension-paragraph";
4
+ import Text from "@tiptap/extension-text";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { createMentionExtension } from "./mention";
7
+
8
+ describe("createMentionExtension", () => {
9
+ it("inserts a mention node and notifies on suggestion updates", () => {
10
+ const onUpdate = vi.fn();
11
+ const contacts = [{ id: "asha", label: "Asha Verma" }];
12
+ const editor = new Editor({
13
+ extensions: [Document, Paragraph, Text, ...createMentionExtension(contacts, onUpdate)],
14
+ content: "<p></p>",
15
+ });
16
+
17
+ editor.commands.insertContent([
18
+ { type: "mention", attrs: { id: "asha", label: "Asha Verma" } },
19
+ { type: "text", text: " " },
20
+ ]);
21
+
22
+ expect(editor.getHTML()).toContain("spk-editor-mention");
23
+ expect(editor.getText()).toContain("@Asha Verma");
24
+
25
+ const storage = editor.extensionManager.extensions.find((ext) => ext.name === "mentionSuggestion")
26
+ ?.storage;
27
+ expect(storage?.contacts).toEqual(contacts);
28
+
29
+ editor.destroy();
30
+ });
31
+ });
@@ -0,0 +1,158 @@
1
+ import { Extension, mergeAttributes, Node } from "@tiptap/core";
2
+ import { PluginKey } from "@tiptap/pm/state";
3
+ import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
4
+ import type { MentionContact } from "../lib/types";
5
+
6
+ export type MentionStorage = {
7
+ contacts: MentionContact[];
8
+ activeIndex: number;
9
+ query: string;
10
+ range: { from: number; to: number } | null;
11
+ filteredItems: MentionContact[];
12
+ runCommand: ((item: MentionContact) => void) | null;
13
+ };
14
+
15
+ export const MentionPluginKey = new PluginKey("spatikaMention");
16
+
17
+ export function createMentionExtension(
18
+ contacts: MentionContact[],
19
+ onUpdate?: (state: MentionStorage) => void,
20
+ ) {
21
+ const MentionNode = Node.create({
22
+ name: "mention",
23
+ group: "inline",
24
+ inline: true,
25
+ atom: true,
26
+ selectable: false,
27
+
28
+ addAttributes() {
29
+ return {
30
+ id: { default: null },
31
+ label: { default: null },
32
+ };
33
+ },
34
+
35
+ parseHTML() {
36
+ return [{ tag: 'span[data-type="mention"]' }];
37
+ },
38
+
39
+ renderHTML({ node, HTMLAttributes }) {
40
+ return [
41
+ "span",
42
+ mergeAttributes(HTMLAttributes, {
43
+ "data-type": "mention",
44
+ class: "spk-editor-mention",
45
+ }),
46
+ `@${node.attrs.label ?? node.attrs.id}`,
47
+ ];
48
+ },
49
+
50
+ renderText({ node }) {
51
+ return `@${node.attrs.label ?? node.attrs.id}`;
52
+ },
53
+ });
54
+
55
+ const suggestionExtension = ExtensionWithSuggestion(contacts, onUpdate);
56
+ return [MentionNode, suggestionExtension];
57
+ }
58
+
59
+ function ExtensionWithSuggestion(
60
+ contacts: MentionContact[],
61
+ onUpdate?: (state: MentionStorage) => void,
62
+ ) {
63
+ return Extension.create({
64
+ name: "mentionSuggestion",
65
+
66
+ addStorage() {
67
+ return {
68
+ contacts,
69
+ activeIndex: 0,
70
+ query: "",
71
+ range: null,
72
+ filteredItems: contacts,
73
+ runCommand: null,
74
+ } satisfies MentionStorage;
75
+ },
76
+
77
+ addProseMirrorPlugins() {
78
+ const editor = this.editor;
79
+ const storage = this.storage as MentionStorage;
80
+
81
+ const suggestion: Partial<SuggestionOptions<MentionContact>> = {
82
+ char: "@",
83
+ pluginKey: MentionPluginKey,
84
+ allowSpaces: false,
85
+ items: ({ query }) => {
86
+ const needle = query.toLowerCase();
87
+ return contacts.filter((contact) =>
88
+ contact.label.toLowerCase().includes(needle),
89
+ );
90
+ },
91
+ command: ({ editor: ed, range, props }) => {
92
+ ed.chain()
93
+ .focus()
94
+ .insertContentAt(range, [
95
+ {
96
+ type: "mention",
97
+ attrs: { id: props.id, label: props.label },
98
+ },
99
+ { type: "text", text: " " },
100
+ ])
101
+ .run();
102
+ },
103
+ render: () => ({
104
+ onStart: (props) => {
105
+ storage.query = props.query;
106
+ storage.activeIndex = 0;
107
+ storage.range = props.range;
108
+ storage.filteredItems = props.items;
109
+ storage.runCommand = (item) => props.command(item);
110
+ onUpdate?.({ ...storage });
111
+ },
112
+ onUpdate: (props) => {
113
+ storage.query = props.query;
114
+ storage.activeIndex = 0;
115
+ storage.range = props.range;
116
+ storage.filteredItems = props.items;
117
+ storage.runCommand = (item) => props.command(item);
118
+ onUpdate?.({ ...storage });
119
+ },
120
+ onKeyDown: (props) => {
121
+ if (props.event.key === "ArrowUp") {
122
+ storage.activeIndex = Math.max(0, storage.activeIndex - 1);
123
+ onUpdate?.({ ...storage });
124
+ return true;
125
+ }
126
+ if (props.event.key === "ArrowDown") {
127
+ storage.activeIndex = Math.min(storage.filteredItems.length - 1, storage.activeIndex + 1);
128
+ onUpdate?.({ ...storage });
129
+ return true;
130
+ }
131
+ if (props.event.key === "Enter") {
132
+ const item = storage.filteredItems[storage.activeIndex];
133
+ if (item) storage.runCommand?.(item);
134
+ return true;
135
+ }
136
+ if (props.event.key === "Escape") {
137
+ storage.query = "";
138
+ storage.range = null;
139
+ onUpdate?.({ ...storage });
140
+ return true;
141
+ }
142
+ return false;
143
+ },
144
+ onExit: () => {
145
+ storage.query = "";
146
+ storage.range = null;
147
+ storage.activeIndex = 0;
148
+ storage.filteredItems = contacts;
149
+ storage.runCommand = null;
150
+ onUpdate?.({ ...storage });
151
+ },
152
+ }),
153
+ };
154
+
155
+ return [Suggestion({ editor, ...suggestion })];
156
+ },
157
+ });
158
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyImageWrapState } from "./resizable-image-view";
3
+
4
+ describe("applyImageWrapState", () => {
5
+ it("applies width, alignment, and selection classes", () => {
6
+ const wrap = document.createElement("div");
7
+ const img = document.createElement("img");
8
+ applyImageWrapState(
9
+ wrap,
10
+ img,
11
+ {
12
+ attrs: {
13
+ src: "https://example.com/a.png",
14
+ width: "240px",
15
+ align: "left",
16
+ },
17
+ } as never,
18
+ true,
19
+ );
20
+
21
+ expect(wrap.className).toContain("spk-editor-image-wrap--selected");
22
+ expect(wrap.className).toContain("spk-editor-image-wrap--left");
23
+ expect(img.style.width).toBe("240px");
24
+ expect(img.src).toContain("https://example.com/a.png");
25
+ });
26
+ });
@@ -0,0 +1,96 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
3
+ import type { ImageAlign } from "./resizable-image";
4
+
5
+ type CreateImageNodeViewOptions = {
6
+ node: ProseMirrorNode;
7
+ getPos: () => number | undefined;
8
+ editor: Editor;
9
+ selected: boolean;
10
+ };
11
+
12
+ export function applyImageWrapState(
13
+ wrap: HTMLElement,
14
+ img: HTMLImageElement,
15
+ node: ProseMirrorNode,
16
+ selected: boolean,
17
+ ) {
18
+ const width = (node.attrs.width as string | undefined) ?? "100%";
19
+ const align = (node.attrs.align as ImageAlign | undefined) ?? "center";
20
+
21
+ wrap.dataset.align = align;
22
+ wrap.className = `spk-editor-image-wrap spk-editor-image-wrap--${align}${selected ? " spk-editor-image-wrap--selected" : ""}`;
23
+ img.src = node.attrs.src as string;
24
+ img.alt = (node.attrs.alt as string | undefined) ?? "";
25
+ img.className = "spk-editor-image";
26
+ img.style.width = width;
27
+ img.draggable = false;
28
+ }
29
+
30
+ export function createImageNodeView({ node, getPos, editor, selected }: CreateImageNodeViewOptions) {
31
+ const wrap = document.createElement("div");
32
+ wrap.contentEditable = "false";
33
+ wrap.className = "spk-editor-image-wrap";
34
+
35
+ const img = document.createElement("img");
36
+ const handle = document.createElement("span");
37
+ handle.className = "spk-editor-image-resize-handle";
38
+ handle.setAttribute("aria-hidden", "true");
39
+ handle.title = "Drag to resize";
40
+
41
+ applyImageWrapState(wrap, img, node, selected);
42
+ wrap.append(img, handle);
43
+
44
+ let startX = 0;
45
+ let startWidth = 0;
46
+
47
+ const onPointerMove = (event: PointerEvent) => {
48
+ const pos = getPos();
49
+ if (pos === undefined) return;
50
+ const delta = event.clientX - startX;
51
+ const nextWidth = Math.max(120, Math.round(startWidth + delta));
52
+ editor.view.dispatch(
53
+ editor.view.state.tr.setNodeMarkup(pos, undefined, {
54
+ ...node.attrs,
55
+ width: `${nextWidth}px`,
56
+ }),
57
+ );
58
+ };
59
+
60
+ const onPointerUp = () => {
61
+ document.removeEventListener("pointermove", onPointerMove);
62
+ document.removeEventListener("pointerup", onPointerUp);
63
+ };
64
+
65
+ const onPointerDown = (event: PointerEvent) => {
66
+ event.preventDefault();
67
+ event.stopPropagation();
68
+ startX = event.clientX;
69
+ startWidth = img.getBoundingClientRect().width;
70
+ document.addEventListener("pointermove", onPointerMove);
71
+ document.addEventListener("pointerup", onPointerUp);
72
+ };
73
+
74
+ handle.addEventListener("pointerdown", onPointerDown);
75
+
76
+ return {
77
+ dom: wrap,
78
+ update(updatedNode: ProseMirrorNode) {
79
+ if (updatedNode.type.name !== "image") return false;
80
+ node = updatedNode;
81
+ applyImageWrapState(wrap, img, updatedNode, selected);
82
+ return true;
83
+ },
84
+ selectNode() {
85
+ wrap.classList.add("spk-editor-image-wrap--selected");
86
+ },
87
+ deselectNode() {
88
+ wrap.classList.remove("spk-editor-image-wrap--selected");
89
+ },
90
+ destroy() {
91
+ handle.removeEventListener("pointerdown", onPointerDown);
92
+ document.removeEventListener("pointermove", onPointerMove);
93
+ document.removeEventListener("pointerup", onPointerUp);
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,28 @@
1
+ import { Editor } from "@tiptap/core";
2
+ import StarterKit from "@tiptap/starter-kit";
3
+ import { describe, expect, it } from "vitest";
4
+ import { ResizableImage } from "./resizable-image";
5
+
6
+ describe("ResizableImage", () => {
7
+ it("parses width and align attributes from HTML", () => {
8
+ const editor = new Editor({
9
+ extensions: [
10
+ StarterKit.configure({
11
+ code: false,
12
+ codeBlock: false,
13
+ link: false,
14
+ underline: false,
15
+ }),
16
+ ResizableImage,
17
+ ],
18
+ content: '<img src="https://example.com/a.png" data-width="50%" data-align="left" />',
19
+ });
20
+
21
+ const html = editor.getHTML();
22
+ expect(html).toContain('data-width="50%"');
23
+ expect(html).toContain('data-align="left"');
24
+ expect(html).toContain("spk-editor-image-wrap--left");
25
+
26
+ editor.destroy();
27
+ });
28
+ });
@@ -0,0 +1,75 @@
1
+ import Image from "@tiptap/extension-image";
2
+ import { mergeAttributes } from "@tiptap/core";
3
+ import { createImageNodeView } from "./resizable-image-view";
4
+
5
+ export type ImageAlign = "left" | "center" | "right";
6
+
7
+ /** Image node with drag-resize handles, width, and float alignment attributes. */
8
+ export const ResizableImage = Image.extend({
9
+ name: "image",
10
+
11
+ addAttributes() {
12
+ return {
13
+ ...this.parent?.(),
14
+ width: {
15
+ default: "100%",
16
+ parseHTML: (element) => {
17
+ const fromData = element.getAttribute("data-width");
18
+ if (fromData) return fromData;
19
+ const styleWidth = element.style.width;
20
+ return styleWidth || "100%";
21
+ },
22
+ renderHTML: (attributes) => ({
23
+ "data-width": attributes.width,
24
+ style: `width: ${attributes.width}`,
25
+ }),
26
+ },
27
+ align: {
28
+ default: "center" as ImageAlign,
29
+ parseHTML: (element) => {
30
+ const parent = element.parentElement;
31
+ const fromParent = parent?.getAttribute("data-align") as ImageAlign | null;
32
+ return fromParent ?? ((element.getAttribute("data-align") as ImageAlign) ?? "center");
33
+ },
34
+ renderHTML: (attributes) => ({
35
+ "data-align": attributes.align,
36
+ class: `spk-editor-image spk-editor-image--${attributes.align}`,
37
+ }),
38
+ },
39
+ };
40
+ },
41
+
42
+ addNodeView() {
43
+ return ({ node, getPos, editor }) =>
44
+ createImageNodeView({
45
+ node,
46
+ getPos,
47
+ editor,
48
+ selected: false,
49
+ });
50
+ },
51
+
52
+ renderHTML({ HTMLAttributes }) {
53
+ const align = (HTMLAttributes["data-align"] as ImageAlign | undefined) ?? "center";
54
+ return [
55
+ "div",
56
+ {
57
+ class: `spk-editor-image-wrap spk-editor-image-wrap--${align}`,
58
+ "data-align": align,
59
+ contenteditable: "false",
60
+ },
61
+ ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)],
62
+ ];
63
+ },
64
+
65
+ parseHTML() {
66
+ return [
67
+ {
68
+ tag: "div.spk-editor-image-wrap img",
69
+ },
70
+ {
71
+ tag: "img[src]",
72
+ },
73
+ ];
74
+ },
75
+ });
@@ -0,0 +1,45 @@
1
+ import { Editor } from "@tiptap/core";
2
+ import Document from "@tiptap/extension-document";
3
+ import Paragraph from "@tiptap/extension-paragraph";
4
+ import Text from "@tiptap/extension-text";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { createSlashCommandExtension, defaultSlashCommands } from "./slash-command";
7
+
8
+ describe("defaultSlashCommands", () => {
9
+ it("includes common block types", () => {
10
+ const ids = defaultSlashCommands().map((item) => item.id);
11
+ expect(ids).toEqual(
12
+ expect.arrayContaining(["paragraph", "h1", "bullet", "task", "code-block", "table", "divider"]),
13
+ );
14
+ });
15
+ });
16
+
17
+ describe("createSlashCommandExtension", () => {
18
+ it("registers slash command storage and filters items by query", () => {
19
+ const onUpdate = vi.fn();
20
+ const custom = {
21
+ id: "custom",
22
+ title: "Custom block",
23
+ command: vi.fn(),
24
+ };
25
+ const editor = new Editor({
26
+ extensions: [
27
+ Document,
28
+ Paragraph,
29
+ Text,
30
+ createSlashCommandExtension([...defaultSlashCommands(), custom], onUpdate),
31
+ ],
32
+ content: "<p></p>",
33
+ });
34
+
35
+ const storage = editor.storage.slashCommand;
36
+ expect(storage.items.some((item: { id: string }) => item.id === "custom")).toBe(true);
37
+
38
+ const pluginItems = storage.items.filter((item: { title: string }) =>
39
+ item.title.toLowerCase().includes("heading"),
40
+ );
41
+ expect(pluginItems.length).toBeGreaterThan(0);
42
+
43
+ editor.destroy();
44
+ });
45
+ });