From f1d2d94c30698c076297ce32fd892c555e5539c4 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Wed, 12 Nov 2025 13:28:36 +0100 Subject: [PATCH 01/14] Allow to add new items to JsonItemsForm --- src/common/invokables/jsons.ts | 14 +++--- src/renderer/EditorRoutes.tsx | 9 ++-- src/renderer/components/JsonItemsForm.tsx | 45 +++++++++++++++---- .../components/content/ItemPreview.tsx | 10 ++--- .../components/form/StringReferenceWidget.tsx | 2 +- src/renderer/state/files.tsx | 13 ++++++ 6 files changed, 69 insertions(+), 24 deletions(-) diff --git a/src/common/invokables/jsons.ts b/src/common/invokables/jsons.ts index 2d7637d..b038ab6 100644 --- a/src/common/invokables/jsons.ts +++ b/src/common/invokables/jsons.ts @@ -53,12 +53,14 @@ const JSON_SCHEMA_SCHEMA = z .object({ title: z.optional(z.string()), description: z.optional(z.string()), - items: z - .object({ - title: z.optional(z.string()), - description: z.optional(z.string()), - }) - .catchall(z.any()), + items: z.optional( + z + .object({ + title: z.optional(z.string()), + description: z.optional(z.string()), + }) + .catchall(z.any()), + ), }) .catchall(z.any()); diff --git a/src/renderer/EditorRoutes.tsx b/src/renderer/EditorRoutes.tsx index 1bafe53..fcf8a21 100644 --- a/src/renderer/EditorRoutes.tsx +++ b/src/renderer/EditorRoutes.tsx @@ -246,6 +246,7 @@ export const MENU: Readonly>> = [ makeFileItem('smoke-effects.json', 'Smoke Effects', JsonItemsForm, { name: 'name', preview: (item: any) => , + canAddNewItem: false, uiSchema: { 'ui:order': [ 'name', @@ -299,6 +300,7 @@ export const MENU: Readonly>> = [ children: [ makeFileItem('ammo-types.json', 'Ammo Types', JsonItemsForm, { name: 'internalName', + canAddNewItem: false, uiSchema: { 'ui:order': ['index', 'internalName'] }, }), makeFileItem('armours.json', 'Armours', JsonItemsForm, { @@ -425,7 +427,8 @@ export const MENU: Readonly>> = [ 'Tactical Map Item Replacements', JsonItemsForm, { - name: (item: any) => `${item.from} to ${item.to}`, + name: (item: any) => + `${item.from ?? 'unknown'} to ${item.to ?? 'unknown'}`, uiSchema: { from: { oneOf: [ @@ -797,7 +800,7 @@ export const MENU: Readonly>> = [ 'Creature Lairs', JsonItemsForm, { - name: (item: any) => item.entranceSector[0], + name: (item: any) => item.entranceSector?.[0] ?? 'unknown', uiSchema: { 'ui:order': [ 'lairId', @@ -884,7 +887,7 @@ export const MENU: Readonly>> = [ }, ), makeFileItem('strategic-map-towns.json', 'Towns', JsonItemsForm, { - name: 'townId', + name: 'internalName', uiSchema: { 'ui:order': [ 'townId', diff --git a/src/renderer/components/JsonItemsForm.tsx b/src/renderer/components/JsonItemsForm.tsx index 4209fae..baa4077 100644 --- a/src/renderer/components/JsonItemsForm.tsx +++ b/src/renderer/components/JsonItemsForm.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, JSX, memo } from 'react'; -import { Collapse, Space } from 'antd'; +import { Button, Collapse, Flex, Space } from 'antd'; import { JsonSchemaForm } from './JsonSchemaForm'; import { FullSizeLoader } from './FullSizeLoader'; @@ -17,6 +17,9 @@ import { } from '../hooks/files'; import { ErrorAlert } from './ErrorAlert'; import { TextEditorOr } from './TextEditor'; +import { useAppDispatch } from '../hooks/state'; +import { addJsonItem } from '../state/files'; +import { PlusCircleOutlined } from '@ant-design/icons'; type PreviewFn = (item: any) => JSX.Element | string | null; @@ -146,6 +149,8 @@ export interface JsonItemsFormProps { name: NameOrPreviewFn; preview?: PreviewFn; uiSchema?: UiSchema; + canAddNewItem?: boolean; + getNewItem?: () => any; } export const JsonItemsForm = memo(function JsonItemsForm({ @@ -153,10 +158,29 @@ export const JsonItemsForm = memo(function JsonItemsForm({ 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 ; @@ -164,16 +188,19 @@ export const JsonItemsForm = memo(function JsonItemsForm({ return ( <> - + + + {addButton} + ); - }, [file, name, numItems, preview, uiSchema]); + }, [addButton, file, name, numItems, preview, uiSchema]); if (error) { return ; diff --git a/src/renderer/components/content/ItemPreview.tsx b/src/renderer/components/content/ItemPreview.tsx index 5ee3306..00e7e38 100644 --- a/src/renderer/components/content/ItemPreview.tsx +++ b/src/renderer/components/content/ItemPreview.tsx @@ -6,12 +6,12 @@ interface SubImage { } interface ItemPreviewProps { - inventoryGraphics: { - small: SubImage; - big: SubImage; + inventoryGraphics?: { + small?: SubImage; + big?: SubImage; }; } -export function ItemPreview({ inventoryGraphics: { big } }: ItemPreviewProps) { - return ; +export function ItemPreview({ inventoryGraphics }: ItemPreviewProps) { + return ; } diff --git a/src/renderer/components/form/StringReferenceWidget.tsx b/src/renderer/components/form/StringReferenceWidget.tsx index d9ee653..4e4b3a5 100644 --- a/src/renderer/components/form/StringReferenceWidget.tsx +++ b/src/renderer/components/form/StringReferenceWidget.tsx @@ -53,7 +53,7 @@ export function StringReferenceWidget({ const fileResults = values[key] as Array; if (!fileResults) continue; for (const item of fileResults) { - const value: string = item[references[key].property]; + const value: string = item[references[key].property] ?? ''; let label: JSX.Element | string | null = value; if (references[key].preview) { label = ( diff --git a/src/renderer/state/files.tsx b/src/renderer/state/files.tsx index 6a324be..3370129 100644 --- a/src/renderer/state/files.tsx +++ b/src/renderer/state/files.tsx @@ -198,6 +198,18 @@ const filesSlice = createSlice({ (open.value as Array)[index] = value; open.modified = isModified(state, filename); }, + addJsonItem: ( + state, + action: PayloadAction<{ filename: string; value: any }>, + ) => { + const { filename, value } = action.payload; + const open = state.open[filename]; + if (!open || open.editMode !== 'visual' || !Array.isArray(open.value)) { + return; + } + open.value = [...open.value, value]; + open.modified = isModified(state, filename); + }, changeSaveMode( state, action: PayloadAction<{ filename: string; saveMode: SaveMode }>, @@ -348,6 +360,7 @@ export const { changeText, changeJson, changeJsonItem, + addJsonItem, changeSaveMode, changeEditMode, } = filesSlice.actions; From 59706a0ec9f1482fbd17038cf0e8806c1c045c52 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Wed, 12 Nov 2025 14:54:56 +0100 Subject: [PATCH 02/14] Allow to add new items to JsonStrategicMapForm --- src/renderer/components/JsonItemsForm.tsx | 19 ++--- src/renderer/components/StrategicMapForm.tsx | 78 ++++++++++++++++--- .../components/content/StrategicMap.css | 53 ++++++------- .../components/content/StrategicMap.tsx | 26 +++++-- src/renderer/components/form/AddNewButton.tsx | 20 +++++ 5 files changed, 142 insertions(+), 54 deletions(-) create mode 100644 src/renderer/components/form/AddNewButton.tsx diff --git a/src/renderer/components/JsonItemsForm.tsx b/src/renderer/components/JsonItemsForm.tsx index baa4077..980219c 100644 --- a/src/renderer/components/JsonItemsForm.tsx +++ b/src/renderer/components/JsonItemsForm.tsx @@ -1,6 +1,5 @@ import { useCallback, useMemo, JSX, memo } from 'react'; -import { Button, Collapse, Flex, Space } from 'antd'; - +import { Collapse, Flex, Space } from 'antd'; import { JsonSchemaForm } from './JsonSchemaForm'; import { FullSizeLoader } from './FullSizeLoader'; import './JsonItemsForm.css'; @@ -19,7 +18,7 @@ import { ErrorAlert } from './ErrorAlert'; import { TextEditorOr } from './TextEditor'; import { useAppDispatch } from '../hooks/state'; import { addJsonItem } from '../state/files'; -import { PlusCircleOutlined } from '@ant-design/icons'; +import { AddNewButton } from './form/AddNewButton'; type PreviewFn = (item: any) => JSX.Element | string | null; @@ -138,9 +137,9 @@ const FormItems = memo(function FormItems({ }, [file, name, numItems, preview, uiSchema]); return ( - + {items} - + ); }); @@ -150,7 +149,7 @@ export interface JsonItemsFormProps { preview?: PreviewFn; uiSchema?: UiSchema; canAddNewItem?: boolean; - getNewItem?: () => any; + getNewItem?: () => object; } export const JsonItemsForm = memo(function JsonItemsForm({ @@ -173,13 +172,7 @@ export const JsonItemsForm = memo(function JsonItemsForm({ const addButton = useMemo(() => { const render = typeof canAddNewItem === 'undefined' ? true : canAddNewItem; if (!render) return null; - return ( -
- -
- ); + return ; }, [addNewItem, canAddNewItem]); const content = useMemo(() => { if (numItems == null) { diff --git a/src/renderer/components/StrategicMapForm.tsx b/src/renderer/components/StrategicMapForm.tsx index e6384a7..2f43d85 100644 --- a/src/renderer/components/StrategicMapForm.tsx +++ b/src/renderer/components/StrategicMapForm.tsx @@ -17,23 +17,56 @@ import { 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'; interface ItemFormProps { file: string; index: number; + property: string; uiSchema?: UiSchema; + sectorId?: string; + canAddNewItem?: boolean; + getNewItem?: () => object; } -function ItemForm({ file, index, uiSchema }: ItemFormProps) { +function ItemForm({ + file, + index, + property, + uiSchema, + sectorId, + canAddNewItem, + getNewItem, +}: ItemFormProps) { + const dispatch = useAppDispatch(); const schema = useFileJsonItemSchema(file); const [value, update] = useFileJsonItem(file, index); const onItemChange = useCallback( (ev: IChangeEvent) => update(ev.formData), [update], ); + const addNewItem = useCallback( + () => + dispatch( + addJsonItem({ + filename: file, + value: { + ...(getNewItem ? getNewItem() : {}), + [property]: sectorId, + }, + }), + ), + [dispatch, file, getNewItem, property, sectorId], + ); if (index === -1 || !value) { - return
Select a sector to the left to edit.
; + if (sectorId && (typeof canAddNewItem === 'undefined' || canAddNewItem)) { + return ; + } else { + return
Select a sector to the left to edit.
; + } } return ( @@ -50,30 +83,37 @@ export interface StrategicMapFormProps { file: string; property?: string; uiSchema?: UiSchema; + canAddNewItem?: boolean; + getNewItem?: () => object; } export function JsonStrategicMapForm({ file, property = 'sector', uiSchema, + canAddNewItem, + getNewItem, }: StrategicMapFormProps) { const loading = useFileLoading(file); const error = useFileLoadingError(file); - const [selectedItem, setSelectedItem] = useState(-1); const [value] = useFileJson(file); + const [selectedSector, setSelectedSector] = useState(); const sectorsWithContent = useMemo( - () => (value ? value.map((d: any) => d[property].toLowerCase()) : []), + () => (value ? value.map((d: any) => d[property]) : []), [value, property], ); + const selectedItem = useMemo(() => { + if (!selectedSector) return null; + return sectorsWithContent.indexOf(selectedSector); + }, [sectorsWithContent, selectedSector]); const onSectorClick = useCallback( (sectorId: string) => { if (!value) { return; } - const idx = sectorsWithContent.indexOf(sectorId.toLowerCase()); - setSelectedItem(idx); + setSelectedSector(sectorId); }, - [sectorsWithContent, value], + [value], ); const contents = useMemo(() => { if (!value) { @@ -82,16 +122,36 @@ export function JsonStrategicMapForm({ return (
- +
); - }, [file, onSectorClick, sectorsWithContent, selectedItem, uiSchema, value]); + }, [ + canAddNewItem, + file, + getNewItem, + onSectorClick, + property, + sectorsWithContent, + selectedItem, + selectedSector, + uiSchema, + value, + ]); if (loading) { return ; diff --git a/src/renderer/components/content/StrategicMap.css b/src/renderer/components/content/StrategicMap.css index e5aec54..6eff78e 100644 --- a/src/renderer/components/content/StrategicMap.css +++ b/src/renderer/components/content/StrategicMap.css @@ -1,41 +1,42 @@ .strategic-map { - width: 338px; - height: 290px; - flex-wrap: wrap; - background-repeat: no-repeat; - box-sizing: border-box; - padding: 1px; - padding-right: 0; + width: 338px; + height: 290px; + flex-wrap: wrap; + background-repeat: no-repeat; + box-sizing: border-box; + padding: 1px; + padding-right: 0; } .strategic-map .strategic-map-tile { - float: left; - width: 22px; - height: 19px; - cursor: pointer; - transition: border-color 0.25s; - box-sizing: border-box; - margin-top: -1px; - margin-left: -1px; - position: relative; - border: 1px solid transparent; + float: left; + width: 22px; + height: 19px; + cursor: pointer; + transition: border-color 0.25s; + box-sizing: border-box; + margin-top: -1px; + margin-left: -1px; + position: relative; + border: 1px solid transparent; } .strategic-map .strategic-map-tile.col-0 { - width: 23px; + width: 23px; } .strategic-map .strategic-map-tile.row-0 { - height: 20px; + height: 20px; } -.strategic-map .strategic-map-tile:hover { - border-color: white; +.strategic-map .strategic-map-tile:hover, +.strategic-map .strategic-map-tile.selected { + border-color: white; } .strategic-map .strategic-map-tile.highlighted .highlight { - background-color: rgba(0, 136, 0, 0.4); - width: 100%; - height: 100%; - display: block; -} \ No newline at end of file + background-color: rgba(0, 136, 0, 0.4); + width: 100%; + height: 100%; + display: block; +} diff --git a/src/renderer/components/content/StrategicMap.tsx b/src/renderer/components/content/StrategicMap.tsx index 73a5e5e..58ef322 100644 --- a/src/renderer/components/content/StrategicMap.tsx +++ b/src/renderer/components/content/StrategicMap.tsx @@ -4,6 +4,7 @@ import { useImageFile } from '../../hooks/useImage'; import './StrategicMap.css'; export interface StrategicMapProps { + selectedSectorId?: string | null; highlightedSectorIds: string[]; onSectorClick?: (sectorId: string) => unknown; } @@ -20,22 +21,31 @@ for (let y = 0; y < 16; y++) { tilePrefabs.push({ x, y, - sectorId: String.fromCharCode(97 + y) + (x + 1).toString(), + sectorId: + `${String.fromCharCode(97 + y)}${(x + 1).toString()}`.toUpperCase(), }); } } interface TileProps extends TilePrefab { + selected: boolean; highlighted: boolean; onSectorClick?: (sectorId: string) => unknown; } -function Tile({ x, y, sectorId, highlighted, onSectorClick }: TileProps) { +function Tile({ + x, + y, + sectorId, + selected, + highlighted, + onSectorClick, +}: TileProps) { const className = useMemo(() => { return `strategic-map-tile row-${y} col-${x} ${sectorId} ${ highlighted ? 'highlighted' : '' - }`; - }, [highlighted, sectorId, x, y]); + } ${selected ? 'selected' : ''}`; + }, [highlighted, selected, sectorId, x, y]); const content = useMemo(() => { return highlighted ?
: null; }, [highlighted]); @@ -52,6 +62,7 @@ function Tile({ x, y, sectorId, highlighted, onSectorClick }: TileProps) { } export function StrategicMap({ + selectedSectorId, highlightedSectorIds, onSectorClick, }: StrategicMapProps) { @@ -66,17 +77,20 @@ export function StrategicMap({ }, [image]); const tiles = useMemo(() => { return tilePrefabs.map((p) => { - const highlighted = highlightedSectorIds.includes(p.sectorId); + const highlighted = highlightedSectorIds + .map((id) => id.toUpperCase()) + .includes(p.sectorId.toUpperCase()); return ( ); }); - }, [highlightedSectorIds, onSectorClick]); + }, [highlightedSectorIds, selectedSectorId, onSectorClick]); useEffect(() => { refresh(); diff --git a/src/renderer/components/form/AddNewButton.tsx b/src/renderer/components/form/AddNewButton.tsx new file mode 100644 index 0000000..6479e43 --- /dev/null +++ b/src/renderer/components/form/AddNewButton.tsx @@ -0,0 +1,20 @@ +import { PlusCircleOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; + +const DIV_STYLE = { + width: '192px', +}; + +export interface AddNewButtonProps { + onClick: () => void; +} + +export function AddNewButton({ onClick }: AddNewButtonProps) { + return ( +
+ +
+ ); +} From aae0fabd502959342b399eede8e0ebcf8b0ee569 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Wed, 12 Nov 2025 15:15:48 +0100 Subject: [PATCH 03/14] Dont render duplicate item descriptions --- package-lock.json | 12 ++++++++++-- package.json | 1 + src/renderer/hooks/files.tsx | 10 ++++------ src/renderer/state/files.tsx | 7 ++++++- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index ced765f..799f66b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.9.1", + "remeda": "^2.32.0", "zod": "^4.1.10" }, "devDependencies": { @@ -18892,6 +18893,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remeda": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.32.0.tgz", + "integrity": "sha512-BZx9DsT4FAgXDTOdgJIc5eY6ECIXMwtlSPQoPglF20ycSWigttDDe88AozEsPPT4OWk5NujroGSBC1phw5uU+w==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.41.0" + } + }, "node_modules/remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -21454,9 +21464,7 @@ "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=16" }, diff --git a/package.json b/package.json index ca980e0..1ef1066 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.9.1", + "remeda": "^2.32.0", "zod": "^4.1.10" }, "devDependencies": { diff --git a/src/renderer/hooks/files.tsx b/src/renderer/hooks/files.tsx index b4e8508..8e8a6a2 100644 --- a/src/renderer/hooks/files.tsx +++ b/src/renderer/hooks/files.tsx @@ -239,12 +239,10 @@ export function useFileText( ]; } -export function useFileJsonItemSchema( - filename: string, -): Record | null { - const schema = useFileSchema(filename); - if (!schema) return null; - return schema.items ?? null; +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 { diff --git a/src/renderer/state/files.tsx b/src/renderer/state/files.tsx index 3370129..116419e 100644 --- a/src/renderer/state/files.tsx +++ b/src/renderer/state/files.tsx @@ -22,6 +22,7 @@ import { JsonSchema, } from '../../common/invokables/jsons'; import { InvokableOutput } from 'src/common/invokables'; +import { omit } from 'remeda'; export type SaveMode = 'patch' | 'replace'; @@ -30,6 +31,7 @@ export type EditMode = 'visual' | 'text'; interface JsonFile { saveMode: SaveMode; schema: JsonSchema; + itemSchema: JsonSchema | null; vanilla: JsonRoot; mod: JsonRoot | null; patch: JsonPatch | null; @@ -298,12 +300,15 @@ const filesSlice = createSlice({ }, }, extraReducers: (builder) => { - const transform = (data: InvokableOutput) => { + const transform = (data: InvokableOutput): JsonFile => { const saveMode: SaveMode = data.value ? 'replace' : 'patch'; const applied = applyPatch(data.value ?? data.vanilla, data.patch ?? []); return { schema: data.schema, + itemSchema: data.schema.items + ? omit(data.schema.items, ['title', 'description']) + : null, vanilla: data.vanilla, mod: data.value, patch: data.patch, From f18ba44e97c21fe2304679f6185177e84a93dd72 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Wed, 12 Nov 2025 15:20:44 +0100 Subject: [PATCH 04/14] Update uiSchema for merc-relations.json --- src/renderer/EditorRoutes.tsx | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/renderer/EditorRoutes.tsx b/src/renderer/EditorRoutes.tsx index fcf8a21..faacc43 100644 --- a/src/renderer/EditorRoutes.tsx +++ b/src/renderer/EditorRoutes.tsx @@ -551,8 +551,35 @@ export const MENU: Readonly>> = [ }, }, ), - makeFileItem('mercs-relations.json', 'Relations', JsonItemsForm, { - name: 'internalName', + makeFileItem('mercs-relations.json', 'Opinions', JsonItemsForm, { + name: 'profile', + preview: (item: any) => , + uiSchema: { + 'ui:order': ['profile', 'relations'], + profile: { + 'ui:widget': stringReferenceToMercProfiles, + }, + relations: { + items: { + 'ui:order': [ + 'target', + 'opinion', + 'friend1', + 'friend2', + 'enemy1', + 'enemy2', + 'eventualFriend', + 'resistanceToBefriending', + 'eventualEnemy', + 'resistanceToMakingEnemy', + 'tolerance', + ], + target: { + 'ui:widget': stringReferenceToMercProfiles, + }, + }, + }, + }, }), makeFileItem('mercs-profile-info.json', 'Profiles', JsonItemsForm, { name: 'internalName', From df6916b8b8d2954fa5d61223c70abe9cb9fe659b Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Wed, 12 Nov 2025 16:03:09 +0100 Subject: [PATCH 05/14] Some more resource references --- src/renderer/EditorRoutes.tsx | 28 ++++++++- .../content/ResourceSelectorModal.tsx | 22 ++++--- .../form/ResourceReferenceWidget.tsx | 61 +++++++++++++++---- 3 files changed, 91 insertions(+), 20 deletions(-) diff --git a/src/renderer/EditorRoutes.tsx b/src/renderer/EditorRoutes.tsx index faacc43..8a39780 100644 --- a/src/renderer/EditorRoutes.tsx +++ b/src/renderer/EditorRoutes.tsx @@ -20,10 +20,12 @@ import { Dashboard } from './components/Dashboard'; import { MercPreview } from './components/content/MercPreview'; import { ItemPreview } from './components/content/ItemPreview'; import { + makeResourceReference, resourceReferenceToGraphics, resourceReferenceToSound, } from './components/form/ResourceReferenceWidget'; import { StiPreview } from './components/content/StiPreview'; +import { ResourceType } from './lib/resourceType'; const baseItemProps = [ 'itemIndex', @@ -262,6 +264,15 @@ export const MENU: Readonly>> = [ 'affectsMonsters', 'affectsRobot', ], + graphics: { + 'ui:widget': resourceReferenceToGraphics, + }, + staticGraphics: { + 'ui:widget': resourceReferenceToGraphics, + }, + dissipatingGraphics: { + 'ui:widget': resourceReferenceToGraphics, + }, }, }), ], @@ -501,7 +512,17 @@ export const MENU: Readonly>> = [ }, makeFileItem('loading-screens.json', 'Loading Screens', JsonItemsForm, { name: 'internalName', - uiSchema: { 'ui:order': ['internalName', 'filename'] }, + preview: (item) => , + uiSchema: { + 'ui:order': ['internalName', 'filename'], + filename: { + 'ui:widget': makeResourceReference({ + type: ResourceType.Graphics, + prefix: ['loadscreens'], + postProcess: (filename) => `/${filename}`, + }), + }, + }, }), makeFileItem( 'loading-screens-mapping.json', @@ -898,6 +919,9 @@ export const MENU: Readonly>> = [ 'isSAMSite', ], sector: { 'ui:disabled': true }, + secretMapIcon: { + 'ui:widget': resourceReferenceToGraphics, + }, }, }, ), @@ -996,6 +1020,8 @@ export const MENU: Readonly>> = [ ], profile: { 'ui:widget': stringReferenceToMercProfiles }, armourType: { 'ui:widget': stringReferenceToArmours }, + enterSound: { 'ui:widget': resourceReferenceToSound }, + moveSound: { 'ui:widget': resourceReferenceToSound }, }, }), ]; diff --git a/src/renderer/components/content/ResourceSelectorModal.tsx b/src/renderer/components/content/ResourceSelectorModal.tsx index 7837739..89d1998 100644 --- a/src/renderer/components/content/ResourceSelectorModal.tsx +++ b/src/renderer/components/content/ResourceSelectorModal.tsx @@ -23,18 +23,20 @@ import { ResourceType, resourceTypeFromFilename } from '../../lib/resourceType'; import { ResourceEntry } from '../../../common/invokables/resources'; const Breadcrumbs = memo(function Breadcrumbs({ + pathPrefix, currentDir, switchDir, }: { + pathPrefix: string[]; currentDir: string[]; switchDir: (currentDir: string[]) => void; }) { const setToParentDir = useCallback(() => { - if (currentDir.length == 0) { + if (currentDir.length === pathPrefix.length) { return; } switchDir(currentDir.slice(0, -1)); - }, [currentDir, switchDir]); + }, [currentDir, pathPrefix.length, switchDir]); const items = useMemo(() => { return currentDir.reduce( (prev, curr, currIndex) => { @@ -54,18 +56,18 @@ const Breadcrumbs = memo(function Breadcrumbs({ ), - onClick: () => switchDir([]), + onClick: () => switchDir(pathPrefix), }, ], ); - }, [currentDir, switchDir]); + }, [currentDir, pathPrefix, switchDir]); return ( @@ -134,18 +136,20 @@ const Preview = memo(function Preview({ export function ResourceSelectorModal({ resourceType, + pathPrefix, initialDir, isOpen, onSelect, onCancel, }: { resourceType: ResourceType; + pathPrefix: string[]; initialDir?: string[]; isOpen: boolean; onSelect: (value: string) => unknown; onCancel: () => unknown; }) { - const [currentDir, setCurrentDir] = useState(initialDir ?? []); + const [currentDir, setCurrentDir] = useState(initialDir ?? pathPrefix ?? []); const [selectedEntry, setSelectedEntry] = useState( null, ); @@ -249,7 +253,11 @@ export function ResourceSelectorModal({ styles={{ body: { overflow: 'hidden', height: '60vh' } }} > - +
string; } export function ResourceReferenceWidget({ resourceType, + pathPrefix, + postProcess, value, onChange, }: ResourceReferenceWidgetProps) { @@ -20,20 +24,35 @@ export function ResourceReferenceWidget({ const closeModal = useCallback(() => setModalOpen(false), []); const onSelect = useCallback( (path: string) => { - onChange(path); + onChange(postProcess(path)); closeModal(); }, - [closeModal, onChange], + [closeModal, onChange, postProcess], + ); + const onInputChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e.target.value); + }, + [onChange], ); const preview = useMemo(() => { + const trimmed = (value ?? '').startsWith('/') ? value.substring(1) : value; if (resourceType === ResourceType.Sound) { - return ; + return ( + 0 ? '/' : ''}${trimmed}`} + /> + ); } if (resourceType === ResourceType.Graphics) { - return ; + return ( + 0 ? '/' : ''}${trimmed}`} + /> + ); } return null; - }, [resourceType, value]); + }, [pathPrefix, resourceType, value]); return ( @@ -41,7 +60,7 @@ export function ResourceReferenceWidget({ @@ -50,19 +69,37 @@ export function ResourceReferenceWidget({ onSelect={onSelect} onCancel={closeModal} resourceType={resourceType} + pathPrefix={pathPrefix} /> ); } -function resourceReference(type: ResourceType = ResourceType.Any) { +export function makeResourceReference({ + type = ResourceType.Any, + prefix = [], + postProcess = (path: string) => path, +}: { + type: ResourceType; + prefix?: string[]; + postProcess?: (path: string) => string; +}) { return function ResourceReference(props: WidgetProps) { - return ; + return ( + + ); }; } -export const resourceReferenceToSound = resourceReference(ResourceType.Sound); +export const resourceReferenceToSound = makeResourceReference({ + type: ResourceType.Sound, +}); -export const resourceReferenceToGraphics = resourceReference( - ResourceType.Graphics, -); +export const resourceReferenceToGraphics = makeResourceReference({ + type: ResourceType.Graphics, +}); From ef18f03964f2bbd1afd20c23041f6452ff041a31 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Wed, 12 Nov 2025 16:12:30 +0100 Subject: [PATCH 06/14] Some more minor cleanups --- src/renderer/EditorRoutes.tsx | 14 +++++++++----- src/renderer/components/content/MercPreview.tsx | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/renderer/EditorRoutes.tsx b/src/renderer/EditorRoutes.tsx index 8a39780..0ef2018 100644 --- a/src/renderer/EditorRoutes.tsx +++ b/src/renderer/EditorRoutes.tsx @@ -780,7 +780,7 @@ export const MENU: Readonly>> = [ 'Shipping Destinations', JsonItemsForm, { - name: 'locationId', + name: (item) => item.locationId.toString(), uiSchema: { 'ui:order': [ 'locationId', @@ -835,13 +835,18 @@ export const MENU: Readonly>> = [ }, ), makeFileItem('strategic-fact-params.json', 'Fact Params', JsonItemsForm, { - name: 'fact', + name: (item) => item.fact.toString(), }), makeFileItem( 'strategic-map-cache-sectors.json', 'Weapon Cache Sectors', JsonForm, - {}, + { + uiSchema: { + 'ui:order': ['sectors', 'numTroops', 'numTroopsVariance'], + sector: { 'ui:disabled': true }, + }, + }, ), makeFileItem( 'strategic-map-creature-lairs.json', @@ -1003,12 +1008,11 @@ export const MENU: Readonly>> = [ 'Tactical Npc Action Params', JsonItemsForm, { - name: 'actionCode', + name: (item) => item.actionCode.toString(), }, ), makeFileItem('vehicles.json', 'Vehicles', JsonItemsForm, { name: 'profile', - preview: (item: any) => , uiSchema: { 'ui:order': [ 'profile', diff --git a/src/renderer/components/content/MercPreview.tsx b/src/renderer/components/content/MercPreview.tsx index 7cd2420..e0823d2 100644 --- a/src/renderer/components/content/MercPreview.tsx +++ b/src/renderer/components/content/MercPreview.tsx @@ -18,7 +18,7 @@ export function MercPreview({ profile }: MercPreviewProps) { return null; } const p = content.find((it: any) => it.internalName === profile); - if (!p || !p.profileID) { + if (!p || typeof p.profileID !== 'number') { return null; } return p.profileID as number; From 64fff6c958e80f9ab42a2fdff389041f15d0bde0 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Wed, 12 Nov 2025 20:30:40 +0100 Subject: [PATCH 07/14] Add widget to select multiple sectors --- src/renderer/EditorRoutes.tsx | 142 ++++++++++-------- src/renderer/components/StrategicMapForm.tsx | 106 +++++++++---- .../components/content/StrategicMap.tsx | 86 +++++++++-- .../form/MultiSectorSelectorWidget.tsx | 62 ++++++++ 4 files changed, 294 insertions(+), 102 deletions(-) create mode 100644 src/renderer/components/form/MultiSectorSelectorWidget.tsx diff --git a/src/renderer/EditorRoutes.tsx b/src/renderer/EditorRoutes.tsx index 0ef2018..7a87cf1 100644 --- a/src/renderer/EditorRoutes.tsx +++ b/src/renderer/EditorRoutes.tsx @@ -15,7 +15,11 @@ import { } from './components/form/StringReferenceWidget'; import { JsonForm } from './components/JsonForm'; import { JsonItemsForm } from './components/JsonItemsForm'; -import { JsonStrategicMapForm } from './components/StrategicMapForm'; +import { + JsonStrategicMapForm, + makeStrategicMapFormPropsForProperties, + makeStrategicMapFormPropsForProperty, +} from './components/StrategicMapForm'; import { Dashboard } from './components/Dashboard'; import { MercPreview } from './components/content/MercPreview'; import { ItemPreview } from './components/content/ItemPreview'; @@ -26,6 +30,8 @@ import { } from './components/form/ResourceReferenceWidget'; import { StiPreview } from './components/content/StiPreview'; import { ResourceType } from './lib/resourceType'; +import { makeMultiSectorSelectorWidget } from './components/form/MultiSectorSelectorWidget'; +import { mergeDeep } from 'remeda'; const baseItemProps = [ 'itemIndex', @@ -136,15 +142,14 @@ export const MENU: Readonly>> = [ 'army-garrison-groups.json', 'Garrison Groups', JsonStrategicMapForm, - { + mergeDeep(makeStrategicMapFormPropsForProperty('sector'), { uiSchema: { 'ui:order': ['sector', 'composition'], - sector: { 'ui:disabled': true }, composition: { 'ui:widget': stringReferenceToArmyCompositions, }, }, - }, + }), ), makeFileItem( 'army-gun-choice-normal.json', @@ -528,15 +533,13 @@ export const MENU: Readonly>> = [ 'loading-screens-mapping.json', 'Loading Screens Mapping', JsonStrategicMapForm, - { + mergeDeep(makeStrategicMapFormPropsForProperties('sector', 'sectorLevel'), { uiSchema: { 'ui:order': ['sector', 'sectorLevel', 'day', 'night'], - sector: { 'ui:disabled': true }, - sectorLevel: { 'ui:disabled': true }, day: { 'ui:widget': stringReferenceToLoadingScreens }, night: { 'ui:widget': stringReferenceToLoadingScreens }, }, - }, + }), ), { type: 'Submenu', @@ -809,18 +812,17 @@ export const MENU: Readonly>> = [ 'strategic-bloodcat-placements.json', 'Bloodcat Placements', JsonStrategicMapForm, - { + mergeDeep(makeStrategicMapFormPropsForProperty('sector'), { uiSchema: { 'ui:order': ['sector', 'bloodCatPlacements'], - sector: { 'ui:disabled': true }, }, - }, + }), ), makeFileItem( 'strategic-bloodcat-spawns.json', 'Bloodcat Spawns', JsonStrategicMapForm, - { + mergeDeep(makeStrategicMapFormPropsForProperty('sector'), { uiSchema: { 'ui:order': [ 'sector', @@ -830,9 +832,8 @@ export const MENU: Readonly>> = [ 'isArena', 'isLair', ], - sector: { 'ui:disabled': true }, }, - }, + }), ), makeFileItem('strategic-fact-params.json', 'Fact Params', JsonItemsForm, { name: (item) => item.fact.toString(), @@ -844,7 +845,12 @@ export const MENU: Readonly>> = [ { uiSchema: { 'ui:order': ['sectors', 'numTroops', 'numTroopsVariance'], - sector: { 'ui:disabled': true }, + sectors: { + 'ui:widget': makeMultiSectorSelectorWidget({ + extractSectorFromItem: (value: string) => [value, 0], + transformSectorToItem: (value) => value[0], + }), + }, }, }, ), @@ -903,18 +909,17 @@ export const MENU: Readonly>> = [ 'strategic-map-sam-sites.json', 'Sam Sites', JsonStrategicMapForm, - { + mergeDeep(makeStrategicMapFormPropsForProperty('sector'), { uiSchema: { 'ui:order': ['sector', 'gridNos'], - sector: { 'ui:disabled': true }, }, - }, + }), ), makeFileItem( 'strategic-map-secrets.json', 'Secrets', JsonStrategicMapForm, - { + mergeDeep(makeStrategicMapFormPropsForProperty('sector'), { uiSchema: { 'ui:order': [ 'sector', @@ -923,24 +928,24 @@ export const MENU: Readonly>> = [ 'secretMapIcon', 'isSAMSite', ], - sector: { 'ui:disabled': true }, secretMapIcon: { 'ui:widget': resourceReferenceToGraphics, }, }, - }, + }), ), makeFileItem( 'strategic-map-sectors-descriptions.json', 'Sector Descriptions', JsonStrategicMapForm, - { - uiSchema: { - 'ui:order': ['sector', 'sectorLevel', 'landType'], - sector: { 'ui:disabled': true }, - sectorLevel: { 'ui:disabled': true }, + mergeDeep( + makeStrategicMapFormPropsForProperties('sector', 'sectorLevel'), + { + uiSchema: { + 'ui:order': ['sector', 'sectorLevel', 'landType'], + }, }, - }, + ), ), makeFileItem('strategic-map-towns.json', 'Towns', JsonItemsForm, { name: 'internalName', @@ -952,6 +957,12 @@ export const MENU: Readonly>> = [ 'townPoint', 'isMilitiaTrainingAllowed', ], + sectors: { + 'ui:widget': makeMultiSectorSelectorWidget({ + extractSectorFromItem: (sector: string) => [sector, 0], + transformSectorToItem: (sector) => sector[0], + }), + }, }, }), makeFileItem( @@ -964,43 +975,56 @@ export const MENU: Readonly>> = [ 'strategic-map-underground-sectors.json', 'Underground Sectors', JsonStrategicMapForm, - { + mergeDeep( + makeStrategicMapFormPropsForProperties('sector', 'sectorLevel'), + { + initialLevel: 1, + uiSchema: { + 'ui:order': [ + 'sector', + 'sectorLevel', + 'adjacentSectors', + 'numTroops', + 'numTroopsVariance', + 'numElites', + 'numElitesVariance', + 'numCreatures', + 'numCreaturesVariance', + ], + }, + }, + ), + ), + makeFileItem( + 'strategic-mines.json', + 'Mines', + JsonStrategicMapForm, + mergeDeep(makeStrategicMapFormPropsForProperty('entranceSector'), { uiSchema: { 'ui:order': [ - 'sector', - 'sectorLevel', - 'adjacentSectors', - 'numTroops', - 'numTroopsVariance', - 'numElites', - 'numElitesVariance', - 'numCreatures', - 'numCreaturesVariance', + 'entranceSector', + 'associatedTownId', + 'associatedTown', + 'mineType', + 'minimumMineProduction', + 'noDepletion', + 'delayDepletion', + 'headMinerAssigned', + 'faceDisplayYOffset', + 'mineSectors', ], - sector: { 'ui:disabled': true }, - sectorLevel: { 'ui:disabled': true }, + associatedTown: { 'ui:widget': stringReferenceToTowns }, + mineSectors: { + 'ui:widget': makeMultiSectorSelectorWidget({ + initialLevel: 1, + canChangeLevel: true, + extractSectorFromItem: (sector: string) => [sector, 0], + transformSectorToItem: (sector) => sector[0], + }), + }, }, - }, + }), ), - makeFileItem('strategic-mines.json', 'Mines', JsonStrategicMapForm, { - property: 'entranceSector', - uiSchema: { - 'ui:order': [ - 'entranceSector', - 'associatedTownId', - 'associatedTown', - 'mineType', - 'minimumMineProduction', - 'noDepletion', - 'delayDepletion', - 'headMinerAssigned', - 'faceDisplayYOffset', - 'mineSectors', - ], - entranceSector: { 'ui:disabled': true }, - associatedTown: { 'ui:widget': stringReferenceToTowns }, - }, - }), ], }, makeFileItem( diff --git a/src/renderer/components/StrategicMapForm.tsx b/src/renderer/components/StrategicMapForm.tsx index 2f43d85..a61d742 100644 --- a/src/renderer/components/StrategicMapForm.tsx +++ b/src/renderer/components/StrategicMapForm.tsx @@ -3,7 +3,7 @@ import { Space } from 'antd'; import { UiSchema } from '@rjsf/utils'; import { FullSizeLoader } from './FullSizeLoader'; -import { StrategicMap } from './content/StrategicMap'; +import { NormalizedSectorId, StrategicMap } from './content/StrategicMap'; import { JsonSchemaForm } from './JsonSchemaForm'; import { EditorContent } from './EditorContent'; import { JsonFormHeader } from './form/JsonFormHeader'; @@ -20,13 +20,14 @@ import { TextEditorOr } from './TextEditor'; import { AddNewButton } from './form/AddNewButton'; import { useAppDispatch } from '../hooks/state'; import { addJsonItem } from '../state/files'; +import { findIndex, isDeepEqual } from 'remeda'; interface ItemFormProps { file: string; index: number; - property: string; + transformSectorToItem: (sectorId: NormalizedSectorId) => any; uiSchema?: UiSchema; - sectorId?: string; + sectorId?: NormalizedSectorId; canAddNewItem?: boolean; getNewItem?: () => object; } @@ -34,9 +35,9 @@ interface ItemFormProps { function ItemForm({ file, index, - property, uiSchema, sectorId, + transformSectorToItem, canAddNewItem, getNewItem, }: ItemFormProps) { @@ -47,19 +48,18 @@ function ItemForm({ (ev: IChangeEvent) => update(ev.formData), [update], ); - const addNewItem = useCallback( - () => - dispatch( - addJsonItem({ - filename: file, - value: { - ...(getNewItem ? getNewItem() : {}), - [property]: sectorId, - }, - }), - ), - [dispatch, file, getNewItem, property, sectorId], - ); + const addNewItem = useCallback(() => { + if (!sectorId) return; + dispatch( + addJsonItem({ + filename: file, + value: { + ...(getNewItem ? getNewItem() : {}), + ...transformSectorToItem(sectorId), + }, + }), + ); + }, [dispatch, file, getNewItem, sectorId, transformSectorToItem]); if (index === -1 || !value) { if (sectorId && (typeof canAddNewItem === 'undefined' || canAddNewItem)) { @@ -81,33 +81,44 @@ function ItemForm({ export interface StrategicMapFormProps { file: string; - property?: string; uiSchema?: UiSchema; canAddNewItem?: boolean; + initialLevel?: number; + canChangeLevel?: boolean; getNewItem?: () => object; + extractSectorFromItem: (value: any) => NormalizedSectorId; + transformSectorToItem: (sectorId: NormalizedSectorId) => any; } export function JsonStrategicMapForm({ file, - property = 'sector', uiSchema, + extractSectorFromItem, + transformSectorToItem, canAddNewItem, + initialLevel = 0, + 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 sectorsWithContent = useMemo( - () => (value ? value.map((d: any) => d[property]) : []), - [value, property], + const [selectedSector, setSelectedSector] = useState< + NormalizedSectorId | undefined + >(); + const sectorsWithContent: NormalizedSectorId[] = useMemo( + () => (value ?? []).map((d: any) => extractSectorFromItem(d)), + [value, extractSectorFromItem], ); const selectedItem = useMemo(() => { - if (!selectedSector) return null; - return sectorsWithContent.indexOf(selectedSector); + if (!selectedSector) return -1; + return findIndex(sectorsWithContent, (sector) => + isDeepEqual(sector, selectedSector), + ); }, [sectorsWithContent, selectedSector]); const onSectorClick = useCallback( - (sectorId: string) => { + (sectorId: NormalizedSectorId) => { if (!value) { return; } @@ -122,18 +133,20 @@ export function JsonStrategicMapForm({ return (
@@ -142,13 +155,15 @@ export function JsonStrategicMapForm({ ); }, [ canAddNewItem, + canChangeLevel, file, getNewItem, + level, onSectorClick, - property, sectorsWithContent, selectedItem, selectedSector, + transformSectorToItem, uiSchema, value, ]); @@ -166,3 +181,38 @@ export function JsonStrategicMapForm({ ); } + +export function makeStrategicMapFormPropsForProperty( + prop: S, +) { + return { + extractSectorFromItem: (item: any): NormalizedSectorId => [item[prop], 0], + transformSectorToItem: (sector: NormalizedSectorId) => ({ + [prop]: sector[0], + }), + uiSchema: { + [prop]: { 'ui:disabled': true }, + }, + }; +} + +export function makeStrategicMapFormPropsForProperties< + S extends string, + L extends string, +>(sectorProp: S, levelProp: L) { + return { + extractSectorFromItem: (item: any): NormalizedSectorId => [ + item[sectorProp], + item[levelProp] ?? 0, + ], + transformSectorToItem: (sector: NormalizedSectorId) => ({ + [sectorProp]: sector[0], + [levelProp]: sector[1], + }), + canChangeLevel: true, + uiSchema: { + [sectorProp]: { 'ui:disabled': true }, + [levelProp]: { 'ui:disabled': true }, + }, + }; +} diff --git a/src/renderer/components/content/StrategicMap.tsx b/src/renderer/components/content/StrategicMap.tsx index 58ef322..5724c93 100644 --- a/src/renderer/components/content/StrategicMap.tsx +++ b/src/renderer/components/content/StrategicMap.tsx @@ -2,11 +2,17 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useImageFile } from '../../hooks/useImage'; import './StrategicMap.css'; +import { Button, Flex } from 'antd'; +import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; + +export type NormalizedSectorId = [string, number]; export interface StrategicMapProps { - selectedSectorId?: string | null; - highlightedSectorIds: string[]; - onSectorClick?: (sectorId: string) => unknown; + level?: number; + selectedSectorId?: NormalizedSectorId | null; + highlightedSectorIds: NormalizedSectorId[]; + onSectorClick?: (sectorId: NormalizedSectorId) => unknown; + onLevelChange?: (level: number) => unknown; } interface TilePrefab { @@ -28,15 +34,17 @@ for (let y = 0; y < 16; y++) { } interface TileProps extends TilePrefab { + level: number; selected: boolean; highlighted: boolean; - onSectorClick?: (sectorId: string) => unknown; + onSectorClick?: (sectorId: NormalizedSectorId) => unknown; } function Tile({ x, y, sectorId, + level, selected, highlighted, onSectorClick, @@ -51,9 +59,9 @@ function Tile({ }, [highlighted]); const onClick = useCallback(() => { if (onSectorClick) { - return onSectorClick(sectorId); + return onSectorClick([sectorId, level]); } - }, [onSectorClick, sectorId]); + }, [level, onSectorClick, sectorId]); return (
{content} @@ -61,12 +69,48 @@ function Tile({ ); } +function LevelControls({ + level, + onLevelChange, +}: { + level: NonNullable; + onLevelChange: NonNullable; +}) { + const levelUp = useCallback(() => { + if (level === 0) return; + onLevelChange(level - 1); + }, [level, onLevelChange]); + const levelDown = useCallback(() => { + if (level === 3) return; + onLevelChange(level + 1); + }, [level, onLevelChange]); + + return ( + + + + + ); +} + export function StrategicMap({ selectedSectorId, highlightedSectorIds, onSectorClick, + level = 0, + onLevelChange, }: StrategicMapProps) { - const { data: image, error, refresh } = useImageFile('interface/map_1.sti'); + const imageFile = useMemo(() => { + if (level === 0) { + return 'interface/map_1.sti'; + } + return `interface/mine_${level}.sti`; + }, [level]); + const { data: image, error, refresh } = useImageFile(imageFile); const imageStyle = useMemo(() => { if (!image) { return {}; @@ -77,20 +121,29 @@ export function StrategicMap({ }, [image]); const tiles = useMemo(() => { return tilePrefabs.map((p) => { - const highlighted = highlightedSectorIds - .map((id) => id.toUpperCase()) - .includes(p.sectorId.toUpperCase()); + const highlighted: boolean = !!highlightedSectorIds.find( + ([sectorId, l]) => sectorId === p.sectorId && l === level, + ); + const selected = + p.sectorId === selectedSectorId?.[0] && level === selectedSectorId?.[1]; return ( ); }); - }, [highlightedSectorIds, selectedSectorId, onSectorClick]); + }, [highlightedSectorIds, level, selectedSectorId, onSectorClick]); + const levelControls = useMemo(() => { + if (!onLevelChange) { + return null; + } + return ; + }, [level, onLevelChange]); useEffect(() => { refresh(); @@ -101,8 +154,11 @@ export function StrategicMap({ } return ( -
- {tiles} -
+ +
+ {tiles} +
+ {levelControls} +
); } diff --git a/src/renderer/components/form/MultiSectorSelectorWidget.tsx b/src/renderer/components/form/MultiSectorSelectorWidget.tsx new file mode 100644 index 0000000..220318d --- /dev/null +++ b/src/renderer/components/form/MultiSectorSelectorWidget.tsx @@ -0,0 +1,62 @@ +import { WidgetProps } from '@rjsf/utils'; +import { NormalizedSectorId, StrategicMap } from '../content/StrategicMap'; +import { useCallback, useMemo, useState } from 'react'; +import { Flex } from 'antd'; +import { find, isDeepEqual } from 'remeda'; + +interface ExtraProps { + initialLevel?: number; + canChangeLevel?: boolean; + extractSectorFromItem?: (value: T) => NormalizedSectorId; + transformSectorToItem?: (sectorId: NormalizedSectorId) => T; +} + +type MultiSectorSelectorWidgetProps = WidgetProps & ExtraProps; + +function MultiSectorSelectorWidget({ + initialLevel = 0, + canChangeLevel = false, + extractSectorFromItem, + transformSectorToItem, + value, + onChange, +}: MultiSectorSelectorWidgetProps) { + const [level, setLevel] = useState(initialLevel); + const highlightedSectorIds = useMemo( + () => value.map(extractSectorFromItem ?? ((s: T) => s)), + [value, extractSectorFromItem], + ); + const handleSectorClick = useCallback( + (sectorId: NormalizedSectorId) => { + const transformed = transformSectorToItem + ? transformSectorToItem(sectorId) + : sectorId; + const newValue = find(value, (s) => isDeepEqual(s, transformed)) + ? value.filter((s: T) => !isDeepEqual(s, transformed)) + : [...value, transformed]; + onChange(newValue); + }, + [transformSectorToItem, value, onChange], + ); + + return ( + + + + ); +} + +export const makeMultiSectorSelectorWidget = ( + extraProps: ExtraProps, +) => { + return function MultiSectorSelectorWidgetWrapper( + props: MultiSectorSelectorWidgetProps, + ) { + return ; + }; +}; From 2c5ae6e9a61eb5c3171be08a128555a8e30773ae Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Thu, 13 Nov 2025 15:07:25 +0100 Subject: [PATCH 08/14] Allow to remove items from JsonItemsForm and StrategicMapForm --- src/renderer/components/JsonItemsForm.tsx | 14 ++-- src/renderer/components/StrategicMapForm.tsx | 75 +++++++++---------- src/renderer/components/form/RemoveButton.tsx | 53 +++++++++++++ src/renderer/state/files.tsx | 26 ++++++- 4 files changed, 119 insertions(+), 49 deletions(-) create mode 100644 src/renderer/components/form/RemoveButton.tsx diff --git a/src/renderer/components/JsonItemsForm.tsx b/src/renderer/components/JsonItemsForm.tsx index 980219c..4ae733a 100644 --- a/src/renderer/components/JsonItemsForm.tsx +++ b/src/renderer/components/JsonItemsForm.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, JSX, memo } from 'react'; -import { Collapse, Flex, Space } from 'antd'; +import { Collapse, Flex } from 'antd'; import { JsonSchemaForm } from './JsonSchemaForm'; import { FullSizeLoader } from './FullSizeLoader'; import './JsonItemsForm.css'; @@ -19,6 +19,7 @@ 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; @@ -51,10 +52,13 @@ const ItemFormHeader = memo(function ItemFormHeader({ const p = useMemo(() => (preview ? preview(value) : null), [preview, value]); return ( - - {p} - {label} - + + + {p} + {label} + + + ); }); diff --git a/src/renderer/components/StrategicMapForm.tsx b/src/renderer/components/StrategicMapForm.tsx index a61d742..3b1d2d9 100644 --- a/src/renderer/components/StrategicMapForm.tsx +++ b/src/renderer/components/StrategicMapForm.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react'; -import { Space } from 'antd'; +import { Space, Typography } from 'antd'; import { UiSchema } from '@rjsf/utils'; import { FullSizeLoader } from './FullSizeLoader'; @@ -21,6 +21,7 @@ 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'; interface ItemFormProps { file: string; @@ -126,47 +127,18 @@ export function JsonStrategicMapForm({ }, [value], ); - const contents = useMemo(() => { - if (!value) { - return ; - } + const removeButton = useMemo(() => { + if (selectedItem === -1) return null; return ( - - + -
- - -
-
+ ); - }, [ - canAddNewItem, - canChangeLevel, - file, - getNewItem, - level, - onSectorClick, - sectorsWithContent, - selectedItem, - selectedSector, - transformSectorToItem, - uiSchema, - value, - ]); + }, [file, selectedItem]); if (loading) { return ; @@ -177,7 +149,30 @@ export function JsonStrategicMapForm({ return ( - {contents} + + + +
+ + {removeButton} + +
+
+
); } diff --git a/src/renderer/components/form/RemoveButton.tsx b/src/renderer/components/form/RemoveButton.tsx new file mode 100644 index 0000000..f5f1dc2 --- /dev/null +++ b/src/renderer/components/form/RemoveButton.tsx @@ -0,0 +1,53 @@ +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'; + +interface RemoveButtonProps { + file: string; + index: number; + label?: string; +} + +export function RemoveButton({ file, index, label }: RemoveButtonProps) { + const dispatch = useAppDispatch(); + const [showModal, setShowModal] = useState(false); + const handleClick: MouseEventHandler = useCallback((ev) => { + setShowModal(true); + ev.stopPropagation(); + }, []); + const handleConfirm: MouseEventHandler = useCallback( + (ev) => { + dispatch( + removeJsonItem({ + filename: file, + index, + }), + ); + setShowModal(false); + ev.stopPropagation(); + }, + [dispatch, file, index], + ); + const handleCancel: MouseEventHandler = useCallback((ev) => { + setShowModal(false); + ev.stopPropagation(); + }, []); + + return ( + <> + + + Removing the item cannot be undone. Are you sure? + + + ); +} diff --git a/src/renderer/state/files.tsx b/src/renderer/state/files.tsx index 116419e..5dba956 100644 --- a/src/renderer/state/files.tsx +++ b/src/renderer/state/files.tsx @@ -22,7 +22,7 @@ import { JsonSchema, } from '../../common/invokables/jsons'; import { InvokableOutput } from 'src/common/invokables'; -import { omit } from 'remeda'; +import { isArray, omit, splice } from 'remeda'; export type SaveMode = 'patch' | 'replace'; @@ -194,10 +194,10 @@ const filesSlice = createSlice({ ) => { const { filename, index, value } = action.payload; const open = state.open[filename]; - if (!open || open.editMode !== 'visual' || !Array.isArray(open.value)) { + if (!open || open.editMode !== 'visual' || !isArray(open.value)) { return; } - (open.value as Array)[index] = value; + open.value[index] = value; open.modified = isModified(state, filename); }, addJsonItem: ( @@ -206,12 +206,29 @@ const filesSlice = createSlice({ ) => { const { filename, value } = action.payload; const open = state.open[filename]; - if (!open || open.editMode !== 'visual' || !Array.isArray(open.value)) { + if (!open || open.editMode !== 'visual' || !isArray(open.value)) { return; } open.value = [...open.value, value]; open.modified = isModified(state, filename); }, + removeJsonItem: ( + state, + action: PayloadAction<{ filename: string; index: number }>, + ) => { + const { filename, index } = action.payload; + const open = state.open[filename]; + if ( + !open || + open.editMode !== 'visual' || + !isArray(open.value) || + typeof open.value[index] === 'undefined' + ) { + return; + } + open.value = splice(open.value, index, 1, []); + open.modified = isModified(state, filename); + }, changeSaveMode( state, action: PayloadAction<{ filename: string; saveMode: SaveMode }>, @@ -366,6 +383,7 @@ export const { changeJson, changeJsonItem, addJsonItem, + removeJsonItem, changeSaveMode, changeEditMode, } = filesSlice.actions; From b01934c54b8a9c45060a64ad1eecdaa81d083fd2 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Thu, 13 Nov 2025 15:58:28 +0100 Subject: [PATCH 09/14] Upgrade react-json-schema-form to 6.1.x --- .erb/configs/webpack.config.base.mts | 10 + package-lock.json | 294 ++++++++++----------- package.json | 10 +- src/renderer/components/JsonSchemaForm.tsx | 126 ++------- 4 files changed, 166 insertions(+), 274 deletions(-) diff --git a/.erb/configs/webpack.config.base.mts b/.erb/configs/webpack.config.base.mts index 35d05b1..35001dc 100644 --- a/.erb/configs/webpack.config.base.mts +++ b/.erb/configs/webpack.config.base.mts @@ -15,6 +15,16 @@ const configuration: webpack.Configuration = { module: { rules: [ + { + test: /\.m?js$/, + type: 'javascript/auto', + }, + { + test: /\.m?js$/, + resolve: { + fullySpecified: false, + }, + }, { test: /\.[jt]sx?$/, exclude: /node_modules/, diff --git a/package-lock.json b/package-lock.json index 799f66b..c01a834 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@ant-design/icons": "^5.6.1", + "@ant-design/icons": "^6.0.0", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.8.2", - "@rjsf/antd": "^5.24.13", - "@rjsf/core": "^5.24.13", - "@rjsf/utils": "^5.24.13", - "@rjsf/validator-ajv8": "^5.24.13", + "@rjsf/antd": "^6.1.0", + "@rjsf/core": "^6.1.0", + "@rjsf/utils": "^6.1.0", + "@rjsf/validator-ajv8": "^6.1.0", "antd": "^5.25.2", "electron-debug": "^4.0.0", "fast-json-patch": "^3.1.1", @@ -167,17 +167,15 @@ } }, "node_modules/@ant-design/icons": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", - "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.1.0.tgz", + "integrity": "sha512-KrWMu1fIg3w/1F2zfn+JlfNDU8dDqILfA5Tg85iqs1lf8ooyGlbkA+TkwfOKKgqpUmAiRY1PTFpuOU2DAIgSUg==", "license": "MIT", - "peer": true, "dependencies": { - "@ant-design/colors": "^7.0.0", + "@ant-design/colors": "^8.0.0", "@ant-design/icons-svg": "^4.4.0", - "@babel/runtime": "^7.24.8", - "classnames": "^2.2.6", - "rc-util": "^5.31.1" + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" }, "engines": { "node": ">=8" @@ -193,6 +191,24 @@ "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", "license": "MIT" }, + "node_modules/@ant-design/icons/node_modules/@ant-design/colors": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.0.tgz", + "integrity": "sha512-6YzkKCw30EI/E9kHOIXsQDHmMvTllT8STzjMb4K2qzit33RW2pqCJP0sk+hidBntXxE+Vz4n1+RvCTfBw6OErw==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/icons/node_modules/@ant-design/fast-color": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.0.tgz", + "integrity": "sha512-eqvpP7xEDm2S7dUzl5srEQCBTXZMmY3ekf97zI+M2DHOYyKdJGH0qua0JACHTqbkRnD/KHFQP9J1uMJ/XWVzzA==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, "node_modules/@ant-design/react-slick": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", @@ -4938,6 +4954,20 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@rc-component/util": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.4.0.tgz", + "integrity": "sha512-LQlShcJKu0p3JUTAenKrWtqVW0+c4PJKedOqEaef9gTVL70O3cG4xZJ7VXfm0blGzORKFEkd3oQGalaUBNZ3Lg==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", @@ -4965,84 +4995,85 @@ } }, "node_modules/@rjsf/antd": { - "version": "5.24.13", - "resolved": "https://registry.npmjs.org/@rjsf/antd/-/antd-5.24.13.tgz", - "integrity": "sha512-UiWE8xoBxxCoe/SEkdQEmL5E6z3I1pw0+y0dTyGt8SHfAxxFc4/OWn7tKOAiNsKCXgf83t0JKn6CHWLD01sAdQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rjsf/antd/-/antd-6.1.0.tgz", + "integrity": "sha512-18SP/sO3nNSd03DI1SegZKPXrWuHVa/V+8sX9wYAUmUnwiRH/3AFSfXzdEhvtydpK3NhJctcHwG7NZHi+VbuAA==", "license": "Apache-2.0", "dependencies": { "classnames": "^2.5.1", "lodash": "^4.17.21", "lodash-es": "^4.17.21", - "rc-picker": "2.7.6" + "rc-picker": "^4.11.3" }, "engines": { - "node": ">=14" + "node": ">=20" }, "peerDependencies": { - "@ant-design/icons": "^4.0.0 || ^5.0.0", - "@rjsf/core": "^5.24.x", - "@rjsf/utils": "^5.24.x", - "antd": "^4.24.0 || ^5.8.5", + "@ant-design/icons": "^6.0.0", + "@rjsf/core": "^6.x", + "@rjsf/utils": "^6.x", + "antd": "^5.8.5", "dayjs": "^1.8.0", - "react": "^16.14.0 || >=17" + "react": ">=18" } }, "node_modules/@rjsf/core": { - "version": "5.24.13", - "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.24.13.tgz", - "integrity": "sha512-ONTr14s7LFIjx2VRFLuOpagL76sM/HPy6/OhdBfq6UukINmTIs6+aFN0GgcR0aXQHFDXQ7f/fel0o/SO05Htdg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.1.0.tgz", + "integrity": "sha512-9H3+BneyKWEPICL1kfRd7OKrwg2+ooR8xskJBnEqaq5p44MGxqQzKORhzWWp8h1yUWJFa4bMutJOcwKJxkKhfw==", "license": "Apache-2.0", "peer": true, "dependencies": { "lodash": "^4.17.21", "lodash-es": "^4.17.21", - "markdown-to-jsx": "^7.4.1", + "markdown-to-jsx": "^8.0.0", "prop-types": "^15.8.1" }, "engines": { - "node": ">=14" + "node": ">=20" }, "peerDependencies": { - "@rjsf/utils": "^5.24.x", - "react": "^16.14.0 || >=17" + "@rjsf/utils": "^6.x", + "react": ">=18" } }, "node_modules/@rjsf/utils": { - "version": "5.24.13", - "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-5.24.13.tgz", - "integrity": "sha512-rNF8tDxIwTtXzz5O/U23QU73nlhgQNYJ+Sv5BAwQOIyhIE2Z3S5tUiSVMwZHt0julkv/Ryfwi+qsD4FiE5rOuw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.1.0.tgz", + "integrity": "sha512-IonU7hukF+EnAuNmK3KuJ4DSvydhQH/Z/nuPQW8IhFsQmKGg0/LiJOOKEDS8HD1PDLpr+4J6qkZ+EDupMLHUZQ==", "license": "Apache-2.0", "peer": true, "dependencies": { + "fast-uri": "^3.1.0", "json-schema-merge-allof": "^0.8.1", "jsonpointer": "^5.0.1", "lodash": "^4.17.21", "lodash-es": "^4.17.21", - "react-is": "^18.2.0" + "react-is": "^18.3.1" }, "engines": { - "node": ">=14" + "node": ">=20" }, "peerDependencies": { - "react": "^16.14.0 || >=17" + "react": ">=18" } }, "node_modules/@rjsf/validator-ajv8": { - "version": "5.24.13", - "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-5.24.13.tgz", - "integrity": "sha512-oWHP7YK581M8I5cF1t+UXFavnv+bhcqjtL1a7MG/Kaffi0EwhgcYjODrD8SsnrhncsEYMqSECr4ZOEoirnEUWw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.1.0.tgz", + "integrity": "sha512-L3vzpE97oI20eMJhq8wP9RvtfwmcF4g/61Yh4rvPuQeo9WusWEZU5pG2L+tpM6yCzreZuHhgCjld6lsMrfV2BA==", "license": "Apache-2.0", "dependencies": { - "ajv": "^8.12.0", + "ajv": "^8.17.1", "ajv-formats": "^2.1.1", "lodash": "^4.17.21", "lodash-es": "^4.17.21" }, "engines": { - "node": ">=14" + "node": ">=20" }, "peerDependencies": { - "@rjsf/utils": "^5.24.x" + "@rjsf/utils": "^6.x" } }, "node_modules/@rolldown/pluginutils": { @@ -7493,43 +7524,24 @@ "react-dom": ">=16.9.0" } }, - "node_modules/antd/node_modules/rc-picker": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", - "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "node_modules/antd/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.24.7", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.2.1", - "rc-overflow": "^1.3.2", - "rc-resize-observer": "^1.4.0", - "rc-util": "^5.43.0" + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" }, "engines": { - "node": ">=8.x" + "node": ">=8" }, "peerDependencies": { - "date-fns": ">= 2.x", - "dayjs": ">= 1.x", - "luxon": ">= 3.x", - "moment": ">= 2.x", - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - }, - "peerDependenciesMeta": { - "date-fns": { - "optional": true - }, - "dayjs": { - "optional": true - }, - "luxon": { - "optional": true - }, - "moment": { - "optional": true - } + "react": ">=16.0.0", + "react-dom": ">=16.0.0" } }, "node_modules/anymatch": { @@ -9036,6 +9048,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -9848,22 +9869,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" - } - }, "node_modules/dayjs": { "version": "1.11.18", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", @@ -10338,12 +10343,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dom-align": { - "version": "1.12.4", - "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz", - "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", - "license": "MIT" - }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -13698,6 +13697,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -14765,15 +14770,20 @@ } }, "node_modules/markdown-to-jsx": { - "version": "7.7.13", - "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.13.tgz", - "integrity": "sha512-DiueEq2bttFcSxUs85GJcQVrOr0+VVsPfj9AEUPqmExJ3f8P/iQNvZHltV4tm1XVhu1kl0vWBZWT3l99izRMaA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-8.0.0.tgz", + "integrity": "sha512-hWEaRxeCDjes1CVUQqU+Ov0mCqBqkGhLKjL98KdbwHSgEWZZSJQeGlJQatVfeZ3RaxrfTrZZ3eczl2dhp5c/pA==", "license": "MIT", "engines": { "node": ">= 10" }, "peerDependencies": { "react": ">= 0.14.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } }, "node_modules/marked": { @@ -15780,15 +15790,6 @@ "node": ">=10" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/monaco-editor": { "version": "0.54.0", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", @@ -17881,23 +17882,6 @@ "node": ">=0.10.0" } }, - "node_modules/rc-align": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz", - "integrity": "sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "dom-align": "^1.7.0", - "rc-util": "^5.26.0", - "resize-observer-polyfill": "^1.5.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, "node_modules/rc-cascader": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", @@ -18167,26 +18151,42 @@ } }, "node_modules/rc-picker": { - "version": "2.7.6", - "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-2.7.6.tgz", - "integrity": "sha512-H9if/BUJUZBOhPfWcPeT15JUI3/ntrG9muzERrXDkSoWmDj4yzmBvumozpxYrHwjcKnjyDGAke68d+whWwvhHA==", + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.1", - "date-fns": "2.x", - "dayjs": "1.x", - "moment": "^2.24.0", - "rc-trigger": "^5.0.4", - "rc-util": "^5.37.0", - "shallowequal": "^1.1.0" + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" }, "engines": { "node": ">=8.x" }, "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } } }, "node_modules/rc-progress": { @@ -18440,26 +18440,6 @@ "react-dom": "*" } }, - "node_modules/rc-trigger": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.3.4.tgz", - "integrity": "sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "classnames": "^2.2.6", - "rc-align": "^4.0.0", - "rc-motion": "^2.0.0", - "rc-util": "^5.19.2" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, "node_modules/rc-upload": { "version": "4.9.2", "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.9.2.tgz", @@ -19856,12 +19836,6 @@ "node": ">=8" } }, - "node_modules/shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 1ef1066..4b3f8c4 100644 --- a/package.json +++ b/package.json @@ -37,14 +37,14 @@ ] }, "dependencies": { - "@ant-design/icons": "^5.6.1", + "@ant-design/icons": "^6.0.0", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.8.2", - "@rjsf/antd": "^5.24.13", - "@rjsf/core": "^5.24.13", - "@rjsf/utils": "^5.24.13", - "@rjsf/validator-ajv8": "^5.24.13", + "@rjsf/antd": "^6.1.0", + "@rjsf/core": "^6.1.0", + "@rjsf/utils": "^6.1.0", + "@rjsf/validator-ajv8": "^6.1.0", "antd": "^5.25.2", "electron-debug": "^4.0.0", "fast-json-patch": "^3.1.1", diff --git a/src/renderer/components/JsonSchemaForm.tsx b/src/renderer/components/JsonSchemaForm.tsx index 54d0e0d..15d4b18 100644 --- a/src/renderer/components/JsonSchemaForm.tsx +++ b/src/renderer/components/JsonSchemaForm.tsx @@ -1,117 +1,17 @@ import { IChangeEvent, withTheme } from '@rjsf/core'; -import { UiSchema, FieldTemplateProps, FieldProps } from '@rjsf/utils'; +import { UiSchema } from '@rjsf/utils'; import { Theme as AntdTheme } from '@rjsf/antd'; -import { Form } from 'antd'; -import ReactMarkdown from 'react-markdown'; -import { memo, useMemo } from 'react'; import validator from '@rjsf/validator-ajv8'; +import { useMemo } from 'react'; -export interface DescriptionFieldProps extends Partial { - description?: string; -} - -const MarkdownDescriptionField = memo(function MarkdownDescriptionField({ - id, - description, -}: DescriptionFieldProps) { - if (!description) { - return null; - } - return ( -
- {description} -
- ); -}); - -const HORIZONTAL_LABEL_COL = { span: 6 }; -const HORIZONTAL_WRAPPER_COL = { span: 18 }; - -// Cloned from Antd theme with some changes -const MarkdownFieldTemplate = memo(function MarkdownFieldTemplate({ - children, - // classNames, - // description, - // disabled, - displayLabel, - // errors, - // fields, - formContext, - // help, - hidden, - id, - label, - // onDropPropertyClick, - // onKeyChange, - rawDescription, - rawErrors, - // rawHelp, - // readonly, - required, - schema, -}: // uiSchema, -FieldTemplateProps) { - const { colon, wrapperStyle } = formContext; - const fieldErrors = useMemo(() => { - if (!rawErrors) { - return null; - } - return [...Array.from(new Set(rawErrors))].map((error: any) => ( -
{error}
- )); - }, [id, rawErrors]); - const renderedDescription = useMemo(() => { - if (!rawDescription) { - return null; - } - return {rawDescription}; - }, [rawDescription]); +const RjsfForm = withTheme(AntdTheme); - if (hidden) { - return
{children}
; - } - - return id === 'root' ? ( - children - ) : ( - - {children} - - ); -}); - -const RjsfForm = withTheme({ - ...AntdTheme, - widgets: { - ...AntdTheme.widgets, - // CheckboxWidget: CheckboxWidgetWithDescription, - }, - fields: { - ...AntdTheme.fields, - DescriptionField: MarkdownDescriptionField, +const DEFAULT_UI_SCHEMA: UiSchema = { + 'ui:globalOptions': { + enableMarkdownInDescription: true, + enableMarkdownInHelp: true, }, - templates: { - ...AntdTheme.templates, - FieldTemplate: MarkdownFieldTemplate, - }, -}); +}; export interface JsonSchemaFormProps { idPrefix?: string; @@ -132,6 +32,14 @@ export function JsonSchemaForm({ onChange, onSubmit, }: JsonSchemaFormProps) { + const appliedUiSchema: UiSchema = useMemo( + () => ({ + ...DEFAULT_UI_SCHEMA, + ...uiSchema, + }), + [uiSchema], + ); + return ( From 2380771be840ee7f8ee5c7bdbcefeb5dc556b5c4 Mon Sep 17 00:00:00 2001 From: Stefan Lau Date: Fri, 14 Nov 2025 09:46:30 +0100 Subject: [PATCH 10/14] Add inventory graphics selector --- src/renderer/EditorRoutes.tsx | 17 +++ .../form/InventoryGraphicsField.tsx | 101 ++++++++++++++++++ .../form/ResourceReferenceWidget.tsx | 11 +- 3 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 src/renderer/components/form/InventoryGraphicsField.tsx diff --git a/src/renderer/EditorRoutes.tsx b/src/renderer/EditorRoutes.tsx index 7a87cf1..535fe99 100644 --- a/src/renderer/EditorRoutes.tsx +++ b/src/renderer/EditorRoutes.tsx @@ -32,6 +32,8 @@ import { StiPreview } from './components/content/StiPreview'; import { ResourceType } from './lib/resourceType'; import { makeMultiSectorSelectorWidget } from './components/form/MultiSectorSelectorWidget'; import { mergeDeep } from 'remeda'; +import { UiSchema } from '@rjsf/utils'; +import { InventoryGraphicsField } from './components/form/InventoryGraphicsField'; const baseItemProps = [ 'itemIndex', @@ -68,6 +70,16 @@ const baseItemFlags = [ 'bUnaerodynamic', 'bSinks', ]; +const baseItemUiSchema: UiSchema = { + inventoryGraphics: { + small: { + 'ui:field': InventoryGraphicsField, + }, + big: { + 'ui:field': InventoryGraphicsField, + }, + }, +}; export interface Route { id: string; @@ -337,6 +349,7 @@ export const MENU: Readonly>> = [ ...baseItemFlags, ], + ...baseItemUiSchema, }, }), makeFileItem('calibres.json', 'Calibres', JsonItemsForm, { @@ -389,6 +402,7 @@ export const MENU: Readonly>> = [ 'isPressureTriggered', ...baseItemFlags, ], + ...baseItemUiSchema, calibre: { 'ui:widget': stringReferenceToExplosiveCalibres, }, @@ -410,6 +424,7 @@ export const MENU: Readonly>> = [ 'ubClassIndex', ...baseItemFlags, ], + ...baseItemUiSchema, }, }), makeFileItem('magazines.json', 'Magazines', JsonItemsForm, { @@ -427,6 +442,7 @@ export const MENU: Readonly>> = [ 'dontUseAsDefaultMagazine', ...baseItemFlags, ], + ...baseItemUiSchema, ammoType: { 'ui:widget': stringReferenceToAmmoTypes, }, @@ -499,6 +515,7 @@ export const MENU: Readonly>> = [ 'attachment_UnderGLauncher', ...baseItemFlags, ], + ...baseItemUiSchema, calibre: { 'ui:widget': stringReferenceToCalibres, }, diff --git a/src/renderer/components/form/InventoryGraphicsField.tsx b/src/renderer/components/form/InventoryGraphicsField.tsx new file mode 100644 index 0000000..58b3a21 --- /dev/null +++ b/src/renderer/components/form/InventoryGraphicsField.tsx @@ -0,0 +1,101 @@ +import { FieldProps } from '@rjsf/utils'; +import { ResourceReferenceWidget } from './ResourceReferenceWidget'; +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'; + +function SubImageSelector({ + file, + subimage, + onChange, +}: { + file: string; + subimage?: number; + onChange: (value: number) => unknown; +}) { + const { data, refresh } = useImageMetadata(file); + const options = useMemo(() => { + if (!data) return []; + return data.images.map((_, index) => ({ + label: ( + + + {index} + + ), + value: index, + })); + }, [data, file]); + + useEffect(() => { + refresh(); + }, [refresh]); + + if (!data) return null; + + return ( + Date: Fri, 14 Nov 2025 11:26:34 +0100 Subject: [PATCH 11/14] Add SamSitesAirControlForm --- src/renderer/EditorRoutes.tsx | 3 +- src/renderer/components/EditorLayout.tsx | 4 +- src/renderer/components/JsonForm.tsx | 2 +- .../components/SamSitesAirControlForm.tsx | 157 ++++++++++++++++++ src/renderer/components/StrategicMapForm.tsx | 11 +- .../components/content/StrategicMap.tsx | 53 ++++-- .../form/MultiSectorSelectorWidget.tsx | 12 +- src/renderer/hooks/files.tsx | 8 +- src/renderer/lib/resourceType.tsx | 5 +- tsconfig.json | 1 + 10 files changed, 230 insertions(+), 26 deletions(-) create mode 100644 src/renderer/components/SamSitesAirControlForm.tsx diff --git a/src/renderer/EditorRoutes.tsx b/src/renderer/EditorRoutes.tsx index 535fe99..b132d22 100644 --- a/src/renderer/EditorRoutes.tsx +++ b/src/renderer/EditorRoutes.tsx @@ -34,6 +34,7 @@ import { makeMultiSectorSelectorWidget } from './components/form/MultiSectorSele import { mergeDeep } from 'remeda'; import { UiSchema } from '@rjsf/utils'; import { InventoryGraphicsField } from './components/form/InventoryGraphicsField'; +import { SamSitesAirControlForm } from './components/SamSitesAirControlForm'; const baseItemProps = [ 'itemIndex', @@ -919,7 +920,7 @@ export const MENU: Readonly>> = [ makeFileItem( 'strategic-map-sam-sites-air-control.json', 'Sam Sites Air Control', - JsonForm, + SamSitesAirControlForm, {}, ), makeFileItem( diff --git a/src/renderer/components/EditorLayout.tsx b/src/renderer/components/EditorLayout.tsx index 2da9bd9..ce60519 100644 --- a/src/renderer/components/EditorLayout.tsx +++ b/src/renderer/components/EditorLayout.tsx @@ -78,7 +78,9 @@ function SideMenu() { sorted.sort((a, b) => a.label.localeCompare(b.label, 'en', { ignorePunctuation: true }), ); - return [dashboard, ...sorted].map((r) => routeToItem(navigate, '', r)); + return [...(dashboard ? [dashboard] : []), ...sorted].map((r) => + routeToItem(navigate, '', r), + ); }, [navigate]); return ( diff --git a/src/renderer/components/JsonForm.tsx b/src/renderer/components/JsonForm.tsx index cd5c5ce..4d5cdfb 100644 --- a/src/renderer/components/JsonForm.tsx +++ b/src/renderer/components/JsonForm.tsx @@ -66,7 +66,7 @@ export function JsonForm({ file, uiSchema }: JsonFormProps) { return ( - {contents} + {contents} ); } diff --git a/src/renderer/components/SamSitesAirControlForm.tsx b/src/renderer/components/SamSitesAirControlForm.tsx new file mode 100644 index 0000000..9ad4afc --- /dev/null +++ b/src/renderer/components/SamSitesAirControlForm.tsx @@ -0,0 +1,157 @@ +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 { + HIGHLIGHT_COLORS, + NormalizedSectorId, + sectorIdStringFromCoords, + StrategicMap, +} from './content/StrategicMap'; +import { Badge, Flex, Select, Space, Typography } from 'antd'; +import { JsonFormHeader } from './form/JsonFormHeader'; + +function coordsFromSectorIdString(sectorId: string): [number, number] | null { + const yStr = sectorId[0]; + const xStr = sectorId.substring(1); + if (!xStr || !yStr) { + return null; + } + const x = parseInt(xStr, 10) - 1; + const y = yStr.charCodeAt(0) - 'A'.charCodeAt(0); + if (isNaN(x) || isNaN(y)) { + return null; + } + return [x, y]; +} + +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. + +