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
+44
View File
@@ -66,6 +66,8 @@ type MapProps = {
onEnterPreview?: () => void;
onExitPreview?: () => void;
onPlayPreviewReplay?: () => void;
viewMode?: "local" | "global";
onViewModeChange?: (mode: "local" | "global") => void;
};
const Map = forwardRef<MapHandle, MapProps>(function Map({
@@ -99,6 +101,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onEnterPreview,
onExitPreview,
onPlayPreviewReplay,
viewMode = "local",
onViewModeChange,
}, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode);
@@ -210,6 +214,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
allowGeometryEditing,
editingEngineRef,
geolocationCenteredRef,
isPreviewMode: isPreviewMode || mode === "preview" || mode === "replay" || mode === "replay_preview",
});
useEffect(() => {
@@ -373,6 +378,45 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
</span>
</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 ? (
<button
type="button"
@@ -14,6 +14,7 @@ type Props = {
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
onRerollEntityId?: (oldId: string, nextId: string) => void;
onDeleteEntity?: (entityId: string) => void;
};
export default function ProjectEntityRefsPanel({
@@ -23,6 +24,7 @@ export default function ProjectEntityRefsPanel({
selectedGeometryTime,
onToggleBindEntityForSelectedGeometry,
onRerollEntityId,
onDeleteEntity,
}: Props) {
const {
snapshotEntityRows,
@@ -234,6 +236,28 @@ export default function ProjectEntityRefsPanel({
)}
</button>
) : 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>
);
})}
@@ -346,28 +370,53 @@ export default function ProjectEntityRefsPanel({
/>
</div>
<button
type="button"
onClick={() => onUpdateEntity!(String(activeEntity.id), {
name: editName,
description: editDescription.trim().length ? editDescription : null,
time_start: editTimeStart,
time_end: editTimeEnd,
})}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Luu entity
</button>
<div style={{ display: "flex", gap: "8px" }}>
<button
type="button"
onClick={() => onUpdateEntity!(String(activeEntity.id), {
name: editName,
description: editDescription.trim().length ? editDescription : null,
time_start: editTimeStart,
time_end: editTimeEnd,
})}
disabled={isEntitySubmitting}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
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>
) : null}
@@ -610,3 +659,17 @@ function ClockIcon() {
</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(
fc: FeatureCollection,
selectedFeatureIds: (string | number)[],
highlightFeatures?: FeatureCollection | null
highlightFeatures?: FeatureCollection | null,
isPreviewMode?: boolean
): FeatureCollection {
const selectedIds = new Set(selectedFeatureIds.map(String));
if (highlightFeatures?.features) {
@@ -185,8 +186,8 @@ export function filterDraftByBinding(
const featureId = String(feature.properties.id);
const parentId = featureParentMap.get(featureId);
// 1. If this feature is a parent and its hierarchy is active, hide it
if (activeParents.has(featureId)) {
// 1. If this feature is a parent and its hierarchy is active, hide it (only in preview/replay modes)
if (isPreviewMode && activeParents.has(featureId)) {
return false;
}
+5 -1
View File
@@ -44,6 +44,7 @@ type UseMapSyncProps = {
clearEditing: () => void;
} | null>;
geolocationCenteredRef: React.MutableRefObject<boolean>;
isPreviewMode?: boolean;
};
export function useMapSync({
@@ -64,6 +65,7 @@ export function useMapSync({
allowGeometryEditing,
editingEngineRef,
geolocationCenteredRef,
isPreviewMode,
}: UseMapSyncProps) {
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
@@ -76,6 +78,7 @@ export function useMapSync({
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection);
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
const isPreviewModeRef = useRef(isPreviewMode);
const fitBoundsAppliedRef = useRef(false);
@@ -90,6 +93,7 @@ export function useMapSync({
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]);
useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]);
useEffect(() => { isPreviewModeRef.current = isPreviewMode; }, [isPreviewMode]);
useEffect(() => {
fitBoundsAppliedRef.current = false;
@@ -119,7 +123,7 @@ export function useMapSync({
const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current;
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
? filterDraftByBinding(renderFc, currentSelectedIds)
? filterDraftByBinding(renderFc, currentSelectedIds, null, isPreviewModeRef.current)
: renderFc;
const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft);
+39 -1
View File
@@ -134,6 +134,7 @@ export default function PublicWikiSidebar({
maxDragWidth,
}: Props) {
const contentRootRef = useRef<HTMLDivElement | null>(null);
const tocContainerRef = useRef<HTMLDivElement | null>(null);
const [localWidth, setLocalWidth] = useState<number>(() => {
if (typeof window !== "undefined") {
@@ -203,6 +204,7 @@ export default function PublicWikiSidebar({
.filter((item): item is HTMLElement => Boolean(item));
if (!headings.length) return;
const scrollContainer = root.parentElement;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
@@ -211,13 +213,30 @@ export default function PublicWikiSidebar({
const top = visible[0]?.target as HTMLElement | undefined;
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);
return () => observer.disconnect();
}, [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(() => {
const root = contentRootRef.current;
if (!root) return;
@@ -373,6 +392,7 @@ export default function PublicWikiSidebar({
}}
>
<div
ref={tocContainerRef}
className="uhm-public-wiki-toc-list"
style={{
display: "flex",
@@ -387,6 +407,24 @@ export default function PublicWikiSidebar({
<a
key={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={{
flexShrink: 0,
borderRadius: 9999,
+26 -8
View File
@@ -676,17 +676,21 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
type="button"
onClick={() => removeWiki(w.id)}
style={{
border: "none",
background: "#111827",
color: "#fca5a5",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
borderRadius: "6px",
padding: "6px 8px",
fontSize: "12px",
flex: "0 0 auto",
}}
title="Remove"
title="Xóa wiki khỏi dự án"
aria-label={`Xóa wiki ${w.id}`}
>
Del
<TrashIcon />
</button>
</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 = [
[{ header: [1, 2, 3, false] }],
[{ align: [] }, { align: "center" }, { align: "right" }],