refactor: enhance wiki navigation, add view mode toggle, and improve map sync preview logic
(important global check)
This commit is contained in:
@@ -22,7 +22,8 @@ import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities"
|
|||||||
import { ApiError } from "@/uhm/api/http";
|
import { ApiError } from "@/uhm/api/http";
|
||||||
import { fetchCurrentUser } from "@/uhm/api/auth";
|
import { fetchCurrentUser } from "@/uhm/api/auth";
|
||||||
import { fetchWikiById, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
import { fetchWikiById, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||||
import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
|
import { searchGeometriesByEntityName, fetchGeometriesByBBox, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
|
||||||
|
import { WORLD_BBOX } from "@/uhm/lib/map/geo/constants";
|
||||||
import {
|
import {
|
||||||
Feature,
|
Feature,
|
||||||
FeatureCollection,
|
FeatureCollection,
|
||||||
@@ -409,6 +410,13 @@ function EditorPageContent() {
|
|||||||
const [previewExpandedEntityId, setPreviewExpandedEntityId] = useState<string | null>(null);
|
const [previewExpandedEntityId, setPreviewExpandedEntityId] = useState<string | null>(null);
|
||||||
const [previewActiveEntityId, setPreviewActiveEntityId] = useState<string | null>(null);
|
const [previewActiveEntityId, setPreviewActiveEntityId] = useState<string | null>(null);
|
||||||
const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false);
|
const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"local" | "global">("local");
|
||||||
|
const [globalGeometries, setGlobalGeometries] = useState<FeatureCollection>({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
const [isGlobalLoading, setIsGlobalLoading] = useState(false);
|
||||||
const [previewLinkEntityPopup, setPreviewLinkEntityPopup] = useState<PreviewLinkEntityPopupState | null>(null);
|
const [previewLinkEntityPopup, setPreviewLinkEntityPopup] = useState<PreviewLinkEntityPopupState | null>(null);
|
||||||
const [previewEntityFocusToken, setPreviewEntityFocusToken] = useState(0);
|
const [previewEntityFocusToken, setPreviewEntityFocusToken] = useState(0);
|
||||||
const [previewSidebarWidth, setPreviewSidebarWidth] = useState<number>(() => {
|
const [previewSidebarWidth, setPreviewSidebarWidth] = useState<number>(() => {
|
||||||
@@ -632,6 +640,119 @@ function EditorPageContent() {
|
|||||||
? previewSession?.timelineFilterEnabled ?? timelineFilterEnabled
|
? previewSession?.timelineFilterEnabled ?? timelineFilterEnabled
|
||||||
: timelineFilterEnabled;
|
: timelineFilterEnabled;
|
||||||
|
|
||||||
|
// Render draft is the only FeatureCollection that decides what appears on the map.
|
||||||
|
// It may be timeline-filtered, replay-filtered, or preview-filtered, but it is not the edit source.
|
||||||
|
// Fetch global geometries when viewMode is "global", timeline year changes, or timeline filter state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== "global") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
setIsGlobalLoading(true);
|
||||||
|
|
||||||
|
const timeVal = activeTimelineFilterEnabled
|
||||||
|
? clampYearToFixedRange(Math.trunc(activeTimelineYear))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const loadGlobalData = async () => {
|
||||||
|
try {
|
||||||
|
// 1. Fetch all geometries in a single fast query
|
||||||
|
const baseFc = await fetchGeometriesByBBox({
|
||||||
|
...WORLD_BBOX,
|
||||||
|
time: timeVal,
|
||||||
|
timeRange: activeTimelineFilterEnabled ? 0 : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disposed) return;
|
||||||
|
setGlobalGeometries(baseFc);
|
||||||
|
|
||||||
|
// 2. Concurrently fetch per-entity to build the geometry-to-entity mapping
|
||||||
|
const geoToEntities: Record<string, { entity_id: string; entity_name: string; entity_ids: string[] }> = {};
|
||||||
|
|
||||||
|
const concurrency = 6;
|
||||||
|
const items = [...entities];
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||||
|
while (true) {
|
||||||
|
if (disposed) return;
|
||||||
|
const idx = nextIndex++;
|
||||||
|
if (idx >= items.length) return;
|
||||||
|
const entity = items[idx];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fc = await fetchGeometriesByBBox({
|
||||||
|
...WORLD_BBOX,
|
||||||
|
entity_id: entity.id,
|
||||||
|
time: timeVal,
|
||||||
|
timeRange: activeTimelineFilterEnabled ? 0 : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
for (const feature of fc.features) {
|
||||||
|
const gid = String(feature.properties?.id);
|
||||||
|
if (!geoToEntities[gid]) {
|
||||||
|
geoToEntities[gid] = {
|
||||||
|
entity_id: entity.id,
|
||||||
|
entity_name: entity.name,
|
||||||
|
entity_ids: [entity.id],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (!geoToEntities[gid].entity_ids.includes(entity.id)) {
|
||||||
|
geoToEntities[gid].entity_ids.push(entity.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error loading geometry mapping for entity ${entity.id}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
// 3. Update the global geometries with the enriched properties
|
||||||
|
setGlobalGeometries((prev) => {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
features: prev.features.map((feature) => {
|
||||||
|
const gid = String(feature.properties?.id);
|
||||||
|
const mapping = geoToEntities[gid];
|
||||||
|
if (mapping) {
|
||||||
|
return {
|
||||||
|
...feature,
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
entity_id: mapping.entity_id,
|
||||||
|
entity_name: mapping.entity_name,
|
||||||
|
entity_ids: mapping.entity_ids,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return feature;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Load global geometries failed", err);
|
||||||
|
} finally {
|
||||||
|
if (!disposed) {
|
||||||
|
setIsGlobalLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadGlobalData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [viewMode, activeTimelineYear, activeTimelineFilterEnabled, entities]);
|
||||||
|
|
||||||
// Render draft is the only FeatureCollection that decides what appears on the map.
|
// Render draft is the only FeatureCollection that decides what appears on the map.
|
||||||
// It may be timeline-filtered, replay-filtered, or preview-filtered, but it is not the edit source.
|
// It may be timeline-filtered, replay-filtered, or preview-filtered, but it is not the edit source.
|
||||||
const mapRenderDraft = useMemo(() => {
|
const mapRenderDraft = useMemo(() => {
|
||||||
@@ -643,11 +764,46 @@ function EditorPageContent() {
|
|||||||
? editor.replayDraft
|
? editor.replayDraft
|
||||||
: editor.mainDraft;
|
: editor.mainDraft;
|
||||||
|
|
||||||
if (!activeTimelineFilterEnabled) return activeDraft;
|
const filteredDraft = activeTimelineFilterEnabled
|
||||||
const year = clampYearToFixedRange(Math.trunc(activeTimelineYear));
|
? {
|
||||||
|
...activeDraft,
|
||||||
|
features: activeDraft.features.filter((feature) =>
|
||||||
|
isFeatureVisibleAtYear(feature, clampYearToFixedRange(Math.trunc(activeTimelineYear)))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: activeDraft;
|
||||||
|
|
||||||
|
if (viewMode === "local") {
|
||||||
|
return filteredDraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We want to ignore any database geometries whose IDs are present in either the active local features
|
||||||
|
// or the baseline features (since those are owned by the local session/commit context).
|
||||||
|
const localFeatureIds = new Set<string>();
|
||||||
|
for (const f of filteredDraft.features) {
|
||||||
|
if (f.properties?.id != null) {
|
||||||
|
localFeatureIds.add(String(f.properties.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const f of baselineFeatureCollection.features) {
|
||||||
|
if (f.properties?.id != null) {
|
||||||
|
localFeatureIds.add(String(f.properties.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedFeatures = [...filteredDraft.features];
|
||||||
|
|
||||||
|
// Add global features that are not owned/modified/deleted by the local session
|
||||||
|
for (const globalFeature of globalGeometries.features) {
|
||||||
|
const globalId = globalFeature.properties?.id != null ? String(globalFeature.properties.id) : null;
|
||||||
|
if (globalId === null || !localFeatureIds.has(globalId)) {
|
||||||
|
mergedFeatures.push(globalFeature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...activeDraft,
|
...filteredDraft,
|
||||||
features: activeDraft.features.filter((feature) => isFeatureVisibleAtYear(feature, year)),
|
features: mergedFeatures,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeTimelineFilterEnabled,
|
activeTimelineFilterEnabled,
|
||||||
@@ -658,6 +814,9 @@ function EditorPageContent() {
|
|||||||
isViewerPreviewMode,
|
isViewerPreviewMode,
|
||||||
previewSession?.draft,
|
previewSession?.draft,
|
||||||
replayPreviewDraft,
|
replayPreviewDraft,
|
||||||
|
viewMode,
|
||||||
|
baselineFeatureCollection.features,
|
||||||
|
globalGeometries.features,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Danh sách feature đang chọn, map từ selectedFeatureIds sang draft hiện tại.
|
// Danh sách feature đang chọn, map từ selectedFeatureIds sang draft hiện tại.
|
||||||
@@ -1913,6 +2072,16 @@ function EditorPageContent() {
|
|||||||
setSelectedGeometryEntityIds,
|
setSelectedGeometryEntityIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleDeleteEntity = useCallback((entityId: string) => {
|
||||||
|
const id = String(entityId || "").trim();
|
||||||
|
if (!id) return;
|
||||||
|
const confirmed = window.confirm(`Bạn có chắc chắn muốn xóa thực thể này khỏi dự án? Hành động này cũng sẽ gỡ bỏ tất cả liên kết hình học và wiki của thực thể.`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
editor.deleteEntityAndRelations(id, `Xóa thực thể #${id}`);
|
||||||
|
setSelectedGeometryEntityIds((prev) => prev.filter((x) => x !== id));
|
||||||
|
flashEntityFormStatus(`Đã xóa thực thể #${id}.`, 3000);
|
||||||
|
}, [editor, flashEntityFormStatus, setSelectedGeometryEntityIds]);
|
||||||
|
|
||||||
// Bind/unbind geometry con vào selected geometry qua field child.bound_with.
|
// Bind/unbind geometry con vào selected geometry qua field child.bound_with.
|
||||||
const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => {
|
const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => {
|
||||||
if (!selectedFeatures || selectedFeatures.length === 0) {
|
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||||
@@ -2398,9 +2567,41 @@ function EditorPageContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Base draft for label lookup only. It must not decide which geometry is rendered.
|
// Base draft for label lookup only. It must not decide which geometry is rendered.
|
||||||
const labelContextBaseDraft = isAnyPreviewMode
|
const labelContextBaseDraft = useMemo(() => {
|
||||||
? previewSession?.draft || EMPTY_FEATURE_COLLECTION
|
const baseDraft = isAnyPreviewMode
|
||||||
: editor.draft;
|
? previewSession?.draft || EMPTY_FEATURE_COLLECTION
|
||||||
|
: editor.draft;
|
||||||
|
|
||||||
|
if (viewMode === "local") {
|
||||||
|
return baseDraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localFeatureIds = new Set<string>();
|
||||||
|
for (const f of baseDraft.features) {
|
||||||
|
if (f.properties?.id != null) {
|
||||||
|
localFeatureIds.add(String(f.properties.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const f of baselineFeatureCollection.features) {
|
||||||
|
if (f.properties?.id != null) {
|
||||||
|
localFeatureIds.add(String(f.properties.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedFeatures = [...baseDraft.features];
|
||||||
|
for (const globalFeature of globalGeometries.features) {
|
||||||
|
const globalId = globalFeature.properties?.id != null ? String(globalFeature.properties.id) : null;
|
||||||
|
if (globalId === null || !localFeatureIds.has(globalId)) {
|
||||||
|
mergedFeatures.push(globalFeature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseDraft,
|
||||||
|
features: mergedFeatures,
|
||||||
|
};
|
||||||
|
}, [viewMode, isAnyPreviewMode, previewSession?.draft, editor.draft, baselineFeatureCollection.features, globalGeometries.features]);
|
||||||
|
|
||||||
// Enriched label context may contain geometries that mapRenderDraft filtered out.
|
// Enriched label context may contain geometries that mapRenderDraft filtered out.
|
||||||
// Map rendering must still use mapRenderDraft above.
|
// Map rendering must still use mapRenderDraft above.
|
||||||
const mapLabelContextDraft = useMemo(() => {
|
const mapLabelContextDraft = useMemo(() => {
|
||||||
@@ -2657,6 +2858,8 @@ function EditorPageContent() {
|
|||||||
onEnterPreview={!isReplayEditMode && !isAnyPreviewMode ? openViewerPreview : undefined}
|
onEnterPreview={!isReplayEditMode && !isAnyPreviewMode ? openViewerPreview : undefined}
|
||||||
onExitPreview={isReplayPreviewMode ? exitReplayPreview : isViewerPreviewMode ? exitViewerPreview : undefined}
|
onExitPreview={isReplayPreviewMode ? exitReplayPreview : isViewerPreviewMode ? exitViewerPreview : undefined}
|
||||||
onPlayPreviewReplay={isViewerPreviewMode && viewerPreviewSelectedReplay ? openSelectedViewerReplayPreview : undefined}
|
onPlayPreviewReplay={isViewerPreviewMode && viewerPreviewSelectedReplay ? openSelectedViewerReplayPreview : undefined}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||||
@@ -3078,6 +3281,7 @@ function EditorPageContent() {
|
|||||||
selectedGeometryTime={selectedGeometryTime}
|
selectedGeometryTime={selectedGeometryTime}
|
||||||
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
|
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
|
||||||
onRerollEntityId={handleRerollEntityId}
|
onRerollEntityId={handleRerollEntityId}
|
||||||
|
onDeleteEntity={handleDeleteEntity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WikiSidebarPanel
|
<WikiSidebarPanel
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ type MapProps = {
|
|||||||
onEnterPreview?: () => void;
|
onEnterPreview?: () => void;
|
||||||
onExitPreview?: () => void;
|
onExitPreview?: () => void;
|
||||||
onPlayPreviewReplay?: () => void;
|
onPlayPreviewReplay?: () => void;
|
||||||
|
viewMode?: "local" | "global";
|
||||||
|
onViewModeChange?: (mode: "local" | "global") => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||||
@@ -99,6 +101,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
onEnterPreview,
|
onEnterPreview,
|
||||||
onExitPreview,
|
onExitPreview,
|
||||||
onPlayPreviewReplay,
|
onPlayPreviewReplay,
|
||||||
|
viewMode = "local",
|
||||||
|
onViewModeChange,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
|
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
|
||||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||||
@@ -210,6 +214,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
allowGeometryEditing,
|
allowGeometryEditing,
|
||||||
editingEngineRef,
|
editingEngineRef,
|
||||||
geolocationCenteredRef,
|
geolocationCenteredRef,
|
||||||
|
isPreviewMode: isPreviewMode || mode === "preview" || mode === "replay" || mode === "replay_preview",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -373,6 +378,45 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{onViewModeChange ? (
|
||||||
|
<div style={{ display: "flex", background: "rgba(15, 23, 42, 0.6)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(148, 163, 184, 0.2)", gap: "2px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onViewModeChange("local")}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 700,
|
||||||
|
background: viewMode === "local" ? "#2563eb" : "transparent",
|
||||||
|
color: viewMode === "local" ? "white" : "#94a3b8",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 150ms, color 150ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
LOCAL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onViewModeChange("global")}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 700,
|
||||||
|
background: viewMode === "global" ? "#2563eb" : "transparent",
|
||||||
|
color: viewMode === "global" ? "white" : "#94a3b8",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background 150ms, color 150ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
GLOBAL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{onEnterPreview || onExitPreview ? (
|
{onEnterPreview || onExitPreview ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Props = {
|
|||||||
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
|
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
|
||||||
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
||||||
onRerollEntityId?: (oldId: string, nextId: string) => void;
|
onRerollEntityId?: (oldId: string, nextId: string) => void;
|
||||||
|
onDeleteEntity?: (entityId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProjectEntityRefsPanel({
|
export default function ProjectEntityRefsPanel({
|
||||||
@@ -23,6 +24,7 @@ export default function ProjectEntityRefsPanel({
|
|||||||
selectedGeometryTime,
|
selectedGeometryTime,
|
||||||
onToggleBindEntityForSelectedGeometry,
|
onToggleBindEntityForSelectedGeometry,
|
||||||
onRerollEntityId,
|
onRerollEntityId,
|
||||||
|
onDeleteEntity,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
snapshotEntityRows,
|
snapshotEntityRows,
|
||||||
@@ -234,6 +236,28 @@ export default function ProjectEntityRefsPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{typeof onDeleteEntity === "function" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Xóa thực thể khỏi dự án"
|
||||||
|
onClick={() => onDeleteEntity(entityId)}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#0b1220",
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
aria-label={`Xóa thực thể ${entityId}`}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -346,28 +370,53 @@ export default function ProjectEntityRefsPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onUpdateEntity!(String(activeEntity.id), {
|
type="button"
|
||||||
name: editName,
|
onClick={() => onUpdateEntity!(String(activeEntity.id), {
|
||||||
description: editDescription.trim().length ? editDescription : null,
|
name: editName,
|
||||||
time_start: editTimeStart,
|
description: editDescription.trim().length ? editDescription : null,
|
||||||
time_end: editTimeEnd,
|
time_start: editTimeStart,
|
||||||
})}
|
time_end: editTimeEnd,
|
||||||
disabled={isEntitySubmitting}
|
})}
|
||||||
style={{
|
disabled={isEntitySubmitting}
|
||||||
border: "none",
|
style={{
|
||||||
borderRadius: "6px",
|
flex: 1,
|
||||||
padding: "7px 8px",
|
border: "none",
|
||||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
borderRadius: "6px",
|
||||||
background: "#0f766e",
|
padding: "7px 8px",
|
||||||
color: "#ffffff",
|
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
background: "#0f766e",
|
||||||
fontWeight: 600,
|
color: "#ffffff",
|
||||||
}}
|
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||||
>
|
fontWeight: 600,
|
||||||
Luu entity
|
}}
|
||||||
</button>
|
>
|
||||||
|
Luu entity
|
||||||
|
</button>
|
||||||
|
{typeof onDeleteEntity === "function" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onDeleteEntity(String(activeEntity.id));
|
||||||
|
setActiveEntityId(null);
|
||||||
|
}}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||||
|
background: "#7f1d1d",
|
||||||
|
color: "#fecaca",
|
||||||
|
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -610,3 +659,17 @@ function ClockIcon() {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TrashIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0 1 16.138 21H7.862a2 2 0 0 1-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16"
|
||||||
|
stroke="#f87171"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ export function getSelectableLayers(map: maplibregl.Map): string[] {
|
|||||||
export function filterDraftByBinding(
|
export function filterDraftByBinding(
|
||||||
fc: FeatureCollection,
|
fc: FeatureCollection,
|
||||||
selectedFeatureIds: (string | number)[],
|
selectedFeatureIds: (string | number)[],
|
||||||
highlightFeatures?: FeatureCollection | null
|
highlightFeatures?: FeatureCollection | null,
|
||||||
|
isPreviewMode?: boolean
|
||||||
): FeatureCollection {
|
): FeatureCollection {
|
||||||
const selectedIds = new Set(selectedFeatureIds.map(String));
|
const selectedIds = new Set(selectedFeatureIds.map(String));
|
||||||
if (highlightFeatures?.features) {
|
if (highlightFeatures?.features) {
|
||||||
@@ -185,8 +186,8 @@ export function filterDraftByBinding(
|
|||||||
const featureId = String(feature.properties.id);
|
const featureId = String(feature.properties.id);
|
||||||
const parentId = featureParentMap.get(featureId);
|
const parentId = featureParentMap.get(featureId);
|
||||||
|
|
||||||
// 1. If this feature is a parent and its hierarchy is active, hide it
|
// 1. If this feature is a parent and its hierarchy is active, hide it (only in preview/replay modes)
|
||||||
if (activeParents.has(featureId)) {
|
if (isPreviewMode && activeParents.has(featureId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type UseMapSyncProps = {
|
|||||||
clearEditing: () => void;
|
clearEditing: () => void;
|
||||||
} | null>;
|
} | null>;
|
||||||
geolocationCenteredRef: React.MutableRefObject<boolean>;
|
geolocationCenteredRef: React.MutableRefObject<boolean>;
|
||||||
|
isPreviewMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useMapSync({
|
export function useMapSync({
|
||||||
@@ -64,6 +65,7 @@ export function useMapSync({
|
|||||||
allowGeometryEditing,
|
allowGeometryEditing,
|
||||||
editingEngineRef,
|
editingEngineRef,
|
||||||
geolocationCenteredRef,
|
geolocationCenteredRef,
|
||||||
|
isPreviewMode,
|
||||||
}: UseMapSyncProps) {
|
}: UseMapSyncProps) {
|
||||||
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
|
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
|
||||||
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
|
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
|
||||||
@@ -76,6 +78,7 @@ export function useMapSync({
|
|||||||
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
|
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
|
||||||
const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection);
|
const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection);
|
||||||
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
|
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
|
||||||
|
const isPreviewModeRef = useRef(isPreviewMode);
|
||||||
|
|
||||||
const fitBoundsAppliedRef = useRef(false);
|
const fitBoundsAppliedRef = useRef(false);
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ export function useMapSync({
|
|||||||
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
|
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
|
||||||
useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]);
|
useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]);
|
||||||
useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]);
|
useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]);
|
||||||
|
useEffect(() => { isPreviewModeRef.current = isPreviewMode; }, [isPreviewMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fitBoundsAppliedRef.current = false;
|
fitBoundsAppliedRef.current = false;
|
||||||
@@ -119,7 +123,7 @@ export function useMapSync({
|
|||||||
const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current;
|
const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current;
|
||||||
|
|
||||||
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
|
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
|
||||||
? filterDraftByBinding(renderFc, currentSelectedIds)
|
? filterDraftByBinding(renderFc, currentSelectedIds, null, isPreviewModeRef.current)
|
||||||
: renderFc;
|
: renderFc;
|
||||||
const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
|
const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
|
||||||
const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft);
|
const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft);
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export default function PublicWikiSidebar({
|
|||||||
maxDragWidth,
|
maxDragWidth,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tocContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const [localWidth, setLocalWidth] = useState<number>(() => {
|
const [localWidth, setLocalWidth] = useState<number>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -203,6 +204,7 @@ export default function PublicWikiSidebar({
|
|||||||
.filter((item): item is HTMLElement => Boolean(item));
|
.filter((item): item is HTMLElement => Boolean(item));
|
||||||
if (!headings.length) return;
|
if (!headings.length) return;
|
||||||
|
|
||||||
|
const scrollContainer = root.parentElement;
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const visible = entries
|
const visible = entries
|
||||||
@@ -211,13 +213,30 @@ export default function PublicWikiSidebar({
|
|||||||
const top = visible[0]?.target as HTMLElement | undefined;
|
const top = visible[0]?.target as HTMLElement | undefined;
|
||||||
if (top?.id) setActiveHeadingId(top.id);
|
if (top?.id) setActiveHeadingId(top.id);
|
||||||
},
|
},
|
||||||
{ root: null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] }
|
{ root: scrollContainer || null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] }
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const heading of headings) observer.observe(heading);
|
for (const heading of headings) observer.observe(heading);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [toc]);
|
}, [toc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = tocContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
if (e.deltaY !== 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
container.scrollLeft += e.deltaY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("wheel", handleWheel);
|
||||||
|
};
|
||||||
|
}, [toc]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = contentRootRef.current;
|
const root = contentRootRef.current;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -373,6 +392,7 @@ export default function PublicWikiSidebar({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={tocContainerRef}
|
||||||
className="uhm-public-wiki-toc-list"
|
className="uhm-public-wiki-toc-list"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -387,6 +407,24 @@ export default function PublicWikiSidebar({
|
|||||||
<a
|
<a
|
||||||
key={item.id}
|
key={item.id}
|
||||||
href={`#${item.id}`}
|
href={`#${item.id}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveHeadingId(item.id);
|
||||||
|
const root = contentRootRef.current;
|
||||||
|
if (root) {
|
||||||
|
const targetElement = root.querySelector(`#${CSS.escape(item.id)}`) as HTMLElement | null;
|
||||||
|
const scrollContainer = root.parentElement;
|
||||||
|
if (targetElement && scrollContainer) {
|
||||||
|
const containerTop = scrollContainer.getBoundingClientRect().top;
|
||||||
|
const targetTop = targetElement.getBoundingClientRect().top;
|
||||||
|
const scrollOffset = targetTop - containerTop + scrollContainer.scrollTop;
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: scrollOffset - 12,
|
||||||
|
behavior: "smooth"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRadius: 9999,
|
borderRadius: 9999,
|
||||||
|
|||||||
@@ -676,17 +676,21 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeWiki(w.id)}
|
onClick={() => removeWiki(w.id)}
|
||||||
style={{
|
style={{
|
||||||
border: "none",
|
display: "inline-flex",
|
||||||
background: "#111827",
|
alignItems: "center",
|
||||||
color: "#fca5a5",
|
justifyContent: "center",
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#0b1220",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
borderRadius: "6px",
|
flex: "0 0 auto",
|
||||||
padding: "6px 8px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
}}
|
||||||
title="Remove"
|
title="Xóa wiki khỏi dự án"
|
||||||
|
aria-label={`Xóa wiki ${w.id}`}
|
||||||
>
|
>
|
||||||
Del
|
<TrashIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1063,6 +1067,20 @@ function CloseIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TrashIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0 1 16.138 21H7.862a2 2 0 0 1-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16"
|
||||||
|
stroke="#f87171"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const QUILL_TOOLBAR = [
|
const QUILL_TOOLBAR = [
|
||||||
[{ header: [1, 2, 3, false] }],
|
[{ header: [1, 2, 3, false] }],
|
||||||
[{ align: [] }, { align: "center" }, { align: "right" }],
|
[{ align: [] }, { align: "center" }, { align: "right" }],
|
||||||
|
|||||||
@@ -482,12 +482,59 @@ export function buildEditorSnapshot(options: {
|
|||||||
// Persist inline entity records across commits even when they're not currently bound.
|
// Persist inline entity records across commits even when they're not currently bound.
|
||||||
// Without this, "create entity" can disappear on the next commit unless the entity is referenced
|
// Without this, "create entity" can disappear on the next commit unless the entity is referenced
|
||||||
// by geometry_entity/entity_wiki or pinned via projectEntityRefs.
|
// by geometry_entity/entity_wiki or pinned via projectEntityRefs.
|
||||||
|
const activeEntityIds = new Set<string>();
|
||||||
|
for (const row of options.snapshotEntityRows || []) {
|
||||||
|
if (!row) continue;
|
||||||
|
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
|
||||||
|
if (!id) continue;
|
||||||
|
const cloned = JSON.parse(JSON.stringify(row)) as EntitySnapshot;
|
||||||
|
const opRaw = sanitizeEntitySnapshotOperation((cloned as RawEntityRow).operation);
|
||||||
|
if (opRaw === "delete") {
|
||||||
|
entityRows.set(id, {
|
||||||
|
id,
|
||||||
|
source: cloned.source === "inline" ? "inline" : "ref",
|
||||||
|
operation: "delete",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
activeEntityIds.add(id);
|
||||||
|
const name =
|
||||||
|
typeof cloned?.name === "string" && cloned.name.trim().length
|
||||||
|
? cloned.name.trim()
|
||||||
|
: id;
|
||||||
|
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
|
||||||
|
const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw;
|
||||||
|
entityRows.set(id, {
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
name,
|
||||||
|
operation,
|
||||||
|
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
|
||||||
|
time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined,
|
||||||
|
time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist inline entity records across commits even when they're not currently bound.
|
||||||
|
// If they were present in previous snapshot but are no longer in snapshotEntityRows, emit as delete.
|
||||||
for (const prev of options.previousSnapshot?.entities || []) {
|
for (const prev of options.previousSnapshot?.entities || []) {
|
||||||
if (!prev) continue;
|
if (!prev) continue;
|
||||||
const id = typeof prev.id === "string" || typeof prev.id === "number" ? String(prev.id) : "";
|
const id = typeof prev.id === "string" || typeof prev.id === "number" ? String(prev.id) : "";
|
||||||
if (!id || entityRows.has(id)) continue;
|
if (!id) continue;
|
||||||
if (prev.operation === "delete") continue;
|
if (prev.operation === "delete") continue;
|
||||||
|
|
||||||
|
if (!activeEntityIds.has(id)) {
|
||||||
|
entityRows.set(id, {
|
||||||
|
id,
|
||||||
|
source: prev.source === "inline" ? "inline" : "ref",
|
||||||
|
operation: "delete",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityRows.has(id)) continue;
|
||||||
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;
|
||||||
delete cloned.operation;
|
delete cloned.operation;
|
||||||
@@ -501,31 +548,6 @@ export function buildEditorSnapshot(options: {
|
|||||||
time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined,
|
time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const row of options.snapshotEntityRows || []) {
|
|
||||||
if (!row) continue;
|
|
||||||
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
|
|
||||||
if (!id) continue;
|
|
||||||
const cloned = JSON.parse(JSON.stringify(row)) as EntitySnapshot;
|
|
||||||
const name =
|
|
||||||
typeof cloned?.name === "string" && cloned.name.trim().length
|
|
||||||
? cloned.name.trim()
|
|
||||||
: id;
|
|
||||||
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
|
|
||||||
const opRaw = sanitizeEntitySnapshotOperation((cloned as RawEntityRow).operation);
|
|
||||||
// Editor state should delete objects by removing them from the list.
|
|
||||||
// Keep this defensive guard to avoid emitting delete markers unexpectedly.
|
|
||||||
if (opRaw === "delete") continue;
|
|
||||||
const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw;
|
|
||||||
entityRows.set(id, {
|
|
||||||
id,
|
|
||||||
source,
|
|
||||||
name,
|
|
||||||
operation,
|
|
||||||
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
|
|
||||||
time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined,
|
|
||||||
time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entities referenced by wiki links should be present as "reference" too.
|
// Entities referenced by wiki links should be present as "reference" too.
|
||||||
for (const link of options.snapshotEntityWikiLinks || []) {
|
for (const link of options.snapshotEntityWikiLinks || []) {
|
||||||
|
|||||||
@@ -883,6 +883,112 @@ export function useEditorState(
|
|||||||
}
|
}
|
||||||
}, [pushMainUndo, snapshotUndo]);
|
}, [pushMainUndo, snapshotUndo]);
|
||||||
|
|
||||||
|
const deleteEntityAndRelations = useCallback((
|
||||||
|
entityId: string,
|
||||||
|
label = "Xóa entity"
|
||||||
|
) => {
|
||||||
|
if (!snapshotUndo) return;
|
||||||
|
const id = String(entityId || "").trim();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const prevEntities = snapshotUndo.snapshotEntityRowsRef.current || [];
|
||||||
|
const prevEntitiesClone = deepClone(prevEntities);
|
||||||
|
|
||||||
|
const prevWikiLinks = snapshotUndo.snapshotEntityWikiLinksRef.current || [];
|
||||||
|
const prevWikiLinksClone = deepClone(prevWikiLinks);
|
||||||
|
|
||||||
|
const prevFeatures = mainDraftRef.current.features;
|
||||||
|
|
||||||
|
// 1. Cập nhật snapshotEntityRows
|
||||||
|
const nextEntities = prevEntities.map((e) => {
|
||||||
|
if (String(e.id) !== id) return e;
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
operation: "delete" as const,
|
||||||
|
};
|
||||||
|
}).filter((e) => {
|
||||||
|
// Loại bỏ hoàn toàn nếu là inline & create chưa commit
|
||||||
|
return !(String(e.id) === id && e.source === "inline" && e.operation === "create");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Cập nhật snapshotEntityWikiLinks
|
||||||
|
const nextWikiLinks = prevWikiLinks.filter((link) => String(link.entity_id) !== id);
|
||||||
|
|
||||||
|
// 3. Cập nhật draft features
|
||||||
|
const nextFeatures = prevFeatures.map((feature) => {
|
||||||
|
const properties = feature.properties;
|
||||||
|
const entityIds: string[] = Array.isArray(properties.entity_ids)
|
||||||
|
? properties.entity_ids.map(String)
|
||||||
|
: properties.entity_id
|
||||||
|
? [String(properties.entity_id)]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (entityIds.includes(id)) {
|
||||||
|
const nextEntityIds = entityIds.filter((eid) => eid !== id);
|
||||||
|
return {
|
||||||
|
...feature,
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
entity_ids: nextEntityIds,
|
||||||
|
entity_id: nextEntityIds[0] || null,
|
||||||
|
entity_name: nextEntityIds[0] || null,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return feature;
|
||||||
|
});
|
||||||
|
|
||||||
|
const entitiesChanged = !jsonEquals(prevEntities, nextEntities);
|
||||||
|
const linksChanged = !jsonEquals(prevWikiLinks, nextWikiLinks);
|
||||||
|
const featuresChanged = !jsonEquals(prevFeatures, nextFeatures);
|
||||||
|
|
||||||
|
if (!entitiesChanged && !linksChanged && !featuresChanged) return;
|
||||||
|
|
||||||
|
const undoActions: UndoAction[] = [];
|
||||||
|
if (entitiesChanged) {
|
||||||
|
undoActions.push({ type: "snapshot_entities", label: "Cập nhật entities", prev: prevEntitiesClone });
|
||||||
|
}
|
||||||
|
if (linksChanged) {
|
||||||
|
undoActions.push({ type: "snapshot_entity_wiki", label: "Cập nhật entity-wiki", prev: prevWikiLinksClone });
|
||||||
|
}
|
||||||
|
if (featuresChanged) {
|
||||||
|
for (let i = 0; i < prevFeatures.length; i++) {
|
||||||
|
const prevF = prevFeatures[i];
|
||||||
|
const nextF = nextFeatures[i];
|
||||||
|
if (!jsonEquals(prevF.properties, nextF.properties)) {
|
||||||
|
undoActions.push({
|
||||||
|
type: "properties",
|
||||||
|
id: prevF.properties.id,
|
||||||
|
prevProperties: deepClone(prevF.properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushMainUndo(
|
||||||
|
undoActions.length === 1
|
||||||
|
? undoActions[0]
|
||||||
|
: { type: "group", label, actions: undoActions }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entitiesChanged) {
|
||||||
|
const nextEntitiesClone = deepClone(nextEntities);
|
||||||
|
snapshotUndo.snapshotEntityRowsRef.current = nextEntitiesClone;
|
||||||
|
snapshotUndo.setSnapshotEntityRows(nextEntitiesClone);
|
||||||
|
}
|
||||||
|
if (linksChanged) {
|
||||||
|
const nextWikiLinksClone = deepClone(nextWikiLinks);
|
||||||
|
snapshotUndo.snapshotEntityWikiLinksRef.current = nextWikiLinksClone;
|
||||||
|
snapshotUndo.setSnapshotEntityWikiLinks(nextWikiLinksClone);
|
||||||
|
}
|
||||||
|
if (featuresChanged) {
|
||||||
|
commitMainDraft({
|
||||||
|
...mainDraftRef.current,
|
||||||
|
features: nextFeatures,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [pushMainUndo, snapshotUndo, mainDraftRef, commitMainDraft]);
|
||||||
|
|
||||||
const undo = useCallback(() => {
|
const undo = useCallback(() => {
|
||||||
if (mode === "replay") {
|
if (mode === "replay") {
|
||||||
undoReplay();
|
undoReplay();
|
||||||
@@ -928,6 +1034,7 @@ export function useEditorState(
|
|||||||
setSnapshotWikis: setSnapshotWikisUndoable,
|
setSnapshotWikis: setSnapshotWikisUndoable,
|
||||||
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
|
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
|
||||||
setSnapshotWikisAndEntityWikiLinks: setSnapshotWikisAndEntityWikiLinksUndoable,
|
setSnapshotWikisAndEntityWikiLinks: setSnapshotWikisAndEntityWikiLinksUndoable,
|
||||||
|
deleteEntityAndRelations,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user