@firecms/core 3.4.0-canary.f6a889a → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
- package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
- package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
- package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
- package/dist/components/ErrorBoundary.d.ts +1 -3
- package/dist/components/LicenseBanner.d.ts +24 -0
- package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
- package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
- package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
- package/dist/components/common/table_url_params.d.ts +24 -0
- package/dist/components/common/useDataSourceTableController.d.ts +9 -1
- package/dist/components/common/useTableSearchHelper.d.ts +9 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/contexts/LicenseStatusContext.d.ts +16 -0
- package/dist/core/EntityEditView.d.ts +9 -1
- package/dist/core/EntityEditViewFormActions.d.ts +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/plugin_translations.d.ts +38 -0
- package/dist/core/pro_plugins.d.ts +40 -0
- package/dist/de-CBOjCvsy.js +707 -0
- package/dist/de-CBOjCvsy.js.map +1 -0
- package/dist/editor/extensions/Image.d.ts +11 -0
- package/dist/editor/index.d.ts +2 -1
- package/dist/editor/lazy_editor.d.ts +6 -0
- package/dist/editor-BSo_aS1I.js +4563 -0
- package/dist/editor-BSo_aS1I.js.map +1 -0
- package/dist/form/EntityForm.d.ts +8 -1
- package/dist/form/EntityFormActions.d.ts +2 -0
- package/dist/fr-DRfttcG0.js +707 -0
- package/dist/fr-DRfttcG0.js.map +1 -0
- package/dist/hi-DwieOZNf.js +707 -0
- package/dist/hi-DwieOZNf.js.map +1 -0
- package/dist/hooks/data/delete.d.ts +1 -1
- package/dist/hooks/data/save.d.ts +1 -1
- package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
- package/dist/hooks/data/useEntityFetch.d.ts +9 -1
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/useLicenseStatus.d.ts +16 -0
- package/dist/hooks/useProjectLog.d.ts +11 -6
- package/dist/hooks/useTranslation.d.ts +1 -1
- package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
- package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
- package/dist/index.es.js +4059 -11384
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +9090 -7610
- package/dist/index.umd.js.map +1 -1
- package/dist/it-l2KXtx9n.js +707 -0
- package/dist/it-l2KXtx9n.js.map +1 -0
- package/dist/pl-DPZrdKhi.js +709 -0
- package/dist/pl-DPZrdKhi.js.map +1 -0
- package/dist/pt-BoHpabw1.js +709 -0
- package/dist/pt-BoHpabw1.js.map +1 -0
- package/dist/types/analytics.d.ts +1 -1
- package/dist/types/collections.d.ts +14 -0
- package/dist/types/datasource.d.ts +67 -4
- package/dist/types/entities.d.ts +21 -1
- package/dist/types/entity_actions.d.ts +6 -0
- package/dist/types/entity_callbacks.d.ts +21 -0
- package/dist/types/firecms.d.ts +18 -1
- package/dist/types/firecms_context.d.ts +8 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/license.d.ts +81 -0
- package/dist/types/navigation.d.ts +34 -5
- package/dist/types/plugins.d.ts +22 -1
- package/dist/types/properties.d.ts +17 -1
- package/dist/types/side_entity_controller.d.ts +7 -0
- package/dist/types/translations.d.ts +51 -2
- package/dist/util/__tests__/collections.test.d.ts +1 -0
- package/dist/util/__tests__/urls.test.d.ts +1 -0
- package/dist/util/collections.d.ts +1 -1
- package/dist/util/date_fns_locales.d.ts +18 -0
- package/dist/util/entity_cache.d.ts +11 -0
- package/dist/util/index.d.ts +2 -0
- package/dist/util/lazy_eager.d.ts +3 -3
- package/dist/util/local_storage.d.ts +23 -0
- package/dist/util/navigation_from_path.d.ts +7 -0
- package/dist/util/navigation_utils.d.ts +87 -3
- package/dist/util/parent_references_from_path.d.ts +16 -0
- package/dist/util/paths.d.ts +28 -0
- package/dist/util/permissions.d.ts +10 -4
- package/dist/util/urls.d.ts +22 -0
- package/package.json +5 -6
- package/src/app/Scaffold.tsx +7 -0
- package/src/components/DeleteEntityDialog.tsx +2 -0
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
- package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +1 -0
- package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
- package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
- package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
- package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
- package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
- package/src/components/EntityCollectionView/utils.ts +4 -4
- package/src/components/EntityPreview.tsx +4 -1
- package/src/components/ErrorBoundary.tsx +12 -18
- package/src/components/LicenseBanner.tsx +287 -0
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
- package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
- package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
- package/src/components/common/default_entity_actions.tsx +22 -2
- package/src/components/common/table_url_params.ts +172 -0
- package/src/components/common/useDataSourceTableController.tsx +86 -179
- package/src/components/common/useTableSearchHelper.ts +26 -2
- package/src/components/index.tsx +1 -0
- package/src/contexts/LicenseStatusContext.tsx +50 -0
- package/src/contexts/SnackbarProvider.tsx +14 -1
- package/src/core/DefaultAppBar.tsx +28 -16
- package/src/core/EntityEditView.tsx +20 -3
- package/src/core/EntityEditViewFormActions.tsx +3 -2
- package/src/core/EntitySidePanel.tsx +8 -4
- package/src/core/FireCMS.tsx +73 -47
- package/src/core/SideDialogs.tsx +44 -1
- package/src/core/index.tsx +1 -0
- package/src/core/plugin_translations.ts +87 -0
- package/src/core/pro_plugins.ts +79 -0
- package/src/editor/components/SlashCommandMenu.tsx +4 -1
- package/src/editor/extensions/Image.ts +17 -1
- package/src/editor/hooks/useProseMirror.ts +5 -0
- package/src/editor/index.ts +11 -1
- package/src/editor/lazy_editor.tsx +33 -0
- package/src/form/EntityForm.tsx +31 -10
- package/src/form/EntityFormActions.tsx +2 -0
- package/src/hooks/data/delete.ts +8 -0
- package/src/hooks/data/save.ts +6 -1
- package/src/hooks/data/useCollectionFetch.tsx +21 -1
- package/src/hooks/data/useEntityFetch.tsx +35 -4
- package/src/hooks/index.tsx +2 -0
- package/src/hooks/useBuildLocalConfigurationPersistence.tsx +9 -9
- package/src/hooks/useBuildModeController.tsx +6 -4
- package/src/hooks/useBuildNavigationController.tsx +70 -15
- package/src/hooks/useFireCMSContext.tsx +7 -3
- package/src/hooks/useLicenseStatus.tsx +32 -0
- package/src/hooks/useProjectLog.tsx +27 -9
- package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
- package/src/hooks/useTranslation.ts +3 -1
- package/src/hooks/useValidateAuthenticator.tsx +4 -0
- package/src/i18n/FireCMSi18nProvider.tsx +204 -33
- package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
- package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
- package/src/internal/useBuildDataSource.ts +37 -15
- package/src/internal/useBuildSideEntityController.tsx +19 -5
- package/src/locales/de.ts +28 -2
- package/src/locales/en.ts +33 -5
- package/src/locales/es.ts +28 -2
- package/src/locales/fr.ts +28 -2
- package/src/locales/hi.ts +28 -2
- package/src/locales/it.ts +28 -2
- package/src/locales/pl.ts +28 -2
- package/src/locales/pt.ts +28 -2
- package/src/preview/components/DatePreview.tsx +2 -4
- package/src/preview/components/ReferencePreview.tsx +7 -3
- package/src/preview/components/StorageThumbnail.tsx +24 -2
- package/src/routes/FireCMSRoute.tsx +11 -3
- package/src/types/analytics.ts +3 -0
- package/src/types/collections.ts +16 -0
- package/src/types/datasource.ts +67 -4
- package/src/types/entities.ts +24 -1
- package/src/types/entity_actions.tsx +6 -0
- package/src/types/entity_callbacks.ts +24 -0
- package/src/types/firecms.tsx +19 -1
- package/src/types/firecms_context.tsx +9 -0
- package/src/types/index.ts +1 -0
- package/src/types/license.ts +90 -0
- package/src/types/navigation.ts +35 -5
- package/src/types/plugins.tsx +23 -1
- package/src/types/properties.ts +17 -0
- package/src/types/side_entity_controller.tsx +7 -0
- package/src/types/translations.ts +51 -2
- package/src/util/__tests__/collections.test.ts +32 -0
- package/src/util/__tests__/urls.test.ts +131 -0
- package/src/util/collections.ts +1 -1
- package/src/util/date_fns_locales.ts +168 -0
- package/src/util/entities.ts +3 -1
- package/src/util/entity_cache.ts +28 -2
- package/src/util/index.ts +2 -0
- package/src/util/lazy_eager.tsx +30 -8
- package/src/util/local_storage.ts +52 -0
- package/src/util/navigation_from_path.ts +25 -2
- package/src/util/navigation_utils.ts +214 -3
- package/src/util/parent_references_from_path.ts +42 -3
- package/src/util/paths.ts +40 -3
- package/src/util/permissions.ts +22 -10
- package/src/util/previews.ts +8 -1
- package/src/util/resolutions.ts +8 -0
- package/src/util/urls.ts +52 -0
- package/src/util/useStorageUploadController.tsx +6 -1
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
|
|
13
13
|
import { CircularProgressCenter, EntityCollectionView, EntityView, ErrorBoundary } from "../components";
|
|
14
14
|
import {
|
|
15
|
+
buildSubcollectionPathSegments,
|
|
15
16
|
canEditEntity,
|
|
16
17
|
encodeEntityId,
|
|
17
18
|
removeInitialAndTrailingSlashes,
|
|
@@ -28,7 +29,7 @@ import {
|
|
|
28
29
|
useLargeLayout
|
|
29
30
|
} from "../hooks";
|
|
30
31
|
import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography, Menu, MenuItem, ExpandMoreIcon } from "@firecms/ui";
|
|
31
|
-
import { getEntityFromMemoryCache } from "../util/entity_cache";
|
|
32
|
+
import { entityCacheKey, getEntityFromMemoryCache } from "../util/entity_cache";
|
|
32
33
|
import { EntityForm, EntityFormProps } from "../form";
|
|
33
34
|
import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
|
|
34
35
|
import { EntityJsonPreview } from "../components/EntityJsonPreview";
|
|
@@ -42,6 +43,8 @@ export type OnUpdateParams = {
|
|
|
42
43
|
entity: Entity<any>,
|
|
43
44
|
status: EntityStatus,
|
|
44
45
|
path: string,
|
|
46
|
+
/** `path` split at its real segment boundaries, when known. Never derived from `path`. */
|
|
47
|
+
pathSegments?: string[],
|
|
45
48
|
entityId?: string;
|
|
46
49
|
selectedTab?: string;
|
|
47
50
|
collection: EntityCollection<any>
|
|
@@ -71,6 +74,12 @@ export interface EntityEditViewProps<M extends Record<string, any>> {
|
|
|
71
74
|
* The navigation path to the entity.
|
|
72
75
|
*/
|
|
73
76
|
fullIdPath?: string;
|
|
77
|
+
/**
|
|
78
|
+
* `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
|
|
79
|
+
* Forwarded to the datasource, and extended for any subcollection rendered inside this
|
|
80
|
+
* view, so a parent entity id containing "/" stays unambiguous.
|
|
81
|
+
*/
|
|
82
|
+
pathSegments?: string[];
|
|
74
83
|
collection: EntityCollection<M>;
|
|
75
84
|
entityId?: string;
|
|
76
85
|
databaseId?: string;
|
|
@@ -101,6 +110,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
|
|
|
101
110
|
dataLoadingError
|
|
102
111
|
} = useEntityFetch<M, USER>({
|
|
103
112
|
path: props.path,
|
|
113
|
+
pathSegments: props.pathSegments,
|
|
104
114
|
entityId: entityId,
|
|
105
115
|
collection: props.collection,
|
|
106
116
|
databaseId: props.databaseId,
|
|
@@ -108,7 +118,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
|
|
|
108
118
|
});
|
|
109
119
|
|
|
110
120
|
const initialDirtyValues = entityId
|
|
111
|
-
? getEntityFromMemoryCache(props.path
|
|
121
|
+
? getEntityFromMemoryCache(entityCacheKey(props.path, entityId))
|
|
112
122
|
: getEntityFromMemoryCache(props.path + "#new");
|
|
113
123
|
|
|
114
124
|
const authController = useAuthController();
|
|
@@ -120,7 +130,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
|
|
|
120
130
|
if (status === "new" || status === "copy") {
|
|
121
131
|
return true;
|
|
122
132
|
} else {
|
|
123
|
-
return entity ? canEditEntity(props.collection, authController, props.path, entity ?? null) : undefined;
|
|
133
|
+
return entity ? canEditEntity(props.collection, authController, props.path, entity ?? null, props.pathSegments) : undefined;
|
|
124
134
|
}
|
|
125
135
|
}, [authController, entity, status]);
|
|
126
136
|
|
|
@@ -146,6 +156,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
|
|
|
146
156
|
export function EntityEditViewInner<M extends Record<string, any>>({
|
|
147
157
|
path,
|
|
148
158
|
fullIdPath,
|
|
159
|
+
pathSegments,
|
|
149
160
|
entityId,
|
|
150
161
|
selectedTab: selectedTabProp,
|
|
151
162
|
collection,
|
|
@@ -194,6 +205,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
|
|
|
194
205
|
entityId,
|
|
195
206
|
parentCollectionIds,
|
|
196
207
|
path,
|
|
208
|
+
pathSegments,
|
|
197
209
|
status,
|
|
198
210
|
collection,
|
|
199
211
|
context,
|
|
@@ -351,6 +363,9 @@ export function EntityEditViewInner<M extends Record<string, any>>({
|
|
|
351
363
|
const newFullPath = usedEntity ? `${path}/${usedEntity?.id}/${removeInitialAndTrailingSlashes(subcollection.path)}` : undefined;
|
|
352
364
|
// `newFullIdPath` is fed to buildUrlCollectionPath downstream, so it stays escaped.
|
|
353
365
|
const newFullIdPath = fullIdPath && usedEntity ? `${fullIdPath}/${encodeEntityId(usedEntity.id)}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
|
|
366
|
+
// Only extendable when we were given real segments — deriving the parent chain
|
|
367
|
+
// from the flattened `path` would drop the boundary of a slash-bearing parent id.
|
|
368
|
+
const newPathSegments = buildSubcollectionPathSegments(pathSegments, usedEntity?.id, subcollection.path);
|
|
354
369
|
|
|
355
370
|
if (selectedTab !== subcollectionId) return null;
|
|
356
371
|
return (
|
|
@@ -365,6 +380,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
|
|
|
365
380
|
(usedEntity && newFullPath
|
|
366
381
|
? <EntityCollectionView
|
|
367
382
|
fullPath={newFullPath}
|
|
383
|
+
pathSegments={newPathSegments}
|
|
368
384
|
fullIdPath={newFullIdPath}
|
|
369
385
|
parentCollectionIds={[...parentCollectionIds, collection.id]}
|
|
370
386
|
isSubCollection={true}
|
|
@@ -414,6 +430,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
|
|
|
414
430
|
fullIdPath={fullIdPath}
|
|
415
431
|
collection={collection}
|
|
416
432
|
path={path}
|
|
433
|
+
pathSegments={pathSegments}
|
|
417
434
|
entityId={entityId ?? usedEntity?.id}
|
|
418
435
|
onValuesModified={onValuesModified}
|
|
419
436
|
entity={entity}
|
|
@@ -39,6 +39,7 @@ import { ErrorTooltip } from "../components/ErrorTooltip";
|
|
|
39
39
|
export function EntityEditViewFormActions({
|
|
40
40
|
collection,
|
|
41
41
|
path,
|
|
42
|
+
pathSegments,
|
|
42
43
|
entity,
|
|
43
44
|
layout,
|
|
44
45
|
savingError,
|
|
@@ -63,8 +64,8 @@ export function EntityEditViewFormActions({
|
|
|
63
64
|
const customEntityActions = (collection.entityActions ?? [])
|
|
64
65
|
.map(action => resolveEntityAction(action, customizationController.entityActions))
|
|
65
66
|
.filter(Boolean) as EntityAction[];
|
|
66
|
-
const createEnabled = canCreateEntity(collection, authController, path, null);
|
|
67
|
-
const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity) : false;
|
|
67
|
+
const createEnabled = canCreateEntity(collection, authController, path, null, pathSegments);
|
|
68
|
+
const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity, entity.pathSegments ?? pathSegments) : false;
|
|
68
69
|
const actions: EntityAction[] = [];
|
|
69
70
|
if (createEnabled)
|
|
70
71
|
actions.push(copyEntityAction);
|
|
@@ -8,7 +8,7 @@ import { EntityEditView, OnUpdateParams } from "./EntityEditView";
|
|
|
8
8
|
import { useSideDialogContext } from "./SideDialogs";
|
|
9
9
|
import { CloseIcon, IconButton, OpenInFullIcon } from "@firecms/ui";
|
|
10
10
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
11
|
-
import { saveEntityToMemoryCache } from "../util/entity_cache";
|
|
11
|
+
import { entityCacheKey, saveEntityToMemoryCache } from "../util/entity_cache";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* This is the component in charge of rendering the side dialog used
|
|
@@ -25,6 +25,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
25
25
|
path,
|
|
26
26
|
entityId,
|
|
27
27
|
fullIdPath,
|
|
28
|
+
pathSegments,
|
|
28
29
|
formProps,
|
|
29
30
|
} = props;
|
|
30
31
|
|
|
@@ -59,6 +60,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
59
60
|
if (params.status !== "existing") {
|
|
60
61
|
sideEntityController.replace({
|
|
61
62
|
path: params.path,
|
|
63
|
+
pathSegments: params.pathSegments ?? pathSegments,
|
|
62
64
|
entityId: params.entityId,
|
|
63
65
|
fullIdPath: props.fullIdPath,
|
|
64
66
|
selectedTab: params.selectedTab,
|
|
@@ -75,10 +77,10 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
const parentCollectionIds = useMemo(() => {
|
|
78
|
-
return navigationController.getParentCollectionIds(path);
|
|
80
|
+
return navigationController.getParentCollectionIds(path, pathSegments);
|
|
79
81
|
}, [navigationController, path]);
|
|
80
82
|
|
|
81
|
-
const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path);
|
|
83
|
+
const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path, false, pathSegments);
|
|
82
84
|
|
|
83
85
|
useEffect(() => {
|
|
84
86
|
function beforeunload(e: any) {
|
|
@@ -116,6 +118,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
116
118
|
<EntityEditView
|
|
117
119
|
{...props}
|
|
118
120
|
fullIdPath={fullIdPath}
|
|
121
|
+
pathSegments={pathSegments}
|
|
119
122
|
layout={"side_panel"}
|
|
120
123
|
collection={collection as EntityCollection}
|
|
121
124
|
parentCollectionIds={parentCollectionIds}
|
|
@@ -135,7 +138,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
135
138
|
className="self-center"
|
|
136
139
|
size={"smallest"}
|
|
137
140
|
onClick={() => {
|
|
138
|
-
const key = (status === "new" || status === "copy") ? path + "#new" : path
|
|
141
|
+
const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
|
|
139
142
|
saveEntityToMemoryCache(key, values);
|
|
140
143
|
if (entityId)
|
|
141
144
|
navigate(location.pathname + location.search);
|
|
@@ -152,6 +155,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
|
|
|
152
155
|
}) => {
|
|
153
156
|
sideEntityController.replace({
|
|
154
157
|
path,
|
|
158
|
+
pathSegments,
|
|
155
159
|
entityId,
|
|
156
160
|
fullIdPath: props.fullIdPath,
|
|
157
161
|
selectedTab,
|
package/src/core/FireCMS.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { useEffect, useMemo } from "react";
|
|
4
|
-
import { CenteredView
|
|
3
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import { CenteredView } from "@firecms/ui";
|
|
5
|
+
import { AppliedBundles, applyPluginTranslations, createAppliedBundles } from "./plugin_translations";
|
|
6
|
+
import { filterPausedPlugins, isProPaused } from "./pro_plugins";
|
|
5
7
|
import { AuthController } from "../types";
|
|
6
8
|
import { CustomizationController, FireCMSContext, FireCMSPlugin, FireCMSProps, User } from "../types";
|
|
7
9
|
import { AuthControllerContext, ModeControllerProvider } from "../contexts";
|
|
@@ -23,6 +25,7 @@ import { AnalyticsContext } from "../contexts/AnalyticsContext";
|
|
|
23
25
|
import { useProjectLog } from "../hooks/useProjectLog";
|
|
24
26
|
import { BreadcrumbsProvider } from "../contexts/BreacrumbsContext";
|
|
25
27
|
import { InternalUserManagementContext } from "../contexts/InternalUserManagementContext";
|
|
28
|
+
import { LicenseStatusContext, publishLicenseStatus } from "../contexts/LicenseStatusContext";
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* If you are using independent components of the CMS
|
|
@@ -54,6 +57,7 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
54
57
|
components,
|
|
55
58
|
navigationController,
|
|
56
59
|
apiKey,
|
|
60
|
+
telemetry,
|
|
57
61
|
userManagement: _userManagement
|
|
58
62
|
} = props;
|
|
59
63
|
|
|
@@ -61,11 +65,42 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
61
65
|
console.warn("The `plugins` prop is deprecated in the FireCMS component. You should pass your plugins to `useBuildNavigationController` instead.");
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
const {
|
|
68
|
+
const { i18n } = useTranslation();
|
|
65
69
|
|
|
66
70
|
const modeController = useBuildModeController();
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
// Every plugin the app mounts. The license check is sent all of their keys:
|
|
73
|
+
// the server decides from them whether the project needs a license.
|
|
74
|
+
const allPlugins = navigationController.plugins ?? _pluginsProp;
|
|
75
|
+
|
|
76
|
+
const licenseStatus = useProjectLog({
|
|
77
|
+
apiKey,
|
|
78
|
+
telemetry,
|
|
79
|
+
authController,
|
|
80
|
+
dataSourceDelegate,
|
|
81
|
+
plugins: allPlugins
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// The CMS always renders. While PRO is paused, PRO plugins drop out of
|
|
85
|
+
// everything below (providers, form, collection view and home page
|
|
86
|
+
// contributions); `useBuildNavigationController` drops their collection,
|
|
87
|
+
// view and navigation contributions from the status published below.
|
|
88
|
+
const plugins = useMemo(
|
|
89
|
+
() => filterPausedPlugins(allPlugins, licenseStatus),
|
|
90
|
+
[allPlugins, licenseStatus]
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
publishLicenseStatus(licenseStatus);
|
|
95
|
+
if (licenseStatus?.message) {
|
|
96
|
+
if (isProPaused(licenseStatus)) console.warn(licenseStatus.message);
|
|
97
|
+
else console.debug(licenseStatus.message);
|
|
98
|
+
}
|
|
99
|
+
}, [licenseStatus]);
|
|
100
|
+
|
|
101
|
+
// The status belongs to this FireCMS; do not leave it to a later one.
|
|
102
|
+
useEffect(() => () => publishLicenseStatus(null), []);
|
|
103
|
+
|
|
69
104
|
const userManagement = plugins?.find(p => p.userManagement)?.userManagement
|
|
70
105
|
?? _userManagement
|
|
71
106
|
?? {
|
|
@@ -95,13 +130,6 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
95
130
|
onAnalyticsEvent
|
|
96
131
|
}), []);
|
|
97
132
|
|
|
98
|
-
const accessResponse = useProjectLog({
|
|
99
|
-
apiKey,
|
|
100
|
-
authController,
|
|
101
|
-
dataSourceDelegate,
|
|
102
|
-
plugins
|
|
103
|
-
});
|
|
104
|
-
|
|
105
133
|
/**
|
|
106
134
|
* Controller in charge of fetching and persisting data
|
|
107
135
|
*/
|
|
@@ -112,27 +140,42 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
112
140
|
authController
|
|
113
141
|
});
|
|
114
142
|
|
|
115
|
-
// Inject plugin translations into the
|
|
143
|
+
// Inject plugin translations into the live i18next instance.
|
|
144
|
+
//
|
|
145
|
+
// The work, and the reason repeat writes have to be suppressed, is in
|
|
146
|
+
// `applyPluginTranslations`. Paused plugins keep their strings: app code
|
|
147
|
+
// may render their components directly, and strings unlock nothing.
|
|
148
|
+
const appliedBundles = useRef<AppliedBundles>(createAppliedBundles());
|
|
149
|
+
const appliedTo = useRef<unknown>(null);
|
|
150
|
+
|
|
116
151
|
useEffect(() => {
|
|
117
152
|
if (!i18n) return;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
153
|
+
|
|
154
|
+
// A different instance has its own store and has been written nothing.
|
|
155
|
+
// Reset here rather than in an effect of its own, which would run after
|
|
156
|
+
// this one and wipe the record of what this pass just wrote.
|
|
157
|
+
if (appliedTo.current !== i18n) {
|
|
158
|
+
appliedTo.current = i18n;
|
|
159
|
+
appliedBundles.current = createAppliedBundles();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let active = true;
|
|
163
|
+
const isActive = () => active;
|
|
164
|
+
const applyFor = (language: string) => applyPluginTranslations({
|
|
165
|
+
i18n,
|
|
166
|
+
plugins: allPlugins,
|
|
167
|
+
language,
|
|
168
|
+
applied: appliedBundles.current,
|
|
169
|
+
isActive
|
|
130
170
|
});
|
|
131
|
-
}, [i18n, plugins]);
|
|
132
171
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
172
|
+
applyFor(i18n.language ?? "en");
|
|
173
|
+
i18n.on("languageChanged", applyFor);
|
|
174
|
+
return () => {
|
|
175
|
+
active = false;
|
|
176
|
+
i18n.off("languageChanged", applyFor);
|
|
177
|
+
};
|
|
178
|
+
}, [i18n, allPlugins]);
|
|
136
179
|
|
|
137
180
|
if (navigationController.navigationLoadingError) {
|
|
138
181
|
return (
|
|
@@ -154,26 +197,8 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
154
197
|
);
|
|
155
198
|
}
|
|
156
199
|
|
|
157
|
-
if (accessResponse?.blocked) {
|
|
158
|
-
return (
|
|
159
|
-
<CenteredView maxWidth={"md"} fullScreen={true} className={"flex flex-col gap-2"}>
|
|
160
|
-
{/* eslint-disable-next-line i18next/no-literal-string */}
|
|
161
|
-
<Typography variant={"h4"} gutterBottom>
|
|
162
|
-
{t("license_needed")}
|
|
163
|
-
</Typography>
|
|
164
|
-
<Typography>
|
|
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
|
-
})()}
|
|
169
|
-
</Typography>
|
|
170
|
-
{accessResponse?.message &&
|
|
171
|
-
<Typography>{accessResponse?.message}</Typography>}
|
|
172
|
-
</CenteredView>
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
200
|
return (
|
|
201
|
+
<LicenseStatusContext.Provider value={licenseStatus}>
|
|
177
202
|
<AnalyticsContext.Provider value={analyticsController}>
|
|
178
203
|
<CustomizationControllerContext.Provider value={customizationController}>
|
|
179
204
|
<UserConfigurationPersistenceContext.Provider
|
|
@@ -209,6 +234,7 @@ export function FireCMS<USER extends User>(props: FireCMSProps<USER>) {
|
|
|
209
234
|
</UserConfigurationPersistenceContext.Provider>
|
|
210
235
|
</CustomizationControllerContext.Provider>
|
|
211
236
|
</AnalyticsContext.Provider>
|
|
237
|
+
</LicenseStatusContext.Provider>
|
|
212
238
|
);
|
|
213
239
|
|
|
214
240
|
}
|
package/src/core/SideDialogs.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useContext, useEffect, useState } from "react";
|
|
1
|
+
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useSideDialogsController } from "../hooks";
|
|
3
3
|
import { EntitySidePanelProps, SideDialogPanelProps } from "../types";
|
|
4
4
|
import { Sheet } from "@firecms/ui";
|
|
@@ -67,6 +67,40 @@ export function SideDialogs() {
|
|
|
67
67
|
</>;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Menus, selects and popovers opened inside a side dialog are dismissed by Radix as
|
|
71
|
+
// soon as the pointer goes down, but the dialog underneath only makes up its mind on
|
|
72
|
+
// the click that follows, when that layer is already gone and the dialog looks like
|
|
73
|
+
// the topmost one. Without this, the click that dismisses an open menu also asks the
|
|
74
|
+
// panel to close, and an edited entity greets the user with the unsaved changes dialog.
|
|
75
|
+
const NESTED_LAYER_SELECTOR = [
|
|
76
|
+
"[data-radix-menu-content]",
|
|
77
|
+
"[data-radix-select-viewport]",
|
|
78
|
+
"[data-radix-popper-content-wrapper] [role=\"dialog\"]",
|
|
79
|
+
"[data-suggestion-menu=\"true\"]"
|
|
80
|
+
].join(",");
|
|
81
|
+
|
|
82
|
+
function useNestedLayerOpenOnPointerDown() {
|
|
83
|
+
const ref = useRef(false);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const onPointerDown = () => {
|
|
86
|
+
ref.current = Array.from(document.querySelectorAll(NESTED_LAYER_SELECTOR))
|
|
87
|
+
.some(element => window.getComputedStyle(element).visibility !== "hidden");
|
|
88
|
+
};
|
|
89
|
+
// the keyboard dismisses one layer at a time on its own, so a stale pointer
|
|
90
|
+
// interaction must not swallow a close requested with Escape
|
|
91
|
+
const onKeyDown = () => {
|
|
92
|
+
ref.current = false;
|
|
93
|
+
};
|
|
94
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
95
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
96
|
+
return () => {
|
|
97
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
98
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
99
|
+
};
|
|
100
|
+
}, []);
|
|
101
|
+
return ref;
|
|
102
|
+
}
|
|
103
|
+
|
|
70
104
|
function SideDialogView({
|
|
71
105
|
offsetPosition,
|
|
72
106
|
panel
|
|
@@ -82,6 +116,8 @@ function SideDialogView({
|
|
|
82
116
|
|
|
83
117
|
const [pendingClose, setPendingClose] = useState(false);
|
|
84
118
|
|
|
119
|
+
const nestedLayerOpenOnPointerDown = useNestedLayerOpenOnPointerDown();
|
|
120
|
+
|
|
85
121
|
const widthRef = React.useRef<string | undefined>(panel?.width);
|
|
86
122
|
const width = widthRef.current;
|
|
87
123
|
|
|
@@ -144,6 +180,13 @@ function SideDialogView({
|
|
|
144
180
|
// Let Tiptap handle closing the menu first
|
|
145
181
|
return;
|
|
146
182
|
}
|
|
183
|
+
// this interaction started on top of a menu or popover: it dismissed
|
|
184
|
+
// that layer, it was not a request to close the panel. The flag is
|
|
185
|
+
// left alone because a single click asks to close more than once;
|
|
186
|
+
// the next pointer or key interaction recomputes it.
|
|
187
|
+
if (nestedLayerOpenOnPointerDown.current) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
147
190
|
onCloseRequest();
|
|
148
191
|
}
|
|
149
192
|
}}
|
package/src/core/index.tsx
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { i18n } from "i18next";
|
|
2
|
+
import { FireCMSPlugin, PluginTranslations } from "../types";
|
|
3
|
+
|
|
4
|
+
const FIRECMS_NS = "firecms_core";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Record of which bundles have already been written to which locale, so the
|
|
8
|
+
* same one is never written twice.
|
|
9
|
+
*
|
|
10
|
+
* Plugin translation objects are module-level constants and a lazily loaded one
|
|
11
|
+
* resolves to the same module object every time, so identity is a sound test.
|
|
12
|
+
*/
|
|
13
|
+
export type AppliedBundles = Map<string, Set<object>>;
|
|
14
|
+
|
|
15
|
+
export function createAppliedBundles(): AppliedBundles {
|
|
16
|
+
return new Map();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Write the plugins' strings for one language into a live i18next instance.
|
|
21
|
+
*
|
|
22
|
+
* Only the language in use is resolved, plus English, which every other language
|
|
23
|
+
* falls back to — a locale given as a function is a bundle that has not been
|
|
24
|
+
* downloaded yet, and downloading all of them would undo the point of deferring
|
|
25
|
+
* them. Plain records are applied for every locale, as before: they are already
|
|
26
|
+
* in the bundle, so there is nothing to save by holding them back.
|
|
27
|
+
*
|
|
28
|
+
* `applied` is what stops the injection feeding itself. A write raises a store
|
|
29
|
+
* event; the event repaints everything subscribed to this instance, including
|
|
30
|
+
* the component that owns the `plugins` array — the collection editor and SaaS
|
|
31
|
+
* plugin hooks both call `useTranslation` — and that array is an inline literal,
|
|
32
|
+
* so its fresh identity re-runs the effect that called this. react-i18next
|
|
33
|
+
* happens to stop re-rendering after one pass, but that is a bail-out rather
|
|
34
|
+
* than a guarantee, and the redundant writes are worth avoiding regardless.
|
|
35
|
+
*
|
|
36
|
+
* Returns a function that reports whether the caller is still mounted, so a
|
|
37
|
+
* bundle that arrives after unmount is dropped rather than written.
|
|
38
|
+
*/
|
|
39
|
+
export function applyPluginTranslations({
|
|
40
|
+
i18n,
|
|
41
|
+
plugins,
|
|
42
|
+
language,
|
|
43
|
+
applied,
|
|
44
|
+
isActive = () => true
|
|
45
|
+
}: {
|
|
46
|
+
i18n: i18n;
|
|
47
|
+
plugins?: FireCMSPlugin<any, any, any>[];
|
|
48
|
+
language: string;
|
|
49
|
+
applied: AppliedBundles;
|
|
50
|
+
isActive?: () => boolean;
|
|
51
|
+
}) {
|
|
52
|
+
const wanted = new Set([language.split("-")[0], "en"]);
|
|
53
|
+
|
|
54
|
+
const add = (locale: string, bundle: PluginTranslations) => {
|
|
55
|
+
if (!isActive()) return;
|
|
56
|
+
let written = applied.get(locale);
|
|
57
|
+
if (!written) {
|
|
58
|
+
written = new Set();
|
|
59
|
+
applied.set(locale, written);
|
|
60
|
+
}
|
|
61
|
+
if (written.has(bundle)) return;
|
|
62
|
+
written.add(bundle);
|
|
63
|
+
i18n.addResourceBundle(
|
|
64
|
+
locale,
|
|
65
|
+
FIRECMS_NS,
|
|
66
|
+
bundle,
|
|
67
|
+
true, // deep merge
|
|
68
|
+
true // overwrite
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
plugins?.forEach((plugin) => {
|
|
73
|
+
if (!plugin.i18n) return;
|
|
74
|
+
Object.entries(plugin.i18n).forEach(([locale, translations]) => {
|
|
75
|
+
if (typeof translations === "function") {
|
|
76
|
+
if (!wanted.has(locale.split("-")[0])) return;
|
|
77
|
+
translations()
|
|
78
|
+
.then((bundle) => add(locale, bundle))
|
|
79
|
+
.catch(() => {
|
|
80
|
+
// A locale that fails to load falls back to English.
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
add(locale, translations);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { AccessResponse, FireCMSPlugin } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keys of the plugins that need a FireCMS PRO license.
|
|
5
|
+
*
|
|
6
|
+
* Kept in sync by hand with `PRO_PLUGIN_KEYS` in
|
|
7
|
+
* `saas_backend/functions/src/business_rules/licenses.ts`, which decides from
|
|
8
|
+
* the same list whether a project needs a license. Change both together.
|
|
9
|
+
*
|
|
10
|
+
* @group Core
|
|
11
|
+
*/
|
|
12
|
+
export const PRO_PLUGIN_KEYS: readonly string[] = [
|
|
13
|
+
"collection_editor",
|
|
14
|
+
"user_management",
|
|
15
|
+
"import_export",
|
|
16
|
+
"import",
|
|
17
|
+
"export",
|
|
18
|
+
"entity_history",
|
|
19
|
+
"data_enhancement",
|
|
20
|
+
"datatalk"
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* PRO plugins that stay mounted while PRO is paused. `user_management` supplies
|
|
25
|
+
* sign-in checks and roles, so removing it would lock users out of the CMS
|
|
26
|
+
* instead of pausing a feature; its own Users and Roles views show a notice.
|
|
27
|
+
*/
|
|
28
|
+
const KEPT_WHILE_PAUSED: readonly string[] = ["user_management"];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether PRO features are paused for this license status. Any `blocked`
|
|
32
|
+
* response pauses them, whatever its `licenseState` (`expired` and
|
|
33
|
+
* `invalid_project` both come with it). An unknown status pauses nothing.
|
|
34
|
+
*
|
|
35
|
+
* @group Core
|
|
36
|
+
*/
|
|
37
|
+
export function isProPaused(licenseStatus?: AccessResponse | null): boolean {
|
|
38
|
+
return Boolean(licenseStatus?.blocked);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether this plugin is dropped while PRO is paused.
|
|
43
|
+
*
|
|
44
|
+
* @group Core
|
|
45
|
+
*/
|
|
46
|
+
export function isPausedPlugin(plugin: Pick<FireCMSPlugin, "key">): boolean {
|
|
47
|
+
return PRO_PLUGIN_KEYS.includes(plugin.key) && !KEPT_WHILE_PAUSED.includes(plugin.key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where to get or fix the license for this status: the server's
|
|
52
|
+
* `subscribeUrl` when it is an https URL, otherwise the license page on
|
|
53
|
+
* app.firecms.co with the project, when known.
|
|
54
|
+
*
|
|
55
|
+
* @group Core
|
|
56
|
+
*/
|
|
57
|
+
export function getLicenseSubscribeUrl(licenseStatus?: AccessResponse | null): string {
|
|
58
|
+
const subscribeUrl = licenseStatus?.subscribeUrl;
|
|
59
|
+
if (subscribeUrl && /^https:\/\//i.test(subscribeUrl))
|
|
60
|
+
return subscribeUrl;
|
|
61
|
+
const params = new URLSearchParams({ intent: "pro" });
|
|
62
|
+
if (licenseStatus?.projectId) params.set("projectId", licenseStatus.projectId);
|
|
63
|
+
return `https://app.firecms.co/subscriptions?${params.toString()}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The plugins that stay active under this license status. Returns the same
|
|
68
|
+
* array when nothing is dropped, so it can feed memo and effect dependencies.
|
|
69
|
+
*
|
|
70
|
+
* @group Core
|
|
71
|
+
*/
|
|
72
|
+
export function filterPausedPlugins<P extends Pick<FireCMSPlugin, "key">>(
|
|
73
|
+
plugins: P[] | undefined,
|
|
74
|
+
licenseStatus?: AccessResponse | null
|
|
75
|
+
): P[] | undefined {
|
|
76
|
+
if (!plugins || !isProPaused(licenseStatus)) return plugins;
|
|
77
|
+
const active = plugins.filter(plugin => !isPausedPlugin(plugin));
|
|
78
|
+
return active.length === plugins.length ? plugins : active;
|
|
79
|
+
}
|
|
@@ -198,11 +198,14 @@ const autocompleteSuggestionItem: SuggestionItem = {
|
|
|
198
198
|
let buffer = "";
|
|
199
199
|
const result = await aiController.autocomplete(textBeforeCursor, textAfterCursor, (delta) => {
|
|
200
200
|
buffer += delta;
|
|
201
|
-
if (delta.length !== 0) {
|
|
201
|
+
if (delta.length !== 0 && !view.isDestroyed) {
|
|
202
202
|
textLoadingCommands.toggleLoadingDecoration(view.state, view.dispatch, buffer);
|
|
203
203
|
}
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
// The editor may have been unmounted while the completion streamed in.
|
|
207
|
+
if (view.isDestroyed) return;
|
|
208
|
+
|
|
206
209
|
// Insert parsed text result at cursor natively
|
|
207
210
|
try {
|
|
208
211
|
// The AI controller might stream literal "\n" characters depending on its JSON decoding layer.
|
|
@@ -4,9 +4,20 @@ import { Plugin, PluginKey } from "prosemirror-state";
|
|
|
4
4
|
|
|
5
5
|
export type UploadFn = (image: File) => Promise<string>;
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Shows the image as a placeholder at `pos`, uploads it, and swaps the
|
|
9
|
+
* placeholder for the uploaded image once it has loaded.
|
|
10
|
+
*
|
|
11
|
+
* Every step after the first runs asynchronously — the FileReader callback that
|
|
12
|
+
* calls this, the upload, and the preload of the uploaded image — and the editor
|
|
13
|
+
* can be unmounted during any of them (the user closes the entity or navigates
|
|
14
|
+
* away while an image is still uploading). Dispatching on a destroyed
|
|
15
|
+
* `EditorView` throws inside ProseMirror (`docView` is null), so each step
|
|
16
|
+
* checks the view is still alive before touching it.
|
|
17
|
+
*/
|
|
7
18
|
export async function onFileRead(view: EditorView, readerEvent: ProgressEvent<FileReader>, pos: number, upload: UploadFn, image: File) {
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
if (view.isDestroyed) return;
|
|
10
21
|
|
|
11
22
|
// @ts-ignore
|
|
12
23
|
const plugin = view.state.plugins.find((p: Plugin) => p.key === ImagePluginKey.key);
|
|
@@ -31,7 +42,12 @@ export async function onFileRead(view: EditorView, readerEvent: ProgressEvent<Fi
|
|
|
31
42
|
const src = await upload(image);
|
|
32
43
|
console.debug("Uploaded image", src);
|
|
33
44
|
|
|
45
|
+
// The upload is kept (the caller stored the file), but there is no longer
|
|
46
|
+
// an editor to put it in.
|
|
47
|
+
if (view.isDestroyed) return;
|
|
48
|
+
|
|
34
49
|
const replacePlaceholder = () => {
|
|
50
|
+
if (view.isDestroyed) return;
|
|
35
51
|
// Retrieve the LATEST state after the async upload
|
|
36
52
|
let currentDecos = plugin.getState(view.state) as DecorationSet;
|
|
37
53
|
const foundDecos = currentDecos.find(undefined, undefined, spec => spec.id === decoId);
|
|
@@ -58,6 +58,11 @@ export function useProseMirror({ initialContent, editable = true, handleImageUpl
|
|
|
58
58
|
state: defaultState,
|
|
59
59
|
editable: () => editable,
|
|
60
60
|
dispatchTransaction: (tr: Transaction) => {
|
|
61
|
+
// Async work (image uploads and their preload, AI autocomplete
|
|
62
|
+
// streams) can finish after the editor unmounted. Applying a
|
|
63
|
+
// transaction to a destroyed view throws inside ProseMirror
|
|
64
|
+
// (`docView` is null), so drop it: there is nothing to update.
|
|
65
|
+
if (editorView.isDestroyed) return;
|
|
61
66
|
const newState = editorView.state.apply(tr);
|
|
62
67
|
editorView.updateState(newState);
|
|
63
68
|
setState(newState);
|