This commit is contained in:
taDuc
2026-04-20 23:27:38 +07:00
parent 2508172489
commit 3ca7098831
36 changed files with 1939 additions and 1695 deletions

View File

@@ -0,0 +1,55 @@
import type {
Feature,
FeatureCollection,
FeatureProperties,
Geometry,
} from "@/types/geo";
import type { Change } from "@/lib/editor/draft/editorTypes";
export const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
export function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
if (!a || !b) return false;
return JSON.stringify(a) === JSON.stringify(b);
}
export function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
if (!a || !b) return false;
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
JSON.stringify(a.properties) === JSON.stringify(b.properties);
}
export function buildInitialMap(fc: FeatureCollection) {
const map = new Map<FeatureProperties["id"], Feature>();
for (const feature of fc.features) {
map.set(feature.properties.id, deepClone(feature));
}
return map;
}
export function diffDraftToInitial(
draft: FeatureCollection,
initialMap: Map<FeatureProperties["id"], Feature>
) {
const next = new Map<FeatureProperties["id"], Change>();
const seen = new Set<FeatureProperties["id"]>();
for (const feature of draft.features) {
const id = feature.properties.id;
seen.add(id);
const initialFeature = initialMap.get(id);
if (!initialFeature) {
next.set(id, { action: "create", feature: deepClone(feature) });
} else if (!featureEquals(initialFeature, feature)) {
next.set(id, { action: "update", id, geometry: deepClone(feature.geometry) });
}
}
for (const [id] of initialMap.entries()) {
if (!seen.has(id)) {
next.set(id, { action: "delete", id });
}
}
return next;
}

View File

@@ -0,0 +1,15 @@
import type {
Feature,
FeatureProperties,
Geometry,
GeometryChange,
} from "@/types/geo";
export type Change = GeometryChange;
export type UndoAction =
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
| { type: "delete"; feature: Feature }
| { type: "create"; id: FeatureProperties["id"] };

View File

@@ -0,0 +1,29 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { FeatureCollection } from "@/types/geo";
import { deepClone } from "@/lib/editor/draft/draftDiff";
export function useDraftState(initialData: FeatureCollection) {
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
const commitDraft = useCallback((nextDraft: FeatureCollection) => {
const cloned = deepClone(nextDraft);
draftRef.current = cloned;
setDraft(cloned);
}, []);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
const resetDraft = useCallback((nextDraft: FeatureCollection) => {
commitDraft(nextDraft);
}, [commitDraft]);
return {
draft,
draftRef,
commitDraft,
resetDraft,
};
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useState } from "react";
import type { UndoAction } from "@/lib/editor/draft/editorTypes";
import { geometryEquals } from "@/lib/editor/draft/draftDiff";
type Options = {
applyUndoAction: (action: UndoAction) => boolean;
};
export function useUndoStack(options: Options) {
const { applyUndoAction } = options;
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
const pushUndo = useCallback((action: UndoAction) => {
setUndoStack((prev) => {
const last = prev[prev.length - 1];
if (isSameUndo(last, action)) return prev;
return [...prev, action];
});
}, []);
const undo = useCallback(() => {
let applied = false;
setUndoStack((prev) => {
if (applied) return prev;
if (!prev.length) return prev;
const last = prev[prev.length - 1];
const remaining = prev.slice(0, -1);
applied = true;
const didApply = applyUndoAction(last);
return didApply ? remaining : prev;
});
}, [applyUndoAction]);
const clearUndo = useCallback(() => {
setUndoStack([]);
}, []);
return {
undoStack,
pushUndo,
undo,
clearUndo,
};
}
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
if (!a) return false;
if (a.type !== b.type) return false;
switch (a.type) {
case "create": {
const next = b as Extract<UndoAction, { type: "create" }>;
return a.id === next.id;
}
case "delete": {
const next = b as Extract<UndoAction, { type: "delete" }>;
return (
a.feature.properties.id === next.feature.properties.id &&
geometryEquals(a.feature.geometry, next.feature.geometry)
);
}
case "update": {
const next = b as Extract<UndoAction, { type: "update" }>;
return (
a.id === next.id &&
geometryEquals(a.prevGeometry, next.prevGeometry)
);
}
case "properties": {
const next = b as Extract<UndoAction, { type: "properties" }>;
return (
a.id === next.id &&
JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties)
);
}
default:
return false;
}
}