init replay mode draft
This commit is contained in:
@@ -46,7 +46,7 @@ import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/tim
|
|||||||
import { useFeatureCommands } from "./featureCommands";
|
import { useFeatureCommands } from "./featureCommands";
|
||||||
import { deleteSubmission } from "@/uhm/api/projects";
|
import { deleteSubmission } from "@/uhm/api/projects";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
import UnifiedSearchBar from "@/uhm/components/ui/UnifiedSearchBar";
|
import UnifiedSearchBar from "@/uhm/components/ui/UnifiedSearchBar";
|
||||||
import {
|
import {
|
||||||
EditorStoreProvider,
|
EditorStoreProvider,
|
||||||
@@ -424,11 +424,22 @@ function EditorPageContent() {
|
|||||||
}
|
}
|
||||||
}, [snapshotEntityWikiLinks, baselineSnapshot?.entity_wiki]);
|
}, [snapshotEntityWikiLinks, baselineSnapshot?.entity_wiki]);
|
||||||
|
|
||||||
|
const replayDirty = useMemo(() => {
|
||||||
|
const prev = normalizeReplaysForCompare(baselineSnapshot?.replays);
|
||||||
|
const next = normalizeReplaysForCompare(editor.effectiveReplays);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(prev) !== JSON.stringify(next);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, [baselineSnapshot?.replays, editor.effectiveReplays]);
|
||||||
|
|
||||||
const pendingSaveCount =
|
const pendingSaveCount =
|
||||||
editor.changeCount
|
editor.changeCount
|
||||||
+ (wikiDirty ? 1 : 0)
|
+ (wikiDirty ? 1 : 0)
|
||||||
+ (entitiesDirty ? 1 : 0)
|
+ (entitiesDirty ? 1 : 0)
|
||||||
+ (entityWikiDirty ? 1 : 0);
|
+ (entityWikiDirty ? 1 : 0)
|
||||||
|
+ (replayDirty ? 1 : 0);
|
||||||
|
|
||||||
const sectionCommands = useProjectCommands({
|
const sectionCommands = useProjectCommands({
|
||||||
editor,
|
editor,
|
||||||
@@ -1380,6 +1391,8 @@ function EditorPageContent() {
|
|||||||
focusPadding={96}
|
focusPadding={96}
|
||||||
hideOutside={hideOutside}
|
hideOutside={hideOutside}
|
||||||
onToggleHideOutside={onToggleHideOutside}
|
onToggleHideOutside={onToggleHideOutside}
|
||||||
|
onUndoReplay={editor.undo}
|
||||||
|
canUndoReplay={editor.canUndoReplay}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||||
@@ -1802,6 +1815,36 @@ function normalizeEntityWikiLinksForCompare(input: Array<{ entity_id: string; wi
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) {
|
||||||
|
const list = Array.isArray(input) ? input : [];
|
||||||
|
return list
|
||||||
|
.filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0)
|
||||||
|
.map((replay) => ({
|
||||||
|
geometry_id: replay.geometry_id,
|
||||||
|
detail: Array.isArray(replay.detail) ? replay.detail : [],
|
||||||
|
replay_features: normalizeReplayFeatureCollection(replay.replay_features),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplayFeatureCollection(input: FeatureCollection | null | undefined) {
|
||||||
|
const features = Array.isArray(input?.features) ? input.features : [];
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection" as const,
|
||||||
|
features: features
|
||||||
|
.filter((feature) => feature && feature.properties && (typeof feature.properties.id === "string" || typeof feature.properties.id === "number"))
|
||||||
|
.map((feature) => ({
|
||||||
|
type: "Feature" as const,
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
id: String(feature.properties.id),
|
||||||
|
},
|
||||||
|
geometry: feature.geometry,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => String(a.properties.id).localeCompare(String(b.properties.id))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
|
function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const g = value as Record<string, unknown>;
|
const g = value as Record<string, unknown>;
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ type MapProps = {
|
|||||||
focusPadding?: number | import("maplibre-gl").PaddingOptions;
|
focusPadding?: number | import("maplibre-gl").PaddingOptions;
|
||||||
hideOutside?: boolean;
|
hideOutside?: boolean;
|
||||||
onToggleHideOutside?: () => void;
|
onToggleHideOutside?: () => void;
|
||||||
|
onUndoReplay?: () => void;
|
||||||
|
canUndoReplay?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||||
@@ -79,6 +81,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
focusPadding,
|
focusPadding,
|
||||||
hideOutside = false,
|
hideOutside = false,
|
||||||
onToggleHideOutside,
|
onToggleHideOutside,
|
||||||
|
onUndoReplay,
|
||||||
|
canUndoReplay = false,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||||
const draftRef = useRef<FeatureCollection>(draft);
|
const draftRef = useRef<FeatureCollection>(draft);
|
||||||
@@ -188,7 +192,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
// Trigger resize after a short delay to allow layout to settle
|
// Trigger resize after a short delay to allow layout to settle
|
||||||
setTimeout(() => map.resize(), 100);
|
setTimeout(() => map.resize(), 100);
|
||||||
}
|
}
|
||||||
}, [mode, isMapLoaded]);
|
}, [mode, isMapLoaded, mapRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height, position: "relative" }}>
|
<div style={{ width: "100%", height, position: "relative" }}>
|
||||||
@@ -295,6 +299,28 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
Capture View
|
Capture View
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUndoReplay}
|
||||||
|
disabled={!onUndoReplay || !canUndoReplay}
|
||||||
|
title="Undo thao tác replay gần nhất"
|
||||||
|
style={{
|
||||||
|
...zoomButtonStyle,
|
||||||
|
width: "auto",
|
||||||
|
padding: "0 12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 700,
|
||||||
|
background: !onUndoReplay || !canUndoReplay ? "#0f172a" : "#1e293b",
|
||||||
|
color: !onUndoReplay || !canUndoReplay ? "#64748b" : "#f8fafc",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "999px",
|
||||||
|
cursor: !onUndoReplay || !canUndoReplay ? "not-allowed" : "pointer",
|
||||||
|
marginRight: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Undo Replay
|
||||||
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={onToggleHideOutside}
|
onClick={onToggleHideOutside}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export function formatUndoLabel(action: UndoAction) {
|
|||||||
case "snapshot_entities":
|
case "snapshot_entities":
|
||||||
case "snapshot_wikis":
|
case "snapshot_wikis":
|
||||||
case "snapshot_entity_wiki":
|
case "snapshot_entity_wiki":
|
||||||
|
case "replay":
|
||||||
|
case "replay_session":
|
||||||
case "group":
|
case "group":
|
||||||
return action.label;
|
return action.label;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
} from "@/uhm/types/geo";
|
} from "@/uhm/types/geo";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
|
|
||||||
export type Change = GeometryChange;
|
export type Change = GeometryChange;
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@ export type UndoAction =
|
|||||||
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
|
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
|
||||||
| { type: "delete"; feature: Feature }
|
| { type: "delete"; feature: Feature }
|
||||||
| { type: "create"; id: FeatureProperties["id"] }
|
| { type: "create"; id: FeatureProperties["id"] }
|
||||||
|
| { type: "replay"; geometryId: string; label: string; prevReplay: BattleReplay | null }
|
||||||
|
| { type: "replay_session"; geometryId: string; label: string; prevReplay: BattleReplay | null }
|
||||||
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
|
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
|
||||||
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
|
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
|
||||||
| { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] }
|
| { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] }
|
||||||
|
|||||||
@@ -88,6 +88,22 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
|||||||
const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>;
|
const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>;
|
||||||
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
||||||
}
|
}
|
||||||
|
case "replay": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "replay" }>;
|
||||||
|
return (
|
||||||
|
a.geometryId === next.geometryId
|
||||||
|
&& a.label === next.label
|
||||||
|
&& JSON.stringify(a.prevReplay) === JSON.stringify(next.prevReplay)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "replay_session": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "replay_session" }>;
|
||||||
|
return (
|
||||||
|
a.geometryId === next.geometryId
|
||||||
|
&& a.label === next.label
|
||||||
|
&& JSON.stringify(a.prevReplay) === JSON.stringify(next.prevReplay)
|
||||||
|
);
|
||||||
|
}
|
||||||
case "group": {
|
case "group": {
|
||||||
const next = b as Extract<UndoAction, { type: "group" }>;
|
const next = b as Extract<UndoAction, { type: "group" }>;
|
||||||
return a.label === next.label && JSON.stringify(a.actions) === JSON.stringify(next.actions);
|
return a.label === next.label && JSON.stringify(a.actions) === JSON.stringify(next.actions);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type EditorDraftApi = {
|
|||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
mainDraft: FeatureCollection;
|
mainDraft: FeatureCollection;
|
||||||
replays: BattleReplay[];
|
replays: BattleReplay[];
|
||||||
|
effectiveReplays: BattleReplay[];
|
||||||
buildPayload: () => Change[];
|
buildPayload: () => Change[];
|
||||||
clearChanges: () => void;
|
clearChanges: () => void;
|
||||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||||
@@ -78,7 +79,7 @@ export function useProjectCommands(options: Options) {
|
|||||||
snapshotEntities: state.snapshotEntities,
|
snapshotEntities: state.snapshotEntities,
|
||||||
snapshotWikis: state.snapshotWikis,
|
snapshotWikis: state.snapshotWikis,
|
||||||
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
|
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
|
||||||
replays: options.editor.replays,
|
replays: options.editor.effectiveReplays,
|
||||||
previousSnapshot: state.baselineSnapshot,
|
previousSnapshot: state.baselineSnapshot,
|
||||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||||
});
|
});
|
||||||
@@ -113,7 +114,7 @@ export function useProjectCommands(options: Options) {
|
|||||||
state.setSnapshotEntities(sessionSnapshot.entities || []);
|
state.setSnapshotEntities(sessionSnapshot.entities || []);
|
||||||
state.setSnapshotWikis(sessionSnapshot.wikis || []);
|
state.setSnapshotWikis(sessionSnapshot.wikis || []);
|
||||||
state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
||||||
state.setInitialData(options.editor.draft);
|
state.setInitialData(options.editor.mainDraft);
|
||||||
options.editor.clearChanges();
|
options.editor.clearChanges();
|
||||||
state.setCommitTitle("");
|
state.setCommitTitle("");
|
||||||
state.setProjectCommits(await fetchProjectCommits(state.activeSection.id));
|
state.setProjectCommits(await fetchProjectCommits(state.activeSection.id));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geo
|
|||||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
|
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
|
||||||
import type { Feature, FeatureCollection, Geometry, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
|
|
||||||
import type { BattleReplay, EditorSnapshot, Project } from "@/uhm/types/projects";
|
import type { BattleReplay, EditorSnapshot, Project } from "@/uhm/types/projects";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
@@ -25,20 +25,6 @@ interface RawEntityRow extends UnknownRecord {
|
|||||||
status?: number;
|
status?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawGeometryRow extends UnknownRecord {
|
|
||||||
id?: string | number;
|
|
||||||
operation?: string;
|
|
||||||
source?: string;
|
|
||||||
ref?: { id?: string };
|
|
||||||
type?: string | number;
|
|
||||||
geo_type?: string | number;
|
|
||||||
draw_geometry?: Geometry;
|
|
||||||
geometry?: Geometry;
|
|
||||||
binding?: string[];
|
|
||||||
time_start?: number;
|
|
||||||
time_end?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawWikiRow extends UnknownRecord {
|
interface RawWikiRow extends UnknownRecord {
|
||||||
id?: string;
|
id?: string;
|
||||||
operation?: string;
|
operation?: string;
|
||||||
@@ -364,9 +350,9 @@ export function buildEditorSnapshot(options: {
|
|||||||
if (prev.source !== "inline") continue;
|
if (prev.source !== "inline") continue;
|
||||||
// Carry forward as current-state inline entity; operation is a per-commit delta signal.
|
// Carry forward as current-state inline entity; operation is a per-commit delta signal.
|
||||||
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
|
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
|
||||||
const { operation: _op, ...rest } = cloned;
|
delete cloned.operation;
|
||||||
entityRows.set(id, {
|
entityRows.set(id, {
|
||||||
...rest,
|
...cloned,
|
||||||
id,
|
id,
|
||||||
source: "inline",
|
source: "inline",
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
@@ -689,14 +675,6 @@ export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(cloned.replays)) {
|
|
||||||
cloned.replays = cloned.replays.map((replay) => {
|
|
||||||
// Strip local-only replay_features before sending to BE
|
|
||||||
const { replay_features: _, ...rest } = replay;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloned;
|
return cloned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
|
|||||||
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { BattleReplay, EditorSnapshot, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
@@ -32,10 +32,14 @@ type FeaturePropertiesPatch = {
|
|||||||
patch: Partial<FeatureProperties>;
|
patch: Partial<FeatureProperties>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DraftRef = { current: FeatureCollection };
|
||||||
|
type DraftCommit = (next: FeatureCollection) => void;
|
||||||
|
type ReplayDraftSyncMode = "none" | "reset";
|
||||||
|
|
||||||
// State trung tâm của editor:
|
// State trung tâm của editor:
|
||||||
// - draft: dữ liệu nguồn để render UI (chuyển đổi giữa main và replay)
|
// - main draft: dữ liệu section thông thường
|
||||||
// - changes: map các thay đổi chờ lưu
|
// - active replay draft: bản sao đầy đủ của toàn bộ BattleReplay đang chỉnh
|
||||||
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
// - replay feature draft: FeatureCollection con để map/editor hiện tại thao tác
|
||||||
export function useEditorState(
|
export function useEditorState(
|
||||||
initialData: FeatureCollection,
|
initialData: FeatureCollection,
|
||||||
options: {
|
options: {
|
||||||
@@ -47,27 +51,87 @@ export function useEditorState(
|
|||||||
const { snapshotUndo, initialReplays, mode } = options;
|
const { snapshotUndo, initialReplays, mode } = options;
|
||||||
|
|
||||||
const mainDraftState = useDraftState(initialData);
|
const mainDraftState = useDraftState(initialData);
|
||||||
const replayDraftState = useDraftState(EMPTY_FEATURE_COLLECTION);
|
const replayFeatureDraftState = useDraftState(EMPTY_FEATURE_COLLECTION);
|
||||||
|
const {
|
||||||
|
draft: mainDraft,
|
||||||
|
draftRef: mainDraftRef,
|
||||||
|
commitDraft: commitMainDraft,
|
||||||
|
resetDraft: resetMainDraft,
|
||||||
|
} = mainDraftState;
|
||||||
|
const {
|
||||||
|
draft: replayDraft,
|
||||||
|
draftRef: replayDraftRef,
|
||||||
|
commitDraft: commitReplayDraft,
|
||||||
|
resetDraft: resetReplayDraft,
|
||||||
|
} = replayFeatureDraftState;
|
||||||
|
|
||||||
const [replays, setReplays] = useState<BattleReplay[]>(initialReplays || []);
|
const [replays, setReplays] = useState<BattleReplay[]>(initialReplays || []);
|
||||||
const [activeReplayId, setActiveReplayId] = useState<string | number | null>(null);
|
const [activeReplayId, setActiveReplayId] = useState<string | number | null>(null);
|
||||||
|
const [activeReplayDraft, setActiveReplayDraft] = useState<BattleReplay | null>(null);
|
||||||
|
|
||||||
const activeDraftState = mode === "replay" ? replayDraftState : mainDraftState;
|
const replaysRef = useRef<BattleReplay[]>(initialReplays || []);
|
||||||
const { draft, draftRef, commitDraft, resetDraft } = activeDraftState;
|
const activeReplayDraftRef = useRef<BattleReplay | null>(null);
|
||||||
|
const activeReplayOriginRef = useRef<BattleReplay | null>(null);
|
||||||
|
const activeReplaySeedRef = useRef<BattleReplay | null>(null);
|
||||||
|
|
||||||
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
|
const activeDraft = mode === "replay" ? replayDraft : mainDraft;
|
||||||
|
const activeDraftRef = mode === "replay" ? replayDraftRef : mainDraftRef;
|
||||||
|
const activeCommitDraft = mode === "replay" ? commitReplayDraft : commitMainDraft;
|
||||||
|
|
||||||
|
const updateReplaysState = useCallback((next: SetStateAction<BattleReplay[]>) => {
|
||||||
|
const resolved = resolveStateAction(next, replaysRef.current);
|
||||||
|
const cloned = deepClone(resolved || []);
|
||||||
|
replaysRef.current = cloned;
|
||||||
|
setReplays(cloned);
|
||||||
|
return cloned;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncReplayFeatureDraft = useCallback((nextFeatures: FeatureCollection) => {
|
||||||
|
resetReplayDraft(deepClone(nextFeatures));
|
||||||
|
}, [resetReplayDraft]);
|
||||||
|
|
||||||
|
const setActiveReplayDraftState = useCallback((
|
||||||
|
next: SetStateAction<BattleReplay | null>,
|
||||||
|
syncMode: ReplayDraftSyncMode = "reset"
|
||||||
|
) => {
|
||||||
|
const resolved = resolveStateAction(next, activeReplayDraftRef.current);
|
||||||
|
const cloned = resolved ? deepClone(resolved) : null;
|
||||||
|
activeReplayDraftRef.current = cloned;
|
||||||
|
setActiveReplayDraft(cloned);
|
||||||
|
|
||||||
|
if (syncMode === "reset") {
|
||||||
|
syncReplayFeatureDraft(cloned?.replay_features || EMPTY_FEATURE_COLLECTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloned;
|
||||||
|
}, [syncReplayFeatureDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
replaysRef.current = replays;
|
||||||
|
}, [replays]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeReplayDraftRef.current = activeReplayDraft;
|
||||||
|
}, [activeReplayDraft]);
|
||||||
|
|
||||||
|
// Map baseline (id -> feature) để diff main draft ra changes.
|
||||||
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||||
buildInitialMap(initialData)
|
buildInitialMap(initialData)
|
||||||
);
|
);
|
||||||
// Version counter để ép diff recalculation sau khi reset/clear baseline.
|
// Version counter để ép diff recalculation sau khi reset/clear baseline.
|
||||||
const [baselineVersion, setBaselineVersion] = useState(0);
|
const [baselineVersion, setBaselineVersion] = useState(0);
|
||||||
|
|
||||||
const applyUndoAction = useCallback((action: UndoAction): boolean => {
|
const applyUndoActionToDraft = useCallback((
|
||||||
|
action: UndoAction,
|
||||||
|
targetDraftRef: DraftRef,
|
||||||
|
targetCommitDraft: DraftCommit,
|
||||||
|
allowSnapshotUndo: boolean
|
||||||
|
): boolean => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "create": {
|
case "create": {
|
||||||
commitDraft({
|
targetCommitDraft({
|
||||||
...draftRef.current,
|
...targetDraftRef.current,
|
||||||
features: draftRef.current.features.filter((feature) =>
|
features: targetDraftRef.current.features.filter((feature) =>
|
||||||
feature.properties.id !== action.id
|
feature.properties.id !== action.id
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -75,54 +139,54 @@ export function useEditorState(
|
|||||||
}
|
}
|
||||||
case "delete": {
|
case "delete": {
|
||||||
const feature = deepClone(action.feature);
|
const feature = deepClone(action.feature);
|
||||||
commitDraft({
|
targetCommitDraft({
|
||||||
...draftRef.current,
|
...targetDraftRef.current,
|
||||||
features: [...draftRef.current.features, feature],
|
features: [...targetDraftRef.current.features, feature],
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "update": {
|
case "update": {
|
||||||
const idx = draftRef.current.features.findIndex((feature) =>
|
const idx = targetDraftRef.current.features.findIndex((feature) =>
|
||||||
feature.properties.id === action.id
|
feature.properties.id === action.id
|
||||||
);
|
);
|
||||||
if (idx === -1) return false;
|
if (idx === -1) return false;
|
||||||
const nextFeatures = [...draftRef.current.features];
|
const nextFeatures = [...targetDraftRef.current.features];
|
||||||
nextFeatures[idx] = {
|
nextFeatures[idx] = {
|
||||||
...nextFeatures[idx],
|
...nextFeatures[idx],
|
||||||
geometry: deepClone(action.prevGeometry),
|
geometry: deepClone(action.prevGeometry),
|
||||||
};
|
};
|
||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
targetCommitDraft({ ...targetDraftRef.current, features: nextFeatures });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "properties": {
|
case "properties": {
|
||||||
const idx = draftRef.current.features.findIndex((feature) =>
|
const idx = targetDraftRef.current.features.findIndex((feature) =>
|
||||||
feature.properties.id === action.id
|
feature.properties.id === action.id
|
||||||
);
|
);
|
||||||
if (idx === -1) return false;
|
if (idx === -1) return false;
|
||||||
const nextFeatures = [...draftRef.current.features];
|
const nextFeatures = [...targetDraftRef.current.features];
|
||||||
nextFeatures[idx] = {
|
nextFeatures[idx] = {
|
||||||
...nextFeatures[idx],
|
...nextFeatures[idx],
|
||||||
properties: deepClone(action.prevProperties),
|
properties: deepClone(action.prevProperties),
|
||||||
};
|
};
|
||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
targetCommitDraft({ ...targetDraftRef.current, features: nextFeatures });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "snapshot_entities": {
|
case "snapshot_entities": {
|
||||||
if (!snapshotUndo) return false;
|
if (!allowSnapshotUndo || !snapshotUndo) return false;
|
||||||
const prev = deepClone(action.prev);
|
const prev = deepClone(action.prev);
|
||||||
snapshotUndo.snapshotEntitiesRef.current = prev;
|
snapshotUndo.snapshotEntitiesRef.current = prev;
|
||||||
snapshotUndo.setSnapshotEntities(prev);
|
snapshotUndo.setSnapshotEntities(prev);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "snapshot_wikis": {
|
case "snapshot_wikis": {
|
||||||
if (!snapshotUndo) return false;
|
if (!allowSnapshotUndo || !snapshotUndo) return false;
|
||||||
const prev = deepClone(action.prev);
|
const prev = deepClone(action.prev);
|
||||||
snapshotUndo.snapshotWikisRef.current = prev;
|
snapshotUndo.snapshotWikisRef.current = prev;
|
||||||
snapshotUndo.setSnapshotWikis(prev);
|
snapshotUndo.setSnapshotWikis(prev);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "snapshot_entity_wiki": {
|
case "snapshot_entity_wiki": {
|
||||||
if (!snapshotUndo) return false;
|
if (!allowSnapshotUndo || !snapshotUndo) return false;
|
||||||
const prev = deepClone(action.prev);
|
const prev = deepClone(action.prev);
|
||||||
snapshotUndo.snapshotEntityWikiLinksRef.current = prev;
|
snapshotUndo.snapshotEntityWikiLinksRef.current = prev;
|
||||||
snapshotUndo.setSnapshotEntityWikiLinks(prev);
|
snapshotUndo.setSnapshotEntityWikiLinks(prev);
|
||||||
@@ -131,41 +195,187 @@ export function useEditorState(
|
|||||||
case "group": {
|
case "group": {
|
||||||
let applied = true;
|
let applied = true;
|
||||||
for (let i = action.actions.length - 1; i >= 0; i -= 1) {
|
for (let i = action.actions.length - 1; i >= 0; i -= 1) {
|
||||||
applied = applyUndoAction(action.actions[i]) && applied;
|
applied = applyUndoActionToDraft(
|
||||||
|
action.actions[i],
|
||||||
|
targetDraftRef,
|
||||||
|
targetCommitDraft,
|
||||||
|
allowSnapshotUndo
|
||||||
|
) && applied;
|
||||||
}
|
}
|
||||||
return applied;
|
return applied;
|
||||||
}
|
}
|
||||||
|
case "replay":
|
||||||
|
case "replay_session":
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [commitDraft, draftRef, snapshotUndo]);
|
}, [snapshotUndo]);
|
||||||
|
|
||||||
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
|
const applyMainUndoAction = useCallback((action: UndoAction): boolean => {
|
||||||
|
if (action.type === "replay") {
|
||||||
|
const restoredReplay = action.prevReplay ? deepClone(action.prevReplay) : null;
|
||||||
|
updateReplaysState((prev) =>
|
||||||
|
replaceReplayByGeometryId(prev, action.geometryId, restoredReplay)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeReplayId != null && String(activeReplayId) === action.geometryId) {
|
||||||
|
activeReplayOriginRef.current = restoredReplay ? deepClone(restoredReplay) : null;
|
||||||
|
activeReplaySeedRef.current = restoredReplay ? deepClone(restoredReplay) : null;
|
||||||
|
setActiveReplayDraftState(restoredReplay, "reset");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyUndoActionToDraft(
|
||||||
|
action,
|
||||||
|
mainDraftRef,
|
||||||
|
commitMainDraft,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
activeReplayId,
|
||||||
|
applyUndoActionToDraft,
|
||||||
|
commitMainDraft,
|
||||||
|
mainDraftRef,
|
||||||
|
setActiveReplayDraftState,
|
||||||
|
updateReplaysState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const applyReplayUndoAction = useCallback((action: UndoAction): boolean => {
|
||||||
|
if (action.type !== "replay_session") return false;
|
||||||
|
const restoredReplay = action.prevReplay ? deepClone(action.prevReplay) : null;
|
||||||
|
if (!restoredReplay) return false;
|
||||||
|
|
||||||
|
setActiveReplayDraftState(restoredReplay, "reset");
|
||||||
|
return true;
|
||||||
|
}, [setActiveReplayDraftState]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
undoStack: mainUndoStack,
|
||||||
|
pushUndo: pushMainUndo,
|
||||||
|
undo: undoMain,
|
||||||
|
clearUndo: clearMainUndo,
|
||||||
|
} = useUndoStack({ applyUndoAction: applyMainUndoAction });
|
||||||
|
const {
|
||||||
|
undoStack: replayUndoStack,
|
||||||
|
pushUndo: pushReplayUndo,
|
||||||
|
undo: undoReplay,
|
||||||
|
clearUndo: clearReplayUndo,
|
||||||
|
} = useUndoStack({ applyUndoAction: applyReplayUndoAction });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mainDraftState.resetDraft(deepClone(initialData));
|
resetMainDraft(deepClone(initialData));
|
||||||
replayDraftState.resetDraft(EMPTY_FEATURE_COLLECTION);
|
resetReplayDraft(EMPTY_FEATURE_COLLECTION);
|
||||||
setReplays(initialReplays || []);
|
updateReplaysState(initialReplays || []);
|
||||||
setActiveReplayId(null);
|
setActiveReplayId(null);
|
||||||
clearUndo();
|
setActiveReplayDraftState(null, "none");
|
||||||
|
activeReplayOriginRef.current = null;
|
||||||
|
activeReplaySeedRef.current = null;
|
||||||
|
clearMainUndo();
|
||||||
|
clearReplayUndo();
|
||||||
initialMapRef.current = buildInitialMap(initialData);
|
initialMapRef.current = buildInitialMap(initialData);
|
||||||
setBaselineVersion((version) => version + 1);
|
setBaselineVersion((version) => version + 1);
|
||||||
}, [clearUndo, initialData, initialReplays, mainDraftState.resetDraft, replayDraftState.resetDraft]);
|
}, [
|
||||||
|
clearMainUndo,
|
||||||
|
clearReplayUndo,
|
||||||
|
initialData,
|
||||||
|
initialReplays,
|
||||||
|
resetMainDraft,
|
||||||
|
resetReplayDraft,
|
||||||
|
setActiveReplayDraftState,
|
||||||
|
updateReplaysState,
|
||||||
|
]);
|
||||||
|
|
||||||
const changes = useMemo(() => {
|
const changes = useMemo(() => {
|
||||||
const baseline = initialMapRef.current;
|
const baseline = initialMapRef.current;
|
||||||
return diffDraftToInitial(draft, baseline);
|
return diffDraftToInitial(mainDraft, baseline);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [draft, baselineVersion]);
|
}, [mainDraft, baselineVersion]);
|
||||||
const changeCount = useMemo(() => changes.size, [changes]);
|
const changeCount = useMemo(() => changes.size, [changes]);
|
||||||
|
|
||||||
|
const applyReplaySessionMutation = useCallback((
|
||||||
|
label: string,
|
||||||
|
mutator: (draftReplay: BattleReplay) => void
|
||||||
|
) => {
|
||||||
|
const currentReplay = activeReplayDraftRef.current;
|
||||||
|
if (!currentReplay) return false;
|
||||||
|
|
||||||
|
const prevReplay = deepClone(currentReplay);
|
||||||
|
const nextReplay = deepClone(currentReplay);
|
||||||
|
if (!nextReplay.replay_features) {
|
||||||
|
nextReplay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
|
||||||
|
}
|
||||||
|
mutator(nextReplay);
|
||||||
|
if (replayEquals(prevReplay, nextReplay)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushReplayUndo({
|
||||||
|
type: "replay_session",
|
||||||
|
geometryId: nextReplay.geometry_id,
|
||||||
|
label,
|
||||||
|
prevReplay,
|
||||||
|
});
|
||||||
|
setActiveReplayDraftState(nextReplay, "reset");
|
||||||
|
return true;
|
||||||
|
}, [pushReplayUndo, setActiveReplayDraftState]);
|
||||||
|
|
||||||
|
const finalizeActiveReplaySession = useCallback((recordMainUndo = true) => {
|
||||||
|
if (activeReplayId == null) return;
|
||||||
|
|
||||||
|
const geometryId = String(activeReplayId);
|
||||||
|
const currentReplay = activeReplayDraftRef.current
|
||||||
|
? deepClone(activeReplayDraftRef.current)
|
||||||
|
: null;
|
||||||
|
const originReplay = activeReplayOriginRef.current
|
||||||
|
? deepClone(activeReplayOriginRef.current)
|
||||||
|
: null;
|
||||||
|
const seedReplay = activeReplaySeedRef.current
|
||||||
|
? deepClone(activeReplaySeedRef.current)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!currentReplay) return;
|
||||||
|
if (replayEquals(currentReplay, seedReplay)) return;
|
||||||
|
|
||||||
|
updateReplaysState((prev) =>
|
||||||
|
replaceReplayByGeometryId(prev, geometryId, currentReplay)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recordMainUndo && !replayEquals(currentReplay, originReplay)) {
|
||||||
|
pushMainUndo({
|
||||||
|
type: "replay",
|
||||||
|
geometryId,
|
||||||
|
label: `Replay #${geometryId}`,
|
||||||
|
prevReplay: originReplay ? deepClone(originReplay) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeReplayId, pushMainUndo, updateReplaysState]);
|
||||||
|
|
||||||
|
const effectiveReplays = useMemo(() => {
|
||||||
|
if (activeReplayId == null || !activeReplayDraft) return replays;
|
||||||
|
const seedReplay = activeReplaySeedRef.current;
|
||||||
|
if (!seedReplay || replayEquals(activeReplayDraft, seedReplay)) {
|
||||||
|
return replays;
|
||||||
|
}
|
||||||
|
return replaceReplayByGeometryId(replays, String(activeReplayId), activeReplayDraft);
|
||||||
|
}, [activeReplayDraft, activeReplayId, replays]);
|
||||||
|
|
||||||
function createFeature(feature: Feature) {
|
function createFeature(feature: Feature) {
|
||||||
const featureClone = deepClone(feature);
|
const featureClone = deepClone(feature);
|
||||||
commitDraft({
|
|
||||||
...draftRef.current,
|
if (mode === "replay") {
|
||||||
features: [...draftRef.current.features, featureClone],
|
applyReplaySessionMutation(`Replay: thêm #${featureClone.properties.id}`, (draftReplay) => {
|
||||||
|
const featureDraft = ensureReplayFeatureCollection(draftReplay);
|
||||||
|
featureDraft.features = [...featureDraft.features, featureClone];
|
||||||
});
|
});
|
||||||
pushUndo({ type: "create", id: featureClone.properties.id });
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCommitDraft({
|
||||||
|
...activeDraftRef.current,
|
||||||
|
features: [...activeDraftRef.current.features, featureClone],
|
||||||
|
});
|
||||||
|
pushMainUndo({ type: "create", id: featureClone.properties.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFeatureWithSnapshotEntities(
|
function createFeatureWithSnapshotEntities(
|
||||||
@@ -173,6 +383,11 @@ export function useEditorState(
|
|||||||
nextEntities: SetStateAction<EntitySnapshot[]>,
|
nextEntities: SetStateAction<EntitySnapshot[]>,
|
||||||
label = "Import geometry"
|
label = "Import geometry"
|
||||||
) {
|
) {
|
||||||
|
if (mode === "replay") {
|
||||||
|
createFeature(feature);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const featureClone = deepClone(feature);
|
const featureClone = deepClone(feature);
|
||||||
const undoActions: UndoAction[] = [];
|
const undoActions: UndoAction[] = [];
|
||||||
|
|
||||||
@@ -202,14 +417,14 @@ export function useEditorState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
undoActions.push({ type: "create", id: featureClone.properties.id });
|
undoActions.push({ type: "create", id: featureClone.properties.id });
|
||||||
pushUndo(
|
pushMainUndo(
|
||||||
undoActions.length === 1
|
undoActions.length === 1
|
||||||
? undoActions[0]
|
? undoActions[0]
|
||||||
: { type: "group", label, actions: undoActions }
|
: { type: "group", label, actions: undoActions }
|
||||||
);
|
);
|
||||||
commitDraft({
|
commitMainDraft({
|
||||||
...draftRef.current,
|
...mainDraftRef.current,
|
||||||
features: [...draftRef.current.features, featureClone],
|
features: [...mainDraftRef.current.features, featureClone],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,10 +432,26 @@ export function useEditorState(
|
|||||||
id: FeatureProperties["id"],
|
id: FeatureProperties["id"],
|
||||||
patch: Partial<FeatureProperties>
|
patch: Partial<FeatureProperties>
|
||||||
) {
|
) {
|
||||||
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
if (mode === "replay") {
|
||||||
|
applyReplaySessionMutation(`Replay: cập nhật thuộc tính #${id}`, (draftReplay) => {
|
||||||
|
const featureDraft = ensureReplayFeatureCollection(draftReplay);
|
||||||
|
const idx = featureDraft.features.findIndex((feature) => feature.properties.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
featureDraft.features[idx] = {
|
||||||
|
...featureDraft.features[idx],
|
||||||
|
properties: {
|
||||||
|
...featureDraft.features[idx].properties,
|
||||||
|
...deepClone(patch),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
|
|
||||||
const nextFeatures = [...draftRef.current.features];
|
const nextFeatures = [...mainDraftRef.current.features];
|
||||||
const prevProperties = deepClone(nextFeatures[idx].properties);
|
const prevProperties = deepClone(nextFeatures[idx].properties);
|
||||||
nextFeatures[idx] = {
|
nextFeatures[idx] = {
|
||||||
...nextFeatures[idx],
|
...nextFeatures[idx],
|
||||||
@@ -234,14 +465,42 @@ export function useEditorState(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pushUndo({ type: "properties", id, prevProperties });
|
pushMainUndo({ type: "properties", id, prevProperties });
|
||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchFeaturePropertiesBatch(
|
function patchFeaturePropertiesBatch(
|
||||||
patches: FeaturePropertiesPatch[],
|
patches: FeaturePropertiesPatch[],
|
||||||
label = "Cập nhật nhiều geometry"
|
label = "Cập nhật nhiều geometry"
|
||||||
) {
|
) {
|
||||||
|
if (mode === "replay") {
|
||||||
|
applyReplaySessionMutation(label, (draftReplay) => {
|
||||||
|
const featureDraft = ensureReplayFeatureCollection(draftReplay);
|
||||||
|
const mergedPatches = new Map<FeatureProperties["id"], Partial<FeatureProperties>>();
|
||||||
|
for (const item of patches || []) {
|
||||||
|
if (!item) continue;
|
||||||
|
const prev = mergedPatches.get(item.id) || {};
|
||||||
|
mergedPatches.set(item.id, {
|
||||||
|
...prev,
|
||||||
|
...deepClone(item.patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
featureDraft.features = featureDraft.features.map((feature) => {
|
||||||
|
const featurePatch = mergedPatches.get(feature.properties.id);
|
||||||
|
if (!featurePatch) return feature;
|
||||||
|
return {
|
||||||
|
...feature,
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
...deepClone(featurePatch),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const mergedPatches = new Map<FeatureProperties["id"], Partial<FeatureProperties>>();
|
const mergedPatches = new Map<FeatureProperties["id"], Partial<FeatureProperties>>();
|
||||||
for (const item of patches || []) {
|
for (const item of patches || []) {
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
@@ -253,7 +512,7 @@ export function useEditorState(
|
|||||||
}
|
}
|
||||||
if (!mergedPatches.size) return;
|
if (!mergedPatches.size) return;
|
||||||
|
|
||||||
const nextFeatures = [...draftRef.current.features];
|
const nextFeatures = [...mainDraftRef.current.features];
|
||||||
const undoActions: UndoAction[] = [];
|
const undoActions: UndoAction[] = [];
|
||||||
|
|
||||||
for (const [id, patch] of mergedPatches.entries()) {
|
for (const [id, patch] of mergedPatches.entries()) {
|
||||||
@@ -278,43 +537,64 @@ export function useEditorState(
|
|||||||
|
|
||||||
if (!undoActions.length) return;
|
if (!undoActions.length) return;
|
||||||
|
|
||||||
pushUndo(
|
pushMainUndo(
|
||||||
undoActions.length === 1
|
undoActions.length === 1
|
||||||
? undoActions[0]
|
? undoActions[0]
|
||||||
: { type: "group", label, actions: undoActions }
|
: { type: "group", label, actions: undoActions }
|
||||||
);
|
);
|
||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||||
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
if (mode === "replay") {
|
||||||
|
applyReplaySessionMutation(`Replay: chỉnh sửa #${id}`, (draftReplay) => {
|
||||||
|
const featureDraft = ensureReplayFeatureCollection(draftReplay);
|
||||||
|
const idx = featureDraft.features.findIndex((feature) => feature.properties.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
featureDraft.features[idx] = {
|
||||||
|
...featureDraft.features[idx],
|
||||||
|
geometry: deepClone(newGeometry),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
|
|
||||||
const prevFeature = draftRef.current.features[idx];
|
const prevFeature = mainDraftRef.current.features[idx];
|
||||||
const prevGeometry = deepClone(prevFeature.geometry);
|
const prevGeometry = deepClone(prevFeature.geometry);
|
||||||
if (geometryEquals(prevGeometry, newGeometry)) {
|
if (geometryEquals(prevGeometry, newGeometry)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextFeatures = [...draftRef.current.features];
|
const nextFeatures = [...mainDraftRef.current.features];
|
||||||
nextFeatures[idx] = {
|
nextFeatures[idx] = {
|
||||||
...prevFeature,
|
...prevFeature,
|
||||||
geometry: deepClone(newGeometry),
|
geometry: deepClone(newGeometry),
|
||||||
};
|
};
|
||||||
|
|
||||||
pushUndo({ type: "update", id, prevGeometry });
|
pushMainUndo({ type: "update", id, prevGeometry });
|
||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteFeature(id: FeatureProperties["id"]) {
|
function deleteFeature(id: FeatureProperties["id"]) {
|
||||||
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
if (mode === "replay") {
|
||||||
|
applyReplaySessionMutation(`Replay: xóa #${id}`, (draftReplay) => {
|
||||||
|
const featureDraft = ensureReplayFeatureCollection(draftReplay);
|
||||||
|
featureDraft.features = featureDraft.features.filter((feature) => feature.properties.id !== id);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
|
|
||||||
const feature = draftRef.current.features[idx];
|
const feature = mainDraftRef.current.features[idx];
|
||||||
const nextFeatures = [...draftRef.current.features];
|
const nextFeatures = [...mainDraftRef.current.features];
|
||||||
nextFeatures.splice(idx, 1);
|
nextFeatures.splice(idx, 1);
|
||||||
|
|
||||||
pushUndo({ type: "delete", feature: deepClone(feature) });
|
pushMainUndo({ type: "delete", feature: deepClone(feature) });
|
||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPayload(): Change[] {
|
function buildPayload(): Change[] {
|
||||||
@@ -322,8 +602,9 @@ export function useEditorState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearChanges() {
|
function clearChanges() {
|
||||||
clearUndo();
|
clearMainUndo();
|
||||||
initialMapRef.current = buildInitialMap(mainDraftState.draftRef.current);
|
clearReplayUndo();
|
||||||
|
initialMapRef.current = buildInitialMap(mainDraftRef.current);
|
||||||
setBaselineVersion((version) => version + 1);
|
setBaselineVersion((version) => version + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,75 +613,42 @@ export function useEditorState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const switchReplayContext = useCallback((featureId: string | number, selectedIds: (string | number)[] = []) => {
|
const switchReplayContext = useCallback((featureId: string | number, selectedIds: (string | number)[] = []) => {
|
||||||
const id = String(featureId);
|
const geometryId = String(featureId);
|
||||||
// Lưu draft replay cũ nếu có (defensive)
|
|
||||||
if (activeReplayId && mode === "replay") {
|
if (activeReplayId != null && String(activeReplayId) === geometryId) {
|
||||||
const currentDraft = replayDraftState.draftRef.current;
|
return;
|
||||||
setReplays(prev => prev.map(r =>
|
|
||||||
r.geometry_id === String(activeReplayId)
|
|
||||||
? { ...r, replay_features: deepClone(currentDraft) }
|
|
||||||
: r
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = replays.find(r => r.geometry_id === id);
|
if (activeReplayId != null) {
|
||||||
|
finalizeActiveReplaySession(true);
|
||||||
// Chuẩn bị data: bao gồm tất cả các geo đang chọn + binding của geo chính
|
|
||||||
const selectedIdsSet = new Set(selectedIds.map(String));
|
|
||||||
selectedIdsSet.add(id); // Luôn bao gồm geo chính
|
|
||||||
|
|
||||||
const triggerFeature = mainDraftState.draftRef.current.features.find(f => String(f.properties.id) === id);
|
|
||||||
const mainBoundIds = new Set(triggerFeature?.properties?.binding?.map(String) || []);
|
|
||||||
|
|
||||||
// Quy tắc: targetIds bao gồm các geo được chọn và binding CHỈ của geo chính.
|
|
||||||
const targetIds = new Set([...selectedIdsSet, ...mainBoundIds]);
|
|
||||||
|
|
||||||
const gatheredFeatures = mainDraftState.draftRef.current.features
|
|
||||||
.filter(f => targetIds.has(String(f.properties.id)))
|
|
||||||
.map(deepClone);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// Đồng bộ lại danh sách geometry theo lựa chọn mới nhất (Sync với Main Draft)
|
|
||||||
// Giúp "reset" danh sách geo theo multi-select và binding mới nhất,
|
|
||||||
// nhưng vẫn giữ nguyên phần kịch bản (detail) đã dựng.
|
|
||||||
const nextFeatures: FeatureCollection = {
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: gatheredFeatures,
|
|
||||||
};
|
|
||||||
|
|
||||||
replayDraftState.resetDraft(deepClone(nextFeatures));
|
|
||||||
// Cập nhật lại list replays để đồng bộ
|
|
||||||
setReplays(prev => prev.map(r =>
|
|
||||||
r.geometry_id === id ? { ...r, replay_features: nextFeatures } : r
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
const initialFeatures: FeatureCollection = {
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: gatheredFeatures,
|
|
||||||
};
|
|
||||||
const newReplay: BattleReplay = {
|
|
||||||
geometry_id: id,
|
|
||||||
detail: [],
|
|
||||||
replay_features: initialFeatures,
|
|
||||||
};
|
|
||||||
setReplays(prev => [...prev, newReplay]);
|
|
||||||
replayDraftState.resetDraft(deepClone(initialFeatures));
|
|
||||||
}
|
}
|
||||||
setActiveReplayId(id);
|
|
||||||
}, [activeReplayId, mode, replayDraftState, replays, mainDraftState.draftRef]);
|
const existing = replaysRef.current.find((replay) => replay.geometry_id === geometryId) || null;
|
||||||
|
const seedReplay = existing
|
||||||
|
? normalizeReplaySessionSeed(existing, mainDraftRef.current, geometryId, selectedIds)
|
||||||
|
: createReplaySessionSeed(mainDraftRef.current, geometryId, selectedIds);
|
||||||
|
|
||||||
|
activeReplayOriginRef.current = existing ? deepClone(existing) : null;
|
||||||
|
activeReplaySeedRef.current = deepClone(seedReplay);
|
||||||
|
clearReplayUndo();
|
||||||
|
setActiveReplayDraftState(seedReplay, "reset");
|
||||||
|
setActiveReplayId(geometryId);
|
||||||
|
}, [
|
||||||
|
activeReplayId,
|
||||||
|
clearReplayUndo,
|
||||||
|
finalizeActiveReplaySession,
|
||||||
|
mainDraftRef,
|
||||||
|
setActiveReplayDraftState,
|
||||||
|
]);
|
||||||
|
|
||||||
const closeReplayContext = useCallback(() => {
|
const closeReplayContext = useCallback(() => {
|
||||||
if (activeReplayId) {
|
finalizeActiveReplaySession(true);
|
||||||
const currentDraft = replayDraftState.draftRef.current;
|
|
||||||
setReplays(prev => prev.map(r =>
|
|
||||||
r.geometry_id === String(activeReplayId)
|
|
||||||
? { ...r, replay_features: deepClone(currentDraft) }
|
|
||||||
: r
|
|
||||||
));
|
|
||||||
}
|
|
||||||
setActiveReplayId(null);
|
setActiveReplayId(null);
|
||||||
replayDraftState.resetDraft(EMPTY_FEATURE_COLLECTION);
|
setActiveReplayDraftState(null, "reset");
|
||||||
}, [activeReplayId, replayDraftState]);
|
activeReplayOriginRef.current = null;
|
||||||
|
activeReplaySeedRef.current = null;
|
||||||
|
clearReplayUndo();
|
||||||
|
}, [clearReplayUndo, finalizeActiveReplaySession, setActiveReplayDraftState]);
|
||||||
|
|
||||||
const setSnapshotEntitiesUndoable = useCallback((
|
const setSnapshotEntitiesUndoable = useCallback((
|
||||||
next: SetStateAction<EntitySnapshot[]>,
|
next: SetStateAction<EntitySnapshot[]>,
|
||||||
@@ -419,10 +667,10 @@ export function useEditorState(
|
|||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
|
|
||||||
const computedClone = deepClone(computed);
|
const computedClone = deepClone(computed);
|
||||||
pushUndo({ type: "snapshot_entities", label, prev: prevClone });
|
pushMainUndo({ type: "snapshot_entities", label, prev: prevClone });
|
||||||
snapshotUndo.snapshotEntitiesRef.current = computedClone;
|
snapshotUndo.snapshotEntitiesRef.current = computedClone;
|
||||||
snapshotUndo.setSnapshotEntities(computedClone);
|
snapshotUndo.setSnapshotEntities(computedClone);
|
||||||
}, [pushUndo, snapshotUndo]);
|
}, [pushMainUndo, snapshotUndo]);
|
||||||
|
|
||||||
const setSnapshotWikisUndoable = useCallback((
|
const setSnapshotWikisUndoable = useCallback((
|
||||||
next: SetStateAction<WikiSnapshot[]>,
|
next: SetStateAction<WikiSnapshot[]>,
|
||||||
@@ -441,10 +689,10 @@ export function useEditorState(
|
|||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
|
|
||||||
const computedClone = deepClone(computed);
|
const computedClone = deepClone(computed);
|
||||||
pushUndo({ type: "snapshot_wikis", label, prev: prevClone });
|
pushMainUndo({ type: "snapshot_wikis", label, prev: prevClone });
|
||||||
snapshotUndo.snapshotWikisRef.current = computedClone;
|
snapshotUndo.snapshotWikisRef.current = computedClone;
|
||||||
snapshotUndo.setSnapshotWikis(computedClone);
|
snapshotUndo.setSnapshotWikis(computedClone);
|
||||||
}, [pushUndo, snapshotUndo]);
|
}, [pushMainUndo, snapshotUndo]);
|
||||||
|
|
||||||
const setSnapshotEntityWikiLinksUndoable = useCallback((
|
const setSnapshotEntityWikiLinksUndoable = useCallback((
|
||||||
next: SetStateAction<EntityWikiLinkSnapshot[]>,
|
next: SetStateAction<EntityWikiLinkSnapshot[]>,
|
||||||
@@ -465,24 +713,38 @@ export function useEditorState(
|
|||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
|
|
||||||
const computedClone = deepClone(computed);
|
const computedClone = deepClone(computed);
|
||||||
pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone });
|
pushMainUndo({ type: "snapshot_entity_wiki", label, prev: prevClone });
|
||||||
snapshotUndo.snapshotEntityWikiLinksRef.current = computedClone;
|
snapshotUndo.snapshotEntityWikiLinksRef.current = computedClone;
|
||||||
snapshotUndo.setSnapshotEntityWikiLinks(computedClone);
|
snapshotUndo.setSnapshotEntityWikiLinks(computedClone);
|
||||||
}, [pushUndo, snapshotUndo]);
|
}, [pushMainUndo, snapshotUndo]);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (mode === "replay") {
|
||||||
|
undoReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
undoMain();
|
||||||
|
}, [mode, undoMain, undoReplay]);
|
||||||
|
|
||||||
|
const undoStack = mode === "replay" ? replayUndoStack : mainUndoStack;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
draft,
|
draft: activeDraft,
|
||||||
draftRef,
|
draftRef: activeDraftRef,
|
||||||
mainDraft: mainDraftState.draft,
|
mainDraft,
|
||||||
replayDraft: replayDraftState.draft,
|
replayDraft,
|
||||||
replays,
|
replays,
|
||||||
setReplays,
|
activeReplayDraft,
|
||||||
|
effectiveReplays,
|
||||||
|
setReplays: updateReplaysState,
|
||||||
activeReplayId,
|
activeReplayId,
|
||||||
switchReplayContext,
|
switchReplayContext,
|
||||||
closeReplayContext,
|
closeReplayContext,
|
||||||
changes,
|
changes,
|
||||||
undoStack,
|
undoStack,
|
||||||
|
replayUndoStack,
|
||||||
changeCount,
|
changeCount,
|
||||||
|
canUndoReplay: replayUndoStack.length > 0,
|
||||||
createFeature,
|
createFeature,
|
||||||
createFeatureWithSnapshotEntities,
|
createFeatureWithSnapshotEntities,
|
||||||
patchFeatureProperties,
|
patchFeatureProperties,
|
||||||
@@ -499,3 +761,99 @@ export function useEditorState(
|
|||||||
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
|
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveStateAction<T>(next: SetStateAction<T>, prev: T): T {
|
||||||
|
return typeof next === "function" ? (next as (value: T) => T)(prev) : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReplaySeedFeatures(
|
||||||
|
sourceDraft: FeatureCollection,
|
||||||
|
featureId: string,
|
||||||
|
selectedIds: (string | number)[]
|
||||||
|
): FeatureCollection {
|
||||||
|
const selectedIdsSet = new Set(selectedIds.map(String));
|
||||||
|
selectedIdsSet.add(featureId);
|
||||||
|
|
||||||
|
const triggerFeature = sourceDraft.features.find(
|
||||||
|
(feature) => String(feature.properties.id) === featureId
|
||||||
|
);
|
||||||
|
const mainBoundIds = new Set(
|
||||||
|
Array.isArray(triggerFeature?.properties?.binding)
|
||||||
|
? triggerFeature.properties.binding.map(String)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const targetIds = new Set([...selectedIdsSet, ...mainBoundIds]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: sourceDraft.features
|
||||||
|
.filter((feature) => targetIds.has(String(feature.properties.id)))
|
||||||
|
.map(deepClone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReplaySessionSeed(
|
||||||
|
sourceDraft: FeatureCollection,
|
||||||
|
geometryId: string,
|
||||||
|
selectedIds: (string | number)[]
|
||||||
|
): BattleReplay {
|
||||||
|
return {
|
||||||
|
geometry_id: geometryId,
|
||||||
|
detail: [],
|
||||||
|
replay_features: buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplaySessionSeed(
|
||||||
|
replay: BattleReplay,
|
||||||
|
sourceDraft: FeatureCollection,
|
||||||
|
geometryId: string,
|
||||||
|
selectedIds: (string | number)[]
|
||||||
|
): BattleReplay {
|
||||||
|
const nextReplay = deepClone(replay);
|
||||||
|
if (!nextReplay.replay_features) {
|
||||||
|
nextReplay.replay_features = buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds);
|
||||||
|
}
|
||||||
|
return nextReplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureReplayFeatureCollection(replay: BattleReplay): FeatureCollection {
|
||||||
|
if (!replay.replay_features) {
|
||||||
|
replay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
|
||||||
|
}
|
||||||
|
return replay.replay_features;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceReplayByGeometryId(
|
||||||
|
replays: BattleReplay[],
|
||||||
|
geometryId: string,
|
||||||
|
nextReplay: BattleReplay | null
|
||||||
|
) {
|
||||||
|
const next: BattleReplay[] = [];
|
||||||
|
let replaced = false;
|
||||||
|
|
||||||
|
for (const replay of replays || []) {
|
||||||
|
if (!replay || replay.geometry_id !== geometryId) {
|
||||||
|
next.push(replay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (nextReplay) {
|
||||||
|
next.push(deepClone(nextReplay));
|
||||||
|
}
|
||||||
|
replaced = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replaced && nextReplay) {
|
||||||
|
next.push(deepClone(nextReplay));
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replayEquals(a: BattleReplay | null | undefined, b: BattleReplay | null | undefined) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user