From a8097c95d47a12182aeb8746562bb506f564abca Mon Sep 17 00:00:00 2001 From: taDuc Date: Sat, 16 May 2026 01:45:19 +0700 Subject: [PATCH] refactor(important): reduce editor drill state --- package-lock.json | 32 +- package.json | 3 +- src/app/editor/[id]/page.tsx | 352 ++++++++--------- .../editor/BackgroundLayersPanel.tsx | 166 +++++--- .../editor/EntityWikiBindingsPanel.tsx | 50 ++- .../editor/GeometryBindingPanel.tsx | 85 ++++- .../editor/ProjectEntityRefsPanel.tsx | 49 ++- .../editor/SelectedGeometryPanel.tsx | 47 ++- src/uhm/components/map/useMapInteraction.ts | 8 +- src/uhm/components/wiki/WikiSidebarPanel.tsx | 42 ++- .../lib/editor/project/useProjectCommands.ts | 267 +++++++------ src/uhm/store/editorStore.tsx | 355 ++++++++++++++++++ 12 files changed, 989 insertions(+), 467 deletions(-) create mode 100644 src/uhm/store/editorStore.tsx diff --git a/package-lock.json b/package-lock.json index 7af6a9f..300c84b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,8 @@ "swiper": "^11.2.10", "tailwind-merge": "^2.6.0", "uuid": "^13.0.0", - "yet-another-react-lightbox": "^3.30.1" + "yet-another-react-lightbox": "^3.30.1", + "zustand": "^5.0.13" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", @@ -10107,6 +10108,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 3c90784..b7a519a 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "swiper": "^11.2.10", "tailwind-merge": "^2.6.0", "uuid": "^13.0.0", - "yet-another-react-lightbox": "^3.30.1" + "yet-another-react-lightbox": "^3.30.1", + "zustand": "^5.0.13" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 19744f5..20b3642 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react"; import { useParams, useRouter } from "next/navigation"; +import { useShallow } from "zustand/react/shallow"; import Map from "@/uhm/components/Map"; import Editor from "@/uhm/components/Editor"; import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel"; @@ -24,22 +25,7 @@ import { Geometry, useEditorState, } from "@/uhm/lib/editor/state/useEditorState"; -import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap"; -import { - BackgroundLayerId, - BackgroundLayerVisibility, - DEFAULT_BACKGROUND_LAYER_VISIBILITY, - HIDDEN_BACKGROUND_LAYER_VISIBILITY, -} from "@/uhm/lib/map/styles/backgroundLayers"; -import { - GEOMETRY_TYPE_OPTIONS, -} from "@/uhm/lib/map/geo/geometryTypeOptions"; -import { - EntityFormState, - EditorMode, - GeometryMetaFormState, - useEditorSessionState, -} from "@/uhm/lib/editor/state/useEditorSessionState"; +import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; import { getDefaultTypeIdForFeature, normalizeFeatureBindingIds, @@ -53,7 +39,6 @@ import { import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; import { loadBackgroundLayerVisibilityFromStorage, - persistBackgroundLayerVisibility, } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands"; import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; @@ -62,69 +47,55 @@ import { useFeatureCommands } from "./featureCommands"; import { deleteSubmission } from "@/uhm/api/projects"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; -import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar"; +import UnifiedSearchBar from "@/uhm/components/ui/UnifiedSearchBar"; +import { + EditorStoreProvider, + useEditorStore, + useEditorStoreApi, +} from "@/uhm/store/editorStore"; const CURRENT_YEAR = new Date().getUTCFullYear(); const DEFAULT_EDITOR_USER_ID = "local-editor"; export default function Page() { + return ( + + + + ); +} + +function EditorPageContent() { const params = useParams(); const router = useRouter(); + const editorStoreApi = useEditorStoreApi(); const projectId = String(params.id || ""); const openedProjectIdRef = useRef(null); - const [blockedPendingSubmissionId, setBlockedPendingSubmissionId] = useState(null); - const [searchKind, setSearchKind] = useState("entity"); - const [searchQuery, setSearchQuery] = useState(""); - const [searchQueryDraft, setSearchQueryDraft] = useState(""); - const [wikiSearchResults, setWikiSearchResults] = useState([]); - const [isWikiSearching, setIsWikiSearching] = useState(false); - const [geoSearchResults, setGeoSearchResults] = useState([]); - const [isGeoSearching, setIsGeoSearching] = useState(false); - const [requestedActiveWikiId, setRequestedActiveWikiId] = useState(null); - const [leftPanelWidth, setLeftPanelWidth] = useState(280); - const [rightPanelWidth, setRightPanelWidth] = useState(420); - const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(true); - const [geometryBindingFilterEnabled, setGeometryBindingFilterEnabled] = useState(true); const entityFormStatusTimeoutRef = useRef(null); const geoBindingStatusTimeoutRef = useRef(null); - const [geoBindingStatus, setGeoBindingStatus] = useState(null); - const [geometryFocusRequest, setGeometryFocusRequest] = useState<{ - key: number; - collection: FeatureCollection; - } | null>(null); const localCreatedEntityIdsRef = useRef>(new Set()); const lastSelectedFeatureIdRef = useRef(null); - - const [replayFeatureId, setReplayFeatureId] = useState(null); - const [hideOutside, setHideOutside] = useState(false); - const { mode, - setMode: internalSetMode, + internalSetMode, initialData, - setInitialData, isSaving, - setIsSaving, isSubmitting, - setIsSubmitting, isOpeningSection, setIsOpeningSection, - setAvailableSections, - selectedProjectId, - setSelectedProjectId, - newSectionTitle, - setNewSectionTitle, commitTitle, setCommitTitle, - editorUserIdInput, activeSection, - setActiveSection, projectState, - setProjectState, sectionCommits, - setProjectCommits, baselineSnapshot, - setBaselineSnapshot, entityCatalog, setEntityCatalog, snapshotEntities, @@ -139,9 +110,7 @@ export default function Page() { setSelectedGeometryEntityIds, geometryMetaForm, setGeometryMetaForm, - isEntitySubmitting, setIsEntitySubmitting, - entityFormStatus, setEntityFormStatus, entitySearchResults, setEntitySearchResults, @@ -157,23 +126,122 @@ export default function Page() { setSnapshotWikis, snapshotEntityWikiLinks, setSnapshotEntityWikiLinks, - } = useEditorSessionState({ - emptyFeatureCollection: EMPTY_FEATURE_COLLECTION, - defaultEditorUserId: DEFAULT_EDITOR_USER_ID, - fallbackTimelineRange: FIXED_TIMELINE_RANGE, - currentYear: CURRENT_YEAR, - }); + blockedPendingSubmissionId, + setBlockedPendingSubmissionId, + searchKind, + setSearchKind, + searchQuery, + setSearchQuery, + searchQueryDraft, + setSearchQueryDraft, + wikiSearchResults, + setWikiSearchResults, + isWikiSearching, + setIsWikiSearching, + geoSearchResults, + setGeoSearchResults, + isGeoSearching, + setIsGeoSearching, + setRequestedActiveWikiId, + leftPanelWidth, + setLeftPanelWidth, + rightPanelWidth, + setRightPanelWidth, + timelineFilterEnabled, + setTimelineFilterEnabled, + geometryBindingFilterEnabled, + setGeoBindingStatus, + hoveredGeometryId, + geometryFocusRequest, + setGeometryFocusRequest, + replayFeatureId, + setReplayFeatureId, + hideOutside, + setHideOutside, + geometryVisibility, + } = useEditorStore(useShallow((state) => ({ + mode: state.mode, + internalSetMode: state.setMode, + initialData: state.initialData, + isSaving: state.isSaving, + isSubmitting: state.isSubmitting, + isOpeningSection: state.isOpeningSection, + setIsOpeningSection: state.setIsOpeningSection, + commitTitle: state.commitTitle, + setCommitTitle: state.setCommitTitle, + activeSection: state.activeSection, + projectState: state.projectState, + sectionCommits: state.sectionCommits, + baselineSnapshot: state.baselineSnapshot, + entityCatalog: state.entityCatalog, + setEntityCatalog: state.setEntityCatalog, + snapshotEntities: state.snapshotEntities, + setSnapshotEntities: state.setSnapshotEntities, + entityStatus: state.entityStatus, + setEntityStatus: state.setEntityStatus, + selectedFeatureIds: state.selectedFeatureIds, + setSelectedFeatureIds: state.setSelectedFeatureIds, + entityForm: state.entityForm, + setEntityForm: state.setEntityForm, + selectedGeometryEntityIds: state.selectedGeometryEntityIds, + setSelectedGeometryEntityIds: state.setSelectedGeometryEntityIds, + geometryMetaForm: state.geometryMetaForm, + setGeometryMetaForm: state.setGeometryMetaForm, + setIsEntitySubmitting: state.setIsEntitySubmitting, + setEntityFormStatus: state.setEntityFormStatus, + entitySearchResults: state.entitySearchResults, + setEntitySearchResults: state.setEntitySearchResults, + isEntitySearchLoading: state.isEntitySearchLoading, + setIsEntitySearchLoading: state.setIsEntitySearchLoading, + timelineDraftYear: state.timelineDraftYear, + setTimelineDraftYear: state.setTimelineDraftYear, + backgroundVisibility: state.backgroundVisibility, + setBackgroundVisibility: state.setBackgroundVisibility, + isBackgroundVisibilityReady: state.isBackgroundVisibilityReady, + setIsBackgroundVisibilityReady: state.setIsBackgroundVisibilityReady, + snapshotWikis: state.snapshotWikis, + setSnapshotWikis: state.setSnapshotWikis, + snapshotEntityWikiLinks: state.snapshotEntityWikiLinks, + setSnapshotEntityWikiLinks: state.setSnapshotEntityWikiLinks, + blockedPendingSubmissionId: state.blockedPendingSubmissionId, + setBlockedPendingSubmissionId: state.setBlockedPendingSubmissionId, + searchKind: state.searchKind, + setSearchKind: state.setSearchKind, + searchQuery: state.searchQuery, + setSearchQuery: state.setSearchQuery, + searchQueryDraft: state.searchQueryDraft, + setSearchQueryDraft: state.setSearchQueryDraft, + wikiSearchResults: state.wikiSearchResults, + setWikiSearchResults: state.setWikiSearchResults, + isWikiSearching: state.isWikiSearching, + setIsWikiSearching: state.setIsWikiSearching, + geoSearchResults: state.geoSearchResults, + setGeoSearchResults: state.setGeoSearchResults, + isGeoSearching: state.isGeoSearching, + setIsGeoSearching: state.setIsGeoSearching, + setRequestedActiveWikiId: state.setRequestedActiveWikiId, + leftPanelWidth: state.leftPanelWidth, + setLeftPanelWidth: state.setLeftPanelWidth, + rightPanelWidth: state.rightPanelWidth, + setRightPanelWidth: state.setRightPanelWidth, + timelineFilterEnabled: state.timelineFilterEnabled, + setTimelineFilterEnabled: state.setTimelineFilterEnabled, + geometryBindingFilterEnabled: state.geometryBindingFilterEnabled, + setGeoBindingStatus: state.setGeoBindingStatus, + hoveredGeometryId: state.hoveredGeometryId, + geometryFocusRequest: state.geometryFocusRequest, + setGeometryFocusRequest: state.setGeometryFocusRequest, + replayFeatureId: state.replayFeatureId, + setReplayFeatureId: state.setReplayFeatureId, + hideOutside: state.hideOutside, + setHideOutside: state.setHideOutside, + geometryVisibility: state.geometryVisibility, + }))); // Counter để bỏ qua response cũ khi user gõ search entity liên tục. const entitySearchRequestRef = useRef(0); const wikiSearchRequestRef = useRef(0); const geoSearchRequestRef = useRef(0); - const [geometryVisibility, setGeometryVisibility] = useState>(() => { - const init: Record = {}; - for (const key of GEO_TYPE_KEYS) init[key] = true; - return init; - }); - const snapshotEntitiesRef = useRef(snapshotEntities); const snapshotWikisRef = useRef(snapshotWikis); const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks); @@ -211,7 +279,6 @@ export default function Page() { }, [editor] ); - const editorUserId = normalizeEditorUserId(editorUserIdInput); const snapshotEntitiesAsEntities = useMemo(() => { const rows = snapshotEntities || []; return rows @@ -235,17 +302,6 @@ export default function Page() { entitiesRef.current = entities; }, [entities]); - const snapshotEntitiesVisible = useMemo(() => { - const byId = new globalThis.Map(); - for (const ref of snapshotEntities || []) { - const id = String(ref?.id || "").trim(); - if (!id || byId.has(id)) continue; - if (ref.operation === "delete") continue; - byId.set(id, ref); - } - return Array.from(byId.values()); - }, [snapshotEntities]); - useEffect(() => { const localCreatedIds = localCreatedEntityIdsRef.current; if (!localCreatedIds.size) return; @@ -284,21 +340,6 @@ export default function Page() { }; }, [editor, mode, timelineDraftYear, timelineFilterEnabled]); - const projectEntityChoices = useMemo(() => { - const ids = new Set(); - for (const ref of snapshotEntitiesVisible) ids.add(String(ref.id)); - const rows = Array.from(ids).map((id) => { - const ref = snapshotEntitiesVisible.find((entity) => String(entity.id) === id) || null; - const found = entities.find((e) => e.id === id) || null; - return { - id, - name: found?.name || id, - isNew: ref?.source === "inline" && ref?.operation === "create", - }; - }); - rows.sort((a, b) => a.name.localeCompare(b.name)); - return rows; - }, [entities, snapshotEntitiesVisible]); const selectedFeatures = useMemo(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return []; return selectedFeatureIds @@ -341,6 +382,18 @@ export default function Page() { return normalizeFeatureBindingIds(selectedFeature); }, [selectedFeature]); + const hoveredGeometryHighlight = useMemo(() => { + if (!hoveredGeometryId) return null; + const feature = editor.draft.features.find( + (item) => String(item.properties.id) === hoveredGeometryId + ); + if (!feature) return null; + return { + type: "FeatureCollection", + features: [feature], + } as FeatureCollection; + }, [editor.draft.features, hoveredGeometryId]); + const wikiDirty = useMemo(() => { const prev = normalizeWikisForCompare(baselineSnapshot?.wikis); const next = normalizeWikisForCompare(snapshotWikis); @@ -379,36 +432,9 @@ export default function Page() { const sectionCommands = useProjectCommands({ editor, - editorUserId, + store: editorStoreApi, emptyFeatureCollection: EMPTY_FEATURE_COLLECTION, - activeSection, - projectState, - selectedProjectId, - newSectionTitle, pendingSaveCount, - snapshotEntities, - snapshotWikis, - snapshotEntityWikiLinks, - baselineSnapshot, - commitTitle, - setActiveSection, - setSelectedProjectId, - setProjectState, - setBaselineSnapshot, - setInitialData, - setProjectCommits, - setSnapshotEntities, - setSnapshotWikis, - setSnapshotEntityWikiLinks, - setEntityFormStatus, - setSelectedFeatureIds, - setEntityStatus, - setIsSaving, - setIsSubmitting, - setIsOpeningSection, - setAvailableSections, - setNewSectionTitle, - setCommitTitle, }); const { openSectionForEditing, @@ -432,7 +458,7 @@ export default function Page() { setHideOutside(false); } internalSetMode(m); - }, [internalSetMode, mode, editor, selectedFeatureIds]); + }, [internalSetMode, mode, editor, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds]); const effectiveGeometryVisibility = useMemo(() => { const visibility: Record = { ...geometryVisibility }; @@ -461,7 +487,7 @@ export default function Page() { const onToggleHideOutside = useCallback(() => { setHideOutside((prev) => !prev); - }, []); + }, [setHideOutside]); const openProject = useCallback(async () => { if (!projectId) return; @@ -500,7 +526,7 @@ export default function Page() { } finally { setIsOpeningSection(false); } - }, [openSectionForEditing, projectId, router, setEntityStatus, setIsOpeningSection]); + }, [openSectionForEditing, projectId, router, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]); const unlockByDeletingPendingSubmission = useCallback(async () => { if (!blockedPendingSubmissionId) return; @@ -521,7 +547,7 @@ export default function Page() { } finally { setIsOpeningSection(false); } - }, [blockedPendingSubmissionId, openProject, setEntityStatus, setIsOpeningSection]); + }, [blockedPendingSubmissionId, openProject, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]); useEffect(() => { let disposed = false; @@ -704,7 +730,7 @@ export default function Page() { disposed = true; window.clearTimeout(timeoutId); }; - }, [searchKind, searchQuery]); + }, [searchKind, searchQuery, setIsWikiSearching, setWikiSearchResults]); useEffect(() => { if (searchKind !== "geo") { @@ -743,7 +769,7 @@ export default function Page() { disposed = true; window.clearTimeout(timeoutId); }; - }, [geoSearchRequestRef, searchKind, searchQuery]); + }, [searchKind, searchQuery, setGeoSearchResults, setIsGeoSearching]); useEffect(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; @@ -831,43 +857,10 @@ export default function Page() { setIsBackgroundVisibilityReady(true); }, [setBackgroundVisibility, setIsBackgroundVisibilityReady]); - const updateBackgroundVisibility = ( - updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility - ) => { - setBackgroundVisibility((prev) => { - const next = updater(prev); - persistBackgroundLayerVisibility(next); - return next; - }); - }; - - const handleToggleBackgroundLayer = (id: BackgroundLayerId) => { - updateBackgroundVisibility((prev) => ({ - ...prev, - [id]: !prev[id], - })); - }; - - const handleShowAllBackgroundLayers = () => { - updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY })); - }; - - const handleHideAllBackgroundLayers = () => { - updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })); - }; - const handleTimelineYearChange = (nextYear: number) => { setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); }; - const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { - setEntityForm((prev) => ({ ...prev, [key]: value })); - }; - - const handleGeometryMetaFormChange = (key: keyof GeometryMetaFormState, value: string) => { - setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); - }; - const handleAddEntityRefToProject = useCallback((entity: Entity) => { const id = String(entity.id || "").trim(); if (!id) return; @@ -1073,6 +1066,7 @@ export default function Page() { }, [ editor.draft.features, flashGeoBindingStatus, + setGeometryFocusRequest, setSelectedFeatureIds, setTimelineFilterEnabled, timelineFilterEnabled, @@ -1380,6 +1374,7 @@ export default function Page() { backgroundVisibility={backgroundVisibility} geometryVisibility={effectiveGeometryVisibility} respectBindingFilter={geometryBindingFilterEnabled} + highlightFeatures={hoveredGeometryHighlight} focusFeatureCollection={geometryFocusRequest?.collection || null} focusRequestKey={geometryFocusRequest?.key ?? null} focusPadding={96} @@ -1414,14 +1409,6 @@ export default function Page() { /> { - setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false })); - }} width={rightPanelWidth} topContent={
@@ -1655,44 +1642,26 @@ export default function Page() { selectedGeometryBindingIds={selectedGeometryBindingIds} onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry} onFocusGeometry={handleFocusGeometryFromBindingPanel} - statusText={geoBindingStatus} - bindingFilterEnabled={geometryBindingFilterEnabled} - onBindingFilterEnabledChange={setGeometryBindingFilterEnabled} /> {selectedFeature ? ( setMode("replay", id)} @@ -1770,11 +1739,6 @@ function clampNumber(value: number, min: number, max: number): number { return value; } -function normalizeEditorUserId(value: string): string { - const normalized = value.trim(); - return normalized || DEFAULT_EDITOR_USER_ID; -} - function formatCommitTitle(commit: ProjectCommit): string { return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`; } diff --git a/src/uhm/components/editor/BackgroundLayersPanel.tsx b/src/uhm/components/editor/BackgroundLayersPanel.tsx index 7fa8470..c4549ae 100644 --- a/src/uhm/components/editor/BackgroundLayersPanel.tsx +++ b/src/uhm/components/editor/BackgroundLayersPanel.tsx @@ -1,34 +1,65 @@ "use client"; import { ReactNode } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { persistBackgroundLayerVisibility } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerId, - BackgroundLayerVisibility, + DEFAULT_BACKGROUND_LAYER_VISIBILITY, + HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/uhm/lib/map/styles/backgroundLayers"; import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap"; +import { useEditorStore } from "@/uhm/store/editorStore"; type Props = { - visibility: BackgroundLayerVisibility; - onToggleLayer: (id: BackgroundLayerId) => void; - onShowAll: () => void; - onHideAll: () => void; - geometryVisibility?: Record; - onToggleGeometryType?: (typeKey: string) => void; topContent?: ReactNode; width?: number; }; export default function BackgroundLayersPanel({ - visibility, - onToggleLayer, - onShowAll, - onHideAll, - geometryVisibility, - onToggleGeometryType, topContent, width = 240, }: Props) { + const { + visibility, + setBackgroundVisibility, + geometryVisibility, + setGeometryVisibility, + } = useEditorStore( + useShallow((state) => ({ + visibility: state.backgroundVisibility, + setBackgroundVisibility: state.setBackgroundVisibility, + geometryVisibility: state.geometryVisibility, + setGeometryVisibility: state.setGeometryVisibility, + })) + ); + + const updateBackgroundVisibility = ( + updater: (prev: typeof visibility) => typeof visibility + ) => { + setBackgroundVisibility((prev) => { + const next = updater(prev); + persistBackgroundLayerVisibility(next); + return next; + }); + }; + + const handleToggleLayer = (id: BackgroundLayerId) => { + updateBackgroundVisibility((prev) => ({ + ...prev, + [id]: !prev[id], + })); + }; + + const handleShowAll = () => { + updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY })); + }; + + const handleHideAll = () => { + updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })); + }; + return (
- {geometryVisibility && onToggleGeometryType ? ( - <> -
-
- Geometries -
-
- {GEO_TYPE_KEYS.map((typeKey) => { - const on = geometryVisibility[typeKey] !== false; - return ( - - ); - })} -
- - ) : null} +
+ + +
+ + <> +
+
+ Geometries +
+
+ {GEO_TYPE_KEYS.map((typeKey) => { + const on = geometryVisibility[typeKey] !== false; + return ( + + ); + })} +
+ ); } + +const secondaryButtonStyle = { + border: "1px solid #334155", + borderRadius: 6, + background: "#0b1220", + color: "#cbd5e1", + cursor: "pointer", + fontSize: 12, + fontWeight: 700, + padding: "6px 8px", +} as const; diff --git a/src/uhm/components/editor/EntityWikiBindingsPanel.tsx b/src/uhm/components/editor/EntityWikiBindingsPanel.tsx index 01c14fd..2066960 100644 --- a/src/uhm/components/editor/EntityWikiBindingsPanel.tsx +++ b/src/uhm/components/editor/EntityWikiBindingsPanel.tsx @@ -1,9 +1,11 @@ "use client"; import { useMemo, useState } from "react"; -import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; +import type { WikiSnapshot } from "@/uhm/types/wiki"; +import { useShallow } from "zustand/react/shallow"; import NewBadge from "@/uhm/components/editor/NewBadge"; +import { useEditorStore } from "@/uhm/store/editorStore"; type EntityChoice = { id: string; name: string; isNew?: boolean }; type WikiChoice = { id: string; title: string; isNew?: boolean }; @@ -18,9 +20,6 @@ type BindingRow = { }; type Props = { - entities: EntityChoice[]; - wikis: WikiSnapshot[]; - links: EntityWikiLinkSnapshot[]; setLinks: React.Dispatch>; }; @@ -29,7 +28,20 @@ function wikiTitle(w: WikiSnapshot): string { return t.length ? t : "Untitled wiki"; } -export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) { +export default function EntityWikiBindingsPanel({ setLinks }: Props) { + const { + entityCatalog, + snapshotEntities, + wikis, + links, + } = useEditorStore( + useShallow((state) => ({ + entityCatalog: state.entityCatalog, + snapshotEntities: state.snapshotEntities, + wikis: state.snapshotWikis, + links: state.snapshotEntityWikiLinks, + })) + ); const [activeEntityId, setActiveEntityId] = useState(""); const [activeWikiId, setActiveWikiId] = useState(""); const [collapsed, setCollapsed] = useState(false); @@ -46,11 +58,29 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin [wikis] ); - const entityChoices = useMemo(() => { - const cleaned = (entities || []).filter((e) => e && typeof e.id === "string" && e.id.trim().length > 0); - cleaned.sort((a, b) => a.name.localeCompare(b.name)); - return cleaned; - }, [entities]); + const entityChoices = useMemo(() => { + const visibleSnapshotEntities = new globalThis.Map(); + for (const ref of snapshotEntities || []) { + const id = String(ref?.id || "").trim(); + if (!id || ref?.operation === "delete" || visibleSnapshotEntities.has(id)) continue; + visibleSnapshotEntities.set(id, { + id, + name: String(ref?.name || id), + isNew: ref?.source === "inline" && ref?.operation === "create", + }); + } + + const rows = Array.from(visibleSnapshotEntities.values()).map((entity) => { + const found = entityCatalog.find((item) => String(item.id) === entity.id) || null; + return { + id: entity.id, + name: String(found?.name || entity.name || entity.id), + isNew: entity.isNew, + }; + }); + rows.sort((a, b) => a.name.localeCompare(b.name)); + return rows; + }, [entityCatalog, snapshotEntities]); const activeLinks = useMemo(() => { const set = new Set(); diff --git a/src/uhm/components/editor/GeometryBindingPanel.tsx b/src/uhm/components/editor/GeometryBindingPanel.tsx index 22dd9db..ed30065 100644 --- a/src/uhm/components/editor/GeometryBindingPanel.tsx +++ b/src/uhm/components/editor/GeometryBindingPanel.tsx @@ -1,7 +1,9 @@ "use client"; import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react"; +import { useShallow } from "zustand/react/shallow"; import NewBadge from "@/uhm/components/editor/NewBadge"; +import { useEditorStore } from "@/uhm/store/editorStore"; type GeometryChoice = { id: string; @@ -11,13 +13,10 @@ type GeometryChoice = { type Props = { geometries: GeometryChoice[]; - selectedGeometryId: string | null; + selectedGeometryId?: string | null; selectedGeometryBindingIds: string[]; onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void; onFocusGeometry?: (geometryId: string) => void; - statusText?: string | null; - bindingFilterEnabled: boolean; - onBindingFilterEnabledChange: (next: boolean) => void; }; export default function GeometryBindingPanel({ @@ -26,12 +25,29 @@ export default function GeometryBindingPanel({ selectedGeometryBindingIds, onToggleBindGeometryForSelectedGeometry, onFocusGeometry, - statusText, - bindingFilterEnabled, - onBindingFilterEnabledChange, }: Props) { + const { + selectedFeatureIds, + statusText, + bindingFilterEnabled, + setGeometryBindingFilterEnabled, + hoveredGeometryId, + setHoveredGeometryId, + } = useEditorStore( + useShallow((state) => ({ + selectedFeatureIds: state.selectedFeatureIds, + statusText: state.geoBindingStatus, + bindingFilterEnabled: state.geometryBindingFilterEnabled, + setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled, + hoveredGeometryId: state.hoveredGeometryId, + setHoveredGeometryId: state.setHoveredGeometryId, + })) + ); + const effectiveSelectedGeometryId = + selectedGeometryId ?? + (selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null); const canBindToggle = - Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function"; + Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function"; const canFocusGeometry = typeof onFocusGeometry === "function"; const [collapsed, setCollapsed] = useState(false); @@ -46,24 +62,30 @@ export default function GeometryBindingPanel({ const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]); const selectedGeometry = useMemo(() => { - if (!selectedGeometryId) return null; - return rows.find((g) => g.id === selectedGeometryId) || null; - }, [rows, selectedGeometryId]); + if (!effectiveSelectedGeometryId) return null; + return rows.find((g) => g.id === effectiveSelectedGeometryId) || null; + }, [effectiveSelectedGeometryId, rows]); const visibleRows = useMemo(() => { return rows - .filter((g) => g.id !== selectedGeometryId) + .filter((g) => g.id !== effectiveSelectedGeometryId) .sort((a, b) => { const aBound = bindingSet.has(a.id); const bBound = bindingSet.has(b.id); if (aBound !== bBound) return aBound ? -1 : 1; return a.id.localeCompare(b.id); }); - }, [bindingSet, rows, selectedGeometryId]); + }, [bindingSet, effectiveSelectedGeometryId, rows]); const handleFocusKeyDown = (event: KeyboardEvent, geometryId: string) => { if (!canFocusGeometry) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); + setHoveredGeometryId((current) => (current === geometryId ? null : current)); + onFocusGeometry?.(geometryId); + }; + + const handleFocusGeometry = (geometryId: string) => { + setHoveredGeometryId((current) => (current === geometryId ? null : current)); onFocusGeometry?.(geometryId); }; @@ -75,6 +97,7 @@ export default function GeometryBindingPanel({ borderRadius: "8px", border: "1px solid #1f2937", }} + onMouseLeave={() => setHoveredGeometryId(null)} >
@@ -92,7 +115,7 @@ export default function GeometryBindingPanel({ onBindingFilterEnabledChange(e.target.checked)} + onChange={(e) => setGeometryBindingFilterEnabled(e.target.checked)} style={{ width: 14, height: 14 }} /> Filter @@ -130,15 +153,26 @@ export default function GeometryBindingPanel({ marginTop: 10, padding: "8px", borderRadius: "6px", - border: "1px solid rgba(59, 130, 246, 0.45)", - background: "rgba(37, 99, 235, 0.12)", + border: + hoveredGeometryId === selectedGeometry.id + ? "1px solid rgba(245, 158, 11, 0.95)" + : "1px solid rgba(59, 130, 246, 0.45)", + background: + hoveredGeometryId === selectedGeometry.id + ? "rgba(245, 158, 11, 0.18)" + : "rgba(37, 99, 235, 0.12)", cursor: canFocusGeometry ? "pointer" : "default", + boxShadow: + hoveredGeometryId === selectedGeometry.id + ? "0 0 0 2px rgba(251, 191, 36, 0.18)" + : "none", }} title={selectedGeometry.id} role={canFocusGeometry ? "button" : undefined} tabIndex={canFocusGeometry ? 0 : undefined} - onClick={() => onFocusGeometry?.(selectedGeometry.id)} + onClick={() => handleFocusGeometry(selectedGeometry.id)} onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)} + onMouseEnter={() => setHoveredGeometryId(selectedGeometry.id)} >
{ const isBound = bindingSet.has(g.id); + const isHovered = hoveredGeometryId === g.id; return (
onFocusGeometry?.(g.id)} + onClick={() => handleFocusGeometry(g.id)} onKeyDown={(event) => handleFocusKeyDown(event, g.id)} + onMouseEnter={() => setHoveredGeometryId(g.id)} >
void; - isEntitySubmitting: boolean; onCreateEntityOnly: () => void; onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void; - entityFormStatus: string | null; - selectedGeometryEntityIds?: string[]; hasSelectedGeometry?: boolean; onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void; }; export default function ProjectEntityRefsPanel({ - entityRefs, - entityForm, - onEntityFormChange, - isEntitySubmitting, onCreateEntityOnly, onUpdateEntity, - entityFormStatus, - selectedGeometryEntityIds, hasSelectedGeometry, onToggleBindEntityForSelectedGeometry, }: Props) { + const { + snapshotEntities, + entityForm, + setEntityForm, + isEntitySubmitting, + entityFormStatus, + selectedGeometryEntityIds, + } = useEditorStore( + useShallow((state) => ({ + snapshotEntities: state.snapshotEntities, + entityForm: state.entityForm, + setEntityForm: state.setEntityForm, + isEntitySubmitting: state.isEntitySubmitting, + entityFormStatus: state.entityFormStatus, + selectedGeometryEntityIds: state.selectedGeometryEntityIds, + })) + ); const canBindToggle = Boolean(hasSelectedGeometry) && Array.isArray(selectedGeometryEntityIds) && @@ -43,6 +49,16 @@ export default function ProjectEntityRefsPanel({ () => new Set((selectedGeometryEntityIds || []).map(String)), [selectedGeometryEntityIds] ); + const entityRefs = useMemo(() => { + const byId = new globalThis.Map(); + for (const ref of snapshotEntities || []) { + const id = String(ref?.id || "").trim(); + if (!id || byId.has(id)) continue; + if (ref.operation === "delete") continue; + byId.set(id, ref); + } + return Array.from(byId.values()); + }, [snapshotEntities]); const sortedEntityRefs = useMemo(() => { const rows = [...(entityRefs || [])]; rows.sort((a, b) => { @@ -68,6 +84,9 @@ export default function ProjectEntityRefsPanel({ setEditName(typeof entity.name === "string" ? entity.name : ""); setEditDescription(entity.description == null ? "" : String(entity.description)); }; + const handleEntityFormChange = (key: "name" | "description", value: string) => { + setEntityForm((prev) => ({ ...prev, [key]: value })); + }; return (
onEntityFormChange("name", event.target.value)} + onChange={(event) => handleEntityFormChange("name", event.target.value)} placeholder="Tên entity mới" disabled={isEntitySubmitting} style={entityInputStyle} /> onEntityFormChange("description", event.target.value)} + onChange={(event) => handleEntityFormChange("description", event.target.value)} placeholder="Description" disabled={isEntitySubmitting} style={entityInputStyle} diff --git a/src/uhm/components/editor/SelectedGeometryPanel.tsx b/src/uhm/components/editor/SelectedGeometryPanel.tsx index 250144b..212bef3 100644 --- a/src/uhm/components/editor/SelectedGeometryPanel.tsx +++ b/src/uhm/components/editor/SelectedGeometryPanel.tsx @@ -1,23 +1,20 @@ "use client"; import { type CSSProperties, useMemo, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; import { Feature } from "@/uhm/lib/editor/state/useEditorState"; import { + GEOMETRY_TYPE_OPTIONS, GeometryPreset, GeometryTypeGroupId, - GeometryTypeOption, findGeometryTypeOption, groupGeometryTypeOptions, } from "@/uhm/lib/map/geo/geometryTypeOptions"; import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap"; -import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes"; +import { useEditorStore } from "@/uhm/store/editorStore"; type Props = { selectedFeatures: Feature[]; - entityTypeOptions: GeometryTypeOption[]; - geometryMetaForm: GeometryMetaFormState; - onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void; - isEntitySubmitting: boolean; onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>; changeCount: number; onReplayEdit?: (id: string | number) => void; @@ -25,14 +22,21 @@ type Props = { export default function SelectedGeometryPanel({ selectedFeatures, - entityTypeOptions, - geometryMetaForm, - onGeometryMetaFormChange, - isEntitySubmitting, onApplyGeometryMetadata, changeCount, onReplayEdit, }: Props) { + const { + geometryMetaForm, + setGeometryMetaForm, + isEntitySubmitting, + } = useEditorStore( + useShallow((state) => ({ + geometryMetaForm: state.geometryMetaForm, + setGeometryMetaForm: state.setGeometryMetaForm, + isEntitySubmitting: state.isEntitySubmitting, + })) + ); const [collapsed, setCollapsed] = useState(false); const [geoApplyFeedback, setGeoApplyFeedback] = useState< | { @@ -73,7 +77,7 @@ export default function SelectedGeometryPanel({ if (!selectedFeatures || selectedFeatures.length === 0) return null; const representativeFeature = selectedFeatures[0]; - const groupedGeometryTypeOptions = groupGeometryTypeOptions(entityTypeOptions); + const groupedGeometryTypeOptions = groupGeometryTypeOptions(GEOMETRY_TYPE_OPTIONS); const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature); const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset); const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) => @@ -143,7 +147,12 @@ export default function SelectedGeometryPanel({
onGeometryMetaFormChange("time_start", event.target.value)} + onChange={(event) => + setGeometryMetaForm((prev) => ({ + ...prev, + time_start: event.target.value, + })) + } placeholder="time_start" disabled={isEntitySubmitting} style={entityInputStyle} /> onGeometryMetaFormChange("time_end", event.target.value)} + onChange={(event) => + setGeometryMetaForm((prev) => ({ + ...prev, + time_end: event.target.value, + })) + } placeholder="time_end" disabled={isEntitySubmitting} style={entityInputStyle} diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts index 26a4a5a..718fb03 100644 --- a/src/uhm/components/map/useMapInteraction.ts +++ b/src/uhm/components/map/useMapInteraction.ts @@ -7,7 +7,7 @@ import { initLine } from "@/uhm/lib/map/engines/lineEngine"; import { initPath } from "@/uhm/lib/map/engines/pathEngine"; import { initCircle } from "@/uhm/lib/map/engines/circleEngine"; import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine"; -import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; +import { FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; import { buildClientFeatureId, getSelectableLayers } from "./mapUtils"; import { MapHoverPayload } from "../Map"; @@ -15,7 +15,7 @@ import { MapHoverPayload } from "../Map"; type EngineBinding = { cleanup: () => void; cancel?: () => void; - clearSelection?: () => void; + clearSelection?: (skipNotify?: boolean) => void; }; type UseMapInteractionProps = { @@ -143,7 +143,9 @@ export function useMapInteraction({ const originalFeature = draftRef.current.features.find( (item) => String(item.properties.id) === String(rawId) ); - editingEngineRef.current?.beginEditing((originalFeature || feature) as any); + editingEngineRef.current?.beginEditing( + (originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature + ); } : undefined, (ids) => onSelectFeatureIdsRef.current?.(ids), diff --git a/src/uhm/components/wiki/WikiSidebarPanel.tsx b/src/uhm/components/wiki/WikiSidebarPanel.tsx index f9e2a7b..4e02244 100644 --- a/src/uhm/components/wiki/WikiSidebarPanel.tsx +++ b/src/uhm/components/wiki/WikiSidebarPanel.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import dynamic from "next/dynamic"; import "react-quill-new/dist/quill.snow.css"; +import { useShallow } from "zustand/react/shallow"; import { Modal } from "@/components/ui/modal"; import Button from "@/components/ui/button/Button"; @@ -13,6 +14,7 @@ import { newId } from "@/uhm/lib/utils/id"; import type ReactQuill from "react-quill-new"; import { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; import NewBadge from "@/uhm/components/editor/NewBadge"; +import { useEditorStore } from "@/uhm/store/editorStore"; type ReactQuillProps = ComponentProps; type QuillRange = { index: number; length: number }; @@ -39,7 +41,7 @@ type QuillLinkFormat = { type QuillImageFormatCtor = { new (): { domNode: Element; - format: (name: string, value: string) => void; + format(name: string, value: string): void; }; formats: (domNode: Element) => Record; }; @@ -53,9 +55,7 @@ let quillLinkSanitizePatched = false; type Props = { projectId: string; - wikis: WikiSnapshot[]; setWikis: React.Dispatch>; - requestedActiveId?: string | null; }; function clampTitle(title: string) { @@ -63,7 +63,13 @@ function clampTitle(title: string) { return t.length ? t.slice(0, 120) : "Untitled wiki"; } -export default function WikiSidebarPanel({ projectId, wikis, setWikis, requestedActiveId }: Props) { +export default function WikiSidebarPanel({ projectId, setWikis }: Props) { + const { wikis, requestedActiveId } = useEditorStore( + useShallow((state) => ({ + wikis: state.snapshotWikis, + requestedActiveId: state.requestedActiveWikiId, + })) + ); const [open, setOpen] = useState(false); const [activeId, setActiveId] = useState(null); const [collapsed, setCollapsed] = useState(false); @@ -122,13 +128,18 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, requested const ImageFormat = Quill.import?.("formats/image") as QuillImageFormatCtor | undefined; if (ImageFormat) { - class CustomImage extends ImageFormat { + const BaseImageFormat = ImageFormat; + class CustomImage extends BaseImageFormat { static formats(domNode: Element) { - const formats = ImageFormat.formats(domNode) || {}; - if (domNode.hasAttribute("style")) formats.style = domNode.getAttribute("style"); - if (domNode.hasAttribute("width")) formats.width = domNode.getAttribute("width"); - if (domNode.hasAttribute("height")) formats.height = domNode.getAttribute("height"); - if (domNode.hasAttribute("class")) formats.class = domNode.getAttribute("class"); + const formats = BaseImageFormat.formats(domNode) || {}; + const style = domNode.getAttribute("style"); + const width = domNode.getAttribute("width"); + const height = domNode.getAttribute("height"); + const className = domNode.getAttribute("class"); + if (style) formats.style = style; + if (width) formats.width = width; + if (height) formats.height = height; + if (className) formats.class = className; return formats; } @@ -291,13 +302,10 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, requested if (!activeId) return; const fmt = detectWikiDocStorageFormat(wikiDocHtml); - const label = fmt === "json" ? "json" : fmt === "text" ? "txt" : "html"; - const mime = - fmt === "json" - ? "application/json;charset=utf-8" - : fmt === "text" - ? "text/plain;charset=utf-8" - : "text/html;charset=utf-8"; + const label = fmt === "text" ? "txt" : "html"; + const mime = fmt === "text" + ? "text/plain;charset=utf-8" + : "text/html;charset=utf-8"; const base = normalizeWikiSlugInput(wikiSlug) || diff --git a/src/uhm/lib/editor/project/useProjectCommands.ts b/src/uhm/lib/editor/project/useProjectCommands.ts index 17d0f22..361cb51 100644 --- a/src/uhm/lib/editor/project/useProjectCommands.ts +++ b/src/uhm/lib/editor/project/useProjectCommands.ts @@ -1,5 +1,4 @@ import { useCallback } from "react"; -import type { Dispatch, SetStateAction } from "react"; import { ApiError } from "@/uhm/api/http"; import { createProject, @@ -11,10 +10,11 @@ import { } from "@/uhm/api/projects"; import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; -import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; -import type { BattleReplay, EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; +import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; +import type { BattleReplay, EditorSnapshot, ProjectCommit, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { EntitySnapshot } from "@/uhm/types/entities"; import type { WikiSnapshot } from "@/uhm/types/wiki"; +import type { EditorStoreApi } from "@/uhm/store/editorStore"; type EditorDraftApi = { draft: FeatureCollection; @@ -27,40 +27,14 @@ type EditorDraftApi = { type Options = { editor: EditorDraftApi; - editorUserId: string; + store: EditorStoreApi; emptyFeatureCollection: FeatureCollection; - activeSection: Project | null; - projectState: ProjectState | null; - selectedProjectId: string; - newSectionTitle: string; pendingSaveCount: number; - snapshotEntities: EntitySnapshot[]; - snapshotWikis: WikiSnapshot[]; - snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]; - baselineSnapshot: EditorSnapshot | null; - commitTitle: string; - setActiveSection: Dispatch>; - setSelectedProjectId: Dispatch>; - setProjectState: Dispatch>; - setBaselineSnapshot: Dispatch>; - setInitialData: Dispatch>; - setProjectCommits: Dispatch>; - setSnapshotEntities: Dispatch>; - setSnapshotWikis: Dispatch>; - setSnapshotEntityWikiLinks: Dispatch>; - setSelectedFeatureIds: Dispatch>; - setEntityFormStatus: Dispatch>; - setEntityStatus: Dispatch>; - setIsSaving: Dispatch>; - setIsSubmitting: Dispatch>; - setIsOpeningSection: Dispatch>; - setAvailableSections: Dispatch>; - setNewSectionTitle: Dispatch>; - setCommitTitle: Dispatch>; }; export function useProjectCommands(options: Options) { const openSectionForEditing = useCallback(async (projectId: string) => { + const state = options.store.getState(); const editorPayload = await openSectionEditor(projectId); const snapshot = normalizeEditorSnapshot(editorPayload.snapshot); // When starting a fresh editor session from a commit snapshot, treat all rows as baseline state: @@ -69,45 +43,46 @@ export function useProjectCommands(options: Options) { const commits = await fetchProjectCommits(projectId); const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection; - options.setActiveSection(editorPayload.project); - options.setSelectedProjectId(editorPayload.project.id); - options.setProjectState(editorPayload.state); - options.setBaselineSnapshot(sessionSnapshot); - options.setInitialData(nextInitialData); - options.setProjectCommits(commits); - options.setSnapshotEntities(sessionSnapshot?.entities || []); - options.setSnapshotWikis(sessionSnapshot?.wikis || []); - options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); - options.setSelectedFeatureIds([]); - options.setEntityFormStatus(null); - }, [options]); + state.setActiveSection(editorPayload.project); + state.setSelectedProjectId(editorPayload.project.id); + state.setProjectState(editorPayload.state); + state.setBaselineSnapshot(sessionSnapshot); + state.setInitialData(nextInitialData); + state.setProjectCommits(commits); + state.setSnapshotEntities(sessionSnapshot?.entities || []); + state.setSnapshotWikis(sessionSnapshot?.wikis || []); + state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); + state.setSelectedFeatureIds([]); + state.setEntityFormStatus(null); + }, [options.emptyFeatureCollection, options.store]); const commitSection = useCallback(async () => { - if (!options.activeSection || !options.projectState) { - options.setEntityStatus("Chưa mở được project editor."); + const state = options.store.getState(); + if (!state.activeSection || !state.projectState) { + state.setEntityStatus("Chưa mở được project editor."); return; } if (options.pendingSaveCount <= 0) { - options.setEntityStatus("Không có thay đổi để Commit."); + state.setEntityStatus("Không có thay đổi để Commit."); return; } const geometryChanges = options.editor.buildPayload(); - options.setIsSaving(true); - options.setEntityStatus(null); + state.setIsSaving(true); + state.setEntityStatus(null); try { const snapshot = buildEditorSnapshot({ - project: options.activeSection, + project: state.activeSection, draft: options.editor.mainDraft, changes: geometryChanges, - snapshotEntities: options.snapshotEntities, - snapshotWikis: options.snapshotWikis, - snapshotEntityWikiLinks: options.snapshotEntityWikiLinks, + snapshotEntities: state.snapshotEntities, + snapshotWikis: state.snapshotWikis, + snapshotEntityWikiLinks: state.snapshotEntityWikiLinks, replays: options.editor.replays, - previousSnapshot: options.baselineSnapshot, + previousSnapshot: state.baselineSnapshot, hasPersistedFeature: options.editor.hasPersistedFeature, }); - const editSummary = options.commitTitle.trim() + const editSummary = state.commitTitle.trim() || `Edit ${new Date().toLocaleString()}`; // Guardrail: commit payload can get large and some deployments reject/close connections for big bodies. @@ -117,7 +92,7 @@ export function useProjectCommands(options: Options) { const bytes = typeof Blob !== "undefined" ? new Blob([payloadText]).size : payloadText.length; const limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits) if (bytes > limitBytes) { - options.setEntityStatus( + state.setEntityStatus( `Commit payload quá lớn (~${(bytes / (1024 * 1024)).toFixed(2)}MB). ` + `Hãy giảm bớt nội dung snapshot/changes hoặc chạy BE local với body limit lớn hơn.` ); @@ -127,39 +102,40 @@ export function useProjectCommands(options: Options) { // If stringify fails, let API call throw a more actionable error downstream. } - const result = await createProjectCommit(options.activeSection.id, { + const result = await createProjectCommit(state.activeSection.id, { snapshot, edit_summary: editSummary, }); const sessionSnapshot = toEditorSessionSnapshot(snapshot); - options.setProjectState(result.state); - options.setBaselineSnapshot(sessionSnapshot); - options.setSnapshotEntities(sessionSnapshot.entities || []); - options.setSnapshotWikis(sessionSnapshot.wikis || []); - options.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []); - options.setInitialData(options.editor.draft); + state.setProjectState(result.state); + state.setBaselineSnapshot(sessionSnapshot); + state.setSnapshotEntities(sessionSnapshot.entities || []); + state.setSnapshotWikis(sessionSnapshot.wikis || []); + state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []); + state.setInitialData(options.editor.draft); options.editor.clearChanges(); - options.setCommitTitle(""); - options.setProjectCommits(await fetchProjectCommits(options.activeSection.id)); - options.setEntityFormStatus("Đã tạo commit."); + state.setCommitTitle(""); + state.setProjectCommits(await fetchProjectCommits(state.activeSection.id)); + state.setEntityFormStatus("Đã tạo commit."); } catch (err) { if (err instanceof ApiError) { console.error("Commit failed", err.body); - options.setEntityStatus(`Commit thất bại: ${err.body}`); + state.setEntityStatus(`Commit thất bại: ${err.body}`); return; } console.error("Commit error", err); - options.setEntityStatus("Commit thất bại."); + state.setEntityStatus("Commit thất bại."); } finally { - options.setIsSaving(false); + state.setIsSaving(false); } - }, [options]); + }, [options.editor, options.pendingSaveCount, options.store]); const openSelectedSection = useCallback(async () => { - const projectId = options.selectedProjectId.trim(); + const state = options.store.getState(); + const projectId = state.selectedProjectId.trim(); if (!projectId) { - options.setEntityStatus("Hãy chọn project để mở."); + state.setEntityStatus("Hãy chọn project để mở."); return; } if (options.pendingSaveCount > 0) { @@ -167,26 +143,27 @@ export function useProjectCommands(options: Options) { if (!confirmed) return; } - options.setIsOpeningSection(true); - options.setEntityStatus(null); + state.setIsOpeningSection(true); + state.setEntityStatus(null); try { await openSectionForEditing(projectId); - options.setEntityStatus("Đã mở project để chỉnh sửa."); + state.setEntityStatus("Đã mở project để chỉnh sửa."); } catch (err) { if (err instanceof ApiError) { - options.setEntityStatus(`Mở project thất bại: ${err.body}`); + state.setEntityStatus(`Mở project thất bại: ${err.body}`); } else { - options.setEntityStatus("Mở project thất bại."); + state.setEntityStatus("Mở project thất bại."); } } finally { - options.setIsOpeningSection(false); + state.setIsOpeningSection(false); } - }, [openSectionForEditing, options]); + }, [openSectionForEditing, options.pendingSaveCount, options.store]); const createAndOpenSection = useCallback(async () => { - const title = options.newSectionTitle.trim(); + const state = options.store.getState(); + const title = state.newSectionTitle.trim(); if (!title) { - options.setEntityStatus("Tên project là bắt buộc."); + state.setEntityStatus("Tên project là bắt buộc."); return; } if (options.pendingSaveCount > 0) { @@ -194,74 +171,76 @@ export function useProjectCommands(options: Options) { if (!confirmed) return; } - options.setIsOpeningSection(true); - options.setEntityStatus(null); + state.setIsOpeningSection(true); + state.setEntityStatus(null); try { const project = await createProject({ title, description: null, }); const projects = await fetchProjects(); - options.setAvailableSections(projects); - options.setNewSectionTitle(""); + state.setAvailableSections(projects); + state.setNewSectionTitle(""); await openSectionForEditing(project.id); - options.setEntityStatus("Đã tạo và mở project mới."); + state.setEntityStatus("Đã tạo và mở project mới."); } catch (err) { if (err instanceof ApiError) { - options.setEntityStatus(`Tạo project thất bại: ${err.body}`); + state.setEntityStatus(`Tạo project thất bại: ${err.body}`); } else { - options.setEntityStatus("Tạo project thất bại."); + state.setEntityStatus("Tạo project thất bại."); } } finally { - options.setIsOpeningSection(false); + state.setIsOpeningSection(false); } - }, [openSectionForEditing, options]); + }, [openSectionForEditing, options.pendingSaveCount, options.store]); const submitCurrentSection = useCallback(async (content: string) => { - if (!options.activeSection || !options.projectState?.head_commit_id) { - options.setEntityStatus("Project hiện tại chưa có head để submit."); + const state = options.store.getState(); + if (!state.activeSection || !state.projectState?.head_commit_id) { + state.setEntityStatus("Project hiện tại chưa có head để submit."); return; } if (options.pendingSaveCount > 0) { - options.setEntityStatus("Hãy Commit các thay đổi trước khi Submit."); + state.setEntityStatus("Hãy Commit các thay đổi trước khi Submit."); return; } - options.setIsSubmitting(true); - options.setEntityStatus(null); + state.setIsSubmitting(true); + state.setEntityStatus(null); try { - const submission = await submitSection(options.activeSection.id, content); - options.setEntityStatus(`Đã submit, submission ${submission.id}.`); + const submission = await submitSection(state.activeSection.id, content); + state.setEntityStatus(`Đã submit, submission ${submission.id}.`); } catch (err) { if (err instanceof ApiError) { - options.setEntityStatus(`Submit thất bại: ${err.body}`); + state.setEntityStatus(`Submit thất bại: ${err.body}`); } else { - options.setEntityStatus("Submit thất bại."); + state.setEntityStatus("Submit thất bại."); } } finally { - options.setIsSubmitting(false); + state.setIsSubmitting(false); } - }, [options]); + }, [options.pendingSaveCount, options.store]); const restoreCommit = useCallback(async (commitId: string) => { - if (!options.activeSection || !options.projectState) { - options.setEntityStatus("Chưa mở được project editor."); + const state = options.store.getState(); + if (!state.activeSection || !state.projectState) { + state.setEntityStatus("Chưa mở được project editor."); return; } if (options.pendingSaveCount > 0) { - options.setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore."); + state.setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore."); return; } - options.setIsSaving(true); - options.setEntityStatus(null); + state.setIsSaving(true); + state.setEntityStatus(null); try { // FE-only restore: load snapshot from selected commit and apply to editor state. // Do NOT move project's head commit on backend. - const commits = await fetchProjectCommits(options.activeSection.id); + const commits = await fetchProjectCommits(state.activeSection.id); const target = commits.find((c: ProjectCommit) => c.id === commitId) || null; if (!target) { - options.setEntityStatus("Không tìm thấy commit để restore."); + state.setEntityStatus("Không tìm thấy commit để restore."); return; } @@ -269,27 +248,27 @@ export function useProjectCommands(options: Options) { const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null; const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection; - options.setBaselineSnapshot(sessionSnapshot); - options.setInitialData(nextInitialData); - options.setSnapshotEntities(sessionSnapshot?.entities || []); - options.setSnapshotWikis(sessionSnapshot?.wikis || []); - options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); - options.setSelectedFeatureIds([]); - options.setEntityFormStatus(null); + state.setBaselineSnapshot(sessionSnapshot); + state.setInitialData(nextInitialData); + state.setSnapshotEntities(sessionSnapshot?.entities || []); + state.setSnapshotWikis(sessionSnapshot?.wikis || []); + state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); + state.setSelectedFeatureIds([]); + state.setEntityFormStatus(null); // Refresh commits list for UI, but keep projectState/head as-is. - options.setProjectCommits(commits); - options.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE)."); + state.setProjectCommits(commits); + state.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE)."); } catch (err) { if (err instanceof ApiError) { - options.setEntityStatus(`Restore thất bại: ${err.body}`); + state.setEntityStatus(`Restore thất bại: ${err.body}`); } else { - options.setEntityStatus("Restore thất bại."); + state.setEntityStatus("Restore thất bại."); } } finally { - options.setIsSaving(false); + state.setIsSaving(false); } - }, [options]); + }, [options.emptyFeatureCollection, options.pendingSaveCount, options.store]); return { openSectionForEditing, @@ -312,17 +291,21 @@ function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot { }; } +type EditorEntityRow = NonNullable[number]; +type EditorGeometryRow = NonNullable[number]; +type EditorGeometryEntityRow = NonNullable[number]; +type EditorWikiRow = NonNullable[number]; + function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnapshot[] { const rows = Array.isArray(input) ? input : []; return rows - .filter((e) => e && (typeof e.id === "string" || typeof e.id === "number")) - .filter((e) => (e as any).operation !== "delete") + .filter((e): e is EditorEntityRow => Boolean(e) && (typeof e.id === "string" || typeof e.id === "number")) + .filter((e) => e.operation !== "delete") .map((e) => { - const { operation: _op, ...rest } = e; const id = String(e.id); const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref"; return { - ...(rest as Omit), + ...e, id, source, operation: "reference", @@ -333,14 +316,13 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] { const rows = Array.isArray(input) ? input : []; return rows - .filter((g) => g && (typeof (g as any).id === "string" || typeof (g as any).id === "number")) - .filter((g) => (g as any).operation !== "delete") + .filter((g): g is EditorGeometryRow => Boolean(g) && (typeof g.id === "string" || typeof g.id === "number")) + .filter((g) => g.operation !== "delete") .map((g) => { - const { operation: _op, ...rest } = g as any; - const id = String((g as any).id); - const source: GeometrySnapshot["source"] = (g as any).source === "inline" ? "inline" : "ref"; + const id = String(g.id); + const source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref"; return { - ...(rest as Omit), + ...g, id, source, operation: "reference", @@ -353,16 +335,22 @@ function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"]) const deduped = new globalThis.Map(); for (const row of rows) { if (!row) continue; - if ((row as any).operation === "delete") continue; - const geometry_id = typeof (row as any).geometry_id === "string" || typeof (row as any).geometry_id === "number" - ? String((row as any).geometry_id).trim() + const safeRow = row as EditorGeometryEntityRow; + if (safeRow.operation === "delete") continue; + const geometry_id = typeof safeRow.geometry_id === "string" || typeof safeRow.geometry_id === "number" + ? String(safeRow.geometry_id).trim() : ""; - const entity_id = typeof (row as any).entity_id === "string" || typeof (row as any).entity_id === "number" - ? String((row as any).entity_id).trim() + const entity_id = typeof safeRow.entity_id === "string" || typeof safeRow.entity_id === "number" + ? String(safeRow.entity_id).trim() : ""; if (!geometry_id || !entity_id) continue; const key = `${geometry_id}::${entity_id}`; - deduped.set(key, { geometry_id, entity_id, operation: "reference", base_links_hash: (row as any).base_links_hash }); + deduped.set(key, { + geometry_id, + entity_id, + operation: "reference", + base_links_hash: safeRow.base_links_hash, + }); } return Array.from(deduped.values()).sort((a, b) => { const g = a.geometry_id.localeCompare(b.geometry_id); @@ -374,13 +362,12 @@ function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"]) function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] { const rows = Array.isArray(input) ? input : []; return rows - .filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0) - .filter((w) => (w as any).operation !== "delete") + .filter((w): w is EditorWikiRow => Boolean(w) && typeof w.id === "string" && w.id.trim().length > 0) + .filter((w) => w.operation !== "delete") .map((w) => { - const { operation: _op, ...rest } = w; const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref"; return { - ...(rest as Omit), + ...w, source, operation: "reference", }; diff --git a/src/uhm/store/editorStore.tsx b/src/uhm/store/editorStore.tsx new file mode 100644 index 0000000..e038208 --- /dev/null +++ b/src/uhm/store/editorStore.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { + createContext, + useContext, + useState, + type ReactNode, + type SetStateAction, +} from "react"; +import { useStore } from "zustand"; +import { createStore, type StoreApi } from "zustand/vanilla"; +import type { EntityGeometriesSearchItem } from "@/uhm/api/geometries"; +import type { Wiki } from "@/uhm/api/wikis"; +import type { FeatureCollection, FeatureId } from "@/uhm/types/geo"; +import type { Entity, EntitySnapshot } from "@/uhm/types/entities"; +import type { EntityWikiLinkSnapshot, EditorSnapshot, Project, ProjectCommit, ProjectState } from "@/uhm/types/projects"; +import type { WikiSnapshot } from "@/uhm/types/wiki"; +import type { + EditorMode, + EntityFormState, + GeometryMetaFormState, + TimelineRange, +} from "@/uhm/lib/editor/session/sessionTypes"; +import type { UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar"; +import { + HIDDEN_BACKGROUND_LAYER_VISIBILITY, + type BackgroundLayerVisibility, +} from "@/uhm/lib/map/styles/backgroundLayers"; +import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap"; +import { clampYearValue } from "@/uhm/lib/utils/timeline"; + +export type GeometryFocusRequest = { + key: number; + collection: FeatureCollection; +}; + +type EditorStoreValues = { + mode: EditorMode; + initialData: FeatureCollection; + isSaving: boolean; + isSubmitting: boolean; + isOpeningSection: boolean; + availableSections: Project[]; + selectedProjectId: string; + newSectionTitle: string; + commitTitle: string; + editorUserIdInput: string; + activeSection: Project | null; + projectState: ProjectState | null; + sectionCommits: ProjectCommit[]; + baselineSnapshot: EditorSnapshot | null; + entityCatalog: Entity[]; + snapshotEntities: EntitySnapshot[]; + entityStatus: string | null; + selectedFeatureIds: FeatureId[]; + entityForm: EntityFormState; + selectedGeometryEntityIds: string[]; + geometryMetaForm: GeometryMetaFormState; + isEntitySubmitting: boolean; + entityFormStatus: string | null; + entitySearchResults: Entity[]; + isEntitySearchLoading: boolean; + timelineDraftYear: number; + backgroundVisibility: BackgroundLayerVisibility; + isBackgroundVisibilityReady: boolean; + snapshotWikis: WikiSnapshot[]; + snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]; + blockedPendingSubmissionId: string | null; + searchKind: UnifiedSearchKind; + searchQuery: string; + searchQueryDraft: string; + wikiSearchResults: Wiki[]; + isWikiSearching: boolean; + geoSearchResults: EntityGeometriesSearchItem[]; + isGeoSearching: boolean; + requestedActiveWikiId: string | null; + leftPanelWidth: number; + rightPanelWidth: number; + timelineFilterEnabled: boolean; + geometryBindingFilterEnabled: boolean; + geoBindingStatus: string | null; + hoveredGeometryId: string | null; + geometryFocusRequest: GeometryFocusRequest | null; + replayFeatureId: string | number | null; + hideOutside: boolean; + geometryVisibility: Record; +}; + +type EditorStoreActions = { + setMode: (next: SetStateAction) => void; + setInitialData: (next: SetStateAction) => void; + setIsSaving: (next: SetStateAction) => void; + setIsSubmitting: (next: SetStateAction) => void; + setIsOpeningSection: (next: SetStateAction) => void; + setAvailableSections: (next: SetStateAction) => void; + setSelectedProjectId: (next: SetStateAction) => void; + setNewSectionTitle: (next: SetStateAction) => void; + setCommitTitle: (next: SetStateAction) => void; + setEditorUserIdInput: (next: SetStateAction) => void; + setActiveSection: (next: SetStateAction) => void; + setProjectState: (next: SetStateAction) => void; + setProjectCommits: (next: SetStateAction) => void; + setBaselineSnapshot: (next: SetStateAction) => void; + setEntityCatalog: (next: SetStateAction) => void; + setSnapshotEntities: (next: SetStateAction) => void; + setEntityStatus: (next: SetStateAction) => void; + setSelectedFeatureIds: (next: SetStateAction) => void; + setEntityForm: (next: SetStateAction) => void; + setSelectedGeometryEntityIds: (next: SetStateAction) => void; + setGeometryMetaForm: (next: SetStateAction) => void; + setIsEntitySubmitting: (next: SetStateAction) => void; + setEntityFormStatus: (next: SetStateAction) => void; + setEntitySearchResults: (next: SetStateAction) => void; + setIsEntitySearchLoading: (next: SetStateAction) => void; + setTimelineDraftYear: (next: SetStateAction) => void; + setBackgroundVisibility: (next: SetStateAction) => void; + setIsBackgroundVisibilityReady: (next: SetStateAction) => void; + setSnapshotWikis: (next: SetStateAction) => void; + setSnapshotEntityWikiLinks: (next: SetStateAction) => void; + setBlockedPendingSubmissionId: (next: SetStateAction) => void; + setSearchKind: (next: SetStateAction) => void; + setSearchQuery: (next: SetStateAction) => void; + setSearchQueryDraft: (next: SetStateAction) => void; + setWikiSearchResults: (next: SetStateAction) => void; + setIsWikiSearching: (next: SetStateAction) => void; + setGeoSearchResults: (next: SetStateAction) => void; + setIsGeoSearching: (next: SetStateAction) => void; + setRequestedActiveWikiId: (next: SetStateAction) => void; + setLeftPanelWidth: (next: SetStateAction) => void; + setRightPanelWidth: (next: SetStateAction) => void; + setTimelineFilterEnabled: (next: SetStateAction) => void; + setGeometryBindingFilterEnabled: (next: SetStateAction) => void; + setGeoBindingStatus: (next: SetStateAction) => void; + setHoveredGeometryId: (next: SetStateAction) => void; + setGeometryFocusRequest: (next: SetStateAction) => void; + setReplayFeatureId: (next: SetStateAction) => void; + setHideOutside: (next: SetStateAction) => void; + setGeometryVisibility: (next: SetStateAction>) => void; +}; + +export type EditorStoreState = EditorStoreValues & EditorStoreActions; +export type EditorStoreApi = StoreApi; + +export type EditorStoreOptions = { + emptyFeatureCollection: FeatureCollection; + defaultEditorUserId: string; + fallbackTimelineRange: TimelineRange; + currentYear: number; +}; + +function resolveNextState(next: SetStateAction, prev: T): T { + return typeof next === "function" ? (next as (prevState: T) => T)(prev) : next; +} + +function buildInitialGeometryVisibility() { + const next: Record = {}; + for (const key of GEO_TYPE_KEYS) { + next[key] = true; + } + return next; +} + +export function createEditorStore(options: EditorStoreOptions): EditorStoreApi { + const initialTimelineYear = clampYearValue( + options.currentYear, + options.fallbackTimelineRange.min, + options.fallbackTimelineRange.max + ); + + return createStore()((set) => { + const setValue = ( + key: K, + next: SetStateAction + ) => { + set((state) => ({ + [key]: resolveNextState(next, state[key]), + } as Pick)); + }; + + const setTaskFlag = ( + task: "saving" | "submitting" | "opening-project", + next: SetStateAction + ) => { + set((state) => { + const currentValue = + task === "saving" + ? state.isSaving + : task === "submitting" + ? state.isSubmitting + : state.isOpeningSection; + const nextValue = resolveNextState(next, currentValue); + + if (nextValue) { + return { + isSaving: task === "saving", + isSubmitting: task === "submitting", + isOpeningSection: task === "opening-project", + }; + } + + if (!currentValue) { + return {}; + } + + if (task === "saving") return { isSaving: false }; + if (task === "submitting") return { isSubmitting: false }; + return { isOpeningSection: false }; + }); + }; + + return { + mode: "idle", + initialData: options.emptyFeatureCollection, + isSaving: false, + isSubmitting: false, + isOpeningSection: false, + availableSections: [], + selectedProjectId: "", + newSectionTitle: "", + commitTitle: "", + editorUserIdInput: options.defaultEditorUserId, + activeSection: null, + projectState: null, + sectionCommits: [], + baselineSnapshot: null, + entityCatalog: [], + snapshotEntities: [], + entityStatus: null, + selectedFeatureIds: [], + entityForm: { + name: "", + description: "", + }, + selectedGeometryEntityIds: [], + geometryMetaForm: { + type_key: "", + time_start: "", + time_end: "", + binding: "", + }, + isEntitySubmitting: false, + entityFormStatus: null, + entitySearchResults: [], + isEntitySearchLoading: false, + timelineDraftYear: initialTimelineYear, + backgroundVisibility: { ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }, + isBackgroundVisibilityReady: false, + snapshotWikis: [], + snapshotEntityWikiLinks: [], + blockedPendingSubmissionId: null, + searchKind: "entity", + searchQuery: "", + searchQueryDraft: "", + wikiSearchResults: [], + isWikiSearching: false, + geoSearchResults: [], + isGeoSearching: false, + requestedActiveWikiId: null, + leftPanelWidth: 280, + rightPanelWidth: 420, + timelineFilterEnabled: true, + geometryBindingFilterEnabled: true, + geoBindingStatus: null, + hoveredGeometryId: null, + geometryFocusRequest: null, + replayFeatureId: null, + hideOutside: false, + geometryVisibility: buildInitialGeometryVisibility(), + setMode: (next) => setValue("mode", next), + setInitialData: (next) => setValue("initialData", next), + setIsSaving: (next) => setTaskFlag("saving", next), + setIsSubmitting: (next) => setTaskFlag("submitting", next), + setIsOpeningSection: (next) => setTaskFlag("opening-project", next), + setAvailableSections: (next) => setValue("availableSections", next), + setSelectedProjectId: (next) => setValue("selectedProjectId", next), + setNewSectionTitle: (next) => setValue("newSectionTitle", next), + setCommitTitle: (next) => setValue("commitTitle", next), + setEditorUserIdInput: (next) => setValue("editorUserIdInput", next), + setActiveSection: (next) => setValue("activeSection", next), + setProjectState: (next) => setValue("projectState", next), + setProjectCommits: (next) => setValue("sectionCommits", next), + setBaselineSnapshot: (next) => setValue("baselineSnapshot", next), + setEntityCatalog: (next) => setValue("entityCatalog", next), + setSnapshotEntities: (next) => setValue("snapshotEntities", next), + setEntityStatus: (next) => setValue("entityStatus", next), + setSelectedFeatureIds: (next) => setValue("selectedFeatureIds", next), + setEntityForm: (next) => setValue("entityForm", next), + setSelectedGeometryEntityIds: (next) => setValue("selectedGeometryEntityIds", next), + setGeometryMetaForm: (next) => setValue("geometryMetaForm", next), + setIsEntitySubmitting: (next) => setValue("isEntitySubmitting", next), + setEntityFormStatus: (next) => setValue("entityFormStatus", next), + setEntitySearchResults: (next) => setValue("entitySearchResults", next), + setIsEntitySearchLoading: (next) => setValue("isEntitySearchLoading", next), + setTimelineDraftYear: (next) => setValue("timelineDraftYear", next), + setBackgroundVisibility: (next) => setValue("backgroundVisibility", next), + setIsBackgroundVisibilityReady: (next) => setValue("isBackgroundVisibilityReady", next), + setSnapshotWikis: (next) => setValue("snapshotWikis", next), + setSnapshotEntityWikiLinks: (next) => setValue("snapshotEntityWikiLinks", next), + setBlockedPendingSubmissionId: (next) => setValue("blockedPendingSubmissionId", next), + setSearchKind: (next) => setValue("searchKind", next), + setSearchQuery: (next) => setValue("searchQuery", next), + setSearchQueryDraft: (next) => setValue("searchQueryDraft", next), + setWikiSearchResults: (next) => setValue("wikiSearchResults", next), + setIsWikiSearching: (next) => setValue("isWikiSearching", next), + setGeoSearchResults: (next) => setValue("geoSearchResults", next), + setIsGeoSearching: (next) => setValue("isGeoSearching", next), + setRequestedActiveWikiId: (next) => setValue("requestedActiveWikiId", next), + setLeftPanelWidth: (next) => setValue("leftPanelWidth", next), + setRightPanelWidth: (next) => setValue("rightPanelWidth", next), + setTimelineFilterEnabled: (next) => setValue("timelineFilterEnabled", next), + setGeometryBindingFilterEnabled: (next) => setValue("geometryBindingFilterEnabled", next), + setGeoBindingStatus: (next) => setValue("geoBindingStatus", next), + setHoveredGeometryId: (next) => setValue("hoveredGeometryId", next), + setGeometryFocusRequest: (next) => setValue("geometryFocusRequest", next), + setReplayFeatureId: (next) => setValue("replayFeatureId", next), + setHideOutside: (next) => setValue("hideOutside", next), + setGeometryVisibility: (next) => setValue("geometryVisibility", next), + }; + }); +} + +const EditorStoreContext = createContext(null); + +type EditorStoreProviderProps = { + children: ReactNode; + options: EditorStoreOptions; +}; + +export function EditorStoreProvider({ children, options }: EditorStoreProviderProps) { + const [store] = useState(() => createEditorStore(options)); + + return ( + + {children} + + ); +} + +export function useEditorStore(selector: (state: EditorStoreState) => T) { + const store = useContext(EditorStoreContext); + if (!store) { + throw new Error("useEditorStore must be used within EditorStoreProvider."); + } + + return useStore(store, selector); +} + +export function useEditorStoreApi() { + const store = useContext(EditorStoreContext); + if (!store) { + throw new Error("useEditorStoreApi must be used within EditorStoreProvider."); + } + + return store; +}