use requestAnimationFrame for hover popup
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user