refactor(important): reduce editor drill state

This commit is contained in:
taDuc
2026-05-16 01:45:19 +07:00
parent 4c81862bb4
commit a8097c95d4
12 changed files with 989 additions and 467 deletions
+158 -194
View File
@@ -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 (
<EditorStoreProvider
options={{
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
fallbackTimelineRange: FIXED_TIMELINE_RANGE,
currentYear: CURRENT_YEAR,
}}
>
<EditorPageContent />
</EditorStoreProvider>
);
}
function EditorPageContent() {
const params = useParams();
const router = useRouter();
const editorStoreApi = useEditorStoreApi();
const projectId = String(params.id || "");
const openedProjectIdRef = useRef<string | null>(null);
const [blockedPendingSubmissionId, setBlockedPendingSubmissionId] = useState<string | null>(null);
const [searchKind, setSearchKind] = useState<UnifiedSearchKind>("entity");
const [searchQuery, setSearchQuery] = useState("");
const [searchQueryDraft, setSearchQueryDraft] = useState("");
const [wikiSearchResults, setWikiSearchResults] = useState<Wiki[]>([]);
const [isWikiSearching, setIsWikiSearching] = useState(false);
const [geoSearchResults, setGeoSearchResults] = useState<EntityGeometriesSearchItem[]>([]);
const [isGeoSearching, setIsGeoSearching] = useState(false);
const [requestedActiveWikiId, setRequestedActiveWikiId] = useState<string | null>(null);
const [leftPanelWidth, setLeftPanelWidth] = useState(280);
const [rightPanelWidth, setRightPanelWidth] = useState(420);
const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(true);
const [geometryBindingFilterEnabled, setGeometryBindingFilterEnabled] = useState(true);
const entityFormStatusTimeoutRef = useRef<number | null>(null);
const geoBindingStatusTimeoutRef = useRef<number | null>(null);
const [geoBindingStatus, setGeoBindingStatus] = useState<string | null>(null);
const [geometryFocusRequest, setGeometryFocusRequest] = useState<{
key: number;
collection: FeatureCollection;
} | null>(null);
const localCreatedEntityIdsRef = useRef<Set<string>>(new Set());
const lastSelectedFeatureIdRef = useRef<string | null>(null);
const [replayFeatureId, setReplayFeatureId] = useState<string | number | null>(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<Record<string, boolean>>(() => {
const init: Record<string, boolean> = {};
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<string, EntitySnapshot>();
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<string>();
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<string, boolean> = { ...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() {
/>
<BackgroundLayersPanel
visibility={backgroundVisibility}
onToggleLayer={handleToggleBackgroundLayer}
onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers}
geometryVisibility={geometryVisibility}
onToggleGeometryType={(typeKey) => {
setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false }));
}}
width={rightPanelWidth}
topContent={
<div style={{ display: "grid", gap: "12px" }}>
@@ -1655,44 +1642,26 @@ export default function Page() {
selectedGeometryBindingIds={selectedGeometryBindingIds}
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
onFocusGeometry={handleFocusGeometryFromBindingPanel}
statusText={geoBindingStatus}
bindingFilterEnabled={geometryBindingFilterEnabled}
onBindingFilterEnabledChange={setGeometryBindingFilterEnabled}
/>
<ProjectEntityRefsPanel
entityRefs={snapshotEntitiesVisible}
entityForm={entityForm}
onEntityFormChange={handleEntityFormChange}
isEntitySubmitting={isEntitySubmitting}
onCreateEntityOnly={handleCreateEntityOnly}
onUpdateEntity={handleUpdateEntityInProject}
entityFormStatus={entityFormStatus}
hasSelectedGeometry={Boolean(selectedFeature)}
selectedGeometryEntityIds={selectedGeometryEntityIds}
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
/>
<WikiSidebarPanel
projectId={projectId}
wikis={snapshotWikis}
setWikis={setSnapshotWikisUndoable}
requestedActiveId={requestedActiveWikiId}
/>
<EntityWikiBindingsPanel
entities={projectEntityChoices}
wikis={snapshotWikis}
links={snapshotEntityWikiLinks}
setLinks={setSnapshotEntityWikiLinksUndoable}
/>
{selectedFeature ? (
<SelectedGeometryPanel
selectedFeatures={selectedFeatures}
entityTypeOptions={GEOMETRY_TYPE_OPTIONS}
geometryMetaForm={geometryMetaForm}
onGeometryMetaFormChange={handleGeometryMetaFormChange}
isEntitySubmitting={isEntitySubmitting}
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
changeCount={editor.changeCount}
onReplayEdit={(id) => 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)}`;
}
@@ -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<string, boolean>;
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 (
<aside
style={{
@@ -52,7 +83,7 @@ export default function BackgroundLayersPanel({
<button
key={layer.id}
type="button"
onClick={() => onToggleLayer(layer.id)}
onClick={() => handleToggleLayer(layer.id)}
style={{
border: "none",
background: "transparent",
@@ -75,44 +106,75 @@ export default function BackgroundLayersPanel({
})}
</div>
{geometryVisibility && onToggleGeometryType ? (
<>
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
Geometries
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
{GEO_TYPE_KEYS.map((typeKey) => {
const on = geometryVisibility[typeKey] !== false;
return (
<button
key={typeKey}
type="button"
onClick={() => onToggleGeometryType(typeKey)}
style={{
border: "none",
background: "transparent",
padding: 0,
margin: 0,
cursor: "pointer",
color: on ? "#22c55e" : "#e5e7eb",
textDecorationLine: on ? "none" : "line-through",
textDecorationThickness: on ? undefined : "2px",
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
fontSize: 13,
fontWeight: 750,
whiteSpace: "nowrap",
textTransform: "capitalize",
}}
title={on ? "On" : "Off"}
>
{typeKey.replaceAll("_", " ")}
</button>
);
})}
</div>
</>
) : null}
<div style={{ marginTop: 10, display: "flex", gap: 8 }}>
<button
type="button"
onClick={handleShowAll}
style={secondaryButtonStyle}
>
Show all
</button>
<button
type="button"
onClick={handleHideAll}
style={secondaryButtonStyle}
>
Hide all
</button>
</div>
<>
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
Geometries
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
{GEO_TYPE_KEYS.map((typeKey) => {
const on = geometryVisibility[typeKey] !== false;
return (
<button
key={typeKey}
type="button"
onClick={() => {
setGeometryVisibility((prev) => ({
...prev,
[typeKey]: prev[typeKey] === false,
}));
}}
style={{
border: "none",
background: "transparent",
padding: 0,
margin: 0,
cursor: "pointer",
color: on ? "#22c55e" : "#e5e7eb",
textDecorationLine: on ? "none" : "line-through",
textDecorationThickness: on ? undefined : "2px",
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
fontSize: 13,
fontWeight: 750,
whiteSpace: "nowrap",
textTransform: "capitalize",
}}
title={on ? "On" : "Off"}
>
{typeKey.replaceAll("_", " ")}
</button>
);
})}
</div>
</>
</aside>
);
}
const secondaryButtonStyle = {
border: "1px solid #334155",
borderRadius: 6,
background: "#0b1220",
color: "#cbd5e1",
cursor: "pointer",
fontSize: 12,
fontWeight: 700,
padding: "6px 8px",
} as const;
@@ -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<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
};
@@ -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<string>("");
const [activeWikiId, setActiveWikiId] = useState<string>("");
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<EntityChoice[]>(() => {
const visibleSnapshotEntities = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
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<string>();
@@ -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<HTMLDivElement>, 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)}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
@@ -92,7 +115,7 @@ export default function GeometryBindingPanel({
<input
type="checkbox"
checked={bindingFilterEnabled}
onChange={(e) => onBindingFilterEnabledChange(e.target.checked)}
onChange={(e) => setGeometryBindingFilterEnabled(e.target.checked)}
style={{ width: 14, height: 14 }}
/>
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
@@ -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)}
>
<div
style={{
@@ -187,25 +221,36 @@ export default function GeometryBindingPanel({
{visibleRows
.map((g) => {
const isBound = bindingSet.has(g.id);
const isHovered = hoveredGeometryId === g.id;
return (
<div
key={g.id}
style={{
padding: "8px",
borderRadius: "6px",
border: isBound ? "1px solid rgba(20, 184, 166, 0.65)" : "1px solid #1f2937",
background: isBound ? "rgba(20, 184, 166, 0.12)" : "transparent",
border: isHovered
? "1px solid rgba(245, 158, 11, 0.95)"
: isBound
? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937",
background: isHovered
? "rgba(245, 158, 11, 0.18)"
: isBound
? "rgba(20, 184, 166, 0.12)"
: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
cursor: canFocusGeometry ? "pointer" : "default",
opacity: canBindToggle ? 1 : 0.75,
boxShadow: isHovered ? "0 0 0 2px rgba(251, 191, 36, 0.18)" : "none",
}}
title={g.id}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => onFocusGeometry?.(g.id)}
onClick={() => handleFocusGeometry(g.id)}
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
onMouseEnter={() => setHoveredGeometryId(g.id)}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
@@ -2,34 +2,40 @@
import { useMemo, useState, type CSSProperties } from "react";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
import { useEditorStore } from "@/uhm/store/editorStore";
type Props = {
entityRefs: EntitySnapshot[];
entityForm: EntityFormState;
onEntityFormChange: (key: keyof EntityFormState, value: string) => 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<string, EntitySnapshot>();
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 (
<div
@@ -325,14 +344,14 @@ export default function ProjectEntityRefsPanel({
<>
<input
value={entityForm.name}
onChange={(event) => onEntityFormChange("name", event.target.value)}
onChange={(event) => handleEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.description}
onChange={(event) => onEntityFormChange("description", event.target.value)}
onChange={(event) => handleEntityFormChange("description", event.target.value)}
placeholder="Description"
disabled={isEntitySubmitting}
style={entityInputStyle}
@@ -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({
</div>
<select
value={geometryMetaForm.type_key}
onChange={(event) => onGeometryMetaFormChange("type_key", event.target.value)}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
type_key: event.target.value,
}))
}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
@@ -176,14 +185,24 @@ export default function SelectedGeometryPanel({
) : null}
<input
value={geometryMetaForm.time_start}
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
time_start: event.target.value,
}))
}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
time_end: event.target.value,
}))
}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
+5 -3
View File
@@ -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),
+25 -17
View File
@@ -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<typeof ReactQuill>;
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<string, string>;
};
@@ -53,9 +55,7 @@ let quillLinkSanitizePatched = false;
type Props = {
projectId: string;
wikis: WikiSnapshot[];
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
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<string | null>(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) ||
+127 -140
View File
@@ -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<SetStateAction<Project | null>>;
setSelectedProjectId: Dispatch<SetStateAction<string>>;
setProjectState: Dispatch<SetStateAction<ProjectState | null>>;
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
setProjectCommits: Dispatch<SetStateAction<ProjectCommit[]>>;
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
setSelectedFeatureIds: Dispatch<SetStateAction<FeatureId[]>>;
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
setEntityStatus: Dispatch<SetStateAction<string | null>>;
setIsSaving: Dispatch<SetStateAction<boolean>>;
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
setAvailableSections: Dispatch<SetStateAction<Project[]>>;
setNewSectionTitle: Dispatch<SetStateAction<string>>;
setCommitTitle: Dispatch<SetStateAction<string>>;
};
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<EditorSnapshot["entities"]>[number];
type EditorGeometryRow = NonNullable<EditorSnapshot["geometries"]>[number];
type EditorGeometryEntityRow = NonNullable<EditorSnapshot["geometry_entity"]>[number];
type EditorWikiRow = NonNullable<EditorSnapshot["wikis"]>[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<EntitySnapshot, "id" | "source" | "operation">),
...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<GeometrySnapshot, "id" | "source" | "operation">),
...g,
id,
source,
operation: "reference",
@@ -353,16 +335,22 @@ function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"])
const deduped = new globalThis.Map<string, GeometryEntitySnapshot>();
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<WikiSnapshot, "source" | "operation">),
...w,
source,
operation: "reference",
};
+355
View File
@@ -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<string, boolean>;
};
type EditorStoreActions = {
setMode: (next: SetStateAction<EditorMode>) => void;
setInitialData: (next: SetStateAction<FeatureCollection>) => void;
setIsSaving: (next: SetStateAction<boolean>) => void;
setIsSubmitting: (next: SetStateAction<boolean>) => void;
setIsOpeningSection: (next: SetStateAction<boolean>) => void;
setAvailableSections: (next: SetStateAction<Project[]>) => void;
setSelectedProjectId: (next: SetStateAction<string>) => void;
setNewSectionTitle: (next: SetStateAction<string>) => void;
setCommitTitle: (next: SetStateAction<string>) => void;
setEditorUserIdInput: (next: SetStateAction<string>) => void;
setActiveSection: (next: SetStateAction<Project | null>) => void;
setProjectState: (next: SetStateAction<ProjectState | null>) => void;
setProjectCommits: (next: SetStateAction<ProjectCommit[]>) => void;
setBaselineSnapshot: (next: SetStateAction<EditorSnapshot | null>) => void;
setEntityCatalog: (next: SetStateAction<Entity[]>) => void;
setSnapshotEntities: (next: SetStateAction<EntitySnapshot[]>) => void;
setEntityStatus: (next: SetStateAction<string | null>) => void;
setSelectedFeatureIds: (next: SetStateAction<FeatureId[]>) => void;
setEntityForm: (next: SetStateAction<EntityFormState>) => void;
setSelectedGeometryEntityIds: (next: SetStateAction<string[]>) => void;
setGeometryMetaForm: (next: SetStateAction<GeometryMetaFormState>) => void;
setIsEntitySubmitting: (next: SetStateAction<boolean>) => void;
setEntityFormStatus: (next: SetStateAction<string | null>) => void;
setEntitySearchResults: (next: SetStateAction<Entity[]>) => void;
setIsEntitySearchLoading: (next: SetStateAction<boolean>) => void;
setTimelineDraftYear: (next: SetStateAction<number>) => void;
setBackgroundVisibility: (next: SetStateAction<BackgroundLayerVisibility>) => void;
setIsBackgroundVisibilityReady: (next: SetStateAction<boolean>) => void;
setSnapshotWikis: (next: SetStateAction<WikiSnapshot[]>) => void;
setSnapshotEntityWikiLinks: (next: SetStateAction<EntityWikiLinkSnapshot[]>) => void;
setBlockedPendingSubmissionId: (next: SetStateAction<string | null>) => void;
setSearchKind: (next: SetStateAction<UnifiedSearchKind>) => void;
setSearchQuery: (next: SetStateAction<string>) => void;
setSearchQueryDraft: (next: SetStateAction<string>) => void;
setWikiSearchResults: (next: SetStateAction<Wiki[]>) => void;
setIsWikiSearching: (next: SetStateAction<boolean>) => void;
setGeoSearchResults: (next: SetStateAction<EntityGeometriesSearchItem[]>) => void;
setIsGeoSearching: (next: SetStateAction<boolean>) => void;
setRequestedActiveWikiId: (next: SetStateAction<string | null>) => void;
setLeftPanelWidth: (next: SetStateAction<number>) => void;
setRightPanelWidth: (next: SetStateAction<number>) => void;
setTimelineFilterEnabled: (next: SetStateAction<boolean>) => void;
setGeometryBindingFilterEnabled: (next: SetStateAction<boolean>) => void;
setGeoBindingStatus: (next: SetStateAction<string | null>) => void;
setHoveredGeometryId: (next: SetStateAction<string | null>) => void;
setGeometryFocusRequest: (next: SetStateAction<GeometryFocusRequest | null>) => void;
setReplayFeatureId: (next: SetStateAction<string | number | null>) => void;
setHideOutside: (next: SetStateAction<boolean>) => void;
setGeometryVisibility: (next: SetStateAction<Record<string, boolean>>) => void;
};
export type EditorStoreState = EditorStoreValues & EditorStoreActions;
export type EditorStoreApi = StoreApi<EditorStoreState>;
export type EditorStoreOptions = {
emptyFeatureCollection: FeatureCollection;
defaultEditorUserId: string;
fallbackTimelineRange: TimelineRange;
currentYear: number;
};
function resolveNextState<T>(next: SetStateAction<T>, prev: T): T {
return typeof next === "function" ? (next as (prevState: T) => T)(prev) : next;
}
function buildInitialGeometryVisibility() {
const next: Record<string, boolean> = {};
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<EditorStoreState>()((set) => {
const setValue = <K extends keyof EditorStoreValues>(
key: K,
next: SetStateAction<EditorStoreValues[K]>
) => {
set((state) => ({
[key]: resolveNextState(next, state[key]),
} as Pick<EditorStoreValues, K>));
};
const setTaskFlag = (
task: "saving" | "submitting" | "opening-project",
next: SetStateAction<boolean>
) => {
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<EditorStoreApi | null>(null);
type EditorStoreProviderProps = {
children: ReactNode;
options: EditorStoreOptions;
};
export function EditorStoreProvider({ children, options }: EditorStoreProviderProps) {
const [store] = useState(() => createEditorStore(options));
return (
<EditorStoreContext.Provider value={store}>
{children}
</EditorStoreContext.Provider>
);
}
export function useEditorStore<T>(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;
}