@firecms/core 3.1.0-canary.1df3b2c → 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 (209) hide show
  1. package/dist/components/EntityCollectionTable/internal/popup_field/useDraggable.d.ts +2 -2
  2. package/dist/components/EntityCollectionView/CollectionDataErrorBanner.d.ts +4 -0
  3. package/dist/components/EntityCollectionView/ViewModeToggle.d.ts +5 -10
  4. package/dist/components/ErrorBoundary.d.ts +4 -2
  5. package/dist/components/HomePage/DefaultHomePage.d.ts +0 -1
  6. package/dist/components/LanguageToggle.d.ts +1 -0
  7. package/dist/components/UnsavedChangesDialog.d.ts +1 -0
  8. package/dist/components/VirtualTable/VirtualTableHeader.d.ts +1 -1
  9. package/dist/components/index.d.ts +1 -0
  10. package/dist/core/DrawerNavigationGroup.d.ts +2 -2
  11. package/dist/editor/components/SlashCommandMenu.d.ts +6 -0
  12. package/dist/editor/components/editor-bubble-item.d.ts +8 -0
  13. package/dist/editor/components/editor-bubble.d.ts +8 -0
  14. package/dist/editor/components/index.d.ts +14 -0
  15. package/dist/editor/editor.d.ts +30 -0
  16. package/dist/editor/extensions/HighlightDecorationExtension.d.ts +24 -0
  17. package/dist/editor/extensions/Image/index.d.ts +6 -0
  18. package/dist/editor/extensions/Image.d.ts +6 -0
  19. package/dist/editor/extensions/TextLoadingDecorationExtension.d.ts +16 -0
  20. package/dist/editor/extensions/clipboard.d.ts +7 -0
  21. package/dist/editor/extensions/custom-keymap.d.ts +1 -0
  22. package/dist/editor/extensions/drag-and-drop.d.ts +9 -0
  23. package/dist/editor/hooks/useProseMirror.d.ts +14 -0
  24. package/dist/editor/hooks/useProseMirrorContext.d.ts +9 -0
  25. package/dist/editor/index.d.ts +2 -0
  26. package/dist/editor/markdown.d.ts +5 -0
  27. package/dist/editor/nodeViews/ImageComponent.d.ts +3 -0
  28. package/dist/editor/nodeViews/ReactNodeView.d.ts +29 -0
  29. package/dist/editor/nodeViews/TaskItemComponent.d.ts +3 -0
  30. package/dist/editor/nodeViews/index.d.ts +6 -0
  31. package/dist/editor/plugins/index.d.ts +2 -0
  32. package/dist/editor/plugins/inputrules.d.ts +6 -0
  33. package/dist/editor/plugins/placeholderPlugin.d.ts +3 -0
  34. package/dist/editor/plugins/slashCommandPlugin.d.ts +11 -0
  35. package/dist/editor/schema.d.ts +2 -0
  36. package/dist/editor/selectors/ai-selector.d.ts +0 -0
  37. package/dist/editor/selectors/color-selector.d.ts +10 -0
  38. package/dist/editor/selectors/link-selector.d.ts +8 -0
  39. package/dist/editor/selectors/node-selector.d.ts +15 -0
  40. package/dist/editor/selectors/text-buttons.d.ts +1 -0
  41. package/dist/editor/types.d.ts +5 -0
  42. package/dist/editor/useProseMirror.d.ts +16 -0
  43. package/dist/editor/utils/prosemirror-utils.d.ts +6 -0
  44. package/dist/editor/utils/remove_classes.d.ts +1 -0
  45. package/dist/editor/utils/useDebouncedCallback.d.ts +1 -0
  46. package/dist/form/components/ErrorFocus.d.ts +1 -1
  47. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  48. package/dist/hooks/index.d.ts +1 -0
  49. package/dist/hooks/useBuildNavigationController.d.ts +0 -1
  50. package/dist/hooks/useCollapsedGroups.d.ts +3 -3
  51. package/dist/hooks/useTranslation.d.ts +17 -0
  52. package/dist/i18n/FireCMSi18nProvider.d.ts +33 -0
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.es.js +11441 -2215
  55. package/dist/index.es.js.map +1 -1
  56. package/dist/index.umd.js +11423 -2216
  57. package/dist/index.umd.js.map +1 -1
  58. package/dist/internal/useRestoreScroll.d.ts +1 -1
  59. package/dist/locales/de.d.ts +2 -0
  60. package/dist/locales/en.d.ts +10 -0
  61. package/dist/locales/es.d.ts +10 -0
  62. package/dist/locales/fr.d.ts +2 -0
  63. package/dist/locales/hi.d.ts +2 -0
  64. package/dist/locales/it.d.ts +2 -0
  65. package/dist/locales/pt.d.ts +7 -0
  66. package/dist/types/analytics.d.ts +1 -1
  67. package/dist/types/collections.d.ts +8 -0
  68. package/dist/types/customization_controller.d.ts +2 -1
  69. package/dist/types/firecms.d.ts +2 -1
  70. package/dist/types/index.d.ts +1 -0
  71. package/dist/types/navigation.d.ts +2 -2
  72. package/dist/types/plugins.d.ts +23 -0
  73. package/dist/types/translations.d.ts +646 -0
  74. package/dist/util/entities.d.ts +1 -1
  75. package/dist/util/resolutions.d.ts +2 -2
  76. package/package.json +47 -13
  77. package/src/app/Scaffold.tsx +7 -5
  78. package/src/components/AIIcon.tsx +3 -1
  79. package/src/components/ArrayContainer.tsx +6 -4
  80. package/src/components/ClearFilterSortButton.tsx +6 -3
  81. package/src/components/ConfirmationDialog.tsx +4 -2
  82. package/src/components/DeleteEntityDialog.tsx +10 -7
  83. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +6 -3
  84. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +3 -1
  85. package/src/components/EntityCollectionTable/internal/EntityTableCellActions.tsx +1 -1
  86. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +3 -2
  87. package/src/components/EntityCollectionTable/internal/popup_field/useDraggable.tsx +11 -11
  88. package/src/components/EntityCollectionView/BoardSortableList.tsx +3 -1
  89. package/src/components/EntityCollectionView/CollectionDataErrorBanner.tsx +43 -0
  90. package/src/components/EntityCollectionView/EntityBoardCard.tsx +1 -1
  91. package/src/components/EntityCollectionView/EntityCard.tsx +4 -0
  92. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +39 -46
  93. package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +17 -25
  94. package/src/components/EntityCollectionView/EntityCollectionView.tsx +73 -31
  95. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +4 -3
  96. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -2
  97. package/src/components/EntityCollectionView/FiltersDialog.tsx +8 -5
  98. package/src/components/EntityCollectionView/ViewModeToggle.tsx +37 -37
  99. package/src/components/EntityView.tsx +3 -2
  100. package/src/components/ErrorBoundary.tsx +27 -15
  101. package/src/components/HomePage/DefaultHomePage.tsx +19 -13
  102. package/src/components/HomePage/HomePageDnD.tsx +3 -1
  103. package/src/components/HomePage/NavigationGroup.tsx +3 -1
  104. package/src/components/HomePage/RenameGroupDialog.tsx +15 -13
  105. package/src/components/LanguageToggle.tsx +66 -0
  106. package/src/components/NotFoundPage.tsx +5 -3
  107. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +9 -7
  108. package/src/components/ReferenceWidget.tsx +3 -2
  109. package/src/components/SearchIconsView.tsx +3 -1
  110. package/src/components/SelectableTable/filters/DateTimeFilterField.tsx +11 -0
  111. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +15 -2
  112. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +11 -0
  113. package/src/components/UnsavedChangesDialog.tsx +6 -4
  114. package/src/components/VirtualTable/VirtualTable.performance.test.tsx +1 -0
  115. package/src/components/VirtualTable/VirtualTable.tsx +116 -113
  116. package/src/components/VirtualTable/VirtualTableHeader.tsx +54 -52
  117. package/src/components/VirtualTable/VirtualTableHeaderRow.tsx +1 -1
  118. package/src/components/VirtualTable/fields/VirtualTableSelect.tsx +3 -3
  119. package/src/components/common/default_entity_actions.tsx +4 -0
  120. package/src/components/common/useDataSourceTableController.tsx +12 -4
  121. package/src/components/index.tsx +1 -0
  122. package/src/core/DefaultAppBar.tsx +15 -11
  123. package/src/core/DefaultDrawer.tsx +8 -2
  124. package/src/core/DrawerNavigationGroup.tsx +5 -3
  125. package/src/core/EntityEditView.tsx +4 -3
  126. package/src/core/EntityEditViewFormActions.tsx +24 -17
  127. package/src/core/EntitySidePanel.tsx +32 -29
  128. package/src/core/FireCMS.tsx +33 -6
  129. package/src/core/field_configs.tsx +14 -9
  130. package/src/editor/components/SlashCommandMenu.tsx +348 -0
  131. package/src/editor/components/editor-bubble-item.tsx +32 -0
  132. package/src/editor/components/editor-bubble.tsx +118 -0
  133. package/src/editor/components/index.ts +12 -0
  134. package/src/editor/editor.tsx +307 -0
  135. package/src/editor/extensions/HighlightDecorationExtension.ts +114 -0
  136. package/src/editor/extensions/Image/index.ts +133 -0
  137. package/src/editor/extensions/Image.ts +144 -0
  138. package/src/editor/extensions/TextLoadingDecorationExtension.tsx +107 -0
  139. package/src/editor/extensions/clipboard.ts +72 -0
  140. package/src/editor/extensions/custom-keymap.ts +24 -0
  141. package/src/editor/extensions/drag-and-drop.tsx +472 -0
  142. package/src/editor/hooks/useProseMirror.ts +115 -0
  143. package/src/editor/hooks/useProseMirrorContext.ts +15 -0
  144. package/src/editor/index.ts +2 -0
  145. package/src/editor/markdown.ts +110 -0
  146. package/src/editor/nodeViews/ImageComponent.tsx +20 -0
  147. package/src/editor/nodeViews/ReactNodeView.tsx +89 -0
  148. package/src/editor/nodeViews/TaskItemComponent.tsx +29 -0
  149. package/src/editor/nodeViews/index.ts +35 -0
  150. package/src/editor/plugins/index.ts +55 -0
  151. package/src/editor/plugins/inputrules.ts +82 -0
  152. package/src/editor/plugins/placeholderPlugin.ts +55 -0
  153. package/src/editor/plugins/slashCommandPlugin.ts +49 -0
  154. package/src/editor/schema.ts +228 -0
  155. package/src/editor/selectors/ai-selector.tsx +111 -0
  156. package/src/editor/selectors/color-selector.tsx +200 -0
  157. package/src/editor/selectors/link-selector.tsx +118 -0
  158. package/src/editor/selectors/node-selector.tsx +157 -0
  159. package/src/editor/selectors/text-buttons.tsx +86 -0
  160. package/src/editor/types.ts +6 -0
  161. package/src/editor/useProseMirror.ts +126 -0
  162. package/src/editor/utils/prosemirror-utils.ts +78 -0
  163. package/src/editor/utils/remove_classes.ts +17 -0
  164. package/src/editor/utils/useDebouncedCallback.ts +25 -0
  165. package/src/form/EntityForm.tsx +76 -63
  166. package/src/form/EntityFormActions.tsx +19 -12
  167. package/src/form/PropertyFieldBinding.tsx +6 -5
  168. package/src/form/components/ErrorFocus.tsx +3 -3
  169. package/src/form/components/LocalChangesMenu.tsx +13 -13
  170. package/src/form/components/StorageItemPreview.tsx +3 -2
  171. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +4 -4
  172. package/src/form/field_bindings/BlockFieldBinding.tsx +5 -2
  173. package/src/form/field_bindings/KeyValueFieldBinding.tsx +23 -18
  174. package/src/form/field_bindings/MapFieldBinding.tsx +4 -3
  175. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +4 -4
  176. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -1
  177. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +87 -85
  178. package/src/hooks/index.tsx +1 -0
  179. package/src/hooks/useBuildNavigationController.tsx +49 -22
  180. package/src/hooks/useCollapsedGroups.ts +7 -6
  181. package/src/hooks/useTranslation.ts +31 -0
  182. package/src/hooks/useValidateAuthenticator.tsx +1 -1
  183. package/src/i18n/FireCMSi18nProvider.tsx +160 -0
  184. package/src/index.ts +4 -0
  185. package/src/internal/useBuildDataSource.ts +1 -2
  186. package/src/internal/useBuildSideEntityController.tsx +22 -20
  187. package/src/locales/de.ts +691 -0
  188. package/src/locales/en.ts +703 -0
  189. package/src/locales/es.ts +703 -0
  190. package/src/locales/fr.ts +691 -0
  191. package/src/locales/hi.ts +691 -0
  192. package/src/locales/it.ts +691 -0
  193. package/src/locales/pt.ts +700 -0
  194. package/src/preview/PropertyPreview.tsx +1 -0
  195. package/src/preview/components/UrlComponentPreview.tsx +4 -2
  196. package/src/preview/components/UserPreview.tsx +3 -1
  197. package/src/types/analytics.ts +10 -0
  198. package/src/types/collections.ts +9 -0
  199. package/src/types/customization_controller.tsx +2 -1
  200. package/src/types/firecms.tsx +2 -1
  201. package/src/types/index.ts +1 -0
  202. package/src/types/navigation.ts +2 -2
  203. package/src/types/plugins.tsx +26 -0
  204. package/src/types/translations.ts +725 -0
  205. package/src/util/entities.ts +1 -1
  206. package/src/util/join_collections.ts +10 -8
  207. package/src/util/previews.ts +2 -2
  208. package/src/util/property_utils.tsx +1 -1
  209. package/src/util/resolutions.ts +5 -3
@@ -1,7 +1,7 @@
1
1
  import React, { useCallback, useEffect, useMemo } from "react";
2
2
 
3
3
  import { EntityCollection, EntitySidePanelProps } from "../types";
4
- import { useNavigationController, useSideEntityController } from "../hooks";
4
+ import { useNavigationController, useSideEntityController, useTranslation } from "../hooks";
5
5
 
6
6
  import { ErrorBoundary } from "../components";
7
7
  import { EntityEditView, OnUpdateParams } from "./EntityEditView";
@@ -41,6 +41,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
41
41
  const sideEntityController = useSideEntityController();
42
42
  const navigationController = useNavigationController();
43
43
  const sideDialogsController = useSideDialogContext();
44
+ const { t } = useTranslation();
44
45
 
45
46
  const onClose = () => {
46
47
  if (props.onClose) {
@@ -99,13 +100,13 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
99
100
 
100
101
  const onValuesModified = useCallback((modified: boolean) => {
101
102
  setBlockedNavigationMessage(modified
102
- ? <> You have unsaved changes in this <b>{collection?.singularName ?? collection?.name}</b>.</>
103
+ ? t("unsaved_changes", { collectionName: collection?.singularName ?? collection?.name ?? "" })
103
104
  : undefined)
104
105
  setBlocked(modified);
105
- }, [collection?.name, setBlocked, setBlockedNavigationMessage]);
106
+ }, [collection?.name, setBlocked, setBlockedNavigationMessage, t]);
106
107
 
107
108
  if (!props || !collection) {
108
- return <div className={"w-full"}/>;
109
+ return <div className={"w-full"} />;
109
110
  }
110
111
 
111
112
  return (
@@ -120,32 +121,34 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
120
121
  onValuesModified={onValuesModified}
121
122
  onSaved={onUpdate}
122
123
  barActions={({
123
- status,
124
- values
125
- }) => <>
126
- <IconButton
127
- className="self-center"
128
- onClick={onClose}>
129
- <CloseIcon size={"small"}/>
130
- </IconButton>
131
- {allowFullScreen && <IconButton
132
- className="self-center"
133
- onClick={() => {
134
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
135
- saveEntityToMemoryCache(key, values);
136
- if (entityId)
137
- navigate(location.pathname);
138
- else
139
- navigate(location.pathname + "#new");
140
- }}>
141
- <OpenInFullIcon size={"small"}/>
142
- </IconButton>}
143
- </>}
124
+ status,
125
+ values
126
+ }) => <>
127
+ <IconButton
128
+ className="self-center"
129
+ size={"smallest"}
130
+ onClick={onClose}>
131
+ <CloseIcon size={"smallest"} />
132
+ </IconButton>
133
+ {allowFullScreen && <IconButton
134
+ className="self-center"
135
+ size={"smallest"}
136
+ onClick={() => {
137
+ const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
138
+ saveEntityToMemoryCache(key, values);
139
+ if (entityId)
140
+ navigate(location.pathname + location.search);
141
+ else
142
+ navigate(location.pathname + location.search + "#new");
143
+ }}>
144
+ <OpenInFullIcon size={"smallest"} />
145
+ </IconButton>}
146
+ </>}
144
147
  onTabChange={({
145
- entityId,
146
- selectedTab,
147
- collection,
148
- }) => {
148
+ entityId,
149
+ selectedTab,
150
+ collection,
151
+ }) => {
149
152
  sideEntityController.replace({
150
153
  path,
151
154
  entityId,
@@ -1,11 +1,13 @@
1
1
  "use client";
2
2
 
3
- import React, { useMemo } from "react";
3
+ import React, { useEffect, useMemo } from "react";
4
4
  import { CenteredView, Typography } from "@firecms/ui";
5
+ import { AuthController } from "../types";
5
6
  import { CustomizationController, FireCMSContext, FireCMSPlugin, FireCMSProps, User } from "../types";
6
- import { AuthControllerContext } from "../contexts";
7
+ import { AuthControllerContext, ModeControllerProvider } from "../contexts";
7
8
  import { useBuildSideEntityController } from "../internal/useBuildSideEntityController";
8
- import { useCustomizationController, useFireCMSContext } from "../hooks";
9
+ import { useCustomizationController, useFireCMSContext, useTranslation, ModeController } from "../hooks";
10
+ import { useBuildModeController } from "../hooks/useBuildModeController";
9
11
  import { useBuildSideDialogsController } from "../internal/useBuildSideDialogsController";
10
12
  import { ErrorView } from "../components";
11
13
  import { StorageSourceContext } from "../contexts/StorageSourceContext";
@@ -59,6 +61,10 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
59
61
  console.warn("The `plugins` prop is deprecated in the FireCMS component. You should pass your plugins to `useBuildNavigationController` instead.");
60
62
  }
61
63
 
64
+ const { t, i18n } = useTranslation();
65
+
66
+ const modeController = useBuildModeController();
67
+
62
68
  const plugins = navigationController.plugins ?? _pluginsProp;
63
69
  const userManagement = plugins?.find(p => p.userManagement)?.userManagement
64
70
  ?? _userManagement
@@ -106,6 +112,24 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
106
112
  authController
107
113
  });
108
114
 
115
+ // Inject plugin translations into the existing i18next instance
116
+ useEffect(() => {
117
+ if (!i18n) return;
118
+ plugins?.forEach(plugin => {
119
+ if (plugin.i18n) {
120
+ Object.keys(plugin.i18n).forEach(locale => {
121
+ i18n.addResourceBundle(
122
+ locale,
123
+ "firecms_core",
124
+ plugin.i18n![locale],
125
+ true, // deep merge
126
+ true // overwrite
127
+ );
128
+ });
129
+ }
130
+ });
131
+ }, [i18n, plugins]);
132
+
109
133
  if (accessResponse?.message) {
110
134
  console.warn(accessResponse.message);
111
135
  }
@@ -133,12 +157,15 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
133
157
  if (accessResponse?.blocked) {
134
158
  return (
135
159
  <CenteredView maxWidth={"md"} fullScreen={true} className={"flex flex-col gap-2"}>
160
+ {/* eslint-disable-next-line i18next/no-literal-string */}
136
161
  <Typography variant={"h4"} gutterBottom>
137
- License needed
162
+ {t("license_needed")}
138
163
  </Typography>
139
164
  <Typography>
140
- You need a valid license to use FireCMS PRO. Please reach out at <a
141
- href={"mailto:hello@firecms.co"}>hello@firecms.co</a> for more information.
165
+ {(() => {
166
+ const parts = t("license_description", { email: "%%EMAIL%%" }).split("%%EMAIL%%");
167
+ return <>{parts[0]}<a href={"mailto:hello@firecms.co"}>hello@firecms.co</a>{parts[1]}</>;
168
+ })()}
142
169
  </Typography>
143
170
  {accessResponse?.message &&
144
171
  <Typography>{accessResponse?.message}</Typography>}
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
 
3
- import { ArrayProperty, FieldProps, Property, PropertyConfig, ResolvedProperty } from "../types";
3
+ import { ArrayProperty, FieldProps, NumberProperty, Property, PropertyConfig, ResolvedProperty, StringProperty } from "../types";
4
4
  import {
5
5
  ArrayCustomShapedFieldBinding,
6
6
  ArrayOfReferencesFieldBinding,
@@ -397,14 +397,19 @@ export function getDefaultFieldId(property: Property | ResolvedProperty) {
397
397
  return "custom_array";
398
398
  } else if (isPropertyBuilder(of)) {
399
399
  return "repeat";
400
- } else if (of?.dataType === "string" && of.enumValues) {
401
- return "multi_select";
402
- } else if (of?.dataType === "number" && of.enumValues) {
403
- return "multi_number_select";
404
- } else if (of?.dataType === "string" && of.storage) {
405
- return "multi_file_upload";
406
- } else if (of?.dataType === "reference") {
407
- return "multi_references";
400
+ } else if (of) {
401
+ const ofProperty = of as Property;
402
+ if (ofProperty.dataType === "string" && (ofProperty as StringProperty).enumValues) {
403
+ return "multi_select";
404
+ } else if (ofProperty.dataType === "number" && (ofProperty as NumberProperty).enumValues) {
405
+ return "multi_number_select";
406
+ } else if (ofProperty.dataType === "string" && (ofProperty as StringProperty).storage) {
407
+ return "multi_file_upload";
408
+ } else if (ofProperty.dataType === "reference") {
409
+ return "multi_references";
410
+ } else {
411
+ return "repeat";
412
+ }
408
413
  } else {
409
414
  return "repeat";
410
415
  }
@@ -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;