use requestAnimationFrame for hover popup

This commit is contained in:
taDuc
2026-05-27 03:18:11 +07:00
parent 184abb25b4
commit 3d21d078cf
33 changed files with 2210 additions and 423 deletions
+4 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { memo, useState } from "react";
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
@@ -49,7 +49,7 @@ type Props = {
onRemoveImageOverlay: () => void;
};
export default function Editor({
function Editor({
mode,
setMode,
entityStatus,
@@ -190,3 +190,5 @@ export default function Editor({
</div>
);
}
export default memo(Editor);
+17
View File
@@ -12,6 +12,7 @@ import { setupMapLayers } from "./map/useMapLayers";
import { useMapInteraction } from "./map/useMapInteraction";
import { useMapSync } from "./map/useMapSync";
import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay";
import { useMapHoverPopup, type MapHoverPopupContent } from "./map/useMapHoverPopup";
export type MapFeaturePayload = {
featureId: string | number;
@@ -56,6 +57,9 @@ type MapProps = {
fitToDraftBounds?: boolean;
fitBoundsKey?: string | number | null;
onFeatureClick?: ((payload: MapFeaturePayload | null) => void) | undefined;
hoverPopupEnabled?: boolean;
getHoverPopupContent?: (feature: Feature) => MapHoverPopupContent | null;
allowFeatureSelection?: boolean;
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | import("maplibre-gl").PaddingOptions;
@@ -93,6 +97,9 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
fitToDraftBounds = false,
fitBoundsKey = null,
onFeatureClick,
hoverPopupEnabled = false,
getHoverPopupContent,
allowFeatureSelection = true,
focusFeatureCollection = null,
focusRequestKey = null,
focusPadding,
@@ -118,6 +125,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
const onSetModeRef = useRef(onSetMode);
// Ref callback click feature mới nhất cho tooltip/panel ngoài map.
const onFeatureClickRef = useRef<MapProps["onFeatureClick"]>(onFeatureClick);
const getHoverPopupContentRef = useRef<MapProps["getHoverPopupContent"]>(getHoverPopupContent);
// Ref callback create mới nhất khi drawing engine tạo feature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
// Ref callback add geometry global vào project mới nhất cho context menu select.
@@ -142,6 +150,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
useEffect(() => { onFeatureClickRef.current = onFeatureClick; }, [onFeatureClick]);
useEffect(() => { getHoverPopupContentRef.current = getHoverPopupContent; }, [getHoverPopupContent]);
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
useEffect(() => { onAddFeatureToProjectRef.current = onAddFeatureToProject; }, [onAddFeatureToProject]);
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
@@ -201,6 +210,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
onBindGeometriesRef,
localFeatureIdsRef,
onAddFeatureToProjectRef,
allowFeatureSelection,
});
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
@@ -229,6 +239,13 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
isPreviewMode: isPreviewMode || mode === "preview" || mode === "replay" || mode === "replay_preview",
});
useMapHoverPopup({
mapRef,
enabled: hoverPopupEnabled,
renderDraftRef,
getContentRef: getHoverPopupContentRef,
});
useEffect(() => {
const map = mapRef.current;
if (!map || !isMapLoaded) return;
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useState, memo } from "react";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import { useShallow } from "zustand/react/shallow";
@@ -28,7 +28,7 @@ function wikiTitle(w: WikiSnapshot): string {
return t.length ? t : "Untitled wiki";
}
export default function EntityWikiBindingsPanel({ setLinks }: Props) {
function EntityWikiBindingsPanel({ setLinks }: Props) {
const {
entityCatalog,
snapshotEntityRows,
@@ -476,3 +476,5 @@ function MinusIcon() {
</svg>
);
}
export default memo(EntityWikiBindingsPanel);
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
import { useMemo, useState, memo, type CSSProperties, type KeyboardEvent } from "react";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
@@ -34,7 +34,7 @@ type Props = {
onFocusGeometry?: (geometryId: string) => void;
};
export default function GeometryBindingPanel({
function GeometryBindingPanel({
geometries,
selectedGeometryId,
selectedGeometryChildIds,
@@ -686,3 +686,5 @@ function MinusIcon() {
</svg>
);
}
export default memo(GeometryBindingPanel);
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState, type CSSProperties } from "react";
import { useMemo, useState, memo, type CSSProperties } from "react";
import type { EntitySnapshot } from "@/uhm/types/entities";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
@@ -17,7 +17,7 @@ type Props = {
onDeleteEntity?: (entityId: string) => void;
};
export default function ProjectEntityRefsPanel({
function ProjectEntityRefsPanel({
onCreateEntityOnly,
onUpdateEntity,
hasSelectedGeometry,
@@ -673,3 +673,5 @@ function TrashIcon() {
</svg>
);
}
export default memo(ProjectEntityRefsPanel);
@@ -10,6 +10,7 @@ type Props = {
dialog: DialogState | null;
toasts: ReplayPreviewToast[];
sidebarOpen: boolean;
sidebarWidth?: number;
playbackSpeed: number;
activeStepLabel: string | null;
activeStepNumber: number | null;
@@ -26,6 +27,7 @@ export default function ReplayPreviewOverlay({
dialog,
toasts,
sidebarOpen,
sidebarWidth = 420,
playbackSpeed,
activeStepLabel,
activeStepNumber,
@@ -36,6 +38,7 @@ export default function ReplayPreviewOverlay({
onExitPreview,
}: Props) {
const hasWikiPreview = sidebarOpen;
const rightOffset = hasWikiPreview ? sidebarWidth + 32 : 18;
const shouldRender =
isPreviewMode ||
isPlaying ||
@@ -60,7 +63,7 @@ export default function ReplayPreviewOverlay({
style={{
position: "absolute",
top: 72,
right: hasWikiPreview ? 454 : 18,
right: rightOffset,
display: "grid",
gap: 8,
width: 280,
@@ -90,9 +93,9 @@ export default function ReplayPreviewOverlay({
<div
style={{
position: "absolute",
right: hasWikiPreview ? 472 : 18,
left: 18,
right: rightOffset,
bottom: 96,
width: 380,
borderRadius: 20,
overflow: "hidden",
border: "1px solid rgba(255, 255, 255, 0.1)",
@@ -102,6 +105,7 @@ export default function ReplayPreviewOverlay({
pointerEvents: "auto",
display: "flex",
flexDirection: "column",
maxHeight: "calc(100vh - 180px)",
}}
>
{dialog.image_url?.trim() ? (
@@ -111,7 +115,7 @@ export default function ReplayPreviewOverlay({
style={{
width: "100%",
display: "block",
maxHeight: 220,
maxHeight: 140,
objectFit: "cover",
background: "#020617",
}}
@@ -119,14 +123,15 @@ export default function ReplayPreviewOverlay({
) : null}
{dialog.text?.trim() ? (
<div
className="ql-editor"
className="uhm-replay-dialog-content"
style={{
padding: "16px",
color: "#f8fafc",
fontSize: "14px",
lineHeight: "1.6",
overflowY: "auto",
maxHeight: "250px",
maxHeight: dialog.image_url?.trim() ? "180px" : "140px",
minHeight: 0,
background: "transparent",
}}
dangerouslySetInnerHTML={{ __html: dialog.text }}
@@ -134,6 +139,19 @@ export default function ReplayPreviewOverlay({
) : null}
</div>
) : null}
<style jsx>{`
.uhm-replay-dialog-content :global(p) {
margin: 0;
}
.uhm-replay-dialog-content :global(p + p) {
margin-top: 6px;
}
.uhm-replay-dialog-content :global(ul),
.uhm-replay-dialog-content :global(ol) {
margin: 0;
padding-left: 20px;
}
`}</style>
{isPreviewMode ? (
<div
@@ -1,6 +1,6 @@
"use client";
import { type CSSProperties, useMemo, useState } from "react";
import { type CSSProperties, memo, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
import {
@@ -23,7 +23,7 @@ type Props = {
onRerollGeometryId?: (oldId: string | number) => void;
};
export default function SelectedGeometryPanel({
function SelectedGeometryPanel({
selectedFeatures,
onApplyGeometryMetadata,
changeCount,
@@ -466,3 +466,5 @@ function getAllowedGroupIdsForPreset(
return ["polygon"];
}
export default memo(SelectedGeometryPanel);
+48 -4
View File
@@ -25,6 +25,16 @@ type FeatureLabelInfo = {
};
const rasterBaseVisibilityGenerationByMap = new WeakMap<maplibregl.Map, number>();
const resolverCache = new WeakMap<
FeatureCollection,
Map<number | null | undefined, (feature: Feature) => string | null>
>();
const featureLabelInfoCache = new WeakMap<
Feature,
Map<number | null | undefined, FeatureLabelInfo | null>
>();
export function applyBackgroundLayerVisibility(
map: maplibregl.Map,
visibility: BackgroundLayerVisibility
@@ -265,7 +275,7 @@ export function decoratePointFeaturesWithLabels(
labelContext: FeatureCollection = fc,
timelineYear?: number | null
): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
const getLabel = getFeatureLabelResolver(labelContext, timelineYear);
let changed = false;
const nextFeatures = fc.features.map((feature) => {
const point_label = getLabel(feature);
@@ -289,7 +299,7 @@ export function decorateLineFeaturesWithLabels(
labelContext: FeatureCollection = fc,
timelineYear?: number | null
): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
const getLabel = getFeatureLabelResolver(labelContext, timelineYear);
let changed = false;
const nextFeatures = fc.features.map((feature) => {
const line_label = isLineGeometry(feature.geometry) ? getLabel(feature) : null;
@@ -315,7 +325,7 @@ export function buildPolygonLabelFeatureCollection(
labelContext: FeatureCollection = fc,
timelineYear?: number | null
): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
const getLabel = getFeatureLabelResolver(labelContext, timelineYear);
const features: Feature[] = [];
for (const feature of fc.features) {
@@ -762,6 +772,40 @@ export function roundZoom(value: number): number {
return Math.round(value * 10) / 10;
}
export function getFeatureLabelResolver(
fc: FeatureCollection,
timelineYear?: number | null
): (feature: Feature) => string | null {
let yearMap = resolverCache.get(fc);
if (!yearMap) {
yearMap = new Map();
resolverCache.set(fc, yearMap);
}
let resolver = yearMap.get(timelineYear);
if (!resolver) {
resolver = createFeatureLabelResolver(fc, timelineYear);
yearMap.set(timelineYear, resolver);
}
return resolver;
}
function getSingleEntityFeatureLabelInfoCached(
feature: Feature,
timelineYear?: number | null
): FeatureLabelInfo | null {
let yearMap = featureLabelInfoCache.get(feature);
if (!yearMap) {
yearMap = new Map();
featureLabelInfoCache.set(feature, yearMap);
}
let info = yearMap.get(timelineYear);
if (info === undefined) {
info = getSingleEntityFeatureLabelInfo(feature, timelineYear);
yearMap.set(timelineYear, info);
}
return info;
}
function createFeatureLabelResolver(
fc: FeatureCollection,
timelineYear?: number | null
@@ -770,7 +814,7 @@ function createFeatureLabelResolver(
const inheritedLabelsByChildId = new Map<string, FeatureLabelInfo | null>();
for (const feature of fc.features) {
const labelInfo = getSingleEntityFeatureLabelInfo(feature, timelineYear);
const labelInfo = getSingleEntityFeatureLabelInfoCached(feature, timelineYear);
if (!labelInfo) continue;
directLabelsByFeatureId.set(String(feature.properties.id), labelInfo);
}
+246
View File
@@ -0,0 +1,246 @@
import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import type { Feature, FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
import { FEATURE_STATE_SOURCE_IDS } from "@/uhm/lib/map/constants";
export type MapHoverPopupContent = {
rows: Array<{
title: string;
quote?: string | null;
}>;
};
type UseMapHoverPopupProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
enabled: boolean;
renderDraftRef: React.MutableRefObject<FeatureCollection>;
getContentRef: React.MutableRefObject<((feature: Feature) => MapHoverPopupContent | null) | undefined>;
};
export function useMapHoverPopup({
mapRef,
enabled,
renderDraftRef,
getContentRef,
}: UseMapHoverPopupProps) {
const enabledRef = useRef(enabled);
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
offset: 12,
className: "uhm-map-hover-popup",
});
let hoveredId: string | null = null;
let frameId: number | null = null;
let pendingEvent: maplibregl.MapMouseEvent | null = null;
const removePopup = () => {
hoveredId = null;
popup.remove();
};
const updatePopup = () => {
frameId = null;
const event = pendingEvent;
pendingEvent = null;
if (!event || !enabledRef.current) {
removePopup();
return;
}
const layerIds = getHoverLayerIds(map);
if (!layerIds.length) {
removePopup();
return;
}
const features = map.queryRenderedFeatures(event.point, { layers: layerIds }) as maplibregl.MapGeoJSONFeature[];
if (!features.length) {
removePopup();
return;
}
const renderedFeature = pickPreferredFeature(features);
const rawId = renderedFeature.id ?? renderedFeature.properties?.id;
if (rawId === undefined || rawId === null) {
removePopup();
return;
}
const id = String(rawId);
const sourceFeature = renderDraftRef.current.features.find((item) => String(item.properties.id) === id);
if (!sourceFeature) {
removePopup();
return;
}
const content = getContentRef.current?.(sourceFeature) || null;
if (!content?.rows?.some((row) => row.title.trim())) {
removePopup();
return;
}
if (id !== hoveredId) {
hoveredId = id;
popup.setDOMContent(buildPopupNode(content));
}
popup.setLngLat(event.lngLat).addTo(map);
stylePopupChrome(popup);
};
const onMouseMove = (event: maplibregl.MapMouseEvent) => {
pendingEvent = event;
if (frameId !== null) return;
frameId = window.requestAnimationFrame(updatePopup);
};
const onMouseOut = () => {
pendingEvent = null;
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
frameId = null;
}
removePopup();
};
map.on("mousemove", onMouseMove);
map.on("mouseout", onMouseOut);
map.on("dragstart", removePopup);
map.on("zoomstart", removePopup);
return () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
map.off("mousemove", onMouseMove);
map.off("mouseout", onMouseOut);
map.off("dragstart", removePopup);
map.off("zoomstart", removePopup);
popup.remove();
};
}, [getContentRef, mapRef, renderDraftRef]);
}
function getHoverLayerIds(map: maplibregl.Map): string[] {
const style = map.getStyle();
if (!style?.layers) return [];
return style.layers
.filter((layer) =>
"source" in layer &&
typeof layer.source === "string" &&
FEATURE_STATE_SOURCE_IDS.includes(layer.source as (typeof FEATURE_STATE_SOURCE_IDS)[number])
)
.map((layer) => layer.id);
}
function pickPreferredFeature(features: maplibregl.MapGeoJSONFeature[]) {
return [...features].sort((a, b) => featureSelectPriority(b) - featureSelectPriority(a))[0];
}
function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) {
const layerId = typeof feature.layer?.id === "string" ? feature.layer.id : "";
const geometryType = feature.geometry?.type;
const source = typeof feature.source === "string" ? feature.source : "";
if (layerId.endsWith("-hit")) return 400;
if (source === "path-arrow-shapes") return 300;
if (geometryType === "LineString" || geometryType === "MultiLineString") return 200;
if (geometryType === "Point" || geometryType === "MultiPoint") return 100;
return 0;
}
function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
const root = document.createElement("div");
root.style.width = "320px";
root.style.maxWidth = "calc(100vw - 2rem)";
root.style.maxHeight = "300px";
root.style.overflowY = "auto";
root.style.padding = "12px";
root.style.border = "1px solid rgba(255, 255, 255, 0.10)";
root.style.borderRadius = "12px";
root.style.background = "rgba(2, 6, 23, 0.95)";
root.style.boxShadow = "0 18px 36px rgba(0, 0, 0, 0.35)";
root.style.backdropFilter = "blur(8px)";
root.style.color = "#e2e8f0";
const grid = document.createElement("div");
grid.style.display = "grid";
grid.style.gap = "8px";
root.appendChild(grid);
for (const row of content.rows) {
const titleText = row.title.trim();
if (!titleText) continue;
const card = document.createElement("div");
card.style.width = "100%";
card.style.border = "1px solid rgba(255, 255, 255, 0.10)";
card.style.borderRadius = "8px";
card.style.background = "rgba(255, 255, 255, 0.03)";
card.style.padding = "12px";
card.style.textAlign = "left";
const title = document.createElement("div");
title.textContent = titleText;
title.style.fontSize = "14px";
title.style.fontWeight = "700";
title.style.lineHeight = "20px";
title.style.color = "#ffffff";
title.style.overflow = "hidden";
title.style.textOverflow = "ellipsis";
title.style.whiteSpace = "nowrap";
card.appendChild(title);
const quoteText = row.quote?.trim();
if (quoteText) {
const quote = document.createElement("div");
quote.textContent = quoteText;
quote.style.marginTop = "8px";
quote.style.paddingLeft = "10px";
quote.style.paddingRight = "4px";
quote.style.borderLeft = "3px solid rgba(56, 189, 248, 0.40)";
quote.style.fontSize = "14px";
quote.style.fontStyle = "italic";
quote.style.lineHeight = "20px";
quote.style.color = "#cbd5e1";
quote.style.display = "-webkit-box";
quote.style.webkitLineClamp = "4";
quote.style.webkitBoxOrient = "vertical";
quote.style.overflow = "hidden";
quote.style.whiteSpace = "normal";
card.appendChild(quote);
}
grid.appendChild(card);
}
return root;
}
function stylePopupChrome(popup: maplibregl.Popup) {
const element = popup.getElement();
const content = element.querySelector(".maplibregl-popup-content") as HTMLElement | null;
if (content) {
content.style.padding = "0";
content.style.borderRadius = "12px";
content.style.background = "transparent";
content.style.boxShadow = "none";
}
for (const tip of Array.from(element.querySelectorAll(".maplibregl-popup-tip")) as HTMLElement[]) {
tip.style.display = "none";
}
}
+4 -1
View File
@@ -38,6 +38,7 @@ type UseMapInteractionProps = {
onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>;
localFeatureIdsRef?: React.MutableRefObject<(string | number)[] | undefined>;
onAddFeatureToProjectRef?: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
allowFeatureSelection?: boolean;
};
export function useMapInteraction({
@@ -57,6 +58,7 @@ export function useMapInteraction({
onBindGeometriesRef,
localFeatureIdsRef,
onAddFeatureToProjectRef,
allowFeatureSelection = true,
}: UseMapInteractionProps) {
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
@@ -223,7 +225,8 @@ export function useMapInteraction({
if (!Array.isArray(localIds)) return true;
return localIds.some((localId) => String(localId) === String(id));
}
: undefined
: undefined,
() => allowFeatureSelection
);
const cleanupPoint = initPoint(
+355 -21
View File
@@ -54,23 +54,27 @@ export default function TimelineBar({
const lastTriggerTimeRef = useRef<number>(0);
const commitYearChange = useCallback((nextVal: number) => {
if (nextVal === lastTriggeredYearRef.current) return;
lastTriggeredYearRef.current = nextVal;
const clamped = clampYearValue(Math.trunc(nextVal), lower, upper);
if (!Number.isFinite(clamped)) return;
lastTriggeredYearRef.current = clamped;
lastTriggerTimeRef.current = Date.now();
onYearChangeRef.current(nextVal);
onYearChangeRef.current(clamped);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
}, []);
}, [lower, upper]);
const handleLocalYearChange = useCallback((nextVal: number) => {
if (!Number.isFinite(nextVal)) {
return;
}
const clamped = clampYearValue(Math.trunc(nextVal), lower, upper);
localYearRef.current = clamped;
setLocalYear(clamped);
const now = Date.now();
if (now - lastTriggerTimeRef.current >= 1000) {
if (now - lastTriggerTimeRef.current >= 100) {
commitYearChange(clamped);
} else {
if (debounceTimerRef.current) {
@@ -78,7 +82,7 @@ export default function TimelineBar({
}
debounceTimerRef.current = setTimeout(() => {
commitYearChange(clamped);
}, 1000);
}, 100);
}
}, [lower, upper, commitYearChange]);
@@ -87,6 +91,12 @@ export default function TimelineBar({
setLocalYear(null);
}, [commitYearChange]);
useEffect(() => {
if (localYear !== null) return;
localYearRef.current = safeYear;
lastTriggeredYearRef.current = safeYear;
}, [localYear, safeYear]);
const startChangingYear = (direction: number) => {
if (effectiveDisabled) return;
const nextVal = localYearRef.current + direction;
@@ -163,23 +173,14 @@ export default function TimelineBar({
</span>
</button>
) : null}
<span className={styles.labelBounds}>{formatYear(lower)}</span>
<input
type="range"
min={lower}
max={upper}
step={1}
value={displayYear}
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
onMouseUp={finishLocalYearChange}
onTouchEnd={finishLocalYearChange}
<CanvasTimelineRuler
year={displayYear}
onYearChange={handleLocalYearChange}
onYearCommit={finishLocalYearChange}
minYear={lower}
maxYear={upper}
disabled={effectiveDisabled}
className={styles.slider}
aria-label="Timeline year"
/>
<span className={styles.labelBoundsRight}>
{formatYear(upper)}
</span>
<div className={styles.numberWrapper}>
<input
type="number"
@@ -259,3 +260,336 @@ function formatYear(year: number): string {
}
return `${year}`;
}
interface CanvasRulerProps {
year: number;
onYearChange: (year: number) => void;
onYearCommit: () => void;
minYear: number;
maxYear: number;
disabled?: boolean;
}
function CanvasTimelineRuler({
year,
onYearChange,
onYearCommit,
minYear,
maxYear,
disabled = false,
}: CanvasRulerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Visible span (in years)
const [span, setSpan] = useState(400); // default show 400 years
// Dimensions
const [dimensions, setDimensions] = useState({ width: 0, height: 48 });
// Internal tracker for current display year to decouple render lag
const displayYearRef = useRef(year);
// Dragging state
const dragRef = useRef<{
isDragging: boolean;
startX: number;
startYear: number;
hasDragged: boolean;
} | null>(null);
// Sync dimensions using ResizeObserver
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
if (!entries || !entries[0]) return;
const { width, height } = entries[0].contentRect;
setDimensions({ width, height: height || 48 });
});
observer.observe(container);
return () => observer.disconnect();
}, []);
// Draw the ruler on canvas
const drawYear = useCallback((currentYear: number) => {
const canvas = canvasRef.current;
if (!canvas || dimensions.width === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const width = dimensions.width;
const height = dimensions.height;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.scale(dpr, dpr);
// Center year is the selected year
const startYear = currentYear - span / 2;
const endYear = currentYear + span / 2;
const yearToX = (y: number) => {
return ((y - startYear) / span) * width;
};
// Determine tick step based on span
let majorStep = 100;
let mediumStep = 10;
let minorStep = 1;
if (span > 3000) {
majorStep = 1000;
mediumStep = 100;
minorStep = 10;
} else if (span > 1500) {
majorStep = 500;
mediumStep = 50;
minorStep = 10;
} else if (span > 600) {
majorStep = 100;
mediumStep = 20;
minorStep = 5;
} else if (span > 200) {
majorStep = 100;
mediumStep = 10;
minorStep = 1;
} else if (span > 60) {
majorStep = 50;
mediumStep = 10;
minorStep = 1;
} else {
majorStep = 10;
mediumStep = 5;
minorStep = 1;
}
// Ticks drawing bounds
const firstMajor = Math.floor(startYear / majorStep) * majorStep;
const lastMajor = Math.ceil(endYear / majorStep) * majorStep;
const pixelsPerYear = width / span;
const showMinor = pixelsPerYear * minorStep >= 3;
const showMedium = pixelsPerYear * mediumStep >= 5;
// Draw ruler track baseline
ctx.beginPath();
ctx.moveTo(0, height - 8);
ctx.lineTo(width, height - 8);
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
ctx.lineWidth = 1;
ctx.stroke();
// 1. Draw minor & medium ticks
ctx.beginPath();
for (let y = Math.floor(startYear); y <= Math.ceil(endYear); y++) {
if (y < minYear || y > maxYear) continue;
const isMajor = y % majorStep === 0;
const isMedium = y % mediumStep === 0;
const isMinor = y % minorStep === 0;
if (isMajor) continue;
let tickHeight = 0;
if (isMedium && showMedium) {
tickHeight = 7;
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
} else if (isMinor && showMinor) {
tickHeight = 4;
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
}
if (tickHeight > 0) {
const x = yearToX(y);
ctx.moveTo(x, height - 8);
ctx.lineTo(x, height - 8 - tickHeight);
}
}
ctx.lineWidth = 1;
ctx.stroke();
// 2. Draw major ticks and labels
ctx.fillStyle = "rgba(255, 255, 255, 0.75)";
ctx.font = "600 10px system-ui, -apple-system, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (let y = firstMajor; y <= lastMajor; y += majorStep) {
if (y < minYear || y > maxYear) continue;
const x = yearToX(y);
// Draw tick line
ctx.beginPath();
ctx.moveTo(x, height - 8);
ctx.lineTo(x, height - 20);
ctx.strokeStyle = "rgba(255, 255, 255, 0.65)";
ctx.lineWidth = 1.25;
ctx.stroke();
// Draw label
const label = formatYear(y);
ctx.fillText(label, x, height - 33);
}
// 3. Draw needle indicator in the center
const needleX = width / 2;
ctx.beginPath();
ctx.moveTo(needleX, 0);
ctx.lineTo(needleX, height - 4);
ctx.strokeStyle = "#10b981";
ctx.lineWidth = 2;
ctx.shadowColor = "rgba(16, 185, 129, 0.6)";
ctx.shadowBlur = 6;
ctx.stroke();
// Draw needle head triangle
ctx.fillStyle = "#10b981";
ctx.beginPath();
ctx.moveTo(needleX - 5, 0);
ctx.lineTo(needleX + 5, 0);
ctx.lineTo(needleX, 6);
ctx.closePath();
ctx.fill();
ctx.restore();
}, [span, dimensions, minYear, maxYear]);
// Redraw when dimensions change
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || dimensions.width === 0) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = dimensions.width * dpr;
canvas.height = dimensions.height * dpr;
drawYear(displayYearRef.current);
}, [dimensions, drawYear]);
// Redraw when span changes
useEffect(() => {
drawYear(displayYearRef.current);
}, [span, drawYear]);
// Sync externally changed year
useEffect(() => {
if (!dragRef.current || !dragRef.current.isDragging) {
displayYearRef.current = year;
drawYear(year);
}
}, [year, drawYear]);
const handleWheel = (e: React.WheelEvent) => {
if (disabled) return;
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 1.15 : 0.85;
const nextSpan = Math.max(10, Math.min(10000, span * zoomFactor));
setSpan(Math.round(nextSpan));
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
e.preventDefault();
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {}
dragRef.current = {
isDragging: true,
startX: e.clientX,
startYear: displayYearRef.current,
hasDragged: false,
};
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!dragRef.current || !dragRef.current.isDragging) return;
e.preventDefault();
const dx = e.clientX - dragRef.current.startX;
if (Math.abs(dx) > 3) {
dragRef.current.hasDragged = true;
}
const yearsPerPixel = span / dimensions.width;
const deltaYears = -dx * yearsPerPixel;
const nextYear = clampYearValue(Math.round(dragRef.current.startYear + deltaYears), minYear, maxYear);
if (nextYear !== displayYearRef.current) {
displayYearRef.current = nextYear;
// Draw synchronously at 60fps
requestAnimationFrame(() => {
drawYear(displayYearRef.current);
});
onYearChange(nextYear);
}
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!dragRef.current) return;
e.preventDefault();
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
const dragInfo = dragRef.current;
dragRef.current = null;
if (!dragInfo.hasDragged) {
// Click to jump
const canvas = canvasRef.current;
if (canvas) {
const rect = canvas.getBoundingClientRect();
const clickedX = e.clientX - rect.left;
const centerYear = displayYearRef.current;
const startYear = centerYear - span / 2;
const clickedYear = clampYearValue(
Math.round(startYear + (clickedX / rect.width) * span),
minYear,
maxYear
);
displayYearRef.current = clickedYear;
drawYear(clickedYear);
onYearChange(clickedYear);
}
}
onYearCommit();
};
return (
<div
ref={containerRef}
style={{
flex: 1,
height: 44,
position: "relative",
background: "rgba(255, 255, 255, 0.04)",
borderRadius: 22,
border: "1px solid rgba(255, 255, 255, 0.08)",
overflow: "hidden",
cursor: disabled ? "not-allowed" : "ew-resize",
}}
onWheel={handleWheel}
>
<canvas
ref={canvasRef}
style={{
display: "block",
width: "100%",
height: "100%",
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
</div>
);
}
+22 -15
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, memo } from "react";
import "react-quill-new/dist/quill.snow.css";
import type { Entity } from "@/uhm/api/entities";
@@ -22,6 +22,7 @@ type Props = {
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxDragWidth?: number;
compactHeader?: boolean;
};
function escapeHtml(input: string): string {
@@ -50,6 +51,7 @@ function slugifyHeading(raw: string): string {
if (!input.length) return "";
return input
.toLowerCase()
.replace(/đ/g, "d")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
@@ -122,7 +124,7 @@ function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
return { html: doc.body.innerHTML, toc };
}
export default function PublicWikiSidebar({
function PublicWikiSidebar({
entity,
wiki,
isLoading,
@@ -132,6 +134,7 @@ export default function PublicWikiSidebar({
sidebarWidth,
onSidebarWidthChange,
maxDragWidth,
compactHeader = false,
}: Props) {
const contentRootRef = useRef<HTMLDivElement | null>(null);
const tocContainerRef = useRef<HTMLDivElement | null>(null);
@@ -311,20 +314,22 @@ export default function PublicWikiSidebar({
>
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 12 }}>
<div style={{ minWidth: 0, flex: 1 }}>
{compactHeader ? null : (
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Wiki
</div>
)}
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Wiki
</div>
<div
style={{
marginTop: 4,
marginTop: compactHeader ? 0 : 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
@@ -345,7 +350,7 @@ export default function PublicWikiSidebar({
{entity.description.trim()}
</div>
) : null}
{wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
{!compactHeader && wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
<div
style={{
marginTop: 6,
@@ -638,3 +643,5 @@ export default function PublicWikiSidebar({
</div>
);
}
export default memo(PublicWikiSidebar);
+130 -128
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, memo, type ComponentProps } from "react";
import dynamic from "next/dynamic";
import "react-quill-new/dist/quill.snow.css";
import { useShallow } from "zustand/react/shallow";
@@ -39,7 +39,7 @@ type QuillLinkFormat = {
__uhmOriginalSanitize?: unknown;
};
type QuillImageFormatCtor = {
new (): {
new(): {
domNode: Element;
format(name: string, value: string): void;
};
@@ -64,7 +64,7 @@ function clampTitle(title: string) {
return t.length ? t.slice(0, 120) : "Untitled wiki";
}
export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }: Props) {
const { wikis, requestedActiveId } = useEditorStore(
useShallow((state) => ({
wikis: state.snapshotWikis,
@@ -95,7 +95,7 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
activeWikiId: string | null;
existingHref: string | null;
} | null>(null);
const wikiLinkHandlerRef = useRef<(quill: QuillLike | null | undefined) => void>(() => {});
const wikiLinkHandlerRef = useRef<(quill: QuillLike | null | undefined) => void>(() => { });
const [isWikiLinkOpen, setIsWikiLinkOpen] = useState(false);
const [wikiLinkQuery, setWikiLinkQuery] = useState("");
const [wikiLinkError, setWikiLinkError] = useState<string | null>(null);
@@ -285,17 +285,17 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
setWikiSaveError(null);
setWikis((prev) =>
prev.map((w) =>
w.id !== activeId
? w
: {
...w,
source: w.source,
operation: w.operation === "create" ? "create" : "update",
title: nextTitle,
slug: nextSlug,
doc: payload,
}
prev.map((w) =>
w.id !== activeId
? w
: {
...w,
source: w.source,
operation: w.operation === "create" ? "create" : "update",
title: nextTitle,
slug: nextSlug,
doc: payload,
}
)
);
setOpen(false);
@@ -703,122 +703,122 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
)}
{collapsed ? null : (
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo wiki mới
</div>
<button
type="button"
onClick={() =>
setIsCreateOpen((v) => {
const next = !v;
if (next) {
setCreateError(null);
setIsCheckingCreateSlug(false);
setCreateSlugTouched(false);
}
return next;
})
}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
</div>
{isCreateOpen ? (
<>
<input
value={createTitle}
onChange={(e) => {
const nextTitle = e.target.value;
setCreateTitle(nextTitle);
setCreateError(null);
if (!createSlugTouched) {
setCreateSlug(slugifyWikiTitle(nextTitle));
}
}}
placeholder="Tieu de wiki"
disabled={isCheckingCreateSlug}
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<input
value={createSlug}
onChange={(e) => {
setCreateSlugTouched(true);
setCreateSlug(e.target.value);
setCreateError(null);
}}
placeholder="Slug"
disabled={isCheckingCreateSlug}
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo wiki mới
</div>
<button
type="button"
onClick={handleCreateWikiFromPanel}
disabled={isCheckingCreateSlug}
onClick={() =>
setIsCreateOpen((v) => {
const next = !v;
if (next) {
setCreateError(null);
setIsCheckingCreateSlug(false);
setCreateSlugTouched(false);
}
return next;
})
}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 600,
opacity: isCheckingCreateSlug ? 0.7 : 1,
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
Tạo wiki mới
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
{createError ? (
<div style={{ color: "#fca5a5", fontSize: 12 }}>
{createError}
</div>
) : null}
</>
) : null}
</div>
</div>
{isCreateOpen ? (
<>
<input
value={createTitle}
onChange={(e) => {
const nextTitle = e.target.value;
setCreateTitle(nextTitle);
setCreateError(null);
if (!createSlugTouched) {
setCreateSlug(slugifyWikiTitle(nextTitle));
}
}}
placeholder="Tieu de wiki"
disabled={isCheckingCreateSlug}
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<input
value={createSlug}
onChange={(e) => {
setCreateSlugTouched(true);
setCreateSlug(e.target.value);
setCreateError(null);
}}
placeholder="Slug"
disabled={isCheckingCreateSlug}
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<button
type="button"
onClick={handleCreateWikiFromPanel}
disabled={isCheckingCreateSlug}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 600,
opacity: isCheckingCreateSlug ? 0.7 : 1,
}}
>
Tạo wiki mới
</button>
{createError ? (
<div style={{ color: "#fca5a5", fontSize: 12 }}>
{createError}
</div>
) : null}
</>
) : null}
</div>
)}
<Modal
@@ -1001,11 +1001,10 @@ export default function WikiSidebarPanel({ projectId, setWikis, onRemoveWiki }:
</div>
</div>
<span
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${
w.source === "local"
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${w.source === "local"
? "border-emerald-300/60 text-emerald-600 dark:text-emerald-300"
: "border-blue-300/60 text-blue-600 dark:text-blue-300"
}`}
}`}
>
{w.source}
</span>
@@ -1110,6 +1109,7 @@ function slugifyWikiTitle(raw: string): string {
if (!input.length) return "";
return input
.toLowerCase()
.replace(/đ/g, "d")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
@@ -1149,3 +1149,5 @@ function downloadTextFile(filename: string, contents: string, mime: string): voi
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
export default memo(WikiSidebarPanel);
+10 -1
View File
@@ -26,6 +26,15 @@ export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean
return true;
}
function getFastHash(str: string | null | undefined): number {
if (!str) return 0;
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return hash >>> 0;
}
// Chuẩn hóa wiki snapshot để so sánh dirty-state ổn định, không phụ thuộc thứ tự mảng.
export function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
@@ -43,7 +52,7 @@ export function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefine
source: w.source,
title: typeof w.title === "string" ? w.title.trim() : "",
slug: typeof w.slug === "string" ? w.slug : null,
doc: w.doc === null ? null : typeof w.doc === "string" ? w.doc.trim() : null,
docHash: typeof w.doc === "string" ? getFastHash(w.doc.trim()) : 0,
}))
.sort((a, b) => a.id.localeCompare(b.id));
}
+18 -4
View File
@@ -21,7 +21,8 @@ export function initSelect(
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void,
onFeatureClick?: (payload: SelectFeatureClickPayload | null) => void,
onAddToProject?: (feature: maplibregl.MapGeoJSONFeature) => void,
isLocalFeature?: (id: string | number) => boolean
isLocalFeature?: (id: string | number) => boolean,
allowFeatureSelection?: () => boolean
) {
const FEATURE_STATE_SOURCES = [
@@ -85,21 +86,34 @@ export function initSelect(
}) as maplibregl.MapGeoJSONFeature[];
if (!features.length) {
if (allowFeatureSelection && !allowFeatureSelection()) {
onFeatureClick?.(null);
return;
}
clearSelection();
onFeatureClick?.(null);
return;
}
const additive = !!e.originalEvent?.altKey;
const feature = pickPreferredFeature(features);
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
if (allowFeatureSelection && !allowFeatureSelection()) {
onFeatureClick?.({
featureId: id,
point: { x: e.point.x, y: e.point.y },
lngLat: { lng: e.lngLat.lng, lat: e.lngLat.lat },
});
return;
}
const additive = !!e.originalEvent?.altKey;
const didSelect = selectFeature(feature, additive);
if (!didSelect) {
onFeatureClick?.(null);
return;
}
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
onFeatureClick?.({
featureId: id,
point: { x: e.point.x, y: e.point.y },