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/common/invokables/jsons.ts b/src/common/invokables/jsons.ts index b038ab6..3f5ca9f 100644 --- a/src/common/invokables/jsons.ts +++ b/src/common/invokables/jsons.ts @@ -5,10 +5,13 @@ 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())), + z.array(z.array(z.string())), ]); export type JsonRoot = z.infer; 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/EditorContent.tsx b/src/renderer/components/EditorContent.tsx deleted file mode 100644 index a2ecee7..0000000 --- a/src/renderer/components/EditorContent.tsx +++ /dev/null @@ -1,162 +0,0 @@ -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 { - changeSaveMode, - changeEditMode, - EditMode, - persistJSON, - SaveMode, -} from '../state/files'; -import { - useFileEditMode, - useFileModified, - useFilePersistingError, - useFileSaveMode, - useFileSaving, -} from '../hooks/files'; - -const SAVE_MODE_SELECT_OPTIONS = [ - { - label: 'Patch', - title: - 'Use this option if your mod should only adapt content, not fully replace it. The resulting file will only contain the changes you made to the original file.', - value: 'patch', - }, - { - label: 'Replace', - title: - 'Use this option if you want to fully replace content from vanilla or other mods. The resulting file will contain the modified original file.', - value: 'replace', - }, -]; - -const EDIT_MODE_SELECT_OPTIONS = [ - { - label: 'Visual', - title: 'Use a visual editor to edit the file', - value: 'visual', - }, - { - label: 'Text', - title: 'Use a text editor to edit the file', - value: 'text', - }, -]; - -interface ContentProps { - path: 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' }), []); - 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], - ); - - useHotkeys('ctrl+s', saveFile, { - enableOnFormTags: true, - preventDefault: true, - }); - - return ( - - - - {error ? ( - - ) : null} - - - - Edit Mode - - - - - ); -}); - -export const EditorContent = memo(function EditorContent({ - path, - children, -}: ContentProps) { - return ( - -
- -
-
-
- {children} -
-
-
- ); -}); diff --git a/src/renderer/components/FullSizeDialogLayout.css b/src/renderer/components/FullSizeDialogLayout.css deleted file mode 100644 index 083d213..0000000 --- a/src/renderer/components/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/FullSizeDialogLayout.tsx b/src/renderer/components/FullSizeDialogLayout.tsx deleted file mode 100644 index 08f883e..0000000 --- a/src/renderer/components/FullSizeDialogLayout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Layout } from 'antd'; -import './FullSizeDialogLayout.css'; - -export function FullSizeDialogLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - -
{children}
-
- ); -} 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/FullSizeLoader.tsx b/src/renderer/components/FullSizeLoader.tsx deleted file mode 100644 index 12497b9..0000000 --- a/src/renderer/components/FullSizeLoader.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Spin } from 'antd'; -import { LoadingOutlined } from '@ant-design/icons'; - -import './FullSizeLoader.css'; - -const antIcon = ; - -export function FullSizeLoader() { - return ( -
- -
- ); -} diff --git a/src/renderer/components/JsonForm.tsx b/src/renderer/components/JsonForm.tsx deleted file mode 100644 index 4d5cdfb..0000000 --- a/src/renderer/components/JsonForm.tsx +++ /dev/null @@ -1,72 +0,0 @@ -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 { 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 { miniSerializeError } from '@reduxjs/toolkit'; -import { TextEditorOr } from './TextEditor'; - -export interface JsonFormProps { - file: string; - 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); - const schema = useMemo(() => { - if (!baseSchema) { - return null; - } - return { - ...baseSchema, - title: undefined, - description: undefined, - }; - }, [baseSchema]); - const onFormChange = useCallback( - (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 ; - } - - return ( - - {contents} - - ); -} diff --git a/src/renderer/components/JsonItemsForm.css b/src/renderer/components/JsonItemsForm.css deleted file mode 100644 index 45db550..0000000 --- a/src/renderer/components/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/JsonItemsForm.tsx b/src/renderer/components/JsonItemsForm.tsx deleted file mode 100644 index 4ae733a..0000000 --- a/src/renderer/components/JsonItemsForm.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useCallback, useMemo, JSX, memo } from 'react'; -import { Collapse, Flex } from 'antd'; -import { JsonSchemaForm } from './JsonSchemaForm'; -import { FullSizeLoader } from './FullSizeLoader'; -import './JsonItemsForm.css'; -import { IChangeEvent } from '@rjsf/core'; -import { UiSchema } from '@rjsf/utils'; -import { EditorContent } from './EditorContent'; -import { JsonFormHeader } from './form/JsonFormHeader'; -import { - useFileLoading, - useFileLoadingError, - useFileJsonItem, - useFileJsonItemSchema, - useFileJsonNumberOfItems, -} from '../hooks/files'; -import { ErrorAlert } from './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'; - -type PreviewFn = (item: any) => JSX.Element | string | null; - -type NameOrPreviewFn = string | PreviewFn; - -interface ItemFormHeaderProps { - file: string; - index: number; - name: NameOrPreviewFn; - preview?: PreviewFn; -} - -const ItemFormHeader = memo(function ItemFormHeader({ - file, - index, - name, - preview, -}: ItemFormHeaderProps) { - const [value] = useFileJsonItem(file, index); - const label = useMemo(() => { - if (typeof name === 'string') { - const label = value ? value[name] : null; - if (typeof label == 'string') { - return label; - } - return ''; - } - return name(value); - }, [name, value]); - const p = useMemo(() => (preview ? preview(value) : null), [preview, value]); - - return ( - - - {p} - {label} - - - - ); -}); - -interface ItemFormProps { - file: string; - name: NameOrPreviewFn; - preview?: PreviewFn; - uiSchema?: UiSchema; - index: number; -} - -function ItemForm({ file, name, preview, uiSchema, index }: ItemFormProps) { - const schema = useFileJsonItemSchema(file); - const [value, update] = useFileJsonItem(file, index); - const onItemChange = useCallback( - (ev: IChangeEvent) => update(ev.formData), - [update], - ); - - return ( - - ), - children: ( - - ), - }, - ]} - /> - ); -} - -export interface FormItemsProps { - file: string; - name: NameOrPreviewFn; - preview?: PreviewFn; - numItems: number | null; - uiSchema?: UiSchema; -} - -const FormItems = memo(function FormItems({ - file, - name, - preview, - numItems, - uiSchema, -}: FormItemsProps) { - const items = useMemo(() => { - if (numItems == null) { - return null; - } - const i = []; - for (let it = 0; it < numItems; it++) { - i.push( - , - ); - } - return i; - }, [file, name, numItems, preview, uiSchema]); - - return ( - - {items} - - ); -}); - -export interface JsonItemsFormProps { - file: string; - name: NameOrPreviewFn; - preview?: PreviewFn; - uiSchema?: UiSchema; - canAddNewItem?: boolean; - getNewItem?: () => object; -} - -export const JsonItemsForm = memo(function JsonItemsForm({ - file, - name, - preview, - uiSchema, - canAddNewItem, - 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() : {} }), - ); - }, [dispatch, file, getNewItem]); - const addButton = useMemo(() => { - const render = typeof canAddNewItem === 'undefined' ? true : canAddNewItem; - if (!render) return null; - return ; - }, [addNewItem, canAddNewItem]); - const content = useMemo(() => { - if (numItems == null) { - return ; - } - return ( - <> - - - - {addButton} - - - ); - }, [addButton, file, name, numItems, preview, uiSchema]); - - if (error) { - return ; - } - if (loading) { - return ; - } - return ( - - {content} - - ); -}); diff --git a/src/renderer/components/SamSitesAirControlForm.tsx b/src/renderer/components/SamSitesAirControlForm.tsx deleted file mode 100644 index 1cb2d90..0000000 --- a/src/renderer/components/SamSitesAirControlForm.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { clone } from 'remeda'; -import { - useFileJson, - useFileLoading, - useFileLoadingError, -} from '../hooks/files'; -import { EditorContent } from './EditorContent'; -import { ErrorAlert } from './ErrorAlert'; -import { FullSizeLoader } from './FullSizeLoader'; -import { TextEditorOr } from './TextEditor'; -import { 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'; - -interface SamSitesAirControlProps { - file: string; -} - -function SamSitesAirControl({ - file, - samSites, - value, - 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], - ); - const highlightedSectorIds = useMemo(() => { - const result: { [color: string]: NormalizedSectorId[] } = {}; - - for (let y = 0; y < 16; y++) { - for (let x = 0; x < 16; x++) { - const idx = value[y]?.[x] ?? 0; - const color = idx !== 0 ? samSiteOptions[idx - 1]?.color : undefined; - - if (idx !== -1 && color) { - result[color] = result[color] || []; - result[color].push([sectorIdStringFromCoords(x, y), 0]); - } - } - } - - return result; - }, [samSiteOptions, value]); - const handleSectorClick = (sectorId: NormalizedSectorId) => { - const coordinates = coordsFromSectorIdString(sectorId[0]); - if (!coordinates) return; - const newValue = clone(value); - const [x, y] = coordinates; - if (newValue[y] === undefined || newValue[y][x] === undefined) { - return; - } - newValue[y][x] = newValue[y][x] === selectedSite + 1 ? 0 : selectedSite + 1; - onChange(newValue); - }; - - return ( - - -
- - - Select a SAM site below and click on the map to change sectors. - - + + ); +} + +function SaveModeSelect({ file }: Pick) { + const loading = useFileLoading(file); + const saving = useFileSaving(file); + const editMode = useFileSaveMode(file); + const update = useFileSaveModeUpdate(file); + + return ( + + Save Mode + + + ); +} + +export function SamSitesAirControlForm({ file }: SamSitesAirControlProps) { + const [selectedSector, setSelectedSector] = + useState(null); + const value = useFileJsonValue(file); + const samSites = useSamSites(); + const update = useFileJsonUpdate(file); + const selectedIndex = useMemo(() => { + if (!samSites || !isArray(samSites)) return null; + const index = samSites.findIndex((item: any) => + selectedSector ? item.sector === selectedSector[0] : false, + ); + return index === -1 ? null : index; + }, [selectedSector, samSites]); + const highlightedSectorIds = useMemo(() => { + if (!value || !isArray(value)) return; + const result: { [color: string]: NormalizedSectorId[] } = {}; + + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const idx = (value[y] as number[])[x] ?? 0; + const color = + idx !== 0 + ? HIGHLIGHT_COLORS[(idx - 1) % HIGHLIGHT_COLORS.length] + : undefined; + + if (idx !== -1 && color) { + result[color] = result[color] || []; + result[color].push([sectorIdStringFromCoords(x, y), 0]); + } + } + } + + return result; + }, [value]); + const handleSectorClick = (sectorId: NormalizedSectorId) => { + const coordinates = coordsFromSectorIdString(sectorId[0]); + if (!coordinates || selectedIndex === null) return; + + const newValue = clone(value) as number[][]; + const [x, y] = coordinates; + if (newValue[y] === undefined || newValue[y][x] === undefined) { + return; + } + newValue[y][x] = + newValue[y][x] === selectedIndex + 1 ? 0 : selectedIndex + 1; + update(newValue); + }; + + return ( + + + + ); +} diff --git a/src/renderer/components/StrategicMapForm.tsx b/src/renderer/components/visual/StrategicMapForm.tsx similarity index 56% rename from src/renderer/components/StrategicMapForm.tsx rename to src/renderer/components/visual/StrategicMapForm.tsx index 00d3f91..0d66599 100644 --- a/src/renderer/components/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 './FullSizeLoader'; import { DEFAULT_HIGHLIGHT_COLOR, NormalizedSectorId, - StrategicMap, -} from './content/StrategicMap'; -import { JsonSchemaForm } from './JsonSchemaForm'; -import { EditorContent } from './EditorContent'; -import { JsonFormHeader } from './form/JsonFormHeader'; -import { - useFileLoadingError, - useFileJson, - useFileJsonItem, - useFileJsonItemSchema, - useFileLoading, -} from '../hooks/files'; +} from '../content/StrategicMap'; +import { JsonSchemaForm } from './form/JsonSchemaForm'; import { IChangeEvent } from '@rjsf/core'; -import { ErrorAlert } from './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'; +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,32 +82,30 @@ 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({ file, + extraFiles, uiSchema, 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 +116,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 +140,31 @@ export function JsonStrategicMapForm({ ); }, [file, selectedItem]); - - if (loading) { - return ; - } - if (error) { - return ; - } + const onLevelChange = canChangeLevel ? setLevel : undefined; return ( - - - - -
- - {removeButton} - -
-
-
-
+ + {removeButton} + + ); } @@ -188,8 +172,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 +190,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/VisualErrorBoundary.tsx b/src/renderer/components/visual/VisualErrorBoundary.tsx new file mode 100644 index 0000000..5cd13b6 --- /dev/null +++ b/src/renderer/components/visual/VisualErrorBoundary.tsx @@ -0,0 +1,48 @@ +import { PropsWithChildren, useCallback } from 'react'; +import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; +import { ErrorAlert } from '../common/ErrorAlert'; +import { Button, Flex, Typography } from 'antd'; +import { VisualFormProps } from './VisualFormWrapper'; +import { useFileEditModeUpdate } from '../../hooks/useFileEditModeUpdate'; + +function Fallback({ + file, + error, + resetErrorBoundary, +}: FallbackProps & VisualFormProps) { + const updateEditMode = useFileEditModeUpdate(file); + const handleEditModeText = useCallback(() => { + 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/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..4f4a198 --- /dev/null +++ b/src/renderer/components/visual/VisualFormWrapper.tsx @@ -0,0 +1,67 @@ +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 { useFileLoad } from '../../hooks/useFileLoad'; +import { useFileHasDiskValue } from '../../hooks/useFileHasDiskValue'; +import { VisualErrorBoundary } from './VisualErrorBoundary'; + +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({ + file, + loading, + children, +}: PropsWithChildren & { loading: boolean }>) { + const hasDiskValue = useFileHasDiskValue(file); + return loading && !hasDiskValue ? : children; +} + +function ErrorOr({ children, ...rest }: PropsWithChildren) { + return rest.error ? : children; +} + +export function VisualFormWrapper({ + file, + extraFiles, + children, +}: PropsWithChildren) { + const loadFile = useFileLoad(); + const allFiles = useMemo( + () => [file, ...(extraFiles ?? [])], + [file, extraFiles], + ); + const loading = useAnyFileLoading(allFiles); + const error = useAnyFileLoadingError(allFiles); + + useEffect(() => { + for (const file of allFiles) { + loadFile(file); + } + }, [allFiles, loadFile]); + + 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/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/visual/form/JsonFormHeader.tsx b/src/renderer/components/visual/form/JsonFormHeader.tsx new file mode 100644 index 0000000..dc330b9 --- /dev/null +++ b/src/renderer/components/visual/form/JsonFormHeader.tsx @@ -0,0 +1,22 @@ +import { Typography } from 'antd'; +import ReactMarkdown from 'react-markdown'; +import { useFileTitle } from '../../../hooks/useFileTitle'; +import { useFileDescription } from '../../../hooks/useFileDescription'; + +interface JsonFormHeaderProps { + file: string; +} + +export const JsonFormHeader = function Header({ file }: JsonFormHeaderProps) { + const title = useFileTitle(file); + const description = useFileDescription(file); + + return ( +
+ {title} +
+ {description} +
+
+ ); +}; 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 60% rename from src/renderer/components/form/StringReferenceWidget.tsx rename to src/renderer/components/visual/form/StringReferenceWidget.tsx index 4e4b3a5..2f96e47 100644 --- a/src/renderer/components/form/StringReferenceWidget.tsx +++ b/src/renderer/components/visual/form/StringReferenceWidget.tsx @@ -1,88 +1,91 @@ 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 { BaseOptionType } from 'antd/lib/select'; +import { Input, AutoComplete, Flex } from 'antd'; +import { JSX, useCallback, useEffect, useMemo } from 'react'; 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 { MercPreview } from '../../content/MercPreview'; +import { ItemPreview } from '../../content/ItemPreview'; +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) => { + (value: string) => { onChange(value); }, [onChange], ); - if (loadingDidNotComplete) { - return ; + useEffect(() => { + files.forEach((file, idx) => { + if (!values[idx] && !error) { + loadFile(file); + } + }); + }, [error, files, loadFile, values]); + + if (loading) { + return ( + + + + ); + } + if (error) { + return ( + + ; + + + ); } return ( @@ -106,13 +109,13 @@ export function stringReferenceTo( return function StringReference(props: WidgetProps) { return ( ); @@ -122,8 +125,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 2f39c3d..0000000 --- a/src/renderer/hooks/files.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { useCallback, useEffect } 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/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/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/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/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; + }); +} 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; + }); +} 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/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; + }); +} 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; + }); +} 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/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); +} 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); }); }