@firecms/core 3.1.0 → 3.2.0-canary.4c3b8f2

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 (191) 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/image-bubble.d.ts +5 -0
  12. package/dist/editor/components/index.d.ts +16 -0
  13. package/dist/editor/components/table-bubble.d.ts +5 -0
  14. package/dist/editor/editor.d.ts +30 -0
  15. package/dist/editor/extensions/HighlightDecorationExtension.d.ts +24 -0
  16. package/dist/editor/extensions/Image/index.d.ts +6 -0
  17. package/dist/editor/extensions/Image.d.ts +6 -0
  18. package/dist/editor/extensions/TextLoadingDecorationExtension.d.ts +16 -0
  19. package/dist/editor/extensions/clipboard.d.ts +7 -0
  20. package/dist/editor/extensions/custom-keymap.d.ts +1 -0
  21. package/dist/editor/extensions/drag-and-drop.d.ts +9 -0
  22. package/dist/editor/hooks/useProseMirror.d.ts +13 -0
  23. package/dist/editor/hooks/useProseMirrorContext.d.ts +9 -0
  24. package/dist/editor/index.d.ts +2 -0
  25. package/dist/editor/markdown.d.ts +5 -0
  26. package/dist/editor/nodeViews/ImageComponent.d.ts +3 -0
  27. package/dist/editor/nodeViews/ReactNodeView.d.ts +29 -0
  28. package/dist/editor/nodeViews/TaskItemComponent.d.ts +3 -0
  29. package/dist/editor/nodeViews/index.d.ts +6 -0
  30. package/dist/editor/plugins/index.d.ts +2 -0
  31. package/dist/editor/plugins/inputrules.d.ts +6 -0
  32. package/dist/editor/plugins/placeholderPlugin.d.ts +3 -0
  33. package/dist/editor/plugins/slashCommandPlugin.d.ts +12 -0
  34. package/dist/editor/schema.d.ts +2 -0
  35. package/dist/editor/selectors/ai-selector.d.ts +0 -0
  36. package/dist/editor/selectors/color-selector.d.ts +10 -0
  37. package/dist/editor/selectors/link-selector.d.ts +8 -0
  38. package/dist/editor/selectors/node-selector.d.ts +15 -0
  39. package/dist/editor/selectors/text-buttons.d.ts +1 -0
  40. package/dist/editor/types.d.ts +5 -0
  41. package/dist/editor/useProseMirror.d.ts +16 -0
  42. package/dist/editor/utils/prosemirror-utils.d.ts +6 -0
  43. package/dist/editor/utils/remove_classes.d.ts +1 -0
  44. package/dist/editor/utils/useDebouncedCallback.d.ts +1 -0
  45. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  46. package/dist/hooks/index.d.ts +1 -0
  47. package/dist/hooks/useBuildNavigationController.d.ts +0 -1
  48. package/dist/hooks/useCollapsedGroups.d.ts +3 -3
  49. package/dist/hooks/useTranslation.d.ts +17 -0
  50. package/dist/i18n/FireCMSi18nProvider.d.ts +33 -0
  51. package/dist/index.d.ts +4 -0
  52. package/dist/index.es.js +12898 -2265
  53. package/dist/index.es.js.map +1 -1
  54. package/dist/index.umd.js +12877 -2264
  55. package/dist/index.umd.js.map +1 -1
  56. package/dist/locales/de.d.ts +2 -0
  57. package/dist/locales/en.d.ts +10 -0
  58. package/dist/locales/es.d.ts +10 -0
  59. package/dist/locales/fr.d.ts +2 -0
  60. package/dist/locales/hi.d.ts +2 -0
  61. package/dist/locales/it.d.ts +2 -0
  62. package/dist/locales/pt.d.ts +7 -0
  63. package/dist/types/customization_controller.d.ts +2 -1
  64. package/dist/types/firecms.d.ts +2 -1
  65. package/dist/types/index.d.ts +1 -0
  66. package/dist/types/navigation.d.ts +2 -2
  67. package/dist/types/plugins.d.ts +7 -0
  68. package/dist/types/storage.d.ts +1 -0
  69. package/dist/types/translations.d.ts +646 -0
  70. package/dist/util/useStorageUploadController.d.ts +10 -1
  71. package/package.json +45 -9
  72. package/src/app/Scaffold.tsx +7 -5
  73. package/src/components/AIIcon.tsx +3 -1
  74. package/src/components/ArrayContainer.tsx +6 -4
  75. package/src/components/ClearFilterSortButton.tsx +6 -3
  76. package/src/components/ConfirmationDialog.tsx +4 -2
  77. package/src/components/DeleteEntityDialog.tsx +10 -7
  78. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +6 -3
  79. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +3 -1
  80. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +3 -2
  81. package/src/components/EntityCollectionView/BoardSortableList.tsx +3 -1
  82. package/src/components/EntityCollectionView/CollectionDataErrorBanner.tsx +43 -0
  83. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +16 -43
  84. package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +17 -25
  85. package/src/components/EntityCollectionView/EntityCollectionView.tsx +26 -18
  86. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +4 -3
  87. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -2
  88. package/src/components/EntityCollectionView/FiltersDialog.tsx +8 -5
  89. package/src/components/EntityCollectionView/ViewModeToggle.tsx +11 -8
  90. package/src/components/EntityView.tsx +3 -2
  91. package/src/components/ErrorBoundary.tsx +27 -15
  92. package/src/components/HomePage/DefaultHomePage.tsx +19 -13
  93. package/src/components/HomePage/HomePageDnD.tsx +3 -1
  94. package/src/components/HomePage/NavigationGroup.tsx +3 -1
  95. package/src/components/HomePage/RenameGroupDialog.tsx +15 -13
  96. package/src/components/LanguageToggle.tsx +66 -0
  97. package/src/components/NotFoundPage.tsx +5 -3
  98. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +9 -7
  99. package/src/components/ReferenceWidget.tsx +3 -2
  100. package/src/components/SearchIconsView.tsx +3 -1
  101. package/src/components/SelectableTable/filters/DateTimeFilterField.tsx +11 -0
  102. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +15 -2
  103. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +11 -0
  104. package/src/components/UnsavedChangesDialog.tsx +6 -4
  105. package/src/components/VirtualTable/VirtualTable.performance.test.tsx +1 -0
  106. package/src/components/VirtualTable/VirtualTableHeader.tsx +12 -10
  107. package/src/components/common/default_entity_actions.tsx +4 -0
  108. package/src/components/common/useDataSourceTableController.tsx +12 -4
  109. package/src/components/index.tsx +1 -0
  110. package/src/core/DefaultAppBar.tsx +14 -10
  111. package/src/core/DefaultDrawer.tsx +8 -2
  112. package/src/core/DrawerNavigationGroup.tsx +5 -3
  113. package/src/core/EntityEditView.tsx +4 -3
  114. package/src/core/EntityEditViewFormActions.tsx +24 -17
  115. package/src/core/EntitySidePanel.tsx +6 -5
  116. package/src/core/FireCMS.tsx +33 -6
  117. package/src/editor/components/SlashCommandMenu.tsx +516 -0
  118. package/src/editor/components/editor-bubble-item.tsx +32 -0
  119. package/src/editor/components/editor-bubble.tsx +118 -0
  120. package/src/editor/components/image-bubble.tsx +156 -0
  121. package/src/editor/components/index.ts +14 -0
  122. package/src/editor/components/table-bubble.tsx +165 -0
  123. package/src/editor/editor.tsx +455 -0
  124. package/src/editor/extensions/HighlightDecorationExtension.ts +114 -0
  125. package/src/editor/extensions/Image/index.ts +133 -0
  126. package/src/editor/extensions/Image.ts +159 -0
  127. package/src/editor/extensions/TextLoadingDecorationExtension.tsx +107 -0
  128. package/src/editor/extensions/clipboard.ts +72 -0
  129. package/src/editor/extensions/custom-keymap.ts +24 -0
  130. package/src/editor/extensions/drag-and-drop.tsx +480 -0
  131. package/src/editor/hooks/useProseMirror.ts +124 -0
  132. package/src/editor/hooks/useProseMirrorContext.ts +15 -0
  133. package/src/editor/index.ts +2 -0
  134. package/src/editor/markdown.ts +172 -0
  135. package/src/editor/nodeViews/ImageComponent.tsx +20 -0
  136. package/src/editor/nodeViews/ReactNodeView.tsx +89 -0
  137. package/src/editor/nodeViews/TaskItemComponent.tsx +29 -0
  138. package/src/editor/nodeViews/index.ts +35 -0
  139. package/src/editor/plugins/index.ts +58 -0
  140. package/src/editor/plugins/inputrules.ts +82 -0
  141. package/src/editor/plugins/placeholderPlugin.ts +55 -0
  142. package/src/editor/plugins/slashCommandPlugin.ts +61 -0
  143. package/src/editor/schema.ts +240 -0
  144. package/src/editor/selectors/ai-selector.tsx +111 -0
  145. package/src/editor/selectors/color-selector.tsx +200 -0
  146. package/src/editor/selectors/link-selector.tsx +118 -0
  147. package/src/editor/selectors/node-selector.tsx +157 -0
  148. package/src/editor/selectors/text-buttons.tsx +86 -0
  149. package/src/editor/types.ts +6 -0
  150. package/src/editor/useProseMirror.ts +126 -0
  151. package/src/editor/utils/prosemirror-utils.ts +108 -0
  152. package/src/editor/utils/remove_classes.ts +17 -0
  153. package/src/editor/utils/useDebouncedCallback.ts +25 -0
  154. package/src/form/EntityForm.tsx +16 -3
  155. package/src/form/EntityFormActions.tsx +19 -12
  156. package/src/form/PropertyFieldBinding.tsx +3 -2
  157. package/src/form/components/LocalChangesMenu.tsx +13 -13
  158. package/src/form/components/StorageItemPreview.tsx +3 -2
  159. package/src/form/components/StorageUploadProgress.tsx +18 -3
  160. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +4 -4
  161. package/src/form/field_bindings/BlockFieldBinding.tsx +5 -2
  162. package/src/form/field_bindings/KeyValueFieldBinding.tsx +23 -18
  163. package/src/form/field_bindings/MapFieldBinding.tsx +4 -3
  164. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +33 -19
  165. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -1
  166. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +4 -3
  167. package/src/hooks/index.tsx +1 -0
  168. package/src/hooks/useBuildNavigationController.tsx +45 -18
  169. package/src/hooks/useCollapsedGroups.ts +7 -6
  170. package/src/hooks/useTranslation.ts +31 -0
  171. package/src/i18n/FireCMSi18nProvider.tsx +160 -0
  172. package/src/index.ts +4 -0
  173. package/src/internal/useBuildSideEntityController.tsx +22 -20
  174. package/src/locales/de.ts +691 -0
  175. package/src/locales/en.ts +703 -0
  176. package/src/locales/es.ts +703 -0
  177. package/src/locales/fr.ts +691 -0
  178. package/src/locales/hi.ts +691 -0
  179. package/src/locales/it.ts +691 -0
  180. package/src/locales/pt.ts +700 -0
  181. package/src/preview/components/UrlComponentPreview.tsx +4 -2
  182. package/src/preview/components/UserPreview.tsx +3 -1
  183. package/src/types/customization_controller.tsx +2 -1
  184. package/src/types/firecms.tsx +2 -1
  185. package/src/types/index.ts +1 -0
  186. package/src/types/navigation.ts +2 -2
  187. package/src/types/plugins.tsx +8 -0
  188. package/src/types/properties.ts +1 -0
  189. package/src/types/storage.ts +2 -1
  190. package/src/types/translations.ts +725 -0
  191. package/src/util/useStorageUploadController.tsx +23 -29
@@ -0,0 +1,516 @@
1
+ import React, { useEffect, useRef, useState, ReactNode } from "react";
2
+ import { Fragment, DOMParser } from "prosemirror-model";
3
+ import { useProseMirrorContext } from "../hooks/useProseMirrorContext";
4
+ import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
5
+ import { SlashCommandPluginKey } from "../plugins/slashCommandPlugin";
6
+ import {
7
+ cls,
8
+ defaultBorderMixin,
9
+ TextFieldsIcon,
10
+ CheckBoxIcon,
11
+ LooksOneIcon,
12
+ LooksTwoIcon,
13
+ Looks3Icon,
14
+ FormatListBulletedIcon,
15
+ FormatListNumberedIcon,
16
+ FormatQuoteIcon,
17
+ CodeIcon,
18
+ ImageIcon,
19
+ AutoFixHighIcon,
20
+ TableChartIcon
21
+ } from "@firecms/ui";
22
+ import { setBlockType, wrapIn } from "prosemirror-commands";
23
+ import { wrapInList } from "prosemirror-schema-list";
24
+ import { schema } from "../schema";
25
+ import { EditorView } from "prosemirror-view";
26
+ import { TextSelection } from "prosemirror-state";
27
+ import { EditorAIController } from "../types";
28
+ import { onFileRead, UploadFn } from "../extensions/Image";
29
+ import { textLoadingCommands } from "../extensions/TextLoadingDecorationExtension";
30
+ import { parser } from "../markdown";
31
+
32
+ interface SuggestionItem {
33
+ title: string;
34
+ description: string;
35
+ icon: ReactNode;
36
+ searchTerms?: string[];
37
+ command: (
38
+ view: EditorView,
39
+ range: { from: number; to: number },
40
+ upload: UploadFn,
41
+ aiController?: EditorAIController,
42
+ setSubView?: (viewId: string | null) => void
43
+ ) => void;
44
+ }
45
+
46
+ const suggestionItems: SuggestionItem[] = [
47
+ {
48
+ title: "Text",
49
+ description: "Just start typing with plain text.",
50
+ searchTerms: ["p", "paragraph"],
51
+ icon: <TextFieldsIcon size={18} />,
52
+ command: (view, range) => {
53
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
54
+ setBlockType(schema.nodes.paragraph)(view.state, view.dispatch);
55
+ }
56
+ },
57
+ {
58
+ title: "To-do List",
59
+ description: "Track tasks with a to-do list.",
60
+ searchTerms: ["todo", "task", "list", "check", "checkbox"],
61
+ icon: <CheckBoxIcon size={18} />,
62
+ command: (view, range) => {
63
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
64
+ wrapInList(schema.nodes.task_list)(view.state, view.dispatch);
65
+ }
66
+ },
67
+ {
68
+ title: "Heading 1",
69
+ description: "Big section heading.",
70
+ searchTerms: ["title", "big", "large"],
71
+ icon: <LooksOneIcon size={18} />,
72
+ command: (view, range) => {
73
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
74
+ setBlockType(schema.nodes.heading, { level: 1 })(view.state, view.dispatch);
75
+ }
76
+ },
77
+ {
78
+ title: "Heading 2",
79
+ description: "Medium section heading.",
80
+ searchTerms: ["subtitle", "medium"],
81
+ icon: <LooksTwoIcon size={18} />,
82
+ command: (view, range) => {
83
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
84
+ setBlockType(schema.nodes.heading, { level: 2 })(view.state, view.dispatch);
85
+ }
86
+ },
87
+ {
88
+ title: "Heading 3",
89
+ description: "Small section heading.",
90
+ searchTerms: ["subtitle", "small"],
91
+ icon: <Looks3Icon size={18} />,
92
+ command: (view, range) => {
93
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
94
+ setBlockType(schema.nodes.heading, { level: 3 })(view.state, view.dispatch);
95
+ }
96
+ },
97
+ {
98
+ title: "Bullet List",
99
+ description: "Create a simple bullet list.",
100
+ searchTerms: ["unordered", "point"],
101
+ icon: <FormatListBulletedIcon size={18} />,
102
+ command: (view, range) => {
103
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
104
+ wrapInList(schema.nodes.bullet_list)(view.state, view.dispatch);
105
+ }
106
+ },
107
+ {
108
+ title: "Numbered List",
109
+ description: "Create a list with numbering.",
110
+ searchTerms: ["ordered"],
111
+ icon: <FormatListNumberedIcon size={18} />,
112
+ command: (view, range) => {
113
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
114
+ wrapInList(schema.nodes.ordered_list)(view.state, view.dispatch);
115
+ }
116
+ },
117
+ {
118
+ title: "Quote",
119
+ description: "Capture a quote.",
120
+ searchTerms: ["blockquote"],
121
+ icon: <FormatQuoteIcon size={18} />,
122
+ command: (view, range) => {
123
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
124
+ wrapIn(schema.nodes.blockquote)(view.state, view.dispatch);
125
+ }
126
+ },
127
+ {
128
+ title: "Code",
129
+ description: "Capture a code snippet.",
130
+ searchTerms: ["codeblock"],
131
+ icon: <CodeIcon size={18} />,
132
+ command: (view, range) => {
133
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
134
+ setBlockType(schema.nodes.code_block)(view.state, view.dispatch);
135
+ }
136
+ },
137
+ {
138
+ title: "Image",
139
+ description: "Upload an image from your computer.",
140
+ searchTerms: ["photo", "picture", "media", "upload", "file"],
141
+ icon: <ImageIcon size={18} />,
142
+ command: (view, range, upload) => {
143
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
144
+
145
+ const input = document.createElement("input");
146
+ input.type = "file";
147
+ input.accept = "image/*";
148
+ input.onchange = async () => {
149
+ if (input.files?.length) {
150
+ const file = input.files[0];
151
+ if (!file) return;
152
+ const pos = view.state.selection.from;
153
+
154
+ const images = Array.from(input.files).filter(f => /image/i.test(f.type));
155
+ if (images.length === 0) return false;
156
+
157
+ images.forEach(image => {
158
+ const reader = new FileReader();
159
+ reader.onload = async (readerEvent) => {
160
+ await onFileRead(view, readerEvent, pos, upload, image);
161
+ };
162
+ reader.readAsDataURL(image);
163
+ });
164
+ }
165
+ return true;
166
+ };
167
+ input.click();
168
+ }
169
+ },
170
+ {
171
+ title: "Table",
172
+ description: "Insert a custom grid table.",
173
+ searchTerms: ["table", "grid", "row", "col"],
174
+ icon: <TableChartIcon size={18} />,
175
+ command: (view, range, upload, aiController, setSubView) => {
176
+ if (setSubView) setSubView("table-grid");
177
+ }
178
+ }
179
+ ];
180
+
181
+ const autocompleteSuggestionItem: SuggestionItem = {
182
+ title: "Autocomplete",
183
+ description: "Add text based on the context.",
184
+ searchTerms: ["ai"],
185
+ icon: <AutoFixHighIcon size={18} />,
186
+ command: async (view, range, upload, aiController) => {
187
+ if (!aiController) throw Error("No AiController");
188
+
189
+ view.dispatch(view.state.tr.deleteRange(range.from, range.to));
190
+ setBlockType(schema.nodes.paragraph)(view.state, view.dispatch);
191
+
192
+ const { state } = view;
193
+ const { from, to } = state.selection;
194
+
195
+ const textBeforeCursor = state.doc.textBetween(0, from, "\n");
196
+ const textAfterCursor = state.doc.textBetween(to, state.doc.content.size, "\n");
197
+
198
+ let buffer = "";
199
+ const result = await aiController.autocomplete(textBeforeCursor, textAfterCursor, (delta) => {
200
+ buffer += delta;
201
+ if (delta.length !== 0) {
202
+ textLoadingCommands.toggleLoadingDecoration(view.state, view.dispatch, buffer);
203
+ }
204
+ });
205
+
206
+ // Insert parsed text result at cursor natively
207
+ try {
208
+ // The AI controller might stream literal "\n" characters depending on its JSON decoding layer.
209
+ // We need to un-escape these back to genuine newlines so MarkdownIt block-parsing works.
210
+ const unescapedResult = result.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
211
+
212
+ const isHTML = /<\/?[a-z][\s\S]*>/i.test(unescapedResult);
213
+ let parsedDoc;
214
+
215
+ if (isHTML) {
216
+ const div = document.createElement("div");
217
+ div.innerHTML = unescapedResult;
218
+ parsedDoc = DOMParser.fromSchema(view.state.schema).parse(div);
219
+ } else {
220
+ parsedDoc = parser.parse(unescapedResult);
221
+ }
222
+
223
+ if (parsedDoc) {
224
+ const tr = view.state.tr.replaceWith(view.state.selection.from, view.state.selection.from, parsedDoc.content);
225
+ view.dispatch(tr);
226
+ } else {
227
+ view.dispatch(view.state.tr.insertText(unescapedResult));
228
+ }
229
+ } catch (e) {
230
+ console.error(e);
231
+ view.dispatch(view.state.tr.insertText(result));
232
+ }
233
+ }
234
+ };
235
+
236
+ export const SlashCommandMenu = ({ upload, aiController }: { upload: UploadFn, aiController?: EditorAIController }) => {
237
+ const { view, state } = useProseMirrorContext();
238
+ const menuRef = useRef<HTMLDivElement>(null);
239
+ const [selectedIndex, setSelectedIndex] = useState(0);
240
+ const [subView, setSubView] = useState<string | null>(null);
241
+ const [tableGridCoords, setTableGridCoords] = useState({ r: 0, c: 0 });
242
+
243
+ const pluginState = state ? SlashCommandPluginKey.getState(state) : null;
244
+ const isActive = pluginState?.active;
245
+ const query = pluginState?.query || "";
246
+ const range = pluginState?.range;
247
+
248
+ const filteredItems = React.useMemo(() => {
249
+ if (!isActive) return [];
250
+ const availableItems = [...suggestionItems];
251
+ if (aiController) availableItems.push(autocompleteSuggestionItem);
252
+
253
+ return availableItems.filter(item => {
254
+ const inTitle = item.title.toLowerCase().includes(query.toLowerCase());
255
+ if (inTitle) return inTitle;
256
+ return item.searchTerms?.some(term => term.toLowerCase().includes(query.toLowerCase()));
257
+ });
258
+ }, [query, isActive, aiController]);
259
+
260
+ useEffect(() => {
261
+ setSelectedIndex(0);
262
+ }, [query]);
263
+
264
+ useEffect(() => {
265
+ if (!isActive) setSubView(null);
266
+ }, [isActive]);
267
+
268
+ useEffect(() => {
269
+ if (!view || !isActive || !range || !menuRef.current) return;
270
+
271
+ const start = view.coordsAtPos(range.from);
272
+ const virtualEl = {
273
+ getBoundingClientRect() {
274
+ return {
275
+ width: 0,
276
+ height: start.bottom - start.top,
277
+ x: start.left,
278
+ y: start.top,
279
+ top: start.top,
280
+ left: start.left,
281
+ right: start.left,
282
+ bottom: start.bottom,
283
+ };
284
+ }
285
+ };
286
+
287
+ const cleanup = autoUpdate(virtualEl as any, menuRef.current, () => {
288
+ if (!menuRef.current) return;
289
+ computePosition(virtualEl as any, menuRef.current, {
290
+ placement: "bottom-start",
291
+ middleware: [offset(4), flip(), shift()],
292
+ strategy: "fixed"
293
+ }).then(({ x, y }) => {
294
+ if (menuRef.current) {
295
+ Object.assign(menuRef.current.style, {
296
+ left: `${x}px`,
297
+ top: `${y}px`,
298
+ visibility: "visible",
299
+ });
300
+ }
301
+ });
302
+ });
303
+ return () => cleanup();
304
+ }, [view, isActive, range]);
305
+
306
+ useEffect(() => {
307
+ if (!isActive || !view) return;
308
+
309
+ const handleKeyDown = (e: KeyboardEvent) => {
310
+ if (subView === "table-grid") {
311
+ if (e.key === "Escape") {
312
+ e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
313
+ setSubView(null);
314
+ setTableGridCoords({ r: 0, c: 0 });
315
+ } else if (e.key === "ArrowUp") {
316
+ e.preventDefault(); e.stopPropagation();
317
+ setTableGridCoords(prev => ({ r: Math.max(0, prev.r - 1), c: prev.c }));
318
+ } else if (e.key === "ArrowDown") {
319
+ e.preventDefault(); e.stopPropagation();
320
+ setTableGridCoords(prev => ({ r: Math.min(4, prev.r + 1), c: prev.c }));
321
+ } else if (e.key === "ArrowLeft") {
322
+ e.preventDefault(); e.stopPropagation();
323
+ setTableGridCoords(prev => ({ r: prev.r, c: Math.max(0, prev.c - 1) }));
324
+ } else if (e.key === "ArrowRight") {
325
+ e.preventDefault(); e.stopPropagation();
326
+ setTableGridCoords(prev => ({ r: prev.r, c: Math.min(4, prev.c + 1) }));
327
+ } else if (e.key === "Enter") {
328
+ e.preventDefault(); e.stopPropagation();
329
+ if (range) {
330
+ const tableNode = createTableNode(view.state.schema, tableGridCoords.r + 1, tableGridCoords.c + 1);
331
+ const tr = view.state.tr.replaceWith(range.from, range.to, tableNode);
332
+ try {
333
+ const selection = TextSelection.create(tr.doc, range.from + 4);
334
+ tr.setSelection(selection);
335
+ } catch (e) {
336
+ console.warn("Could not select first cell", e);
337
+ }
338
+ tr.setMeta(SlashCommandPluginKey, { active: false });
339
+ view.dispatch(tr);
340
+ view.focus();
341
+ setSubView(null);
342
+ setTableGridCoords({ r: 0, c: 0 });
343
+ }
344
+ }
345
+ return;
346
+ }
347
+
348
+ if (e.key === "ArrowUp") {
349
+ e.preventDefault();
350
+ e.stopPropagation();
351
+ setSelectedIndex(prev => (prev + filteredItems.length - 1) % filteredItems.length);
352
+ } else if (e.key === "ArrowDown") {
353
+ e.preventDefault();
354
+ e.stopPropagation();
355
+ setSelectedIndex(prev => (prev + 1) % filteredItems.length);
356
+ } else if (e.key === "Enter") {
357
+ e.preventDefault();
358
+ e.stopPropagation();
359
+ if (filteredItems[selectedIndex] && range) {
360
+ filteredItems[selectedIndex].command(view, range, upload, aiController, setSubView);
361
+ // Do not focus view if a subview opened
362
+ setTimeout(() => {
363
+ // Focus is managed by the caller
364
+ }, 0);
365
+ }
366
+ } else if (e.key === "Escape") {
367
+ e.preventDefault();
368
+ e.stopPropagation();
369
+ e.stopImmediatePropagation();
370
+ // Close menu gracefully and keep it dismissed
371
+ view.dispatch(view.state.tr.setMeta(SlashCommandPluginKey, { active: false, dismissed: true }));
372
+ }
373
+ };
374
+
375
+ window.addEventListener("keydown", handleKeyDown, { capture: true });
376
+ return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
377
+ }, [isActive, selectedIndex, filteredItems, view, range, upload, aiController, subView, tableGridCoords]);
378
+
379
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
380
+
381
+ useEffect(() => {
382
+ if (itemRefs.current[selectedIndex]) {
383
+ itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
384
+ }
385
+ }, [selectedIndex]);
386
+
387
+ useEffect(() => {
388
+ if (!subView) {
389
+ setTableGridCoords({ r: 0, c: 0 });
390
+ }
391
+ }, [subView]);
392
+
393
+ if (!isActive || filteredItems.length === 0) return null;
394
+
395
+ if (subView === "table-grid" && range && view) {
396
+ return (
397
+ <div
398
+ ref={menuRef}
399
+ onMouseDown={(e) => e.preventDefault()}
400
+ style={{ position: "fixed", zIndex: 9999, visibility: "hidden" }}
401
+ className={cls("text-surface-900 dark:text-white rounded-md border bg-white dark:bg-surface-900 p-2 shadow transition-none", defaultBorderMixin)}
402
+ >
403
+ <TableGridPicker
404
+ hoveredRow={tableGridCoords.r}
405
+ hoveredCol={tableGridCoords.c}
406
+ onHover={(r, c) => setTableGridCoords({ r, c })}
407
+ onSelect={(rows, cols) => {
408
+ const tableNode = createTableNode(view.state.schema, rows, cols);
409
+ const tr = view.state.tr.replaceWith(range.from, range.to, tableNode);
410
+ try {
411
+ const selection = TextSelection.create(tr.doc, range.from + 4);
412
+ tr.setSelection(selection);
413
+ } catch (e) {
414
+ console.warn("Could not select first cell", e);
415
+ }
416
+ tr.setMeta(SlashCommandPluginKey, { active: false });
417
+ view.dispatch(tr);
418
+ view.focus();
419
+ setSubView(null);
420
+ }}
421
+ />
422
+ </div>
423
+ );
424
+ }
425
+
426
+ return (
427
+ <div
428
+ ref={menuRef}
429
+ style={{ position: "fixed", zIndex: 9999, visibility: "hidden" }}
430
+ 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)}
431
+ >
432
+ {filteredItems.map((item, index) => (
433
+ <button
434
+ key={item.title}
435
+ ref={el => { itemRefs.current[index] = el; }}
436
+ onClick={(e) => {
437
+ e.preventDefault();
438
+ if (range && view) {
439
+ item.command(view, range, upload, aiController, setSubView);
440
+ // Only focus back to editor if it didn't open a sub-view
441
+ if (!subView) view.focus();
442
+ }
443
+ }}
444
+ onMouseDown={(e) => e.preventDefault()}
445
+ 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",
446
+ index === selectedIndex ? "bg-blue-100 dark:bg-surface-accent-950" : "")}
447
+ >
448
+ <div className={cls("flex h-10 w-10 shrink-0 items-center justify-center rounded-md border bg-white dark:bg-surface-900", defaultBorderMixin)}>
449
+ {item.icon}
450
+ </div>
451
+ <div className="flex flex-col overflow-hidden">
452
+ <p className="font-medium truncate">{item.title}</p>
453
+ <p className="text-xs text-surface-700 dark:text-surface-accent-300 truncate">
454
+ {item.description}
455
+ </p>
456
+ </div>
457
+ </button>
458
+ ))}
459
+ </div>
460
+ );
461
+ };
462
+
463
+ const createTableNode = (schema: any, rowsCount: number, colsCount: number) => {
464
+ const rows = [];
465
+ for (let r = 0; r < rowsCount; r++) {
466
+ const cells = [];
467
+ for (let c = 0; c < colsCount; c++) {
468
+ const isHeader = r === 0;
469
+ const cellType = isHeader ? schema.nodes.table_header : schema.nodes.table_cell;
470
+ const cell = cellType.createAndFill();
471
+ if (cell) cells.push(cell);
472
+ }
473
+ const row = schema.nodes.table_row.create(null, Fragment.from(cells));
474
+ rows.push(row);
475
+ }
476
+ return schema.nodes.table.create(null, Fragment.from(rows));
477
+ };
478
+
479
+ const TableGridPicker = ({
480
+ hoveredRow,
481
+ hoveredCol,
482
+ onHover,
483
+ onSelect
484
+ }: {
485
+ hoveredRow: number;
486
+ hoveredCol: number;
487
+ onHover: (r: number, c: number) => void;
488
+ onSelect: (r: number, c: number) => void;
489
+ }) => {
490
+ return (
491
+ <div className="flex flex-col gap-1 items-center justify-center p-1 w-fit">
492
+ <span className="text-xs text-gray-500 font-medium mb-1">
493
+ {hoveredCol + 1} x {hoveredRow + 1} Table
494
+ </span>
495
+ <div className="flex flex-col gap-1">
496
+ {Array.from({ length: 5 }).map((_, r) => (
497
+ <div key={r} className="flex gap-1">
498
+ {Array.from({ length: 5 }).map((_, c) => (
499
+ <div
500
+ key={c}
501
+ className={cls(
502
+ "w-5 h-5 border rounded-sm cursor-pointer transition-colors duration-75",
503
+ r <= hoveredRow && c <= hoveredCol
504
+ ? "bg-blue-100 border-blue-400 dark:bg-blue-900 dark:border-blue-500"
505
+ : "bg-white dark:bg-surface-800 border-gray-200 dark:border-gray-700 hover:border-blue-300"
506
+ )}
507
+ onMouseEnter={() => onHover(r, c)}
508
+ onClick={() => onSelect(hoveredRow + 1, hoveredCol + 1)}
509
+ />
510
+ ))}
511
+ </div>
512
+ ))}
513
+ </div>
514
+ </div>
515
+ );
516
+ };
@@ -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;