refactor: enhance wiki navigation, add view mode toggle, and improve map sync preview logic

(important global check)
This commit is contained in:
taDuc
2026-05-26 03:14:14 +07:00
parent 8306543828
commit 9d04076921
9 changed files with 570 additions and 69 deletions
+210 -6
View File
@@ -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)); ? {
return {
...activeDraft, ...activeDraft,
features: activeDraft.features.filter((feature) => isFeatureVisibleAtYear(feature, year)), 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 {
...filteredDraft,
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(() => {
const baseDraft = isAnyPreviewMode
? previewSession?.draft || EMPTY_FEATURE_COLLECTION ? previewSession?.draft || EMPTY_FEATURE_COLLECTION
: editor.draft; : 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
+44
View File
@@ -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,6 +370,7 @@ export default function ProjectEntityRefsPanel({
/> />
</div> </div>
<div style={{ display: "flex", gap: "8px" }}>
<button <button
type="button" type="button"
onClick={() => onUpdateEntity!(String(activeEntity.id), { onClick={() => onUpdateEntity!(String(activeEntity.id), {
@@ -356,6 +381,7 @@ export default function ProjectEntityRefsPanel({
})} })}
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={{ style={{
flex: 1,
border: "none", border: "none",
borderRadius: "6px", borderRadius: "6px",
padding: "7px 8px", padding: "7px 8px",
@@ -368,6 +394,29 @@ export default function ProjectEntityRefsPanel({
> >
Luu entity Luu entity
</button> </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>
);
}
+4 -3
View File
@@ -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;
} }
+5 -1
View File
@@ -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);
+39 -1
View File
@@ -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,
+26 -8
View File
@@ -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" }],
+48 -26
View File
@@ -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 || []) {
+107
View File
@@ -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,
}; };
} }