From 453e9dd1e8b6430d1ae9b1b48c8109962ed6e6ed Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Sun, 16 Nov 2025 14:32:09 +0100 Subject: [PATCH 1/9] Restructure rendering components --- src/renderer/App.tsx | 2 +- src/renderer/EditorRoutes.tsx | 18 +++++++++--------- src/renderer/components/FullSizeLoader.css | 7 ------- src/renderer/components/TextEditor.tsx | 2 +- src/renderer/components/WithToolsetConfig.tsx | 10 +++++----- .../components/{ => common}/ErrorAlert.tsx | 0 .../components/common/FullSizeLoader.css | 7 +++++++ .../components/{ => common}/FullSizeLoader.tsx | 0 .../content/ResourceSelectorModal.tsx | 2 +- .../components/{ => layout}/EditorContent.tsx | 6 +++--- .../components/{ => layout}/EditorLayout.css | 0 .../components/{ => layout}/EditorLayout.tsx | 6 +++--- .../{ => layout}/FullSizeDialogLayout.css | 0 .../{ => layout}/FullSizeDialogLayout.tsx | 0 src/renderer/components/selectedMod/NewMod.tsx | 6 +++--- .../components/selectedMod/SelectMod.tsx | 6 +++--- .../components/selectedMod/WithSelectedMod.tsx | 6 +++--- .../components/{ => visual}/JsonForm.tsx | 16 ++++++++-------- .../components/{ => visual}/JsonItemsForm.css | 0 .../components/{ => visual}/JsonItemsForm.tsx | 16 ++++++++-------- .../{ => visual}/MovementCostsForm.tsx | 14 +++++++------- .../{ => visual}/SamSitesAirControlForm.tsx | 12 ++++++------ .../{ => visual}/StrategicMapForm.tsx | 18 +++++++++--------- .../{ => visual}/form/AddNewButton.tsx | 0 .../{ => visual}/form/HostPathWidget.tsx | 2 +- .../form/InventoryGraphicsField.tsx | 6 +++--- .../{ => visual}/form/JsonFormHeader.tsx | 2 +- .../{ => visual/form}/JsonSchemaForm.tsx | 0 .../form/MultiSectorSelectorWidget.tsx | 2 +- .../{ => visual}/form/RemoveButton.tsx | 4 ++-- .../form/ResourceReferenceWidget.tsx | 8 ++++---- .../form/StringReferenceWidget.tsx | 6 +++--- 32 files changed, 92 insertions(+), 92 deletions(-) delete mode 100644 src/renderer/components/FullSizeLoader.css rename src/renderer/components/{ => common}/ErrorAlert.tsx (100%) create mode 100644 src/renderer/components/common/FullSizeLoader.css rename src/renderer/components/{ => common}/FullSizeLoader.tsx (100%) rename src/renderer/components/{ => layout}/EditorContent.tsx (97%) rename src/renderer/components/{ => layout}/EditorLayout.css (100%) rename src/renderer/components/{ => layout}/EditorLayout.tsx (94%) rename src/renderer/components/{ => layout}/FullSizeDialogLayout.css (100%) rename src/renderer/components/{ => layout}/FullSizeDialogLayout.tsx (100%) rename src/renderer/components/{ => visual}/JsonForm.tsx (78%) rename src/renderer/components/{ => visual}/JsonItemsForm.css (100%) rename src/renderer/components/{ => visual}/JsonItemsForm.tsx (92%) rename src/renderer/components/{ => visual}/MovementCostsForm.tsx (94%) rename src/renderer/components/{ => visual}/SamSitesAirControlForm.tsx (93%) rename src/renderer/components/{ => visual}/StrategicMapForm.tsx (92%) rename src/renderer/components/{ => visual}/form/AddNewButton.tsx (100%) rename src/renderer/components/{ => visual}/form/HostPathWidget.tsx (96%) rename src/renderer/components/{ => visual}/form/InventoryGraphicsField.tsx (92%) rename src/renderer/components/{ => visual}/form/JsonFormHeader.tsx (94%) rename src/renderer/components/{ => visual/form}/JsonSchemaForm.tsx (100%) rename src/renderer/components/{ => visual}/form/MultiSectorSelectorWidget.tsx (98%) rename src/renderer/components/{ => visual}/form/RemoveButton.tsx (91%) rename src/renderer/components/{ => visual}/form/ResourceReferenceWidget.tsx (91%) rename src/renderer/components/{ => visual}/form/StringReferenceWidget.tsx (97%) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5e0d7cc..20b6358 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6,7 +6,7 @@ import { ROUTES } from './EditorRoutes'; import './App.css'; import { WithToolsetConfig } from './components/WithToolsetConfig'; import { WithSelectedMod } from './components/selectedMod/WithSelectedMod'; -import { EditorLayout } from './components/EditorLayout'; +import { EditorLayout } from './components/layout/EditorLayout'; import { Provider } from 'react-redux'; import { appStore } from './state/store'; import { ListenAll } from './components/events/ListenAll'; diff --git a/src/renderer/EditorRoutes.tsx b/src/renderer/EditorRoutes.tsx index 95de70c..3b635da 100644 --- a/src/renderer/EditorRoutes.tsx +++ b/src/renderer/EditorRoutes.tsx @@ -12,14 +12,14 @@ import { stringReferenceToMercProfiles, stringReferenceToTowns, stringReferenceToWeapons, -} from './components/form/StringReferenceWidget'; -import { JsonForm } from './components/JsonForm'; -import { JsonItemsForm } from './components/JsonItemsForm'; +} from './components/visual/form/StringReferenceWidget'; +import { JsonForm } from './components/visual/JsonForm'; +import { JsonItemsForm } from './components/visual/JsonItemsForm'; import { JsonStrategicMapForm, makeStrategicMapFormPropsForProperties, makeStrategicMapFormPropsForProperty, -} from './components/StrategicMapForm'; +} from './components/visual/StrategicMapForm'; import { Dashboard } from './components/Dashboard'; import { MercPreview } from './components/content/MercPreview'; import { ItemPreview } from './components/content/ItemPreview'; @@ -27,15 +27,15 @@ import { makeResourceReference, resourceReferenceToGraphics, resourceReferenceToSound, -} from './components/form/ResourceReferenceWidget'; +} from './components/visual/form/ResourceReferenceWidget'; import { StiPreview } from './components/content/StiPreview'; import { ResourceType } from './lib/resourceType'; -import { makeMultiSectorSelectorWidget } from './components/form/MultiSectorSelectorWidget'; +import { makeMultiSectorSelectorWidget } from './components/visual/form/MultiSectorSelectorWidget'; import { mergeDeep } from 'remeda'; import { UiSchema } from '@rjsf/utils'; -import { InventoryGraphicsField } from './components/form/InventoryGraphicsField'; -import { SamSitesAirControlForm } from './components/SamSitesAirControlForm'; -import { MovementCostsForm } from './components/MovementCostsForm'; +import { InventoryGraphicsField } from './components/visual/form/InventoryGraphicsField'; +import { SamSitesAirControlForm } from './components/visual/SamSitesAirControlForm'; +import { MovementCostsForm } from './components/visual/MovementCostsForm'; import { NormalizedSectorId } from './components/content/StrategicMap'; const baseItemProps = [ diff --git a/src/renderer/components/FullSizeLoader.css b/src/renderer/components/FullSizeLoader.css deleted file mode 100644 index 9e0c32a..0000000 --- a/src/renderer/components/FullSizeLoader.css +++ /dev/null @@ -1,7 +0,0 @@ -.full-size-loader { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} \ No newline at end of file diff --git a/src/renderer/components/TextEditor.tsx b/src/renderer/components/TextEditor.tsx index 779432b..9b5a080 100644 --- a/src/renderer/components/TextEditor.tsx +++ b/src/renderer/components/TextEditor.tsx @@ -6,7 +6,7 @@ import { useFileText, } from '../hooks/files'; import * as monaco from 'monaco-editor'; -import { FullSizeLoader } from './FullSizeLoader'; +import { FullSizeLoader } from './common/FullSizeLoader'; import { useCallback, useEffect } from 'react'; import { toJSONSchema } from 'zod'; import { JSON_PATCH_SCHEMA } from '../../common/invokables/jsons'; diff --git a/src/renderer/components/WithToolsetConfig.tsx b/src/renderer/components/WithToolsetConfig.tsx index 17440d6..2de0509 100644 --- a/src/renderer/components/WithToolsetConfig.tsx +++ b/src/renderer/components/WithToolsetConfig.tsx @@ -1,11 +1,11 @@ import { PropsWithChildren, useCallback, useEffect, useState } from 'react'; import { Button, Typography } from 'antd'; import { IChangeEvent } from '@rjsf/core'; -import { FullSizeLoader } from './FullSizeLoader'; -import { JsonSchemaForm } from './JsonSchemaForm'; -import { FullSizeDialogLayout } from './FullSizeDialogLayout'; -import { ErrorAlert } from './ErrorAlert'; -import { HostPathWidget } from './form/HostPathWidget'; +import { FullSizeLoader } from './common/FullSizeLoader'; +import { JsonSchemaForm } from './visual/form/JsonSchemaForm'; +import { FullSizeDialogLayout } from './layout/FullSizeDialogLayout'; +import { ErrorAlert } from './common/ErrorAlert'; +import { HostPathWidget } from './visual/form/HostPathWidget'; import { useToolsetConfig } from '../hooks/useToolsetConfig'; import { toJSONSchema } from 'zod'; import { diff --git a/src/renderer/components/ErrorAlert.tsx b/src/renderer/components/common/ErrorAlert.tsx similarity index 100% rename from src/renderer/components/ErrorAlert.tsx rename to src/renderer/components/common/ErrorAlert.tsx diff --git a/src/renderer/components/common/FullSizeLoader.css b/src/renderer/components/common/FullSizeLoader.css new file mode 100644 index 0000000..6735e1a --- /dev/null +++ b/src/renderer/components/common/FullSizeLoader.css @@ -0,0 +1,7 @@ +.full-size-loader { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/renderer/components/FullSizeLoader.tsx b/src/renderer/components/common/FullSizeLoader.tsx similarity index 100% rename from src/renderer/components/FullSizeLoader.tsx rename to src/renderer/components/common/FullSizeLoader.tsx diff --git a/src/renderer/components/content/ResourceSelectorModal.tsx b/src/renderer/components/content/ResourceSelectorModal.tsx index 89d1998..b5ce9dc 100644 --- a/src/renderer/components/content/ResourceSelectorModal.tsx +++ b/src/renderer/components/content/ResourceSelectorModal.tsx @@ -14,7 +14,7 @@ import { FunctionComponent, useEffect, } from 'react'; -import { ErrorAlert } from '../ErrorAlert'; +import { ErrorAlert } from '../common/ErrorAlert'; import { SoundPreview } from './SoundPreview'; import { useImageMetadata } from '../../hooks/useImageMetadata'; import { StiPreview } from './StiPreview'; diff --git a/src/renderer/components/EditorContent.tsx b/src/renderer/components/layout/EditorContent.tsx similarity index 97% rename from src/renderer/components/EditorContent.tsx rename to src/renderer/components/layout/EditorContent.tsx index a2ecee7..7d64d1a 100644 --- a/src/renderer/components/EditorContent.tsx +++ b/src/renderer/components/layout/EditorContent.tsx @@ -2,21 +2,21 @@ import { ExclamationCircleOutlined, SaveOutlined } from '@ant-design/icons'; import { Button, Flex, Select, Typography } from 'antd'; import { ReactNode, memo, useCallback, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { useAppDispatch } from '../hooks/state'; +import { useAppDispatch } from '../../hooks/state'; import { changeSaveMode, changeEditMode, EditMode, persistJSON, SaveMode, -} from '../state/files'; +} from '../../state/files'; import { useFileEditMode, useFileModified, useFilePersistingError, useFileSaveMode, useFileSaving, -} from '../hooks/files'; +} from '../../hooks/files'; const SAVE_MODE_SELECT_OPTIONS = [ { diff --git a/src/renderer/components/EditorLayout.css b/src/renderer/components/layout/EditorLayout.css similarity index 100% rename from src/renderer/components/EditorLayout.css rename to src/renderer/components/layout/EditorLayout.css diff --git a/src/renderer/components/EditorLayout.tsx b/src/renderer/components/layout/EditorLayout.tsx similarity index 94% rename from src/renderer/components/EditorLayout.tsx rename to src/renderer/components/layout/EditorLayout.tsx index ce60519..ad90237 100644 --- a/src/renderer/components/EditorLayout.tsx +++ b/src/renderer/components/layout/EditorLayout.tsx @@ -3,9 +3,9 @@ import { NavigateFunction, useLocation, useNavigate } from 'react-router-dom'; import { Badge, Layout, Menu, MenuProps, Space, Typography } from 'antd'; import './EditorLayout.css'; -import { Item, MENU, MenuItem } from '../EditorRoutes'; -import { useAppSelector } from '../hooks/state'; -import { useFileModified } from '../hooks/files'; +import { Item, MENU, MenuItem } from '../../EditorRoutes'; +import { useAppSelector } from '../../hooks/state'; +import { useFileModified } from '../../hooks/files'; type ItemType = NonNullable[number]; diff --git a/src/renderer/components/FullSizeDialogLayout.css b/src/renderer/components/layout/FullSizeDialogLayout.css similarity index 100% rename from src/renderer/components/FullSizeDialogLayout.css rename to src/renderer/components/layout/FullSizeDialogLayout.css diff --git a/src/renderer/components/FullSizeDialogLayout.tsx b/src/renderer/components/layout/FullSizeDialogLayout.tsx similarity index 100% rename from src/renderer/components/FullSizeDialogLayout.tsx rename to src/renderer/components/layout/FullSizeDialogLayout.tsx diff --git a/src/renderer/components/selectedMod/NewMod.tsx b/src/renderer/components/selectedMod/NewMod.tsx index 6274499..f40bd85 100644 --- a/src/renderer/components/selectedMod/NewMod.tsx +++ b/src/renderer/components/selectedMod/NewMod.tsx @@ -1,11 +1,11 @@ import { useCallback, useMemo, useState } from 'react'; import { Button, Typography } from 'antd'; import { IChangeEvent } from '@rjsf/core'; -import { JsonSchemaForm } from '../JsonSchemaForm'; +import { JsonSchemaForm } from '../visual/form/JsonSchemaForm'; import { useAppSelector } from '../../hooks/state'; -import { FullSizeDialogLayout } from '../FullSizeDialogLayout'; +import { FullSizeDialogLayout } from '../layout/FullSizeDialogLayout'; import { Space } from 'antd/lib'; -import { ErrorAlert } from '../ErrorAlert'; +import { ErrorAlert } from '../common/ErrorAlert'; import { useSelectedMod } from '../../hooks/useSelectedMod'; import { selectStracciatellaHome } from '../../state/selectors'; import { toJSONSchema } from 'zod'; diff --git a/src/renderer/components/selectedMod/SelectMod.tsx b/src/renderer/components/selectedMod/SelectMod.tsx index 09aba58..0c3e604 100644 --- a/src/renderer/components/selectedMod/SelectMod.tsx +++ b/src/renderer/components/selectedMod/SelectMod.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useState } from 'react'; import { List, Button, Typography } from 'antd'; import { NewMod } from './NewMod'; -import { FullSizeDialogLayout } from '../FullSizeDialogLayout'; -import { ErrorAlert } from '../ErrorAlert'; +import { FullSizeDialogLayout } from '../layout/FullSizeDialogLayout'; +import { ErrorAlert } from '../common/ErrorAlert'; import { useMods } from '../../hooks/useMods'; import { useSelectedMod } from '../../hooks/useSelectedMod'; -import { FullSizeLoader } from '../FullSizeLoader'; +import { FullSizeLoader } from '../common/FullSizeLoader'; import { EditableMod } from '../../../common/invokables/mods'; export function SelectMod() { diff --git a/src/renderer/components/selectedMod/WithSelectedMod.tsx b/src/renderer/components/selectedMod/WithSelectedMod.tsx index 19704b9..0be920f 100644 --- a/src/renderer/components/selectedMod/WithSelectedMod.tsx +++ b/src/renderer/components/selectedMod/WithSelectedMod.tsx @@ -1,8 +1,8 @@ import { PropsWithChildren, useEffect } from 'react'; import { SelectMod } from './SelectMod'; -import { FullSizeLoader } from '../FullSizeLoader'; -import { FullSizeDialogLayout } from '../FullSizeDialogLayout'; -import { ErrorAlert } from '../ErrorAlert'; +import { FullSizeLoader } from '../common/FullSizeLoader'; +import { FullSizeDialogLayout } from '../layout/FullSizeDialogLayout'; +import { ErrorAlert } from '../common/ErrorAlert'; import { useSelectedMod } from '../../hooks/useSelectedMod'; import { ConfirmCloseMod } from './ConfirmCloseMod'; diff --git a/src/renderer/components/JsonForm.tsx b/src/renderer/components/visual/JsonForm.tsx similarity index 78% rename from src/renderer/components/JsonForm.tsx rename to src/renderer/components/visual/JsonForm.tsx index 4d5cdfb..33ec8f6 100644 --- a/src/renderer/components/JsonForm.tsx +++ b/src/renderer/components/visual/JsonForm.tsx @@ -1,16 +1,16 @@ import { useCallback, useMemo } from 'react'; import { IChangeEvent } from '@rjsf/core'; import { UiSchema } from '@rjsf/utils'; -import { JsonSchemaForm } from './JsonSchemaForm'; -import { FullSizeLoader } from './FullSizeLoader'; -import { EditorContent } from './EditorContent'; +import { JsonSchemaForm } from './form/JsonSchemaForm'; +import { FullSizeLoader } from '../common/FullSizeLoader'; +import { EditorContent } from '../layout/EditorContent'; import { JsonFormHeader } from './form/JsonFormHeader'; -import { useFileLoading, useFileSchema } from '../hooks/files'; -import { useFileLoadingError } from '../hooks/files'; -import { useFileJson } from '../hooks/files'; -import { ErrorAlert } from './ErrorAlert'; +import { useFileLoading, useFileSchema } from '../../hooks/files'; +import { useFileLoadingError } from '../../hooks/files'; +import { useFileJson } from '../../hooks/files'; +import { ErrorAlert } from '../common/ErrorAlert'; import { miniSerializeError } from '@reduxjs/toolkit'; -import { TextEditorOr } from './TextEditor'; +import { TextEditorOr } from '../TextEditor'; export interface JsonFormProps { file: string; diff --git a/src/renderer/components/JsonItemsForm.css b/src/renderer/components/visual/JsonItemsForm.css similarity index 100% rename from src/renderer/components/JsonItemsForm.css rename to src/renderer/components/visual/JsonItemsForm.css diff --git a/src/renderer/components/JsonItemsForm.tsx b/src/renderer/components/visual/JsonItemsForm.tsx similarity index 92% rename from src/renderer/components/JsonItemsForm.tsx rename to src/renderer/components/visual/JsonItemsForm.tsx index 4ae733a..579b8ee 100644 --- a/src/renderer/components/JsonItemsForm.tsx +++ b/src/renderer/components/visual/JsonItemsForm.tsx @@ -1,11 +1,11 @@ import { useCallback, useMemo, JSX, memo } from 'react'; import { Collapse, Flex } from 'antd'; -import { JsonSchemaForm } from './JsonSchemaForm'; -import { FullSizeLoader } from './FullSizeLoader'; +import { JsonSchemaForm } from './form/JsonSchemaForm'; +import { FullSizeLoader } from '../common/FullSizeLoader'; import './JsonItemsForm.css'; import { IChangeEvent } from '@rjsf/core'; import { UiSchema } from '@rjsf/utils'; -import { EditorContent } from './EditorContent'; +import { EditorContent } from '../layout/EditorContent'; import { JsonFormHeader } from './form/JsonFormHeader'; import { useFileLoading, @@ -13,11 +13,11 @@ import { useFileJsonItem, useFileJsonItemSchema, useFileJsonNumberOfItems, -} from '../hooks/files'; -import { ErrorAlert } from './ErrorAlert'; -import { TextEditorOr } from './TextEditor'; -import { useAppDispatch } from '../hooks/state'; -import { addJsonItem } from '../state/files'; +} from '../../hooks/files'; +import { ErrorAlert } from '../common/ErrorAlert'; +import { TextEditorOr } from '../TextEditor'; +import { useAppDispatch } from '../../hooks/state'; +import { addJsonItem } from '../../state/files'; import { AddNewButton } from './form/AddNewButton'; import { RemoveButton } from './form/RemoveButton'; diff --git a/src/renderer/components/MovementCostsForm.tsx b/src/renderer/components/visual/MovementCostsForm.tsx similarity index 94% rename from src/renderer/components/MovementCostsForm.tsx rename to src/renderer/components/visual/MovementCostsForm.tsx index 560b9fd..83aba00 100644 --- a/src/renderer/components/MovementCostsForm.tsx +++ b/src/renderer/components/visual/MovementCostsForm.tsx @@ -3,20 +3,20 @@ import { useFileJson, useFileLoading, useFileLoadingError, -} from '../hooks/files'; +} from '../../hooks/files'; import { coordsFromSectorIdString, NormalizedSectorId, StrategicMap, -} from './content/StrategicMap'; -import { EditorContent } from './EditorContent'; -import { ErrorAlert } from './ErrorAlert'; -import { FullSizeLoader } from './FullSizeLoader'; -import { TextEditorOr } from './TextEditor'; +} from '../content/StrategicMap'; +import { EditorContent } from '../layout/EditorContent'; +import { ErrorAlert } from '../common/ErrorAlert'; +import { FullSizeLoader } from '../common/FullSizeLoader'; +import { TextEditorOr } from '../TextEditor'; import { Flex, Typography } from 'antd'; import { JsonFormHeader } from './form/JsonFormHeader'; import { JsonSchema } from 'src/common/invokables/jsons'; -import { JsonSchemaForm } from './JsonSchemaForm'; +import { JsonSchemaForm } from './form/JsonSchemaForm'; import { UiSchema } from '@rjsf/utils'; import { IChangeEvent } from '@rjsf/core'; import { clone } from 'remeda'; diff --git a/src/renderer/components/SamSitesAirControlForm.tsx b/src/renderer/components/visual/SamSitesAirControlForm.tsx similarity index 93% rename from src/renderer/components/SamSitesAirControlForm.tsx rename to src/renderer/components/visual/SamSitesAirControlForm.tsx index 1cb2d90..f9dcb61 100644 --- a/src/renderer/components/SamSitesAirControlForm.tsx +++ b/src/renderer/components/visual/SamSitesAirControlForm.tsx @@ -3,11 +3,11 @@ import { useFileJson, useFileLoading, useFileLoadingError, -} from '../hooks/files'; -import { EditorContent } from './EditorContent'; -import { ErrorAlert } from './ErrorAlert'; -import { FullSizeLoader } from './FullSizeLoader'; -import { TextEditorOr } from './TextEditor'; +} from '../../hooks/files'; +import { EditorContent } from '../layout/EditorContent'; +import { ErrorAlert } from '../common/ErrorAlert'; +import { FullSizeLoader } from '../common/FullSizeLoader'; +import { TextEditorOr } from '../TextEditor'; import { useMemo, useState } from 'react'; import { coordsFromSectorIdString, @@ -15,7 +15,7 @@ import { NormalizedSectorId, sectorIdStringFromCoords, StrategicMap, -} from './content/StrategicMap'; +} from '../content/StrategicMap'; import { Badge, Flex, Select, Space, Typography } from 'antd'; import { JsonFormHeader } from './form/JsonFormHeader'; diff --git a/src/renderer/components/StrategicMapForm.tsx b/src/renderer/components/visual/StrategicMapForm.tsx similarity index 92% rename from src/renderer/components/StrategicMapForm.tsx rename to src/renderer/components/visual/StrategicMapForm.tsx index 00d3f91..57998ca 100644 --- a/src/renderer/components/StrategicMapForm.tsx +++ b/src/renderer/components/visual/StrategicMapForm.tsx @@ -2,14 +2,14 @@ import { useCallback, useMemo, useState } from 'react'; import { Space, Typography } from 'antd'; import { UiSchema } from '@rjsf/utils'; -import { FullSizeLoader } from './FullSizeLoader'; +import { FullSizeLoader } from '../common/FullSizeLoader'; import { DEFAULT_HIGHLIGHT_COLOR, NormalizedSectorId, StrategicMap, -} from './content/StrategicMap'; -import { JsonSchemaForm } from './JsonSchemaForm'; -import { EditorContent } from './EditorContent'; +} from '../content/StrategicMap'; +import { JsonSchemaForm } from './form/JsonSchemaForm'; +import { EditorContent } from '../layout/EditorContent'; import { JsonFormHeader } from './form/JsonFormHeader'; import { useFileLoadingError, @@ -17,13 +17,13 @@ import { useFileJsonItem, useFileJsonItemSchema, useFileLoading, -} from '../hooks/files'; +} from '../../hooks/files'; import { IChangeEvent } from '@rjsf/core'; -import { ErrorAlert } from './ErrorAlert'; -import { TextEditorOr } from './TextEditor'; +import { ErrorAlert } from '../common/ErrorAlert'; +import { TextEditorOr } from '../TextEditor'; import { AddNewButton } from './form/AddNewButton'; -import { useAppDispatch } from '../hooks/state'; -import { addJsonItem } from '../state/files'; +import { useAppDispatch } from '../../hooks/state'; +import { addJsonItem } from '../../state/files'; import { findIndex, isDeepEqual } from 'remeda'; import { RemoveButton } from './form/RemoveButton'; diff --git a/src/renderer/components/form/AddNewButton.tsx b/src/renderer/components/visual/form/AddNewButton.tsx similarity index 100% rename from src/renderer/components/form/AddNewButton.tsx rename to src/renderer/components/visual/form/AddNewButton.tsx diff --git a/src/renderer/components/form/HostPathWidget.tsx b/src/renderer/components/visual/form/HostPathWidget.tsx similarity index 96% rename from src/renderer/components/form/HostPathWidget.tsx rename to src/renderer/components/visual/form/HostPathWidget.tsx index baeff0f..95dfb0f 100644 --- a/src/renderer/components/form/HostPathWidget.tsx +++ b/src/renderer/components/visual/form/HostPathWidget.tsx @@ -1,7 +1,7 @@ import { WidgetProps } from '@rjsf/utils'; import { Button, Col, Input, Row, Space } from 'antd'; import { useCallback, useState } from 'react'; -import { invoke } from '../../lib/invoke'; +import { invoke } from '../../../lib/invoke'; export function HostPathWidget({ value, onChange }: WidgetProps) { const [modalIsOpen, setModalOpen] = useState(false); diff --git a/src/renderer/components/form/InventoryGraphicsField.tsx b/src/renderer/components/visual/form/InventoryGraphicsField.tsx similarity index 92% rename from src/renderer/components/form/InventoryGraphicsField.tsx rename to src/renderer/components/visual/form/InventoryGraphicsField.tsx index 58b3a21..da25895 100644 --- a/src/renderer/components/form/InventoryGraphicsField.tsx +++ b/src/renderer/components/visual/form/InventoryGraphicsField.tsx @@ -1,10 +1,10 @@ import { FieldProps } from '@rjsf/utils'; import { ResourceReferenceWidget } from './ResourceReferenceWidget'; -import { ResourceType } from '../../lib/resourceType'; +import { ResourceType } from '../../../lib/resourceType'; import { useCallback, useEffect, useMemo } from 'react'; import { Select, Space } from 'antd'; -import { useImageMetadata } from '../../hooks/useImageMetadata'; -import { StiPreview } from '../content/StiPreview'; +import { useImageMetadata } from '../../../hooks/useImageMetadata'; +import { StiPreview } from '../../content/StiPreview'; function SubImageSelector({ file, diff --git a/src/renderer/components/form/JsonFormHeader.tsx b/src/renderer/components/visual/form/JsonFormHeader.tsx similarity index 94% rename from src/renderer/components/form/JsonFormHeader.tsx rename to src/renderer/components/visual/form/JsonFormHeader.tsx index 383e1b3..9fb8170 100644 --- a/src/renderer/components/form/JsonFormHeader.tsx +++ b/src/renderer/components/visual/form/JsonFormHeader.tsx @@ -1,7 +1,7 @@ import { memo, useMemo } from 'react'; import { Typography } from 'antd'; import ReactMarkdown from 'react-markdown'; -import { useFileSchema } from '../../hooks/files'; +import { useFileSchema } from '../../../hooks/files'; interface JsonFormHeaderProps { file: string; diff --git a/src/renderer/components/JsonSchemaForm.tsx b/src/renderer/components/visual/form/JsonSchemaForm.tsx similarity index 100% rename from src/renderer/components/JsonSchemaForm.tsx rename to src/renderer/components/visual/form/JsonSchemaForm.tsx diff --git a/src/renderer/components/form/MultiSectorSelectorWidget.tsx b/src/renderer/components/visual/form/MultiSectorSelectorWidget.tsx similarity index 98% rename from src/renderer/components/form/MultiSectorSelectorWidget.tsx rename to src/renderer/components/visual/form/MultiSectorSelectorWidget.tsx index 23aa7f5..6b8017d 100644 --- a/src/renderer/components/form/MultiSectorSelectorWidget.tsx +++ b/src/renderer/components/visual/form/MultiSectorSelectorWidget.tsx @@ -3,7 +3,7 @@ import { DEFAULT_HIGHLIGHT_COLOR, NormalizedSectorId, StrategicMap, -} from '../content/StrategicMap'; +} from '../../content/StrategicMap'; import { useCallback, useMemo, useState } from 'react'; import { Flex } from 'antd'; import { find, isDeepEqual } from 'remeda'; diff --git a/src/renderer/components/form/RemoveButton.tsx b/src/renderer/components/visual/form/RemoveButton.tsx similarity index 91% rename from src/renderer/components/form/RemoveButton.tsx rename to src/renderer/components/visual/form/RemoveButton.tsx index f5f1dc2..631be62 100644 --- a/src/renderer/components/form/RemoveButton.tsx +++ b/src/renderer/components/visual/form/RemoveButton.tsx @@ -1,8 +1,8 @@ import { DeleteOutlined } from '@ant-design/icons'; import { Button, Modal } from 'antd'; import { MouseEventHandler, useCallback, useState } from 'react'; -import { useAppDispatch } from '../../hooks/state'; -import { removeJsonItem } from '../../state/files'; +import { useAppDispatch } from '../../../hooks/state'; +import { removeJsonItem } from '../../../state/files'; interface RemoveButtonProps { file: string; diff --git a/src/renderer/components/form/ResourceReferenceWidget.tsx b/src/renderer/components/visual/form/ResourceReferenceWidget.tsx similarity index 91% rename from src/renderer/components/form/ResourceReferenceWidget.tsx rename to src/renderer/components/visual/form/ResourceReferenceWidget.tsx index eb0ad35..2f5e518 100644 --- a/src/renderer/components/form/ResourceReferenceWidget.tsx +++ b/src/renderer/components/visual/form/ResourceReferenceWidget.tsx @@ -1,10 +1,10 @@ import { WidgetProps } from '@rjsf/utils'; import { Input, Button, Flex } from 'antd'; import { useCallback, useMemo, useState } from 'react'; -import { ResourceSelectorModal } from '../content/ResourceSelectorModal'; -import { SoundPreview } from '../content/SoundPreview'; -import { StiPreview } from '../content/StiPreview'; -import { ResourceType } from '../../lib/resourceType'; +import { ResourceSelectorModal } from '../../content/ResourceSelectorModal'; +import { SoundPreview } from '../../content/SoundPreview'; +import { StiPreview } from '../../content/StiPreview'; +import { ResourceType } from '../../../lib/resourceType'; interface ResourceReferenceWidgetProps extends WidgetProps { resourceType: ResourceType; diff --git a/src/renderer/components/form/StringReferenceWidget.tsx b/src/renderer/components/visual/form/StringReferenceWidget.tsx similarity index 97% rename from src/renderer/components/form/StringReferenceWidget.tsx rename to src/renderer/components/visual/form/StringReferenceWidget.tsx index 4e4b3a5..4bf9188 100644 --- a/src/renderer/components/form/StringReferenceWidget.tsx +++ b/src/renderer/components/visual/form/StringReferenceWidget.tsx @@ -5,11 +5,11 @@ import { useFilesError, useFilesJson, useFilesLoading, -} from '../../hooks/files'; +} from '../../../hooks/files'; import { BaseOptionType } from 'antd/lib/select'; import { Space } from 'antd/lib'; -import { MercPreview } from '../content/MercPreview'; -import { ItemPreview } from '../content/ItemPreview'; +import { MercPreview } from '../../content/MercPreview'; +import { ItemPreview } from '../../content/ItemPreview'; type PreviewFn = (item: any) => JSX.Element | string | null; From fbdfef0256e43c3b738e9e217ac2554fba4fa38d Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Sun, 16 Nov 2025 14:44:04 +0100 Subject: [PATCH 2/9] Refactor loading components --- .../components/common/FullSizeLoader.css | 7 ------- .../components/common/FullSizeLoader.tsx | 21 ++++++++++++------- src/renderer/components/common/Loader.tsx | 12 +++++++++++ .../components/content/MercPreview.tsx | 5 +++-- .../components/content/StiPreview.tsx | 5 +++-- 5 files changed, 31 insertions(+), 19 deletions(-) delete mode 100644 src/renderer/components/common/FullSizeLoader.css create mode 100644 src/renderer/components/common/Loader.tsx diff --git a/src/renderer/components/common/FullSizeLoader.css b/src/renderer/components/common/FullSizeLoader.css deleted file mode 100644 index 6735e1a..0000000 --- a/src/renderer/components/common/FullSizeLoader.css +++ /dev/null @@ -1,7 +0,0 @@ -.full-size-loader { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} diff --git a/src/renderer/components/common/FullSizeLoader.tsx b/src/renderer/components/common/FullSizeLoader.tsx index 12497b9..4c766ce 100644 --- a/src/renderer/components/common/FullSizeLoader.tsx +++ b/src/renderer/components/common/FullSizeLoader.tsx @@ -1,14 +1,19 @@ -import { Spin } from 'antd'; -import { LoadingOutlined } from '@ant-design/icons'; +import { Flex } from 'antd'; +import { Loader } from './Loader'; -import './FullSizeLoader.css'; +interface FullSizeLoaderProps { + size?: 'small' | 'default' | 'large'; +} -const antIcon = ; +const FLEX_STYLE = { + width: '100%', + height: '100%', +}; -export function FullSizeLoader() { +export function FullSizeLoader({ size = 'large' }: FullSizeLoaderProps) { return ( -
- -
+ + + ); } diff --git a/src/renderer/components/common/Loader.tsx b/src/renderer/components/common/Loader.tsx new file mode 100644 index 0000000..2ecf955 --- /dev/null +++ b/src/renderer/components/common/Loader.tsx @@ -0,0 +1,12 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { Spin } from 'antd'; + +interface LoaderProps { + size?: 'small' | 'default' | 'large'; +} + +const ICON = ; + +export function Loader({ size = 'small' }: LoaderProps) { + return ; +} diff --git a/src/renderer/components/content/MercPreview.tsx b/src/renderer/components/content/MercPreview.tsx index e0823d2..5198954 100644 --- a/src/renderer/components/content/MercPreview.tsx +++ b/src/renderer/components/content/MercPreview.tsx @@ -1,8 +1,9 @@ import { ExclamationOutlined } from '@ant-design/icons'; -import { Image, Flex, Spin } from 'antd'; +import { Image, Flex } from 'antd'; import { useEffect, useMemo } from 'react'; import { useImageFile } from '../../hooks/useImage'; import { useFileJson } from '../../hooks/files'; +import { Loader } from '../common/Loader'; interface MercPreviewProps { profile: string; @@ -50,7 +51,7 @@ export function MercPreview({ profile }: MercPreviewProps) { const image = useMemo(() => { const loading = loading1 || loading2; if (loading) { - return ; + return ; } const error = error1 && error2 ? error1 || error2 : null; if (error) { diff --git a/src/renderer/components/content/StiPreview.tsx b/src/renderer/components/content/StiPreview.tsx index a219611..72938d7 100644 --- a/src/renderer/components/content/StiPreview.tsx +++ b/src/renderer/components/content/StiPreview.tsx @@ -1,7 +1,8 @@ import { ExclamationOutlined } from '@ant-design/icons'; -import { Flex, Image, Spin } from 'antd'; +import { Flex, Image } from 'antd'; import { useEffect, useMemo } from 'react'; import { useImageFile } from '../../hooks/useImage'; +import { Loader } from '../common/Loader'; export function StiPreview({ file, @@ -13,7 +14,7 @@ export function StiPreview({ const { loading, data, error, refresh } = useImageFile(file, subimage); const image = useMemo(() => { if (loading) { - return ; + return ; } if (error) { return ; From 01dc38b0f1850b19b9c894dc6cd0d21c2b86153b Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Sun, 16 Nov 2025 15:05:32 +0100 Subject: [PATCH 3/9] Refactor FullSizeDialogLayout --- .../layout/FullSizeDialogLayout.css | 21 ---------- .../layout/FullSizeDialogLayout.tsx | 41 ++++++++++++++++--- 2 files changed, 36 insertions(+), 26 deletions(-) delete mode 100644 src/renderer/components/layout/FullSizeDialogLayout.css diff --git a/src/renderer/components/layout/FullSizeDialogLayout.css b/src/renderer/components/layout/FullSizeDialogLayout.css deleted file mode 100644 index 083d213..0000000 --- a/src/renderer/components/layout/FullSizeDialogLayout.css +++ /dev/null @@ -1,21 +0,0 @@ -.full-size-dialog-layout-root { - height: 100vh; - width: 100vw; - overflow-x: hidden; - overflow-y: hidden; - - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; -} - -.full-size-dialog-layout-content { - background: white; - max-width: 1024px; - max-height: calc(100% - 20px); - margin: 10px; - padding: 20px; - overflow: auto; - flex-grow: 1; -} diff --git a/src/renderer/components/layout/FullSizeDialogLayout.tsx b/src/renderer/components/layout/FullSizeDialogLayout.tsx index 08f883e..a108ddc 100644 --- a/src/renderer/components/layout/FullSizeDialogLayout.tsx +++ b/src/renderer/components/layout/FullSizeDialogLayout.tsx @@ -1,14 +1,45 @@ -import { Layout } from 'antd'; -import './FullSizeDialogLayout.css'; +import { Flex, theme } from 'antd'; +import { useMemo } from 'react'; + +const FLEX_BASE_STYLE = { + height: '100vh', + width: '100vw', + overflowX: 'hidden', + overflowY: 'hidden', +} as const; + +const DIALOG_STYLE = { + position: 'relative', + background: 'white', + width: 'calc(100% - 20px)', + maxWidth: '1024px', + maxHeight: 'calc(100% - 20px)', + minHeight: '60vh', + margin: '10px', + padding: '20px', + overflow: 'auto', + flexGrow: 1, +} as const; export function FullSizeDialogLayout({ children, }: { children: React.ReactNode; }) { + const { + token: { colorBgLayout }, + } = theme.useToken(); + const FLEX_STYLE = useMemo( + () => ({ + ...FLEX_BASE_STYLE, + backgroundColor: colorBgLayout, + }), + [colorBgLayout], + ); + return ( - -
{children}
-
+ +
{children}
+
); } From 7185f31d75c994d043f66903fc51c4205313ef49 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Mon, 17 Nov 2025 11:47:54 +0100 Subject: [PATCH 4/9] Refactor visual form editors --- src/common/invokables/jsons.ts | 4 +- src/renderer/components/common/ErrorAlert.tsx | 2 +- src/renderer/components/visual/JsonForm.tsx | 71 +++--- .../components/visual/JsonItemsForm.css | 7 - .../components/visual/JsonItemsForm.tsx | 151 +++++-------- .../components/visual/MovementCostsForm.tsx | 128 +++++------ .../visual/SamSitesAirControlForm.tsx | 203 +++++++++--------- .../components/visual/StrategicMapForm.tsx | 129 +++++------ .../visual/VisualFormWithHeader.tsx | 15 ++ .../components/visual/VisualFormWrapper.tsx | 62 ++++++ .../visual/VisualStrategicMapFormWrapper.tsx | 29 +++ src/renderer/hooks/files.tsx | 46 ++-- src/renderer/hooks/useAnyFileLoading.tsx | 11 + src/renderer/hooks/useAnyFileLoadingError.tsx | 16 ++ src/renderer/hooks/useFileJsonDiskValue.tsx | 8 + src/renderer/hooks/useFileJsonItem.tsx | 17 ++ src/renderer/hooks/useFileJsonItemCount.tsx | 10 + src/renderer/hooks/useFileJsonItemSchema.tsx | 8 + src/renderer/hooks/useFileJsonItemUpdate.tsx | 22 ++ src/renderer/hooks/useFileJsonSchema.tsx | 8 + src/renderer/hooks/useFileJsonUpdate.tsx | 19 ++ src/renderer/hooks/useFileJsonValue.tsx | 12 ++ 22 files changed, 548 insertions(+), 430 deletions(-) delete mode 100644 src/renderer/components/visual/JsonItemsForm.css create mode 100644 src/renderer/components/visual/VisualFormWithHeader.tsx create mode 100644 src/renderer/components/visual/VisualFormWrapper.tsx create mode 100644 src/renderer/components/visual/VisualStrategicMapFormWrapper.tsx create mode 100644 src/renderer/hooks/useAnyFileLoading.tsx create mode 100644 src/renderer/hooks/useAnyFileLoadingError.tsx create mode 100644 src/renderer/hooks/useFileJsonDiskValue.tsx create mode 100644 src/renderer/hooks/useFileJsonItem.tsx create mode 100644 src/renderer/hooks/useFileJsonItemCount.tsx create mode 100644 src/renderer/hooks/useFileJsonItemSchema.tsx create mode 100644 src/renderer/hooks/useFileJsonItemUpdate.tsx create mode 100644 src/renderer/hooks/useFileJsonSchema.tsx create mode 100644 src/renderer/hooks/useFileJsonUpdate.tsx create mode 100644 src/renderer/hooks/useFileJsonValue.tsx diff --git a/src/common/invokables/jsons.ts b/src/common/invokables/jsons.ts index b038ab6..28dcc92 100644 --- a/src/common/invokables/jsons.ts +++ b/src/common/invokables/jsons.ts @@ -5,10 +5,12 @@ type Category = 'json'; const ANY_JSON_OBJECT_SCHEMA = z.object().catchall(z.any()); +export type AnyJsonObject = z.infer; + export const JSON_ROOT_SCHEMA = z.union([ ANY_JSON_OBJECT_SCHEMA, z.array(ANY_JSON_OBJECT_SCHEMA), - z.array(z.array(z.any())), + z.array(z.array(z.number())), ]); export type JsonRoot = z.infer; diff --git a/src/renderer/components/common/ErrorAlert.tsx b/src/renderer/components/common/ErrorAlert.tsx index 5b39657..a109348 100644 --- a/src/renderer/components/common/ErrorAlert.tsx +++ b/src/renderer/components/common/ErrorAlert.tsx @@ -1,6 +1,6 @@ import { Alert, Typography } from 'antd'; -interface ErrorAlertProps { +export interface ErrorAlertProps { error: { message?: string } | null; } diff --git a/src/renderer/components/visual/JsonForm.tsx b/src/renderer/components/visual/JsonForm.tsx index 33ec8f6..59f8224 100644 --- a/src/renderer/components/visual/JsonForm.tsx +++ b/src/renderer/components/visual/JsonForm.tsx @@ -2,26 +2,20 @@ import { useCallback, useMemo } from 'react'; import { IChangeEvent } from '@rjsf/core'; import { UiSchema } from '@rjsf/utils'; import { JsonSchemaForm } from './form/JsonSchemaForm'; -import { FullSizeLoader } from '../common/FullSizeLoader'; -import { EditorContent } from '../layout/EditorContent'; -import { JsonFormHeader } from './form/JsonFormHeader'; -import { useFileLoading, useFileSchema } from '../../hooks/files'; -import { useFileLoadingError } from '../../hooks/files'; -import { useFileJson } from '../../hooks/files'; -import { ErrorAlert } from '../common/ErrorAlert'; -import { miniSerializeError } from '@reduxjs/toolkit'; -import { TextEditorOr } from '../TextEditor'; +import { VisualFormProps, VisualFormWrapper } from './VisualFormWrapper'; +import { VisualFormWithHeader } from './VisualFormWithHeader'; +import { useFileJsonValue } from '../../hooks/useFileJsonValue'; +import { useFileJsonUpdate } from '../../hooks/useFileJsonUpdate'; +import { useFileJsonSchema } from '../../hooks/useFileJsonSchema'; -export interface JsonFormProps { - file: string; +export interface JsonFormProps extends VisualFormProps { uiSchema?: UiSchema; } -export function JsonForm({ file, uiSchema }: JsonFormProps) { - const loading = useFileLoading(file); - const error = useFileLoadingError(file); - const [value, update] = useFileJson(file); - const baseSchema = useFileSchema(file); +function Form({ file, uiSchema }: JsonFormProps) { + const value = useFileJsonValue(file); + const update = useFileJsonUpdate(file); + const baseSchema = useFileJsonSchema(file); const schema = useMemo(() => { if (!baseSchema) { return null; @@ -36,37 +30,26 @@ export function JsonForm({ file, uiSchema }: JsonFormProps) { (value: IChangeEvent) => update(value.formData), [update], ); - const contents = useMemo(() => { - if (!schema || !value) { - return ( - - ); - } - return ( - <> - - - - ); - }, [file, onFormChange, schema, uiSchema, value]); - if (error) { - return ; - } - if (loading == null || loading) { - return ; + if (!schema || !value) { + return null; } + return ( + + + + ); +} +export function JsonForm({ file, ...rest }: JsonFormProps) { return ( - - {contents} - + +
+ ); } diff --git a/src/renderer/components/visual/JsonItemsForm.css b/src/renderer/components/visual/JsonItemsForm.css deleted file mode 100644 index 45db550..0000000 --- a/src/renderer/components/visual/JsonItemsForm.css +++ /dev/null @@ -1,7 +0,0 @@ -.json-items-form-form { - padding: 20px; -} - -.json-items-form-item { - padding: 20px; -} \ No newline at end of file diff --git a/src/renderer/components/visual/JsonItemsForm.tsx b/src/renderer/components/visual/JsonItemsForm.tsx index 579b8ee..96b35a5 100644 --- a/src/renderer/components/visual/JsonItemsForm.tsx +++ b/src/renderer/components/visual/JsonItemsForm.tsx @@ -1,55 +1,55 @@ -import { useCallback, useMemo, JSX, memo } from 'react'; +import { useCallback, useMemo, JSX } from 'react'; import { Collapse, Flex } from 'antd'; import { JsonSchemaForm } from './form/JsonSchemaForm'; -import { FullSizeLoader } from '../common/FullSizeLoader'; -import './JsonItemsForm.css'; import { IChangeEvent } from '@rjsf/core'; import { UiSchema } from '@rjsf/utils'; -import { EditorContent } from '../layout/EditorContent'; -import { JsonFormHeader } from './form/JsonFormHeader'; -import { - useFileLoading, - useFileLoadingError, - useFileJsonItem, - useFileJsonItemSchema, - useFileJsonNumberOfItems, -} from '../../hooks/files'; -import { ErrorAlert } from '../common/ErrorAlert'; -import { TextEditorOr } from '../TextEditor'; import { useAppDispatch } from '../../hooks/state'; import { addJsonItem } from '../../state/files'; import { AddNewButton } from './form/AddNewButton'; import { RemoveButton } from './form/RemoveButton'; +import { VisualFormProps, VisualFormWrapper } from './VisualFormWrapper'; +import { VisualFormWithHeader } from './VisualFormWithHeader'; +import { useFileJsonItemCount } from '../../hooks/useFileJsonItemCount'; +import { useFileJsonItemSchema } from '../../hooks/useFileJsonItemSchema'; +import { useFileJsonItem } from '../../hooks/useFileJsonItem'; +import { useFileJsonItemUpdate } from '../../hooks/useFileJsonItemUpdate'; +import { AnyJsonObject } from '../../../common/invokables/jsons'; -type PreviewFn = (item: any) => JSX.Element | string | null; +type PreviewFn = (item: AnyJsonObject) => JSX.Element | string | null; type NameOrPreviewFn = string | PreviewFn; -interface ItemFormHeaderProps { - file: string; - index: number; +export interface JsonItemsFormProps extends VisualFormProps { name: NameOrPreviewFn; preview?: PreviewFn; + uiSchema?: UiSchema; + canAddNewItem?: boolean; + getNewItem?: () => object; } -const ItemFormHeader = memo(function ItemFormHeader({ +interface ItemFormHeaderProps + extends Pick { + index: number; +} + +const ItemFormHeader = function ItemFormHeader({ file, index, name, preview, }: ItemFormHeaderProps) { - const [value] = useFileJsonItem(file, index); + const value = useFileJsonItem(file, index); const label = useMemo(() => { + if (!value) return ''; if (typeof name === 'string') { - const label = value ? value[name] : null; - if (typeof label == 'string') { - return label; - } - return ''; + return name in value ? value[name] : ''; } return name(value); }, [name, value]); - const p = useMemo(() => (preview ? preview(value) : null), [preview, value]); + const p = useMemo( + () => (preview && value ? preview(value) : null), + [preview, value], + ); return ( @@ -60,19 +60,17 @@ const ItemFormHeader = memo(function ItemFormHeader({ ); -}); +}; -interface ItemFormProps { - file: string; - name: NameOrPreviewFn; - preview?: PreviewFn; - uiSchema?: UiSchema; +interface ItemFormProps + extends Pick { index: number; } function ItemForm({ file, name, preview, uiSchema, index }: ItemFormProps) { const schema = useFileJsonItemSchema(file); - const [value, update] = useFileJsonItem(file, index); + const value = useFileJsonItem(file, index); + const update = useFileJsonItemUpdate(file, index); const onItemChange = useCallback( (ev: IChangeEvent) => update(ev.formData), [update], @@ -105,39 +103,24 @@ function ItemForm({ file, name, preview, uiSchema, index }: ItemFormProps) { ); } -export interface FormItemsProps { - file: string; - name: NameOrPreviewFn; - preview?: PreviewFn; - numItems: number | null; - uiSchema?: UiSchema; -} - -const FormItems = memo(function FormItems({ +const FormItems = function FormItems({ file, name, preview, - numItems, uiSchema, -}: FormItemsProps) { +}: JsonItemsFormProps) { + const numItems = useFileJsonItemCount(file); const items = useMemo(() => { - if (numItems == null) { - return null; - } - const i = []; - for (let it = 0; it < numItems; it++) { - i.push( - , - ); - } - return i; + return [...Array(numItems).keys()].map((idx) => ( + + )); }, [file, name, numItems, preview, uiSchema]); return ( @@ -145,18 +128,9 @@ const FormItems = memo(function FormItems({ {items} ); -}); - -export interface JsonItemsFormProps { - file: string; - name: NameOrPreviewFn; - preview?: PreviewFn; - uiSchema?: UiSchema; - canAddNewItem?: boolean; - getNewItem?: () => object; -} +}; -export const JsonItemsForm = memo(function JsonItemsForm({ +export const JsonItemsForm = function JsonItemsForm({ file, name, preview, @@ -165,9 +139,6 @@ export const JsonItemsForm = memo(function JsonItemsForm({ getNewItem, }: JsonItemsFormProps) { const dispatch = useAppDispatch(); - const loading = useFileLoading(file); - const error = useFileLoadingError(file); - const numItems = useFileJsonNumberOfItems(file); const addNewItem = useCallback(() => { dispatch( addJsonItem({ filename: file, value: getNewItem ? getNewItem() : {} }), @@ -178,36 +149,20 @@ export const JsonItemsForm = memo(function JsonItemsForm({ if (!render) return null; return ; }, [addNewItem, canAddNewItem]); - const content = useMemo(() => { - if (numItems == null) { - return ; - } - return ( - <> - + + return ( + + {addButton} - - ); - }, [addButton, file, name, numItems, preview, uiSchema]); - - if (error) { - return ; - } - if (loading) { - return ; - } - return ( - - {content} - + + ); -}); +}; diff --git a/src/renderer/components/visual/MovementCostsForm.tsx b/src/renderer/components/visual/MovementCostsForm.tsx index 83aba00..790eb85 100644 --- a/src/renderer/components/visual/MovementCostsForm.tsx +++ b/src/renderer/components/visual/MovementCostsForm.tsx @@ -1,25 +1,18 @@ import { useCallback, useMemo, useState } from 'react'; -import { - useFileJson, - useFileLoading, - useFileLoadingError, -} from '../../hooks/files'; import { coordsFromSectorIdString, NormalizedSectorId, - StrategicMap, } from '../content/StrategicMap'; -import { EditorContent } from '../layout/EditorContent'; -import { ErrorAlert } from '../common/ErrorAlert'; -import { FullSizeLoader } from '../common/FullSizeLoader'; -import { TextEditorOr } from '../TextEditor'; -import { Flex, Typography } from 'antd'; -import { JsonFormHeader } from './form/JsonFormHeader'; +import { Typography } from 'antd'; import { JsonSchema } from 'src/common/invokables/jsons'; import { JsonSchemaForm } from './form/JsonSchemaForm'; import { UiSchema } from '@rjsf/utils'; import { IChangeEvent } from '@rjsf/core'; import { clone } from 'remeda'; +import { VisualFormProps } from './VisualFormWrapper'; +import { VisualStrategicMapFormWrapper } from './VisualStrategicMapFormWrapper'; +import { useFileJsonValue } from '../../hooks/useFileJsonValue'; +import { useFileJsonUpdate } from '../../hooks/useFileJsonUpdate'; const TRAVERSABILITY_ENUM_JSON_SCHEMA: JsonSchema = { type: 'string', @@ -138,43 +131,39 @@ const MOVEMENT_COSTS_UI_SCHEMA: UiSchema = { traverseThrough: { 'ui:enumNames': TRAVERSABILITY_ENUM_TITLES }, }; -interface MovementCostsFormProps { - file: string; -} - -function MovementCosts({ +function Form({ file, - value, - onChange, -}: MovementCostsFormProps & { - value: any; - onChange: (value: any) => unknown; + selectedSectorId, +}: VisualFormProps & { + selectedSectorId: NormalizedSectorId | null; }) { - const [selectedSector, setSelectedSector] = - useState(null); + const values = useFileJsonValue(file); + const update = useFileJsonUpdate(file); const coords = useMemo( - () => (selectedSector ? coordsFromSectorIdString(selectedSector[0]) : null), - [selectedSector], + () => + selectedSectorId ? coordsFromSectorIdString(selectedSectorId[0]) : null, + [selectedSectorId], ); const content = useMemo(() => { - if (!coords) { + if (!coords || !values) { return {}; } + const v = values as any; return { - traverseThrough: value.traverseThrough[coords[1]][coords[0]], - traverseNorth: value.traverseNS[coords[1]][coords[0]], - traverseSouth: value.traverseNS[coords[1] + 1][coords[0]], - traverseWest: value.traverseWE[coords[1]][coords[0]], - traverseEast: value.traverseWE[coords[1]][coords[0] + 1], - travelRating: value.travelRatings[coords[1]][coords[0]], + traverseThrough: v.traverseThrough[coords[1]][coords[0]], + traverseNorth: v.traverseNS[coords[1]][coords[0]], + traverseSouth: v.traverseNS[coords[1] + 1][coords[0]], + traverseWest: v.traverseWE[coords[1]][coords[0]], + traverseEast: v.traverseWE[coords[1]][coords[0] + 1], + travelRating: v.travelRatings[coords[1]][coords[0]], }; - }, [value, coords]); + }, [values, coords]); const handleChange = useCallback( (newContent: IChangeEvent) => { - if (!coords) { + if (!coords || !values) { return; } - const n = clone(value); + const n = clone(values) as any; n.traverseThrough[coords[1]][coords[0]] = newContent.formData.traverseThrough; n.traverseNS[coords[1]][coords[0]] = newContent.formData.traverseNorth; @@ -183,59 +172,38 @@ function MovementCosts({ n.traverseWE[coords[1]][coords[0]] = newContent.formData.traverseWest; n.traverseWE[coords[1]][coords[0] + 1] = newContent.formData.traverseEast; n.travelRatings[coords[1]][coords[0]] = newContent.formData.travelRating; - onChange(n); + update(n); }, - [coords, onChange, value], + [coords, update, values], ); - const contentElement = useMemo(() => { - if (!coords) { - return ( - - Select sector to view and edit movement costs. - - ); - } + + if (!selectedSectorId) { return ( - + + Select sector to view and edit movement costs. + ); - }, [content, coords, handleChange]); - + } return ( - - -
- - {contentElement} -
-
+ ); } -export function MovementCostsForm({ file }: MovementCostsFormProps) { - const loading = useFileLoading(file); - const error = useFileLoadingError(file); - const [value, update] = useFileJson(file); - - if (loading == null || loading) { - return ; - } - if (error) { - return ; - } +export function MovementCostsForm({ file }: VisualFormProps) { + const [selectedSectorId, setSelectedSectorId] = + useState(null); return ( - - - - - + + + ); } diff --git a/src/renderer/components/visual/SamSitesAirControlForm.tsx b/src/renderer/components/visual/SamSitesAirControlForm.tsx index f9dcb61..20af264 100644 --- a/src/renderer/components/visual/SamSitesAirControlForm.tsx +++ b/src/renderer/components/visual/SamSitesAirControlForm.tsx @@ -1,68 +1,101 @@ -import { clone } from 'remeda'; -import { - useFileJson, - useFileLoading, - useFileLoadingError, -} from '../../hooks/files'; -import { EditorContent } from '../layout/EditorContent'; -import { ErrorAlert } from '../common/ErrorAlert'; -import { FullSizeLoader } from '../common/FullSizeLoader'; -import { TextEditorOr } from '../TextEditor'; -import { useMemo, useState } from 'react'; +import { clone, isArray } from 'remeda'; +import { useCallback, useMemo, useState } from 'react'; import { coordsFromSectorIdString, HIGHLIGHT_COLORS, NormalizedSectorId, sectorIdStringFromCoords, - StrategicMap, } from '../content/StrategicMap'; -import { Badge, Flex, Select, Space, Typography } from 'antd'; -import { JsonFormHeader } from './form/JsonFormHeader'; +import { Badge, Select, Space, Typography } from 'antd'; +import { VisualStrategicMapFormWrapper } from './VisualStrategicMapFormWrapper'; +import { useFileJsonValue } from '../../hooks/useFileJsonValue'; +import { useFileJsonUpdate } from '../../hooks/useFileJsonUpdate'; +import { useFileJsonDiskValue } from '../../hooks/useFileJsonDiskValue'; +import { JsonRoot } from 'src/common/invokables/jsons'; + +const SAM_SITES_FILE = 'strategic-map-sam-sites.json'; +const EXTRA_FILES = [SAM_SITES_FILE]; interface SamSitesAirControlProps { file: string; } -function SamSitesAirControl({ - file, - samSites, - value, +function useSamSites(): JsonRoot | null { + return useFileJsonDiskValue(SAM_SITES_FILE); +} + +function SamSiteSelect({ + selectedSector, onChange, }: { - samSites: any[]; - value: any[][]; - onChange: (value: any[][]) => unknown; -} & SamSitesAirControlProps) { - const [selectedSite, setSelectedSite] = useState(0); - const selectedSector = useMemo(() => { - return samSites[selectedSite]?.sector - ? ([samSites[selectedSite]?.sector as string, 0] as NormalizedSectorId) - : undefined; - }, [samSites, selectedSite]); - const samSiteOptions = useMemo( - () => - samSites.map((site, index) => { - const color = HIGHLIGHT_COLORS[index % HIGHLIGHT_COLORS.length]; - return { - label: ( - - - {site.sector} - - ), - value: index, - color, - }; - }), - [samSites], + selectedSector: NormalizedSectorId | null; + onChange: (value: NormalizedSectorId) => unknown; +}) { + const values = useSamSites(); + const options = useMemo(() => { + if (!values) return []; + if (!isArray(values)) return []; + + return values.map((site: any, index) => { + const color = HIGHLIGHT_COLORS[index % HIGHLIGHT_COLORS.length]; + return { + label: ( + + + {site.sector} + + ), + value: site.sector, + color, + }; + }); + }, [values]); + const handleChange = useCallback( + (value: string) => { + onChange([value, 0]); + }, + [onChange], ); + + return ( + <> + + Select a SAM site below and click on the map to change sectors. + + - - - ); -} - -export function SamSitesAirControlForm({ file }: SamSitesAirControlProps) { - const samSitesFile = 'strategic-map-sam-sites.json'; - const loadingSamSites = useFileLoading(samSitesFile); - const errorSamSites = useFileLoadingError(samSitesFile); - const [valueSamSites] = useFileJson(samSitesFile); - const loading = useFileLoading(file); - const error = useFileLoadingError(file); - const [value, update] = useFileJson(file); - - if ( - loading == null || - loading || - loadingSamSites == null || - loadingSamSites - ) { - return ; - } - if (error || errorSamSites) { - return ; - } - - return ( - - - - - + ); } diff --git a/src/renderer/components/visual/StrategicMapForm.tsx b/src/renderer/components/visual/StrategicMapForm.tsx index 57998ca..239dc0c 100644 --- a/src/renderer/components/visual/StrategicMapForm.tsx +++ b/src/renderer/components/visual/StrategicMapForm.tsx @@ -1,36 +1,29 @@ import { useCallback, useMemo, useState } from 'react'; -import { Space, Typography } from 'antd'; - +import { Typography } from 'antd'; import { UiSchema } from '@rjsf/utils'; -import { FullSizeLoader } from '../common/FullSizeLoader'; import { DEFAULT_HIGHLIGHT_COLOR, NormalizedSectorId, - StrategicMap, } from '../content/StrategicMap'; import { JsonSchemaForm } from './form/JsonSchemaForm'; -import { EditorContent } from '../layout/EditorContent'; -import { JsonFormHeader } from './form/JsonFormHeader'; -import { - useFileLoadingError, - useFileJson, - useFileJsonItem, - useFileJsonItemSchema, - useFileLoading, -} from '../../hooks/files'; import { IChangeEvent } from '@rjsf/core'; -import { ErrorAlert } from '../common/ErrorAlert'; -import { TextEditorOr } from '../TextEditor'; import { AddNewButton } from './form/AddNewButton'; import { useAppDispatch } from '../../hooks/state'; import { addJsonItem } from '../../state/files'; import { findIndex, isDeepEqual } from 'remeda'; import { RemoveButton } from './form/RemoveButton'; +import { VisualFormProps } from './VisualFormWrapper'; +import { VisualStrategicMapFormWrapper } from './VisualStrategicMapFormWrapper'; +import { useFileJsonValue } from '../../hooks/useFileJsonValue'; +import { useFileJsonItemSchema } from '../../hooks/useFileJsonItemSchema'; +import { useFileJsonItem } from '../../hooks/useFileJsonItem'; +import { useFileJsonItemUpdate } from '../../hooks/useFileJsonItemUpdate'; +import { AnyJsonObject } from '../../../common/invokables/jsons'; interface ItemFormProps { file: string; index: number; - transformSectorToItem: (sectorId: NormalizedSectorId) => any; + transformSectorToItem: (sectorId: NormalizedSectorId) => AnyJsonObject; uiSchema?: UiSchema; sectorId?: NormalizedSectorId; canAddNewItem?: boolean; @@ -48,9 +41,14 @@ function ItemForm({ }: ItemFormProps) { const dispatch = useAppDispatch(); const schema = useFileJsonItemSchema(file); - const [value, update] = useFileJsonItem(file, index); + const value = useFileJsonItem(file, index); + const update = useFileJsonItemUpdate(file, index); const onItemChange = useCallback( - (ev: IChangeEvent) => update(ev.formData), + (ev: IChangeEvent) => { + if (ev.formData) { + update(ev.formData); + } + }, [update], ); const addNewItem = useCallback(() => { @@ -84,15 +82,14 @@ function ItemForm({ ); } -export interface StrategicMapFormProps { - file: string; +export interface StrategicMapFormProps extends VisualFormProps { uiSchema?: UiSchema; canAddNewItem?: boolean; initialLevel?: number; canChangeLevel?: boolean; - getNewItem?: () => object; - extractSectorFromItem: (value: any) => NormalizedSectorId; - transformSectorToItem: (sectorId: NormalizedSectorId) => any; + getNewItem?: () => AnyJsonObject; + extractSectorFromItem: (value: AnyJsonObject) => NormalizedSectorId; + transformSectorToItem: (sectorId: NormalizedSectorId) => AnyJsonObject; } export function JsonStrategicMapForm({ @@ -101,15 +98,13 @@ export function JsonStrategicMapForm({ extractSectorFromItem, transformSectorToItem, canAddNewItem, - initialLevel = 0, + initialLevel, canChangeLevel, getNewItem, }: StrategicMapFormProps) { - const [level, setLevel] = useState(initialLevel); - const loading = useFileLoading(file); - const error = useFileLoadingError(file); - const [value] = useFileJson(file); - const [selectedSector, setSelectedSector] = useState< + const [level, setLevel] = useState(initialLevel ?? 0); + const value = useFileJsonValue(file); + const [selectedSectorId, setSelectedSector] = useState< NormalizedSectorId | undefined >(); const sectorsWithContent: NormalizedSectorId[] = useMemo( @@ -120,19 +115,17 @@ export function JsonStrategicMapForm({ return { [DEFAULT_HIGHLIGHT_COLOR]: sectorsWithContent }; }, [sectorsWithContent]); const selectedItem = useMemo(() => { - if (!selectedSector) return -1; + if (!selectedSectorId) return -1; return findIndex(sectorsWithContent, (sector) => - isDeepEqual(sector, selectedSector), + isDeepEqual(sector, selectedSectorId), ); - }, [sectorsWithContent, selectedSector]); + }, [sectorsWithContent, selectedSectorId]); const onSectorClick = useCallback( (sectorId: NormalizedSectorId) => { - if (!value) { - return; - } + if (isDeepEqual(sectorId, selectedSectorId)) return; setSelectedSector(sectorId); }, - [value], + [selectedSectorId], ); const removeButton = useMemo(() => { if (selectedItem === -1) return null; @@ -146,41 +139,30 @@ export function JsonStrategicMapForm({ ); }, [file, selectedItem]); - - if (loading) { - return ; - } - if (error) { - return ; - } + const onLevelChange = canChangeLevel ? setLevel : undefined; return ( - - - - -
- - {removeButton} - -
-
-
-
+ + {removeButton} + + ); } @@ -188,8 +170,11 @@ export function makeStrategicMapFormPropsForProperty( prop: S, ) { return { - extractSectorFromItem: (item: any): NormalizedSectorId => [item[prop], 0], - transformSectorToItem: (sector: NormalizedSectorId) => ({ + extractSectorFromItem: (item: AnyJsonObject): NormalizedSectorId => [ + item[prop], + 0, + ], + transformSectorToItem: (sector: NormalizedSectorId): AnyJsonObject => ({ [prop]: sector[0], }), uiSchema: { @@ -203,7 +188,7 @@ export function makeStrategicMapFormPropsForProperties< L extends string, >(sectorProp: S, levelProp: L) { return { - extractSectorFromItem: (item: any): NormalizedSectorId => [ + extractSectorFromItem: (item: AnyJsonObject): NormalizedSectorId => [ item[sectorProp], item[levelProp] ?? 0, ], diff --git a/src/renderer/components/visual/VisualFormWithHeader.tsx b/src/renderer/components/visual/VisualFormWithHeader.tsx new file mode 100644 index 0000000..2c34e0c --- /dev/null +++ b/src/renderer/components/visual/VisualFormWithHeader.tsx @@ -0,0 +1,15 @@ +import { PropsWithChildren } from 'react'; +import { VisualFormProps } from './VisualFormWrapper'; +import { JsonFormHeader } from './form/JsonFormHeader'; + +export function VisualFormWithHeader({ + file, + children, +}: PropsWithChildren) { + return ( + <> + + {children} + + ); +} diff --git a/src/renderer/components/visual/VisualFormWrapper.tsx b/src/renderer/components/visual/VisualFormWrapper.tsx new file mode 100644 index 0000000..2fe77d9 --- /dev/null +++ b/src/renderer/components/visual/VisualFormWrapper.tsx @@ -0,0 +1,62 @@ +import { PropsWithChildren, useEffect, useMemo } from 'react'; +import { EditorContent } from '../layout/EditorContent'; +import { TextEditorOr } from '../TextEditor'; +import { FullSizeLoader } from '../common/FullSizeLoader'; +import { ErrorAlert, ErrorAlertProps } from '../common/ErrorAlert'; +import { useAnyFileLoading } from '../../hooks/useAnyFileLoading'; +import { useAnyFileLoadingError } from '../../hooks/useAnyFileLoadingError'; +import { useAppDispatch } from '../../hooks/state'; +import { loadJSON } from '../../state/files'; + +export interface VisualFormProps { + /* + * The file that the form edits + */ + file: string; + /* + * Other files that need to be loaded to display the form + * These files are loaded in parallel to form, but dont affect e.g. the modified state + */ + extraFiles?: string[]; +} + +function LoadingOr({ + children, + loading, +}: PropsWithChildren<{ loading: boolean }>) { + return loading ? : children; +} + +function ErrorOr({ children, ...rest }: PropsWithChildren) { + return rest.error ? : children; +} + +export function VisualFormWrapper({ + file, + extraFiles, + children, +}: PropsWithChildren) { + const dispatch = useAppDispatch(); + const allFiles = useMemo( + () => [file, ...(extraFiles ?? [])], + [file, extraFiles], + ); + const loading = useAnyFileLoading(allFiles); + const error = useAnyFileLoadingError(allFiles); + + useEffect(() => { + for (const file of allFiles) { + dispatch(loadJSON(file)); + } + }, [dispatch, allFiles]); + + return ( + + + + {children} + + + + ); +} diff --git a/src/renderer/components/visual/VisualStrategicMapFormWrapper.tsx b/src/renderer/components/visual/VisualStrategicMapFormWrapper.tsx new file mode 100644 index 0000000..428c70b --- /dev/null +++ b/src/renderer/components/visual/VisualStrategicMapFormWrapper.tsx @@ -0,0 +1,29 @@ +import { Space } from 'antd'; +import { VisualFormProps, VisualFormWrapper } from './VisualFormWrapper'; +import { StrategicMap, StrategicMapProps } from '../content/StrategicMap'; +import { JsonFormHeader } from './form/JsonFormHeader'; +import { PropsWithChildren } from 'react'; + +export interface VisualSrategicMapFormWrapperProps + extends PropsWithChildren { + strategicMap: StrategicMapProps; +} + +export function VisualStrategicMapFormWrapper({ + file, + extraFiles, + strategicMap, + children, +}: VisualSrategicMapFormWrapperProps) { + return ( + + + +
+ + {children} +
+
+
+ ); +} diff --git a/src/renderer/hooks/files.tsx b/src/renderer/hooks/files.tsx index 2f39c3d..aa883dd 100644 --- a/src/renderer/hooks/files.tsx +++ b/src/renderer/hooks/files.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback } from 'react'; import { useAppDispatch, useAppSelector } from './state'; import { changeJson, changeJsonItem, EditMode, - loadJSON, + // loadJSON, SaveMode, } from '../state/files'; import { SerializedError } from '@reduxjs/toolkit'; @@ -85,7 +85,7 @@ export function useFilesJson( update: (file: keyof R, value: JsonRoot) => void; } { const dispatch = useAppDispatch(); - const loading = useFilesLoading(files); + // const loading = useFilesLoading(files); const values = useAppFilesProxySelector( function selectFilesJson(s) { const values: { [key in keyof R]: JsonRoot | null } = {} as any; @@ -113,13 +113,13 @@ export function useFilesJson( [files, dispatch], ); - useEffect(() => { - for (const key in files) { - if (loading[key] === null) { - dispatch(loadJSON(files[key]!)); - } - } - }, [dispatch, files, loading]); + // useEffect(() => { + // for (const key in files) { + // if (loading[key] === null) { + // dispatch(loadJSON(files[key]!)); + // } + // } + // }, [dispatch, files, loading]); return { values, @@ -181,7 +181,7 @@ export function useFileJson( filename: string, ): [JsonRoot | null, (value: JsonRoot) => void] { const dispatch = useAppDispatch(); - const loading = useFileLoading(filename); + // const loading = useFileLoading(filename); const update = useCallback( (value: JsonRoot) => { dispatch( @@ -194,11 +194,11 @@ export function useFileJson( [filename, dispatch], ); - useEffect(() => { - if (loading === null) { - dispatch(loadJSON(filename)); - } - }, [dispatch, filename, loading]); + // useEffect(() => { + // if (loading === null) { + // dispatch(loadJSON(filename)); + // } + // }, [dispatch, filename, loading]); return [ useAppSelector((s) => { @@ -215,17 +215,17 @@ export function useFileJson( export function useFileText( filename: string, ): [string | null, (value: string) => void] { - const dispatch = useAppDispatch(); - const loading = useFileLoading(filename); + // const dispatch = useAppDispatch(); + // const loading = useFileLoading(filename); const update = useCallback(() => { throw new Error('not implemented'); }, []); - useEffect(() => { - if (loading === null) { - dispatch(loadJSON(filename)); - } - }, [dispatch, filename, loading]); + // useEffect(() => { + // if (loading === null) { + // dispatch(loadJSON(filename)); + // } + // }, [dispatch, filename, loading]); return [ useAppSelector((s) => { diff --git a/src/renderer/hooks/useAnyFileLoading.tsx b/src/renderer/hooks/useAnyFileLoading.tsx new file mode 100644 index 0000000..22a84a1 --- /dev/null +++ b/src/renderer/hooks/useAnyFileLoading.tsx @@ -0,0 +1,11 @@ +import { useAppSelector } from './state'; + +export function useAnyFileLoading(files: string[]): boolean { + return useAppSelector((state) => { + let loading = false; + for (const file of files) { + loading = loading || (state.files.disk[file]?.loading ?? false); + } + return loading; + }); +} diff --git a/src/renderer/hooks/useAnyFileLoadingError.tsx b/src/renderer/hooks/useAnyFileLoadingError.tsx new file mode 100644 index 0000000..85a7e46 --- /dev/null +++ b/src/renderer/hooks/useAnyFileLoadingError.tsx @@ -0,0 +1,16 @@ +import { SerializedError } from '@reduxjs/toolkit'; +import { useAppSelector } from './state'; + +export function useAnyFileLoadingError( + files: string[], +): SerializedError | null { + return useAppSelector((state) => { + for (const file of files) { + const error = state.files.disk[file]?.loadingError ?? null; + if (error) { + return error; + } + } + return null; + }); +} diff --git a/src/renderer/hooks/useFileJsonDiskValue.tsx b/src/renderer/hooks/useFileJsonDiskValue.tsx new file mode 100644 index 0000000..8724c92 --- /dev/null +++ b/src/renderer/hooks/useFileJsonDiskValue.tsx @@ -0,0 +1,8 @@ +import { JsonRoot } from '../../common/invokables/jsons'; +import { useAppSelector } from './state'; + +export function useFileJsonDiskValue(file: string): JsonRoot | null { + return useAppSelector((s) => { + return s.files.disk[file]?.data?.applied ?? null; + }); +} diff --git a/src/renderer/hooks/useFileJsonItem.tsx b/src/renderer/hooks/useFileJsonItem.tsx new file mode 100644 index 0000000..78563d9 --- /dev/null +++ b/src/renderer/hooks/useFileJsonItem.tsx @@ -0,0 +1,17 @@ +import { isArray } from 'remeda'; +import { useAppSelector } from './state'; +import { isPlainObject } from '@reduxjs/toolkit'; +import { AnyJsonObject } from '../../common/invokables/jsons'; + +export function useFileJsonItem( + file: string, + index: number, +): AnyJsonObject | null { + return useAppSelector((s) => { + const open = s.files.open[file]?.value; + if (!isArray(open)) return null; + const item = open[index]; + if (!isPlainObject(item)) return null; + return item; + }); +} diff --git a/src/renderer/hooks/useFileJsonItemCount.tsx b/src/renderer/hooks/useFileJsonItemCount.tsx new file mode 100644 index 0000000..7c0a18a --- /dev/null +++ b/src/renderer/hooks/useFileJsonItemCount.tsx @@ -0,0 +1,10 @@ +import { isArray } from 'remeda'; +import { useAppSelector } from './state'; + +export function useFileJsonItemCount(file: string): number { + return useAppSelector((state) => { + const open = state.files.open[file]?.value ?? null; + if (!open || !isArray(open)) return 0; + return open.length; + }); +} diff --git a/src/renderer/hooks/useFileJsonItemSchema.tsx b/src/renderer/hooks/useFileJsonItemSchema.tsx new file mode 100644 index 0000000..ee12257 --- /dev/null +++ b/src/renderer/hooks/useFileJsonItemSchema.tsx @@ -0,0 +1,8 @@ +import { JsonSchema } from 'src/common/invokables/jsons'; +import { useAppSelector } from './state'; + +export function useFileJsonItemSchema(filename: string): JsonSchema | null { + return useAppSelector((s) => { + return s.files.disk[filename]?.data?.itemSchema ?? null; + }); +} diff --git a/src/renderer/hooks/useFileJsonItemUpdate.tsx b/src/renderer/hooks/useFileJsonItemUpdate.tsx new file mode 100644 index 0000000..fbb19c4 --- /dev/null +++ b/src/renderer/hooks/useFileJsonItemUpdate.tsx @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { useAppDispatch } from './state'; +import { changeJsonItem } from '../state/files'; + +export function useFileJsonItemUpdate( + file: string, + index: number, +): (value: object) => unknown { + const dispatch = useAppDispatch(); + return useCallback( + (value: object) => { + dispatch( + changeJsonItem({ + filename: file, + index, + value, + }), + ); + }, + [dispatch, file, index], + ); +} diff --git a/src/renderer/hooks/useFileJsonSchema.tsx b/src/renderer/hooks/useFileJsonSchema.tsx new file mode 100644 index 0000000..0706ad7 --- /dev/null +++ b/src/renderer/hooks/useFileJsonSchema.tsx @@ -0,0 +1,8 @@ +import { JsonSchema } from '../../common/invokables/jsons'; +import { useAppSelector } from './state'; + +export function useFileJsonSchema(file: string): JsonSchema | null { + return useAppSelector((s) => { + return s.files.disk[file]?.data?.schema ?? null; + }); +} diff --git a/src/renderer/hooks/useFileJsonUpdate.tsx b/src/renderer/hooks/useFileJsonUpdate.tsx new file mode 100644 index 0000000..d899b77 --- /dev/null +++ b/src/renderer/hooks/useFileJsonUpdate.tsx @@ -0,0 +1,19 @@ +import { useCallback } from 'react'; +import { JsonRoot } from '../../common/invokables/jsons'; +import { changeJson } from '../state/files'; +import { useAppDispatch } from './state'; + +export function useFileJsonUpdate(file: string): (value: JsonRoot) => unknown { + const dispatch = useAppDispatch(); + return useCallback( + (value: JsonRoot) => { + dispatch( + changeJson({ + filename: file, + value, + }), + ); + }, + [dispatch, file], + ); +} diff --git a/src/renderer/hooks/useFileJsonValue.tsx b/src/renderer/hooks/useFileJsonValue.tsx new file mode 100644 index 0000000..3929af5 --- /dev/null +++ b/src/renderer/hooks/useFileJsonValue.tsx @@ -0,0 +1,12 @@ +import { JsonRoot } from '../../common/invokables/jsons'; +import { useAppSelector } from './state'; + +export function useFileJsonValue(file: string): JsonRoot | null { + return useAppSelector((s) => { + const open = s.files.open[file]; + if (!open || open.editMode === 'text') { + return null; + } + return open.value; + }); +} From ec47256674aeb8af0a90cd024eaac3ea937624be Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Mon, 17 Nov 2025 12:16:56 +0100 Subject: [PATCH 5/9] Refactor EditorContent --- .../components/layout/EditorContent.tsx | 236 ++++++++++-------- .../components/visual/VisualFormWrapper.tsx | 2 +- src/renderer/hooks/useFileEditMode.tsx | 8 + src/renderer/hooks/useFileEditModeUpdate.tsx | 18 ++ src/renderer/hooks/useFileLoading.tsx | 7 + src/renderer/hooks/useFileModified.tsx | 7 + src/renderer/hooks/useFileSave.tsx | 10 + src/renderer/hooks/useFileSaveMode.tsx | 8 + src/renderer/hooks/useFileSaveModeUpdate.tsx | 18 ++ src/renderer/hooks/useFileSaving.tsx | 7 + src/renderer/hooks/useFileSavingError.tsx | 8 + 11 files changed, 222 insertions(+), 107 deletions(-) create mode 100644 src/renderer/hooks/useFileEditMode.tsx create mode 100644 src/renderer/hooks/useFileEditModeUpdate.tsx create mode 100644 src/renderer/hooks/useFileLoading.tsx create mode 100644 src/renderer/hooks/useFileModified.tsx create mode 100644 src/renderer/hooks/useFileSave.tsx create mode 100644 src/renderer/hooks/useFileSaveMode.tsx create mode 100644 src/renderer/hooks/useFileSaveModeUpdate.tsx create mode 100644 src/renderer/hooks/useFileSaving.tsx create mode 100644 src/renderer/hooks/useFileSavingError.tsx diff --git a/src/renderer/components/layout/EditorContent.tsx b/src/renderer/components/layout/EditorContent.tsx index 7d64d1a..9bedb6f 100644 --- a/src/renderer/components/layout/EditorContent.tsx +++ b/src/renderer/components/layout/EditorContent.tsx @@ -1,22 +1,17 @@ import { ExclamationCircleOutlined, SaveOutlined } from '@ant-design/icons'; -import { Button, Flex, Select, Typography } from 'antd'; -import { ReactNode, memo, useCallback, useMemo } from 'react'; +import { Button, Flex, Select, theme, Typography } from 'antd'; +import { ReactNode, useCallback, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { useAppDispatch } from '../../hooks/state'; -import { - changeSaveMode, - changeEditMode, - EditMode, - persistJSON, - SaveMode, -} from '../../state/files'; -import { - useFileEditMode, - useFileModified, - useFilePersistingError, - useFileSaveMode, - useFileSaving, -} from '../../hooks/files'; +import { useFileModified } from '../../hooks/useFileModified'; +import { useFileSaving } from '../../hooks/useFileSaving'; +import { useFileLoading } from '../../hooks/useFileLoading'; +import { useFileSavingError } from '../../hooks/useFileSavingError'; +import { useFileEditMode } from '../../hooks/useFileEditMode'; +import { useFileEditModeUpdate } from '../../hooks/useFileEditModeUpdate'; +import { useFileSaveMode } from '../../hooks/useFileSaveMode'; +import { useFileSaveModeUpdate } from '../../hooks/useFileSaveModeUpdate'; +import { useFileSave } from '../../hooks/useFileSave'; +import { Loader } from '../common/Loader'; const SAVE_MODE_SELECT_OPTIONS = [ { @@ -47,116 +42,145 @@ const EDIT_MODE_SELECT_OPTIONS = [ ]; interface ContentProps { - path: string; + file: string; children: ReactNode; } -const EditorContentHeader = memo(function EditorContentHeader({ - path, -}: { - path: string; -}) { - const dispatch = useAppDispatch(); - const modified = useFileModified(path); - const saving = useFileSaving(path); - const saveMode = useFileSaveMode(path); - const editMode = useFileEditMode(path); - const error = useFilePersistingError(path); - const errorStyle = useMemo(() => ({ color: '#9d1e1c' }), []); +function EditorContentSaveButton({ file }: Pick) { + const modified = useFileModified(file); + const saving = useFileSaving(file); + const loading = useFileLoading(file); + const error = useFileSavingError(file); + const { + token: { colorError }, + } = theme.useToken(); + const errorStyle = useMemo(() => ({ color: colorError }), [colorError]); + const save = useFileSave(file); const saveFile = useCallback(() => { - dispatch(persistJSON(path)); - }, [dispatch, path]); - const setSaveMode = useCallback( - (saveMode: SaveMode) => { - dispatch( - changeSaveMode({ - filename: path, - saveMode, - }), - ); - }, - [dispatch, path], - ); - const setEditMode = useCallback( - (editMode: EditMode) => { - dispatch( - changeEditMode({ - filename: path, - editMode, - }), - ); - }, - [dispatch, path], - ); + if (modified && !saving && !loading) { + save(); + } + }, [loading, modified, save, saving]); useHotkeys('ctrl+s', saveFile, { enableOnFormTags: true, preventDefault: true, }); + return ( + + + {error ? ( + + ) : null} + + ); +} + +function EditModeSelect({ file }: Pick) { + const loading = useFileLoading(file); + const saving = useFileSaving(file); + const editMode = useFileEditMode(file); + const update = useFileEditModeUpdate(file); + + return ( + + Edit Mode + + + ); +} + +function EditorContentHeader({ file }: { file: string }) { return ( - - - {error ? ( - - ) : null} - + - - Edit Mode - - + + ); -}); +} -export const EditorContent = memo(function EditorContent({ - path, +export const EditorContent = function EditorContent({ + file, children, }: ContentProps) { + const flexStyle = useMemo( + () => + ({ + height: '100%', + }) as const, + [], + ); + const headerStyle = useMemo( + () => + ({ + paddingTop: '10px', + paddingLeft: '10px', + paddingRight: '10px', + }) as const, + [], + ); + const contentWrapperStyle = useMemo( + () => + ({ + position: 'relative', + margin: '10px', + padding: '20px', + flexGrow: 1, + background: 'white', + overflowY: 'auto', + }) as const, + [], + ); + const contentStyle = useMemo( + () => + ({ + position: 'relative', + height: '1px', + minHeight: '100%', + }) as const, + [], + ); + return ( - -
- + +
+
-
-
- {children} -
+
+
{children}
); -}); +}; diff --git a/src/renderer/components/visual/VisualFormWrapper.tsx b/src/renderer/components/visual/VisualFormWrapper.tsx index 2fe77d9..5c15b2d 100644 --- a/src/renderer/components/visual/VisualFormWrapper.tsx +++ b/src/renderer/components/visual/VisualFormWrapper.tsx @@ -51,7 +51,7 @@ export function VisualFormWrapper({ }, [dispatch, allFiles]); return ( - + {children} diff --git a/src/renderer/hooks/useFileEditMode.tsx b/src/renderer/hooks/useFileEditMode.tsx new file mode 100644 index 0000000..a538585 --- /dev/null +++ b/src/renderer/hooks/useFileEditMode.tsx @@ -0,0 +1,8 @@ +import { EditMode } from '../state/files'; +import { useAppSelector } from './state'; + +export function useFileEditMode(file: string): EditMode { + return useAppSelector((s) => { + return s.files.open[file]?.editMode ?? 'visual'; + }); +} diff --git a/src/renderer/hooks/useFileEditModeUpdate.tsx b/src/renderer/hooks/useFileEditModeUpdate.tsx new file mode 100644 index 0000000..9e2011e --- /dev/null +++ b/src/renderer/hooks/useFileEditModeUpdate.tsx @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; +import { useAppDispatch } from './state'; +import { changeEditMode, EditMode } from '../state/files'; + +export function useFileEditModeUpdate(file: string) { + const dispatch = useAppDispatch(); + return useCallback( + (editMode: EditMode) => { + dispatch( + changeEditMode({ + filename: file, + editMode, + }), + ); + }, + [dispatch, file], + ); +} diff --git a/src/renderer/hooks/useFileLoading.tsx b/src/renderer/hooks/useFileLoading.tsx new file mode 100644 index 0000000..1b8f3e4 --- /dev/null +++ b/src/renderer/hooks/useFileLoading.tsx @@ -0,0 +1,7 @@ +import { useAppSelector } from './state'; + +export function useFileLoading(filename: string): boolean { + return useAppSelector((s) => { + return s.files.disk[filename]?.loading ?? false; + }); +} diff --git a/src/renderer/hooks/useFileModified.tsx b/src/renderer/hooks/useFileModified.tsx new file mode 100644 index 0000000..12cdf95 --- /dev/null +++ b/src/renderer/hooks/useFileModified.tsx @@ -0,0 +1,7 @@ +import { useAppSelector } from './state'; + +export function useFileModified(filename: string): boolean { + return useAppSelector((s) => { + return s.files.open[filename]?.modified ?? false; + }); +} diff --git a/src/renderer/hooks/useFileSave.tsx b/src/renderer/hooks/useFileSave.tsx new file mode 100644 index 0000000..1a43ec5 --- /dev/null +++ b/src/renderer/hooks/useFileSave.tsx @@ -0,0 +1,10 @@ +import { useCallback } from 'react'; +import { useAppDispatch } from './state'; +import { persistJSON } from '../state/files'; + +export function useFileSave(file: string) { + const dispatch = useAppDispatch(); + return useCallback(() => { + dispatch(persistJSON(file)); + }, [dispatch, file]); +} diff --git a/src/renderer/hooks/useFileSaveMode.tsx b/src/renderer/hooks/useFileSaveMode.tsx new file mode 100644 index 0000000..9e9563b --- /dev/null +++ b/src/renderer/hooks/useFileSaveMode.tsx @@ -0,0 +1,8 @@ +import { SaveMode } from '../state/files'; +import { useAppSelector } from './state'; + +export function useFileSaveMode(file: string): SaveMode { + return useAppSelector((s) => { + return s.files.open[file]?.saveMode ?? 'patch'; + }); +} diff --git a/src/renderer/hooks/useFileSaveModeUpdate.tsx b/src/renderer/hooks/useFileSaveModeUpdate.tsx new file mode 100644 index 0000000..3e9b43c --- /dev/null +++ b/src/renderer/hooks/useFileSaveModeUpdate.tsx @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; +import { useAppDispatch } from './state'; +import { changeSaveMode, SaveMode } from '../state/files'; + +export function useFileSaveModeUpdate(file: string) { + const dispatch = useAppDispatch(); + return useCallback( + (saveMode: SaveMode) => { + dispatch( + changeSaveMode({ + filename: file, + saveMode, + }), + ); + }, + [dispatch, file], + ); +} diff --git a/src/renderer/hooks/useFileSaving.tsx b/src/renderer/hooks/useFileSaving.tsx new file mode 100644 index 0000000..d536845 --- /dev/null +++ b/src/renderer/hooks/useFileSaving.tsx @@ -0,0 +1,7 @@ +import { useAppSelector } from './state'; + +export function useFileSaving(filename: string): boolean { + return useAppSelector((s) => { + return s.files.disk[filename]?.persisting ?? false; + }); +} diff --git a/src/renderer/hooks/useFileSavingError.tsx b/src/renderer/hooks/useFileSavingError.tsx new file mode 100644 index 0000000..9f0a885 --- /dev/null +++ b/src/renderer/hooks/useFileSavingError.tsx @@ -0,0 +1,8 @@ +import { SerializedError } from '@reduxjs/toolkit'; +import { useAppSelector } from './state'; + +export function useFileSavingError(filename: string): SerializedError | null { + return useAppSelector((s) => { + return s.files.disk[filename]?.persistingError ?? null; + }); +} From e0625f9a2b2d1a575e5a7fee4a3557996dabc8e8 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Mon, 17 Nov 2025 12:42:08 +0100 Subject: [PATCH 6/9] Refactor JsonFormHeader --- src/common/invokables/jsons.ts | 1 + .../components/visual/form/JsonFormHeader.tsx | 25 ++++++------------- src/renderer/hooks/useFileDescription.tsx | 7 ++++++ src/renderer/hooks/useFileTitle.tsx | 5 ++++ src/renderer/state/files.tsx | 21 +++++++++++++--- src/renderer/state/types.tsx | 11 +++++--- 6 files changed, 45 insertions(+), 25 deletions(-) create mode 100644 src/renderer/hooks/useFileDescription.tsx create mode 100644 src/renderer/hooks/useFileTitle.tsx diff --git a/src/common/invokables/jsons.ts b/src/common/invokables/jsons.ts index 28dcc92..3f5ca9f 100644 --- a/src/common/invokables/jsons.ts +++ b/src/common/invokables/jsons.ts @@ -11,6 +11,7 @@ export const JSON_ROOT_SCHEMA = z.union([ ANY_JSON_OBJECT_SCHEMA, z.array(ANY_JSON_OBJECT_SCHEMA), z.array(z.array(z.number())), + z.array(z.array(z.string())), ]); export type JsonRoot = z.infer; diff --git a/src/renderer/components/visual/form/JsonFormHeader.tsx b/src/renderer/components/visual/form/JsonFormHeader.tsx index 9fb8170..dc330b9 100644 --- a/src/renderer/components/visual/form/JsonFormHeader.tsx +++ b/src/renderer/components/visual/form/JsonFormHeader.tsx @@ -1,33 +1,22 @@ -import { memo, useMemo } from 'react'; import { Typography } from 'antd'; import ReactMarkdown from 'react-markdown'; -import { useFileSchema } from '../../../hooks/files'; +import { useFileTitle } from '../../../hooks/useFileTitle'; +import { useFileDescription } from '../../../hooks/useFileDescription'; interface JsonFormHeaderProps { file: string; } -export const JsonFormHeader = memo(function Header({ - file, -}: JsonFormHeaderProps) { - const schema = useFileSchema(file); - const title = useMemo(() => schema?.title ?? file, [schema, file]); - const description = useMemo(() => schema?.description ?? null, [schema]); - const itemsDescription = useMemo( - () => schema?.items?.description ?? null, - [schema?.items?.description], - ); - const combinedDescription = useMemo( - () => [itemsDescription, description].filter((v) => !!v).join('\n\n'), - [itemsDescription, description], - ); +export const JsonFormHeader = function Header({ file }: JsonFormHeaderProps) { + const title = useFileTitle(file); + const description = useFileDescription(file); return (
{title}
- {combinedDescription} + {description}
); -}); +}; diff --git a/src/renderer/hooks/useFileDescription.tsx b/src/renderer/hooks/useFileDescription.tsx new file mode 100644 index 0000000..e886728 --- /dev/null +++ b/src/renderer/hooks/useFileDescription.tsx @@ -0,0 +1,7 @@ +import { useAppSelector } from './state'; + +export function useFileDescription(file: string): string | null { + return useAppSelector( + (state) => state.files.disk[file]?.data?.description ?? null, + ); +} diff --git a/src/renderer/hooks/useFileTitle.tsx b/src/renderer/hooks/useFileTitle.tsx new file mode 100644 index 0000000..2c02137 --- /dev/null +++ b/src/renderer/hooks/useFileTitle.tsx @@ -0,0 +1,5 @@ +import { useAppSelector } from './state'; + +export function useFileTitle(file: string): string { + return useAppSelector((state) => state.files.disk[file]?.data?.title ?? ''); +} diff --git a/src/renderer/state/files.tsx b/src/renderer/state/files.tsx index 5dba956..55fdd6b 100644 --- a/src/renderer/state/files.tsx +++ b/src/renderer/state/files.tsx @@ -29,13 +29,15 @@ export type SaveMode = 'patch' | 'replace'; export type EditMode = 'visual' | 'text'; interface JsonFile { - saveMode: SaveMode; + title: string; + description: string | null; schema: JsonSchema; itemSchema: JsonSchema | null; vanilla: JsonRoot; mod: JsonRoot | null; patch: JsonPatch | null; applied: JsonRoot; + saveMode: SaveMode; } interface OpenBase { @@ -317,12 +319,25 @@ const filesSlice = createSlice({ }, }, extraReducers: (builder) => { - const transform = (data: InvokableOutput): JsonFile => { + const transform = ( + data: InvokableOutput, + file: string, + ): JsonFile => { const saveMode: SaveMode = data.value ? 'replace' : 'patch'; const applied = applyPatch(data.value ?? data.vanilla, data.patch ?? []); + const title = + [data.schema.title, data.schema.items?.title].find( + (t) => t && t.trim(), + ) ?? file; + const description = + [data.schema.items?.description, data.schema.description] + .filter((t) => t && t.trim()) + .join('\n\n') || null; return { - schema: data.schema, + title, + description, + schema: omit(data.schema, ['title', 'description']), itemSchema: data.schema.items ? omit(data.schema.items, ['title', 'description']) : null, diff --git a/src/renderer/state/types.tsx b/src/renderer/state/types.tsx index 8787831..25448e4 100644 --- a/src/renderer/state/types.tsx +++ b/src/renderer/state/types.tsx @@ -53,7 +53,7 @@ export function buildLoadableMapping< state: Draft, payload: PayloadAction, ) => Draft>, - transform: (data: Returned) => Transformed, + transform: (data: Returned, input: Input) => Transformed, postProcess: ( state: Draft, payload: PayloadAction, @@ -68,7 +68,7 @@ export function buildLoadableMapping< builder.addCase(thunk.fulfilled, (state, payload) => { fulfilledLoadable( selector(state, payload), - transform(payload.payload), + transform(payload.payload, payload.meta.arg), ); postProcess(state, payload); }); @@ -121,7 +121,7 @@ export function buildPersistableMapping< state: Draft, payload: PayloadAction, ) => Draft>, - transform: (data: Returned) => Transformed, + transform: (data: Returned, input: Input) => Transformed, postProcess: ( state: Draft, payload: PayloadAction, @@ -134,7 +134,10 @@ export function buildPersistableMapping< rejectedPersistable(selector(state, payload), payload), ); builder.addCase(thunk.fulfilled, (state, payload) => { - fulfilledPersistable(selector(state, payload), transform(payload.payload)); + fulfilledPersistable( + selector(state, payload), + transform(payload.payload, payload.meta.arg), + ); postProcess(state, payload); }); } From c6219c6c88ec68029113bc4c9e93831dfed7c22c Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Mon, 17 Nov 2025 18:28:26 +0100 Subject: [PATCH 7/9] Refactor the rest of the editor components --- src/renderer/components/TextEditor.tsx | 28 +++++++------------ .../components/layout/EditorLayout.tsx | 2 +- src/renderer/hooks/useFileTextUpdate.tsx | 18 ++++++++++++ src/renderer/hooks/useFileTextValue.tsx | 11 ++++++++ 4 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 src/renderer/hooks/useFileTextUpdate.tsx create mode 100644 src/renderer/hooks/useFileTextValue.tsx diff --git a/src/renderer/components/TextEditor.tsx b/src/renderer/components/TextEditor.tsx index 9b5a080..881aff4 100644 --- a/src/renderer/components/TextEditor.tsx +++ b/src/renderer/components/TextEditor.tsx @@ -1,17 +1,14 @@ import { Editor, useMonaco, loader } from '@monaco-editor/react'; -import { - useFileEditMode, - useFileSaveMode, - useFileSchema, - useFileText, -} from '../hooks/files'; import * as monaco from 'monaco-editor'; import { FullSizeLoader } from './common/FullSizeLoader'; import { useCallback, useEffect } from 'react'; import { toJSONSchema } from 'zod'; import { JSON_PATCH_SCHEMA } from '../../common/invokables/jsons'; -import { useAppDispatch } from '../hooks/state'; -import { changeText } from '../state/files'; +import { useFileSaveMode } from '../hooks/useFileSaveMode'; +import { useFileJsonSchema } from '../hooks/useFileJsonSchema'; +import { useFileTextValue } from '../hooks/useFileTextValue'; +import { useFileTextUpdate } from '../hooks/useFileTextUpdate'; +import { useFileEditMode } from '../hooks/useFileEditMode'; const JSON_PATCH_JSON_SCHEMA = toJSONSchema(JSON_PATCH_SCHEMA); @@ -24,23 +21,18 @@ export interface TextEditorProps { } export function TextEditor({ file }: TextEditorProps) { - const dispatch = useAppDispatch(); const monaco = useMonaco(); const saveMode = useFileSaveMode(file); - const schema = useFileSchema(file); - const [text] = useFileText(file); + const schema = useFileJsonSchema(file); + const text = useFileTextValue(file); + const update = useFileTextUpdate(file); const loading = !monaco || typeof text !== 'string'; const onChange = useCallback( (value: string | undefined) => { if (typeof value === 'undefined') return; - dispatch( - changeText({ - filename: file, - value, - }), - ); + update(value); }, - [dispatch, file], + [update], ); useEffect(() => { diff --git a/src/renderer/components/layout/EditorLayout.tsx b/src/renderer/components/layout/EditorLayout.tsx index ad90237..3a84b95 100644 --- a/src/renderer/components/layout/EditorLayout.tsx +++ b/src/renderer/components/layout/EditorLayout.tsx @@ -5,7 +5,7 @@ import { Badge, Layout, Menu, MenuProps, Space, Typography } from 'antd'; import './EditorLayout.css'; import { Item, MENU, MenuItem } from '../../EditorRoutes'; import { useAppSelector } from '../../hooks/state'; -import { useFileModified } from '../../hooks/files'; +import { useFileModified } from '../../hooks/useFileModified'; type ItemType = NonNullable[number]; diff --git a/src/renderer/hooks/useFileTextUpdate.tsx b/src/renderer/hooks/useFileTextUpdate.tsx new file mode 100644 index 0000000..7178a0a --- /dev/null +++ b/src/renderer/hooks/useFileTextUpdate.tsx @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; +import { changeText } from '../state/files'; +import { useAppDispatch } from './state'; + +export function useFileTextUpdate(file: string): (value: string) => unknown { + const dispatch = useAppDispatch(); + return useCallback( + (value: string) => { + dispatch( + changeText({ + filename: file, + value, + }), + ); + }, + [dispatch, file], + ); +} diff --git a/src/renderer/hooks/useFileTextValue.tsx b/src/renderer/hooks/useFileTextValue.tsx new file mode 100644 index 0000000..57a13fe --- /dev/null +++ b/src/renderer/hooks/useFileTextValue.tsx @@ -0,0 +1,11 @@ +import { useAppSelector } from './state'; + +export function useFileTextValue(file: string): string | null { + return useAppSelector((s) => { + const open = s.files.open[file]; + if (!open || open.editMode === 'visual') { + return null; + } + return open.value; + }); +} From ebe1f307378e6c22003686e92d67c49af8b98e77 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Tue, 18 Nov 2025 09:13:20 +0100 Subject: [PATCH 8/9] Refactor preview and reference components --- .../components/content/MercPreview.tsx | 55 ++-- .../components/content/StiPreview.tsx | 7 +- .../components/visual/VisualFormWrapper.tsx | 9 +- .../visual/form/StringReferenceWidget.tsx | 127 ++++---- src/renderer/hooks/files.tsx | 278 ------------------ src/renderer/hooks/state.tsx | 8 +- src/renderer/hooks/useFileLoad.tsx | 14 + src/renderer/hooks/useFilesJsonDiskValue.tsx | 17 ++ 8 files changed, 139 insertions(+), 376 deletions(-) delete mode 100644 src/renderer/hooks/files.tsx create mode 100644 src/renderer/hooks/useFileLoad.tsx create mode 100644 src/renderer/hooks/useFilesJsonDiskValue.tsx diff --git a/src/renderer/components/content/MercPreview.tsx b/src/renderer/components/content/MercPreview.tsx index 5198954..cdd027d 100644 --- a/src/renderer/components/content/MercPreview.tsx +++ b/src/renderer/components/content/MercPreview.tsx @@ -1,29 +1,35 @@ -import { ExclamationOutlined } from '@ant-design/icons'; +import { ExclamationOutlined, QuestionOutlined } from '@ant-design/icons'; import { Image, Flex } from 'antd'; import { useEffect, useMemo } from 'react'; import { useImageFile } from '../../hooks/useImage'; -import { useFileJson } from '../../hooks/files'; -import { Loader } from '../common/Loader'; +import { useFileJsonDiskValue } from '../../hooks/useFileJsonDiskValue'; +import { isArray, isPlainObject } from 'remeda'; +import { useFileLoad } from '../../hooks/useFileLoad'; interface MercPreviewProps { profile: string; } +const PROFILES_FILE = 'mercs-profile-info.json'; + export function MercPreview({ profile }: MercPreviewProps) { - const [content] = useFileJson('mercs-profile-info.json'); + const loadFile = useFileLoad(); + const profiles = useFileJsonDiskValue(PROFILES_FILE); const profileId = useMemo(() => { - if (!content) { - return null; - } - if (!Array.isArray(content)) { - return null; - } - const p = content.find((it: any) => it.internalName === profile); - if (!p || typeof p.profileID !== 'number') { + if (!profiles || !isArray(profiles)) return null; + + const profileValue = profiles + .filter(isPlainObject) + .find((it) => it.internalName === profile); + if ( + !profileValue || + !('profileID' in profileValue) || + typeof profileValue.profileID !== 'number' + ) { return null; } - return p.profileID as number; - }, [content, profile]); + return profileValue.profileID; + }, [profiles, profile]); const graphic1 = useMemo(() => { if (profileId === null) { return null; @@ -37,32 +43,31 @@ export function MercPreview({ profile }: MercPreviewProps) { return `faces/B${profileId.toString().padStart(2, '0')}.sti`; }, [profileId]); const { - loading: loading1, data: image1, error: error1, refresh: refresh1, } = useImageFile(graphic1); const { - loading: loading2, data: image2, error: error2, refresh: refresh2, } = useImageFile(graphic2); const image = useMemo(() => { - const loading = loading1 || loading2; - if (loading) { - return ; + const error = error1 && error2 ? (error1 ?? error2) : null; + if (profileId === null || (!image1 && !image2 && !error)) { + return ; } - const error = error1 && error2 ? error1 || error2 : null; if (error) { return ; } - if (!image1 && !image2) { - return null; - } - return ; - }, [error1, error2, image1, image2, loading1, loading2]); + return ; + }, [error1, error2, image1, image2, profileId]); + useEffect(() => { + if (!profiles) { + loadFile(PROFILES_FILE); + } + }, [loadFile, profiles]); useEffect(() => { refresh1(); refresh2(); diff --git a/src/renderer/components/content/StiPreview.tsx b/src/renderer/components/content/StiPreview.tsx index 72938d7..e16bf3c 100644 --- a/src/renderer/components/content/StiPreview.tsx +++ b/src/renderer/components/content/StiPreview.tsx @@ -1,8 +1,7 @@ -import { ExclamationOutlined } from '@ant-design/icons'; +import { ExclamationOutlined, QuestionOutlined } from '@ant-design/icons'; import { Flex, Image } from 'antd'; import { useEffect, useMemo } from 'react'; import { useImageFile } from '../../hooks/useImage'; -import { Loader } from '../common/Loader'; export function StiPreview({ file, @@ -14,13 +13,13 @@ export function StiPreview({ const { loading, data, error, refresh } = useImageFile(file, subimage); const image = useMemo(() => { if (loading) { - return ; + return ; } if (error) { return ; } if (!data) { - return null; + return ; } return ; }, [loading, error, data]); diff --git a/src/renderer/components/visual/VisualFormWrapper.tsx b/src/renderer/components/visual/VisualFormWrapper.tsx index 5c15b2d..3cf4539 100644 --- a/src/renderer/components/visual/VisualFormWrapper.tsx +++ b/src/renderer/components/visual/VisualFormWrapper.tsx @@ -5,8 +5,7 @@ import { FullSizeLoader } from '../common/FullSizeLoader'; import { ErrorAlert, ErrorAlertProps } from '../common/ErrorAlert'; import { useAnyFileLoading } from '../../hooks/useAnyFileLoading'; import { useAnyFileLoadingError } from '../../hooks/useAnyFileLoadingError'; -import { useAppDispatch } from '../../hooks/state'; -import { loadJSON } from '../../state/files'; +import { useFileLoad } from '../../hooks/useFileLoad'; export interface VisualFormProps { /* @@ -36,7 +35,7 @@ export function VisualFormWrapper({ extraFiles, children, }: PropsWithChildren) { - const dispatch = useAppDispatch(); + const loadFile = useFileLoad(); const allFiles = useMemo( () => [file, ...(extraFiles ?? [])], [file, extraFiles], @@ -46,9 +45,9 @@ export function VisualFormWrapper({ useEffect(() => { for (const file of allFiles) { - dispatch(loadJSON(file)); + loadFile(file); } - }, [dispatch, allFiles]); + }, [allFiles, loadFile]); return ( diff --git a/src/renderer/components/visual/form/StringReferenceWidget.tsx b/src/renderer/components/visual/form/StringReferenceWidget.tsx index 4bf9188..e6735f9 100644 --- a/src/renderer/components/visual/form/StringReferenceWidget.tsx +++ b/src/renderer/components/visual/form/StringReferenceWidget.tsx @@ -1,78 +1,62 @@ import { WidgetProps } from '@rjsf/utils'; -import { Input, AutoComplete, AutoCompleteProps } from 'antd'; -import { JSX, useCallback, useMemo } from 'react'; -import { - useFilesError, - useFilesJson, - useFilesLoading, -} from '../../../hooks/files'; +import { Input, AutoComplete, Flex } from 'antd'; +import { JSX, useCallback, useEffect, useMemo } from 'react'; import { BaseOptionType } from 'antd/lib/select'; import { Space } from 'antd/lib'; import { MercPreview } from '../../content/MercPreview'; import { ItemPreview } from '../../content/ItemPreview'; - -type PreviewFn = (item: any) => JSX.Element | string | null; - -interface StringReferenceWidgetProps - extends WidgetProps { - references: { - [key in keyof T]: { file: string; property: string; preview?: PreviewFn }; - }; +import { useAnyFileLoading } from '../../../hooks/useAnyFileLoading'; +import { useAnyFileLoadingError } from '../../../hooks/useAnyFileLoadingError'; +import { useFilesJsonDiskValue } from '../../../hooks/useFilesJsonDiskValue'; +import { isArray, uniqueBy } from 'remeda'; +import { AnyJsonObject } from '../../../../common/invokables/jsons'; +import { Loader } from '../../common/Loader'; +import { ExclamationCircleOutlined } from '@ant-design/icons'; +import { useFileLoad } from '../../../hooks/useFileLoad'; + +type PreviewFn = (item: AnyJsonObject) => JSX.Element | string | null; + +interface StringReferenceWidgetProps extends WidgetProps { + references: Array<{ file: string; property: string; preview?: PreviewFn }>; } -export function StringReferenceWidget({ +export function StringReferenceWidget({ value, onChange, required, references, -}: StringReferenceWidgetProps) { - const files = useMemo(() => { - const f: { [key in keyof T]: string } = {} as any; - for (const key in references) { - f[key] = references[key].file; - } - return f; - }, [references]); - const loadings = useFilesLoading(files); - const errors = useFilesError(files); - const loadingDidNotComplete = useMemo(() => { - return ( - Object.values(loadings).some((l) => l) || - Object.values(errors).some((e) => e) - ); - }, [loadings, errors]); - const { values } = useFilesJson(files); +}: StringReferenceWidgetProps) { + const files = useMemo(() => references.map((r) => r.file), [references]); + const loading = useAnyFileLoading(files); + const error = useAnyFileLoadingError(files); + const values = useFilesJsonDiskValue(files); + const loadFile = useFileLoad(); const options = useMemo(() => { - if (!Object.values(values).every((v) => v)) { - return []; - } - const options: Array< - NonNullable[0] & { value: string } - > = []; - for (const key in values) { - const fileResults = values[key] as Array; - if (!fileResults) continue; - for (const item of fileResults) { - const value: string = item[references[key].property] ?? ''; + const options = values.flatMap((value, index) => { + if (!isArray(value)) return []; + return value.flatMap((o) => { + const reference = references[index]; + if (isArray(o) || !reference) return []; + const value = (o[reference.property] as any) ?? ''; + if (typeof value !== 'string') return []; let label: JSX.Element | string | null = value; - if (references[key].preview) { + if (reference.preview) { label = ( - {references[key].preview(item)} + {reference.preview(o)} {value} ); } - if (!options.some((option) => option.value === value)) { - options.push({ - value, - label, - }); - } - } - } + + return { + value, + label, + }; + }); + }); options.sort((a, b) => a.value.localeCompare(b.value)); - return options; + return uniqueBy(options, (option) => option.value); }, [values, references]); const onChangeMemo = useCallback( (value: BaseOptionType) => { @@ -81,8 +65,28 @@ export function StringReferenceWidget({ [onChange], ); - if (loadingDidNotComplete) { - return ; + useEffect(() => { + files.forEach((file, idx) => { + if (!values[idx]) { + loadFile(file); + } + }); + }, [files, loadFile, values]); + + if (loading) { + return ( + + + + ); + } + if (error) { + return ( + + ; + + + ); } return ( @@ -106,13 +110,13 @@ export function stringReferenceTo( return function StringReference(props: WidgetProps) { return ( ); @@ -122,8 +126,9 @@ export function stringReferenceTo( export function stringReferenceToMultiple(references: { [k: string]: { file: string; property: string; preview?: PreviewFn }; }) { + const referencesArr = Object.values(references); return function StringReference(props: WidgetProps) { - return ; + return ; }; } diff --git a/src/renderer/hooks/files.tsx b/src/renderer/hooks/files.tsx deleted file mode 100644 index aa883dd..0000000 --- a/src/renderer/hooks/files.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { useCallback } from 'react'; -import { useAppDispatch, useAppSelector } from './state'; -import { - changeJson, - changeJsonItem, - EditMode, - // loadJSON, - SaveMode, -} from '../state/files'; -import { SerializedError } from '@reduxjs/toolkit'; -import { AppState } from '../state/store'; -import { memoize } from 'proxy-memoize'; -import { JsonRoot, JsonSchema } from '../../common/invokables/jsons'; - -type UseFilesRequest = { [key: string]: string }; - -type UseFilesResult = { - [key in keyof R]: V; -}; - -const useAppFilesProxySelector = function useAppFilesProxySelector( - fn: (state: AppState) => R, - deps: any[], -): R { - // eslint-disable-next-line react-hooks/exhaustive-deps - return useAppSelector(useCallback(memoize(fn), deps)); -}; - -export function useFilesLoading( - files: R, -): UseFilesResult { - const loading = useAppFilesProxySelector( - (s: AppState) => { - const loading: { [key in keyof R]: boolean | null } = {} as any; - for (const key in files) { - loading[key] = s.files.disk[files[key]]?.loading ?? null; - } - return loading; - }, - [files], - ); - - return loading; -} - -export function useFilesError( - files: R, -): UseFilesResult { - const errors = useAppFilesProxySelector( - function selectFilesError(s) { - const errors: { [key in keyof R]: SerializedError | null } = {} as any; - for (const key in files) { - errors[key] = s.files.disk[files[key]]?.loadingError ?? null; - } - return errors; - }, - [files], - ); - - return errors; -} - -export function useFilesSchema( - files: R, -): UseFilesResult | null> { - const schemas = useAppFilesProxySelector( - function selectFilesSchema(s) { - const schemas: { [key in keyof R]: Record | null } = - {} as any; - for (const key in files) { - schemas[key] = s.files.disk[files[key]]?.data?.schema ?? null; - } - return schemas; - }, - [files], - ); - - return schemas; -} - -export function useFilesJson( - files: R, -): { - values: UseFilesResult; - update: (file: keyof R, value: JsonRoot) => void; -} { - const dispatch = useAppDispatch(); - // const loading = useFilesLoading(files); - const values = useAppFilesProxySelector( - function selectFilesJson(s) { - const values: { [key in keyof R]: JsonRoot | null } = {} as any; - for (const key in files) { - const open = s.files.open[files[key]!]; - if (!open || open.editMode === 'text') { - values[key] = null; - } else { - values[key] = open.value; - } - } - return values; - }, - [files], - ); - const update = useCallback( - (file: keyof R, value: JsonRoot) => { - dispatch( - changeJson({ - filename: files[file]!, - value, - }), - ); - }, - [files, dispatch], - ); - - // useEffect(() => { - // for (const key in files) { - // if (loading[key] === null) { - // dispatch(loadJSON(files[key]!)); - // } - // } - // }, [dispatch, files, loading]); - - return { - values, - update, - }; -} - -export function useFileLoading(filename: string): boolean | null { - return useAppSelector(function selectFileLoading(s) { - return s.files.disk[filename]?.loading ?? null; - }); -} - -export function useFileSaving(filename: string): boolean | null { - return useAppSelector(function selectFileSaving(s) { - return s.files.disk[filename]?.persisting ?? null; - }); -} - -export function useFileSaveMode(filename: string): SaveMode | null { - return useAppSelector((s) => { - return s.files.open[filename]?.saveMode ?? null; - }); -} - -export function useFileEditMode(filename: string): EditMode | null { - return useAppSelector((s) => { - return s.files.open[filename]?.editMode ?? null; - }); -} - -export function useFileLoadingError(filename: string): SerializedError | null { - return useAppSelector(function selectFileError(s) { - return s.files.disk[filename]?.loadingError ?? null; - }); -} - -export function useFilePersistingError( - filename: string, -): SerializedError | null { - return useAppSelector(function selectFileError(s) { - return s.files.disk[filename]?.persistingError ?? null; - }); -} - -export function useFileSchema(filename: string): JsonSchema | null { - return useAppSelector(function selectFileSchema(s) { - return s.files.disk[filename]?.data?.schema ?? null; - }); -} - -export function useFileModified(filename: string): boolean | null { - return useAppSelector(function selectFileModified(s) { - return s.files.open[filename]?.modified ?? null; - }); -} - -export function useFileJson( - filename: string, -): [JsonRoot | null, (value: JsonRoot) => void] { - const dispatch = useAppDispatch(); - // const loading = useFileLoading(filename); - const update = useCallback( - (value: JsonRoot) => { - dispatch( - changeJson({ - filename, - value, - }), - ); - }, - [filename, dispatch], - ); - - // useEffect(() => { - // if (loading === null) { - // dispatch(loadJSON(filename)); - // } - // }, [dispatch, filename, loading]); - - return [ - useAppSelector((s) => { - const open = s.files.open[filename]; - if (!open || open.editMode === 'text') { - return null; - } - return open.value; - }), - update, - ]; -} - -export function useFileText( - filename: string, -): [string | null, (value: string) => void] { - // const dispatch = useAppDispatch(); - // const loading = useFileLoading(filename); - const update = useCallback(() => { - throw new Error('not implemented'); - }, []); - - // useEffect(() => { - // if (loading === null) { - // dispatch(loadJSON(filename)); - // } - // }, [dispatch, filename, loading]); - - return [ - useAppSelector((s) => { - const open = s.files.open[filename]; - if (!open || open.editMode === 'visual') { - return null; - } - return open.value; - }), - update, - ]; -} - -export function useFileJsonItemSchema(filename: string): JsonSchema | null { - return useAppSelector(function selectFileSchema(s) { - return s.files.disk[filename]?.data?.itemSchema ?? null; - }); -} - -export function useFileJsonNumberOfItems(filename: string): number | null { - const [arr] = useFileJson(filename); - if (!arr) return null; - if (!Array.isArray(arr)) return null; - - return arr.length; -} - -export function useFileJsonItem( - filename: string, - index: number, -): [Record | null, (value: Record) => void] { - const dispatch = useAppDispatch(); - const [arr] = useFileJson(filename); - const update = useCallback( - (value: Record) => { - dispatch( - changeJsonItem({ - filename, - index, - value, - }), - ); - }, - [dispatch, filename, index], - ); - if (!arr) return [null, update]; - if (!Array.isArray(arr)) return [null, update]; - - return [arr[index] ?? null, update]; -} diff --git a/src/renderer/hooks/state.tsx b/src/renderer/hooks/state.tsx index 78f331a..b43d10e 100644 --- a/src/renderer/hooks/state.tsx +++ b/src/renderer/hooks/state.tsx @@ -1,5 +1,7 @@ -import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from '@reduxjs/toolkit'; import type { AppState, AppDispatch } from '../state/store'; -export const useAppDispatch: () => AppDispatch = useDispatch; -export const useAppSelector: TypedUseSelectorHook = useSelector; +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); +export const createAppSelector = createSelector.withTypes(); diff --git a/src/renderer/hooks/useFileLoad.tsx b/src/renderer/hooks/useFileLoad.tsx new file mode 100644 index 0000000..2df1744 --- /dev/null +++ b/src/renderer/hooks/useFileLoad.tsx @@ -0,0 +1,14 @@ +import { useCallback } from 'react'; +import { useAppDispatch } from './state'; +import { loadJSON } from '../state/files'; + +export function useFileLoad(): (file: string) => unknown { + const dispatch = useAppDispatch(); + return useCallback( + (file: string) => { + dispatch(loadJSON(file)); + // Load the file and return the result + }, + [dispatch], + ); +} diff --git a/src/renderer/hooks/useFilesJsonDiskValue.tsx b/src/renderer/hooks/useFilesJsonDiskValue.tsx new file mode 100644 index 0000000..c83aa47 --- /dev/null +++ b/src/renderer/hooks/useFilesJsonDiskValue.tsx @@ -0,0 +1,17 @@ +import { JsonRoot } from '../../common/invokables/jsons'; +import { createAppSelector, useAppSelector } from './state'; +import { AppState } from '../state/store'; +import { useMemo } from 'react'; + +function createSelector(files: string[]) { + return createAppSelector([(state: AppState) => state.files.disk], (disk) => + files.map((file) => disk[file]?.data?.applied ?? null), + ); +} + +export function useFilesJsonDiskValue( + files: Array, +): Array { + const selector = useMemo(() => createSelector(files), [files]); + return useAppSelector(selector); +} From 154d80575e66ba39590ae7232daddece39905fa2 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Tue, 18 Nov 2025 10:20:40 +0100 Subject: [PATCH 9/9] Add error boundary to visual editor --- package-lock.json | 13 +++++ package.json | 1 + src/renderer/components/visual/JsonForm.tsx | 6 +-- .../components/visual/JsonItemsForm.tsx | 3 +- .../components/visual/MovementCostsForm.tsx | 3 +- .../components/visual/StrategicMapForm.tsx | 2 + .../components/visual/VisualErrorBoundary.tsx | 48 +++++++++++++++++++ .../components/visual/VisualFormWrapper.tsx | 16 +++++-- .../visual/form/StringReferenceWidget.tsx | 9 ++-- src/renderer/hooks/useFileHasDiskValue.tsx | 7 +++ 10 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 src/renderer/components/visual/VisualErrorBoundary.tsx create mode 100644 src/renderer/hooks/useFileHasDiskValue.tsx diff --git a/package-lock.json b/package-lock.json index 18592a5..d7f1310 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "proxy-memoize": "^3.0.1", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-error-boundary": "^6.0.0", "react-hotkeys-hook": "^5.2.1", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", @@ -18511,6 +18512,18 @@ "react": "^19.1.1" } }, + "node_modules/react-error-boundary": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz", + "integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-hotkeys-hook": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.2.1.tgz", diff --git a/package.json b/package.json index e27ef61..02884ff 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "proxy-memoize": "^3.0.1", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-error-boundary": "^6.0.0", "react-hotkeys-hook": "^5.2.1", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", diff --git a/src/renderer/components/visual/JsonForm.tsx b/src/renderer/components/visual/JsonForm.tsx index 59f8224..200c803 100644 --- a/src/renderer/components/visual/JsonForm.tsx +++ b/src/renderer/components/visual/JsonForm.tsx @@ -12,7 +12,7 @@ export interface JsonFormProps extends VisualFormProps { uiSchema?: UiSchema; } -function Form({ file, uiSchema }: JsonFormProps) { +function Form({ file, uiSchema }: Omit) { const value = useFileJsonValue(file); const update = useFileJsonUpdate(file); const baseSchema = useFileJsonSchema(file); @@ -46,9 +46,9 @@ function Form({ file, uiSchema }: JsonFormProps) { ); } -export function JsonForm({ file, ...rest }: JsonFormProps) { +export function JsonForm({ file, extraFiles, ...rest }: JsonFormProps) { return ( - + ); diff --git a/src/renderer/components/visual/JsonItemsForm.tsx b/src/renderer/components/visual/JsonItemsForm.tsx index 96b35a5..c4cb985 100644 --- a/src/renderer/components/visual/JsonItemsForm.tsx +++ b/src/renderer/components/visual/JsonItemsForm.tsx @@ -132,6 +132,7 @@ const FormItems = function FormItems({ export const JsonItemsForm = function JsonItemsForm({ file, + extraFiles, name, preview, uiSchema, @@ -151,7 +152,7 @@ export const JsonItemsForm = function JsonItemsForm({ }, [addNewItem, canAddNewItem]); return ( - + (null); @@ -202,6 +202,7 @@ export function MovementCostsForm({ file }: VisualFormProps) { diff --git a/src/renderer/components/visual/StrategicMapForm.tsx b/src/renderer/components/visual/StrategicMapForm.tsx index 239dc0c..0d66599 100644 --- a/src/renderer/components/visual/StrategicMapForm.tsx +++ b/src/renderer/components/visual/StrategicMapForm.tsx @@ -94,6 +94,7 @@ export interface StrategicMapFormProps extends VisualFormProps { export function JsonStrategicMapForm({ file, + extraFiles, uiSchema, extractSectorFromItem, transformSectorToItem, @@ -144,6 +145,7 @@ export function JsonStrategicMapForm({ return ( { + updateEditMode('text'); + }, [updateEditMode]); + + return ( + + + + An error occured in the visual editor. Usually this means your JSON file + does not meet the expectations. To fix it, you can edit the JSON file in + text mode. + + + + + + + ); +} + +export function VisualErrorBoundary({ + file, + children, +}: PropsWithChildren) { + const FallbackComponent = useCallback( + (props: FallbackProps) => , + [file], + ); + + return ( + + {children} + + ); +} diff --git a/src/renderer/components/visual/VisualFormWrapper.tsx b/src/renderer/components/visual/VisualFormWrapper.tsx index 3cf4539..4f4a198 100644 --- a/src/renderer/components/visual/VisualFormWrapper.tsx +++ b/src/renderer/components/visual/VisualFormWrapper.tsx @@ -6,6 +6,8 @@ import { ErrorAlert, ErrorAlertProps } from '../common/ErrorAlert'; import { useAnyFileLoading } from '../../hooks/useAnyFileLoading'; import { useAnyFileLoadingError } from '../../hooks/useAnyFileLoadingError'; import { useFileLoad } from '../../hooks/useFileLoad'; +import { useFileHasDiskValue } from '../../hooks/useFileHasDiskValue'; +import { VisualErrorBoundary } from './VisualErrorBoundary'; export interface VisualFormProps { /* @@ -20,10 +22,12 @@ export interface VisualFormProps { } function LoadingOr({ - children, + file, loading, -}: PropsWithChildren<{ loading: boolean }>) { - return loading ? : children; + children, +}: PropsWithChildren & { loading: boolean }>) { + const hasDiskValue = useFileHasDiskValue(file); + return loading && !hasDiskValue ? : children; } function ErrorOr({ children, ...rest }: PropsWithChildren) { @@ -51,9 +55,11 @@ export function VisualFormWrapper({ return ( - + - {children} + + {children} + diff --git a/src/renderer/components/visual/form/StringReferenceWidget.tsx b/src/renderer/components/visual/form/StringReferenceWidget.tsx index e6735f9..2f96e47 100644 --- a/src/renderer/components/visual/form/StringReferenceWidget.tsx +++ b/src/renderer/components/visual/form/StringReferenceWidget.tsx @@ -1,7 +1,6 @@ import { WidgetProps } from '@rjsf/utils'; import { Input, AutoComplete, Flex } from 'antd'; import { JSX, useCallback, useEffect, useMemo } from 'react'; -import { BaseOptionType } from 'antd/lib/select'; import { Space } from 'antd/lib'; import { MercPreview } from '../../content/MercPreview'; import { ItemPreview } from '../../content/ItemPreview'; @@ -59,7 +58,7 @@ export function StringReferenceWidget({ return uniqueBy(options, (option) => option.value); }, [values, references]); const onChangeMemo = useCallback( - (value: BaseOptionType) => { + (value: string) => { onChange(value); }, [onChange], @@ -67,11 +66,11 @@ export function StringReferenceWidget({ useEffect(() => { files.forEach((file, idx) => { - if (!values[idx]) { + if (!values[idx] && !error) { loadFile(file); } }); - }, [files, loadFile, values]); + }, [error, files, loadFile, values]); if (loading) { return ( @@ -82,7 +81,7 @@ export function StringReferenceWidget({ } if (error) { return ( - + ; diff --git a/src/renderer/hooks/useFileHasDiskValue.tsx b/src/renderer/hooks/useFileHasDiskValue.tsx new file mode 100644 index 0000000..83536b1 --- /dev/null +++ b/src/renderer/hooks/useFileHasDiskValue.tsx @@ -0,0 +1,7 @@ +import { useAppSelector } from './state'; + +export function useFileHasDiskValue(file: string): boolean { + return useAppSelector((s) => { + return !!s.files.disk[file]?.data; + }); +}