@firecms/core 3.1.0-canary.24c8270 → 3.1.0-canary.75005e4

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 (180) hide show
  1. package/dist/components/EntityCollectionView/CollectionDataErrorBanner.d.ts +4 -0
  2. package/dist/components/ErrorBoundary.d.ts +3 -1
  3. package/dist/components/HomePage/DefaultHomePage.d.ts +0 -1
  4. package/dist/components/LanguageToggle.d.ts +1 -0
  5. package/dist/components/UnsavedChangesDialog.d.ts +1 -0
  6. package/dist/components/index.d.ts +1 -0
  7. package/dist/core/DrawerNavigationGroup.d.ts +2 -2
  8. package/dist/editor/components/SlashCommandMenu.d.ts +6 -0
  9. package/dist/editor/components/editor-bubble-item.d.ts +8 -0
  10. package/dist/editor/components/editor-bubble.d.ts +8 -0
  11. package/dist/editor/components/index.d.ts +14 -0
  12. package/dist/editor/editor.d.ts +30 -0
  13. package/dist/editor/extensions/HighlightDecorationExtension.d.ts +24 -0
  14. package/dist/editor/extensions/Image/index.d.ts +6 -0
  15. package/dist/editor/extensions/Image.d.ts +6 -0
  16. package/dist/editor/extensions/TextLoadingDecorationExtension.d.ts +16 -0
  17. package/dist/editor/extensions/clipboard.d.ts +7 -0
  18. package/dist/editor/extensions/custom-keymap.d.ts +1 -0
  19. package/dist/editor/extensions/drag-and-drop.d.ts +9 -0
  20. package/dist/editor/hooks/useProseMirror.d.ts +14 -0
  21. package/dist/editor/hooks/useProseMirrorContext.d.ts +9 -0
  22. package/dist/editor/index.d.ts +2 -0
  23. package/dist/editor/markdown.d.ts +5 -0
  24. package/dist/editor/nodeViews/ImageComponent.d.ts +3 -0
  25. package/dist/editor/nodeViews/ReactNodeView.d.ts +29 -0
  26. package/dist/editor/nodeViews/TaskItemComponent.d.ts +3 -0
  27. package/dist/editor/nodeViews/index.d.ts +6 -0
  28. package/dist/editor/plugins/index.d.ts +2 -0
  29. package/dist/editor/plugins/inputrules.d.ts +6 -0
  30. package/dist/editor/plugins/placeholderPlugin.d.ts +3 -0
  31. package/dist/editor/plugins/slashCommandPlugin.d.ts +11 -0
  32. package/dist/editor/schema.d.ts +2 -0
  33. package/dist/editor/selectors/ai-selector.d.ts +0 -0
  34. package/dist/editor/selectors/color-selector.d.ts +10 -0
  35. package/dist/editor/selectors/link-selector.d.ts +8 -0
  36. package/dist/editor/selectors/node-selector.d.ts +15 -0
  37. package/dist/editor/selectors/text-buttons.d.ts +1 -0
  38. package/dist/editor/types.d.ts +5 -0
  39. package/dist/editor/useProseMirror.d.ts +16 -0
  40. package/dist/editor/utils/prosemirror-utils.d.ts +6 -0
  41. package/dist/editor/utils/remove_classes.d.ts +1 -0
  42. package/dist/editor/utils/useDebouncedCallback.d.ts +1 -0
  43. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  44. package/dist/hooks/index.d.ts +1 -0
  45. package/dist/hooks/useBuildNavigationController.d.ts +0 -1
  46. package/dist/hooks/useCollapsedGroups.d.ts +3 -3
  47. package/dist/hooks/useTranslation.d.ts +17 -0
  48. package/dist/i18n/FireCMSi18nProvider.d.ts +33 -0
  49. package/dist/index.d.ts +4 -0
  50. package/dist/index.es.js +11293 -2142
  51. package/dist/index.es.js.map +1 -1
  52. package/dist/index.umd.js +11274 -2142
  53. package/dist/index.umd.js.map +1 -1
  54. package/dist/locales/de.d.ts +2 -0
  55. package/dist/locales/en.d.ts +10 -0
  56. package/dist/locales/es.d.ts +10 -0
  57. package/dist/locales/fr.d.ts +2 -0
  58. package/dist/locales/hi.d.ts +2 -0
  59. package/dist/locales/it.d.ts +2 -0
  60. package/dist/locales/pt.d.ts +7 -0
  61. package/dist/types/customization_controller.d.ts +2 -1
  62. package/dist/types/firecms.d.ts +2 -1
  63. package/dist/types/index.d.ts +1 -0
  64. package/dist/types/navigation.d.ts +2 -2
  65. package/dist/types/plugins.d.ts +7 -0
  66. package/dist/types/translations.d.ts +646 -0
  67. package/package.json +43 -9
  68. package/src/app/Scaffold.tsx +7 -5
  69. package/src/components/AIIcon.tsx +3 -1
  70. package/src/components/ArrayContainer.tsx +6 -4
  71. package/src/components/ClearFilterSortButton.tsx +6 -3
  72. package/src/components/ConfirmationDialog.tsx +4 -2
  73. package/src/components/DeleteEntityDialog.tsx +10 -7
  74. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +6 -3
  75. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +3 -1
  76. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +3 -2
  77. package/src/components/EntityCollectionView/BoardSortableList.tsx +3 -1
  78. package/src/components/EntityCollectionView/CollectionDataErrorBanner.tsx +43 -0
  79. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +16 -43
  80. package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +17 -25
  81. package/src/components/EntityCollectionView/EntityCollectionView.tsx +26 -18
  82. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +4 -3
  83. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -2
  84. package/src/components/EntityCollectionView/FiltersDialog.tsx +8 -5
  85. package/src/components/EntityCollectionView/ViewModeToggle.tsx +11 -8
  86. package/src/components/EntityView.tsx +3 -2
  87. package/src/components/ErrorBoundary.tsx +27 -15
  88. package/src/components/HomePage/DefaultHomePage.tsx +19 -13
  89. package/src/components/HomePage/HomePageDnD.tsx +3 -1
  90. package/src/components/HomePage/NavigationGroup.tsx +3 -1
  91. package/src/components/HomePage/RenameGroupDialog.tsx +15 -13
  92. package/src/components/LanguageToggle.tsx +66 -0
  93. package/src/components/NotFoundPage.tsx +5 -3
  94. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +9 -7
  95. package/src/components/ReferenceWidget.tsx +3 -2
  96. package/src/components/SearchIconsView.tsx +3 -1
  97. package/src/components/SelectableTable/filters/DateTimeFilterField.tsx +11 -0
  98. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +15 -2
  99. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +11 -0
  100. package/src/components/UnsavedChangesDialog.tsx +6 -4
  101. package/src/components/VirtualTable/VirtualTable.performance.test.tsx +1 -0
  102. package/src/components/VirtualTable/VirtualTableHeader.tsx +12 -10
  103. package/src/components/common/default_entity_actions.tsx +4 -0
  104. package/src/components/common/useDataSourceTableController.tsx +5 -14
  105. package/src/components/index.tsx +1 -0
  106. package/src/core/DefaultAppBar.tsx +14 -10
  107. package/src/core/DefaultDrawer.tsx +8 -2
  108. package/src/core/DrawerNavigationGroup.tsx +5 -3
  109. package/src/core/EntityEditView.tsx +3 -2
  110. package/src/core/EntityEditViewFormActions.tsx +24 -17
  111. package/src/core/EntitySidePanel.tsx +4 -3
  112. package/src/core/FireCMS.tsx +33 -6
  113. package/src/editor/components/SlashCommandMenu.tsx +348 -0
  114. package/src/editor/components/editor-bubble-item.tsx +32 -0
  115. package/src/editor/components/editor-bubble.tsx +118 -0
  116. package/src/editor/components/index.ts +12 -0
  117. package/src/editor/editor.tsx +307 -0
  118. package/src/editor/extensions/HighlightDecorationExtension.ts +114 -0
  119. package/src/editor/extensions/Image/index.ts +133 -0
  120. package/src/editor/extensions/Image.ts +144 -0
  121. package/src/editor/extensions/TextLoadingDecorationExtension.tsx +107 -0
  122. package/src/editor/extensions/clipboard.ts +72 -0
  123. package/src/editor/extensions/custom-keymap.ts +24 -0
  124. package/src/editor/extensions/drag-and-drop.tsx +472 -0
  125. package/src/editor/hooks/useProseMirror.ts +115 -0
  126. package/src/editor/hooks/useProseMirrorContext.ts +15 -0
  127. package/src/editor/index.ts +2 -0
  128. package/src/editor/markdown.ts +110 -0
  129. package/src/editor/nodeViews/ImageComponent.tsx +20 -0
  130. package/src/editor/nodeViews/ReactNodeView.tsx +89 -0
  131. package/src/editor/nodeViews/TaskItemComponent.tsx +29 -0
  132. package/src/editor/nodeViews/index.ts +35 -0
  133. package/src/editor/plugins/index.ts +55 -0
  134. package/src/editor/plugins/inputrules.ts +82 -0
  135. package/src/editor/plugins/placeholderPlugin.ts +55 -0
  136. package/src/editor/plugins/slashCommandPlugin.ts +49 -0
  137. package/src/editor/schema.ts +228 -0
  138. package/src/editor/selectors/ai-selector.tsx +111 -0
  139. package/src/editor/selectors/color-selector.tsx +200 -0
  140. package/src/editor/selectors/link-selector.tsx +118 -0
  141. package/src/editor/selectors/node-selector.tsx +157 -0
  142. package/src/editor/selectors/text-buttons.tsx +86 -0
  143. package/src/editor/types.ts +6 -0
  144. package/src/editor/useProseMirror.ts +126 -0
  145. package/src/editor/utils/prosemirror-utils.ts +78 -0
  146. package/src/editor/utils/remove_classes.ts +17 -0
  147. package/src/editor/utils/useDebouncedCallback.ts +25 -0
  148. package/src/form/EntityForm.tsx +7 -3
  149. package/src/form/EntityFormActions.tsx +19 -12
  150. package/src/form/PropertyFieldBinding.tsx +3 -2
  151. package/src/form/components/LocalChangesMenu.tsx +13 -13
  152. package/src/form/components/StorageItemPreview.tsx +3 -2
  153. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +4 -4
  154. package/src/form/field_bindings/BlockFieldBinding.tsx +5 -2
  155. package/src/form/field_bindings/KeyValueFieldBinding.tsx +23 -18
  156. package/src/form/field_bindings/MapFieldBinding.tsx +4 -3
  157. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +3 -3
  158. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -1
  159. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +4 -2
  160. package/src/hooks/index.tsx +1 -0
  161. package/src/hooks/useBuildNavigationController.tsx +20 -13
  162. package/src/hooks/useCollapsedGroups.ts +7 -6
  163. package/src/hooks/useTranslation.ts +31 -0
  164. package/src/i18n/FireCMSi18nProvider.tsx +160 -0
  165. package/src/index.ts +4 -0
  166. package/src/locales/de.ts +691 -0
  167. package/src/locales/en.ts +703 -0
  168. package/src/locales/es.ts +703 -0
  169. package/src/locales/fr.ts +691 -0
  170. package/src/locales/hi.ts +691 -0
  171. package/src/locales/it.ts +691 -0
  172. package/src/locales/pt.ts +700 -0
  173. package/src/preview/components/UrlComponentPreview.tsx +4 -2
  174. package/src/preview/components/UserPreview.tsx +3 -1
  175. package/src/types/customization_controller.tsx +2 -1
  176. package/src/types/firecms.tsx +2 -1
  177. package/src/types/index.ts +1 -0
  178. package/src/types/navigation.ts +2 -2
  179. package/src/types/plugins.tsx +8 -0
  180. package/src/types/translations.ts +725 -0
@@ -0,0 +1,348 @@
1
+ import React, { useEffect, useRef, useState, ReactNode } from "react";
2
+ import { useProseMirrorContext } from "../hooks/useProseMirrorContext";
3
+ import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
4
+ import { SlashCommandPluginKey } from "../plugins/slashCommandPlugin";
5
+ import {
6
+ cls,
7
+ defaultBorderMixin,
8
+ TextFieldsIcon,
9
+ CheckBoxIcon,
10
+ LooksOneIcon,
11
+ LooksTwoIcon,
12
+ Looks3Icon,
13
+ FormatListBulletedIcon,
14
+ FormatListNumberedIcon,
15
+ FormatQuoteIcon,
16
+ CodeIcon,
17
+ ImageIcon,
18
+ AutoFixHighIcon
19
+ } from "@firecms/ui";
20
+ import { setBlockType, wrapIn } from "prosemirror-commands";
21
+ import { wrapInList } from "prosemirror-schema-list";
22
+ import { schema } from "../schema";
23
+ import { EditorView } from "prosemirror-view";
24
+ import { EditorAIController } from "../types";
25
+ import { onFileRead, UploadFn } from "../extensions/Image";
26
+ import { textLoadingCommands } from "../extensions/TextLoadingDecorationExtension";
27
+ import { parser } from "../markdown";
28
+
29
+ interface SuggestionItem {
30
+ title: string;
31
+ description: string;
32
+ icon: ReactNode;
33
+ searchTerms?: string[];
34
+ command: (view: EditorView, range: { from: number; to: number }, upload: UploadFn, aiController?: EditorAIController) => void;
35
+ }
36
+
37
+ const suggestionItems: SuggestionItem[] = [
38
+ {
39
+ title: "Text",
40
+ description: "Just start typing with plain text.",
41
+ searchTerms: ["p", "paragraph"],
42
+ icon: <TextFieldsIcon size={18} />,
43
+ command: (view, range) => {
44
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
45
+ setBlockType(schema.nodes.paragraph)(view.state, view.dispatch);
46
+ }
47
+ },
48
+ {
49
+ title: "To-do List",
50
+ description: "Track tasks with a to-do list.",
51
+ searchTerms: ["todo", "task", "list", "check", "checkbox"],
52
+ icon: <CheckBoxIcon size={18} />,
53
+ command: (view, range) => {
54
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
55
+ wrapInList(schema.nodes.task_list)(view.state, view.dispatch);
56
+ }
57
+ },
58
+ {
59
+ title: "Heading 1",
60
+ description: "Big section heading.",
61
+ searchTerms: ["title", "big", "large"],
62
+ icon: <LooksOneIcon size={18} />,
63
+ command: (view, range) => {
64
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
65
+ setBlockType(schema.nodes.heading, { level: 1 })(view.state, view.dispatch);
66
+ }
67
+ },
68
+ {
69
+ title: "Heading 2",
70
+ description: "Medium section heading.",
71
+ searchTerms: ["subtitle", "medium"],
72
+ icon: <LooksTwoIcon size={18} />,
73
+ command: (view, range) => {
74
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
75
+ setBlockType(schema.nodes.heading, { level: 2 })(view.state, view.dispatch);
76
+ }
77
+ },
78
+ {
79
+ title: "Heading 3",
80
+ description: "Small section heading.",
81
+ searchTerms: ["subtitle", "small"],
82
+ icon: <Looks3Icon size={18} />,
83
+ command: (view, range) => {
84
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
85
+ setBlockType(schema.nodes.heading, { level: 3 })(view.state, view.dispatch);
86
+ }
87
+ },
88
+ {
89
+ title: "Bullet List",
90
+ description: "Create a simple bullet list.",
91
+ searchTerms: ["unordered", "point"],
92
+ icon: <FormatListBulletedIcon size={18} />,
93
+ command: (view, range) => {
94
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
95
+ wrapInList(schema.nodes.bullet_list)(view.state, view.dispatch);
96
+ }
97
+ },
98
+ {
99
+ title: "Numbered List",
100
+ description: "Create a list with numbering.",
101
+ searchTerms: ["ordered"],
102
+ icon: <FormatListNumberedIcon size={18} />,
103
+ command: (view, range) => {
104
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
105
+ wrapInList(schema.nodes.ordered_list)(view.state, view.dispatch);
106
+ }
107
+ },
108
+ {
109
+ title: "Quote",
110
+ description: "Capture a quote.",
111
+ searchTerms: ["blockquote"],
112
+ icon: <FormatQuoteIcon size={18} />,
113
+ command: (view, range) => {
114
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
115
+ wrapIn(schema.nodes.blockquote)(view.state, view.dispatch);
116
+ }
117
+ },
118
+ {
119
+ title: "Code",
120
+ description: "Capture a code snippet.",
121
+ searchTerms: ["codeblock"],
122
+ icon: <CodeIcon size={18} />,
123
+ command: (view, range) => {
124
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
125
+ setBlockType(schema.nodes.code_block)(view.state, view.dispatch);
126
+ }
127
+ },
128
+ {
129
+ title: "Image",
130
+ description: "Upload an image from your computer.",
131
+ searchTerms: ["photo", "picture", "media", "upload", "file"],
132
+ icon: <ImageIcon size={18} />,
133
+ command: (view, range, upload) => {
134
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
135
+
136
+ const input = document.createElement("input");
137
+ input.type = "file";
138
+ input.accept = "image/*";
139
+ input.onchange = async () => {
140
+ if (input.files?.length) {
141
+ const file = input.files[0];
142
+ if (!file) return;
143
+ const pos = view.state.selection.from;
144
+
145
+ const images = Array.from(input.files).filter(f => /image/i.test(f.type));
146
+ if (images.length === 0) return false;
147
+
148
+ images.forEach(image => {
149
+ const reader = new FileReader();
150
+ reader.onload = async (readerEvent) => {
151
+ await onFileRead(view, readerEvent, pos, upload, image);
152
+ };
153
+ reader.readAsDataURL(image);
154
+ });
155
+ }
156
+ return true;
157
+ };
158
+ input.click();
159
+ }
160
+ }
161
+ ];
162
+
163
+ const autocompleteSuggestionItem: SuggestionItem = {
164
+ title: "Autocomplete",
165
+ description: "Add text based on the context.",
166
+ searchTerms: ["ai"],
167
+ icon: <AutoFixHighIcon size={18} />,
168
+ command: async (view, range, upload, aiController) => {
169
+ if (!aiController) throw Error("No AiController");
170
+
171
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
172
+ setBlockType(schema.nodes.paragraph)(view.state, view.dispatch);
173
+
174
+ const { state } = view;
175
+ const { from, to } = state.selection;
176
+
177
+ const textBeforeCursor = state.doc.textBetween(0, from, "\n");
178
+ const textAfterCursor = state.doc.textBetween(to, state.doc.content.size, "\n");
179
+
180
+ let buffer = "";
181
+ const result = await aiController.autocomplete(textBeforeCursor, textAfterCursor, (delta) => {
182
+ buffer += delta;
183
+ if (delta.length !== 0) {
184
+ textLoadingCommands.toggleLoadingDecoration(view.state, view.dispatch, buffer);
185
+ }
186
+ });
187
+
188
+ // Insert parsed text result at cursor natively
189
+ try {
190
+ // The AI controller might stream literal "\n" characters depending on its JSON decoding layer.
191
+ // We need to un-escape these back to genuine newlines so MarkdownIt block-parsing works.
192
+ const unescapedResult = result.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
193
+
194
+ const parsedDoc = parser.parse(unescapedResult);
195
+ if (parsedDoc) {
196
+ const tr = view.state.tr.replaceWith(view.state.selection.from, view.state.selection.from, parsedDoc.content);
197
+ view.dispatch(tr);
198
+ } else {
199
+ view.dispatch(view.state.tr.insertText(unescapedResult));
200
+ }
201
+ } catch (e) {
202
+ console.error(e);
203
+ view.dispatch(view.state.tr.insertText(result));
204
+ }
205
+ }
206
+ };
207
+
208
+ export const SlashCommandMenu = ({ upload, aiController }: { upload: UploadFn, aiController?: EditorAIController }) => {
209
+ const { view, state } = useProseMirrorContext();
210
+ const menuRef = useRef<HTMLDivElement>(null);
211
+ const [selectedIndex, setSelectedIndex] = useState(0);
212
+
213
+ const pluginState = state ? SlashCommandPluginKey.getState(state) : null;
214
+ const isActive = pluginState?.active;
215
+ const query = pluginState?.query || "";
216
+ const range = pluginState?.range;
217
+
218
+ const filteredItems = React.useMemo(() => {
219
+ if (!isActive) return [];
220
+ const availableItems = [...suggestionItems];
221
+ if (aiController) availableItems.push(autocompleteSuggestionItem);
222
+
223
+ return availableItems.filter(item => {
224
+ const inTitle = item.title.toLowerCase().includes(query.toLowerCase());
225
+ if (inTitle) return inTitle;
226
+ return item.searchTerms?.some(term => term.toLowerCase().includes(query.toLowerCase()));
227
+ });
228
+ }, [query, isActive, aiController]);
229
+
230
+ useEffect(() => {
231
+ setSelectedIndex(0);
232
+ }, [query]);
233
+
234
+ useEffect(() => {
235
+ if (!view || !isActive || !range || !menuRef.current) return;
236
+
237
+ const start = view.coordsAtPos(range.from);
238
+ const virtualEl = {
239
+ getBoundingClientRect() {
240
+ return {
241
+ width: 0,
242
+ height: start.bottom - start.top,
243
+ x: start.left,
244
+ y: start.top,
245
+ top: start.top,
246
+ left: start.left,
247
+ right: start.left,
248
+ bottom: start.bottom,
249
+ };
250
+ }
251
+ };
252
+
253
+ const cleanup = autoUpdate(virtualEl as any, menuRef.current, () => {
254
+ if (!menuRef.current) return;
255
+ computePosition(virtualEl as any, menuRef.current, {
256
+ placement: "bottom-start",
257
+ middleware: [offset(4), flip(), shift()],
258
+ strategy: "fixed"
259
+ }).then(({ x, y }) => {
260
+ if (menuRef.current) {
261
+ Object.assign(menuRef.current.style, {
262
+ left: `${x}px`,
263
+ top: `${y}px`,
264
+ visibility: "visible",
265
+ });
266
+ }
267
+ });
268
+ });
269
+ return () => cleanup();
270
+ }, [view, isActive, range]);
271
+
272
+ useEffect(() => {
273
+ if (!isActive || !view) return;
274
+
275
+ const handleKeyDown = (e: KeyboardEvent) => {
276
+ if (e.key === "ArrowUp") {
277
+ e.preventDefault();
278
+ e.stopPropagation();
279
+ setSelectedIndex(prev => (prev + filteredItems.length - 1) % filteredItems.length);
280
+ } else if (e.key === "ArrowDown") {
281
+ e.preventDefault();
282
+ e.stopPropagation();
283
+ setSelectedIndex(prev => (prev + 1) % filteredItems.length);
284
+ } else if (e.key === "Enter") {
285
+ e.preventDefault();
286
+ e.stopPropagation();
287
+ if (filteredItems[selectedIndex] && range) {
288
+ filteredItems[selectedIndex].command(view, range, upload, aiController);
289
+ view.focus();
290
+ }
291
+ } else if (e.key === "Escape") {
292
+ e.preventDefault();
293
+ e.stopPropagation();
294
+ // Close menu gracefully
295
+ view.dispatch(view.state.tr.setMeta(SlashCommandPluginKey, { active: false }));
296
+ }
297
+ };
298
+
299
+ const dom = view.dom;
300
+ dom.addEventListener("keydown", handleKeyDown, { capture: true });
301
+ return () => dom.removeEventListener("keydown", handleKeyDown, { capture: true });
302
+ }, [isActive, selectedIndex, filteredItems, view, range, upload, aiController]);
303
+
304
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
305
+
306
+ useEffect(() => {
307
+ if (itemRefs.current[selectedIndex]) {
308
+ itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
309
+ }
310
+ }, [selectedIndex]);
311
+
312
+ if (!isActive || filteredItems.length === 0) return null;
313
+
314
+ return (
315
+ <div
316
+ ref={menuRef}
317
+ style={{ position: "fixed", zIndex: 9999, visibility: "hidden" }}
318
+ className={cls("text-surface-900 dark:text-white max-h-[280px] w-72 overflow-y-auto rounded-md border bg-white dark:bg-surface-900 px-1 py-2 shadow transition-none", defaultBorderMixin)}
319
+ >
320
+ {filteredItems.map((item, index) => (
321
+ <button
322
+ key={item.title}
323
+ ref={el => { itemRefs.current[index] = el; }}
324
+ onClick={(e) => {
325
+ e.preventDefault();
326
+ if (range && view) {
327
+ item.command(view, range, upload, aiController);
328
+ view.focus();
329
+ }
330
+ }}
331
+ onMouseDown={(e) => e.preventDefault()}
332
+ className={cls("flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm hover:bg-blue-50 hover:dark:bg-surface-700",
333
+ index === selectedIndex ? "bg-blue-100 dark:bg-surface-accent-950" : "")}
334
+ >
335
+ <div className={cls("flex h-10 w-10 shrink-0 items-center justify-center rounded-md border bg-white dark:bg-surface-900", defaultBorderMixin)}>
336
+ {item.icon}
337
+ </div>
338
+ <div className="flex flex-col overflow-hidden">
339
+ <p className="font-medium truncate">{item.title}</p>
340
+ <p className="text-xs text-surface-700 dark:text-surface-accent-300 truncate">
341
+ {item.description}
342
+ </p>
343
+ </div>
344
+ </button>
345
+ ))}
346
+ </div>
347
+ );
348
+ };
@@ -0,0 +1,32 @@
1
+ import { type ComponentPropsWithoutRef, type ReactNode, forwardRef } from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { useProseMirrorContext } from "../hooks/useProseMirrorContext";
4
+
5
+ interface EditorBubbleItemProps {
6
+ children: ReactNode;
7
+ asChild?: boolean;
8
+ onSelect?: () => void;
9
+ }
10
+
11
+ export const EditorBubbleItem = forwardRef<
12
+ HTMLDivElement,
13
+ EditorBubbleItemProps & Omit<ComponentPropsWithoutRef<"div">, "onSelect">
14
+ >(({ children, asChild, onSelect, ...rest }, ref) => {
15
+ const { view } = useProseMirrorContext();
16
+ const Comp = asChild ? Slot : "div";
17
+
18
+ if (!view) return null;
19
+
20
+ return (
21
+ <Comp ref={ref} {...rest} onMouseDown={(e: React.MouseEvent) => {
22
+ // Prevent default to avoid losing focus
23
+ e.preventDefault();
24
+ }} onClick={() => onSelect?.()}>
25
+ {children}
26
+ </Comp>
27
+ );
28
+ });
29
+
30
+ EditorBubbleItem.displayName = "EditorBubbleItem";
31
+
32
+ export default EditorBubbleItem;
@@ -0,0 +1,118 @@
1
+ import { forwardRef, type ReactNode, useEffect, useRef, useState } from "react";
2
+ import { useProseMirrorContext } from "../hooks/useProseMirrorContext";
3
+ import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
4
+ import { NodeSelection } from "prosemirror-state";
5
+
6
+ export interface EditorBubbleProps {
7
+ children: ReactNode;
8
+ options?: any;
9
+ className?: string;
10
+ }
11
+
12
+ export const EditorBubble = forwardRef<HTMLDivElement, EditorBubbleProps>(
13
+ ({ children, options, className }, ref) => {
14
+ const { view, state } = useProseMirrorContext();
15
+ const menuRef = useRef<HTMLDivElement>(null);
16
+ const [show, setShow] = useState(false);
17
+
18
+ useEffect(() => {
19
+ if (!view || !state) return;
20
+
21
+ // Delay evaluation slightly to let selection settle
22
+ const timer = setTimeout(() => {
23
+ const { selection } = state;
24
+ const { empty } = selection;
25
+
26
+ // check if image is selected
27
+ let isImage = false;
28
+ state.doc.nodesBetween(selection.from, selection.to, (node) => {
29
+ if (node.type.name === "image") isImage = true;
30
+ });
31
+
32
+ if (isImage || empty || selection instanceof NodeSelection) {
33
+ setShow(false);
34
+ return;
35
+ }
36
+
37
+ setShow(true);
38
+ }, 0);
39
+
40
+ return () => clearTimeout(timer);
41
+ }, [view, state]);
42
+
43
+ useEffect(() => {
44
+ if (!show || !view || !state || !menuRef.current) return;
45
+
46
+ const { from, to } = state.selection;
47
+
48
+ // Fallback for end selection coords
49
+ let start = view.coordsAtPos(from);
50
+ let end = view.coordsAtPos(to);
51
+
52
+ const virtualEl = {
53
+ getBoundingClientRect() {
54
+ const top = Math.min(start.top, end.top);
55
+ const bottom = Math.max(start.bottom, end.bottom);
56
+ const left = Math.min(start.left, end.left);
57
+ const right = Math.max(start.right, end.right);
58
+ return {
59
+ width: right - left,
60
+ height: bottom - top,
61
+ x: left,
62
+ y: top,
63
+ top,
64
+ left,
65
+ right,
66
+ bottom,
67
+ };
68
+ }
69
+ };
70
+
71
+ const cleanup = autoUpdate(virtualEl as any, menuRef.current, () => {
72
+ if (!menuRef.current) return;
73
+
74
+ // Recompute coords in case of scroll
75
+ try {
76
+ start = view.coordsAtPos(state.selection.from);
77
+ end = view.coordsAtPos(state.selection.to);
78
+ } catch (e) {
79
+ // Ignore error if selection is out of bounds
80
+ }
81
+
82
+ computePosition(virtualEl as any, menuRef.current, {
83
+ placement: options?.placement || "top",
84
+ middleware: [offset(options?.offset || 8), flip(), shift()],
85
+ strategy: "fixed"
86
+ }).then(({ x, y }) => {
87
+ if (menuRef.current) {
88
+ Object.assign(menuRef.current.style, {
89
+ left: `${x}px`,
90
+ top: `${y}px`,
91
+ visibility: "visible",
92
+ });
93
+ }
94
+ });
95
+ });
96
+ return () => cleanup();
97
+ }, [show, view, state, options]);
98
+
99
+ if (!show) return null;
100
+
101
+ return (
102
+ <div
103
+ ref={menuRef}
104
+ className={className}
105
+ style={{ position: "fixed", zIndex: 9999, visibility: "hidden" }}
106
+ onMouseDown={(e) => {
107
+ e.preventDefault(); // Don't lose focus inside ProseMirror
108
+ }}
109
+ >
110
+ {children}
111
+ </div>
112
+ );
113
+ }
114
+ );
115
+
116
+ EditorBubble.displayName = "EditorBubble";
117
+
118
+ export default EditorBubble;
@@ -0,0 +1,12 @@
1
+ export { EditorBubble } from "./editor-bubble";
2
+ export { EditorBubbleItem } from "./editor-bubble-item";
3
+ export { SlashCommandMenu } from "./SlashCommandMenu";
4
+
5
+ export type JSONContent = {
6
+ type?: string;
7
+ attrs?: Record<string, any>;
8
+ content?: JSONContent[];
9
+ marks?: { type: string; attrs?: Record<string, any> }[];
10
+ text?: string;
11
+ [key: string]: any;
12
+ };