refactor: enhance selectingEngine with multi-delete support and structured context menu items

This commit is contained in:
taDuc
2026-05-22 16:42:51 +07:00
parent ee468fe4fe
commit 3b4ff71b9a
8 changed files with 2989 additions and 180 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ type MapProps = {
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number) => void;
onDeleteFeature?: (id: string | number | (string | number)[]) => void;
onHideFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean;
@@ -18,6 +18,8 @@ type Props = {
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
changeCount: number;
onReplayEdit?: (id: string | number) => void;
onDeleteFeatures?: (ids: (string | number)[]) => void;
onDeselectAll?: () => void;
};
export default function SelectedGeometryPanel({
@@ -25,6 +27,8 @@ export default function SelectedGeometryPanel({
onApplyGeometryMetadata,
changeCount,
onReplayEdit,
onDeleteFeatures,
onDeselectAll,
}: Props) {
const {
geometryMetaForm,
@@ -74,6 +78,13 @@ export default function SelectedGeometryPanel({
const visibleGeoApplyFeedback =
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
const isBulkMode = selectedFeatures.length >= 2;
const isMultiEditValid = useMemo(() => {
if (selectedFeatures.length <= 1) return true;
const firstShape = selectedFeatures[0].geometry.type;
return selectedFeatures.every((f) => f.geometry.type === firstShape);
}, [selectedFeatures]);
if (!selectedFeatures || selectedFeatures.length === 0) return null;
const representativeFeature = selectedFeatures[0];
@@ -99,7 +110,7 @@ export default function SelectedGeometryPanel({
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>
Geometry property
{isBulkMode ? `Đang chọn ${selectedFeatures.length} Geometries` : "Geometry property"}
</div>
<button
type="button"
@@ -125,7 +136,78 @@ export default function SelectedGeometryPanel({
</div>
{collapsed ? null : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
{isBulkMode && (
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #334155",
borderRadius: "8px",
padding: "8px",
background: "#1e293b",
}}
>
<div style={{ color: "#93c5fd", fontWeight: 700, fontSize: "12px" }}>
HÀNH ĐNG NHANH
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px" }}>
<button
type="button"
onClick={() => onReplayEdit?.(representativeFeature.properties.id)}
style={{
border: "none",
borderRadius: "6px",
padding: "8px 10px",
cursor: "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 700,
fontSize: "13px",
textAlign: "center",
gridColumn: "span 2",
}}
>
Vào Replay ({selectedFeatures.length} geo)
</button>
<button
type="button"
onClick={() => onDeleteFeatures?.(selectedFeatures.map(f => f.properties.id))}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 10px",
cursor: "pointer",
background: "#dc2626",
color: "#ffffff",
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
}}
>
Xóa ({selectedFeatures.length} geo)
</button>
<button
type="button"
onClick={() => onDeselectAll?.()}
style={{
border: "1px solid #475569",
borderRadius: "6px",
padding: "7px 10px",
cursor: "pointer",
background: "transparent",
color: "#cbd5e1",
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
}}
>
Bỏ chọn tất cả
</button>
</div>
</div>
)}
<div
style={{
display: "grid",
@@ -142,111 +224,113 @@ export default function SelectedGeometryPanel({
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
</div>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Loại GEO
</div>
<select
value={geometryMetaForm.type_key}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
type_key: event.target.value,
}))
}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
<option value={geometryMetaForm.type_key}>
Custom Type ({geometryMetaForm.type_key})
</option>
) : null}
{groupedGeoTypeOptions.map((group) => (
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
{!isMultiEditValid ? (
<div style={{ color: "#fca5a5", fontSize: "12px", padding: "8px", border: "1px solid #7f1d1d", borderRadius: "6px", background: "#450a0a", marginTop: "4px" }}>
Không thể chỉnh sửa thuộc tính cho các geometry không cùng loại hình dạng (Point, Line, Polygon).
</div>
) : (
<>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Loại GEO
</div>
<select
value={geometryMetaForm.type_key}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
type_key: event.target.value,
}))
}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
<option value={geometryMetaForm.type_key}>
Custom Type ({geometryMetaForm.type_key})
</option>
) : null}
{groupedGeoTypeOptions.map((group) => (
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</optgroup>
))}
</select>
{selectedTypeOption ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : geometryMetaForm.type_key ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Đang chọn: <b>{geometryMetaForm.type_key}</b>
</div>
) : null}
<input
value={geometryMetaForm.time_start}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
time_start: event.target.value,
}))
}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
time_end: event.target.value,
}))
}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
{/*<input*/}
{/* value={geometryMetaForm.binding}*/}
{/* onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}*/}
{/* placeholder="binding (geometry ids, comma separated)"*/}
{/* disabled={isEntitySubmitting}*/}
{/* style={entityInputStyle}*/}
{/*/>*/}
<button
type="button"
onClick={handleApplyGeoMeta}
disabled={isEntitySubmitting}
style={primaryGeometryButtonStyle}
>
Apply
</button>
{onReplayEdit && selectedFeatures.length > 0 && (
<button
type="button"
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
style={{
...primaryGeometryButtonStyle,
background: "#1e293b",
border: "1px solid #334155",
color: "#38bdf8",
}}
>
Replay Edit
</button>
</select>
{selectedTypeOption ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : geometryMetaForm.type_key ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Đang chọn: <b>{geometryMetaForm.type_key}</b>
</div>
) : null}
<input
value={geometryMetaForm.time_start}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
time_start: event.target.value,
}))
}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
time_end: event.target.value,
}))
}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
type="button"
onClick={handleApplyGeoMeta}
disabled={isEntitySubmitting}
style={primaryGeometryButtonStyle}
>
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
</button>
{onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
<button
type="button"
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
style={{
...primaryGeometryButtonStyle,
background: "#1e293b",
border: "1px solid #334155",
color: "#38bdf8",
}}
>
Replay Edit
</button>
)}
{visibleGeoApplyFeedback ? (
<div
style={{
fontSize: "12px",
color:
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
}}
>
{visibleGeoApplyFeedback.text}
</div>
) : null}
</>
)}
{visibleGeoApplyFeedback ? (
<div
style={{
fontSize: "12px",
color:
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
}}
>
{visibleGeoApplyFeedback.text}
</div>
) : null}
</div>
{changeCount > 0 ? (
@@ -254,7 +338,7 @@ export default function SelectedGeometryPanel({
Thay đi sẽ vào lịch sử khi Commit.
</div>
) : null}
</div>
</div>
)}
</div>
);
+2 -2
View File
@@ -29,7 +29,7 @@ type UseMapInteractionProps = {
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>;
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onDeleteRef: React.MutableRefObject<((id: string | number | (string | number)[]) => void) | undefined>;
onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
@@ -144,7 +144,7 @@ export function useMapInteraction({
map,
() => modeRef.current,
allowGeometryEditing
? (id: string | number) => {
? (id: string | number | (string | number)[]) => {
editingEngineRef.current?.clearEditing();
onSelectFeatureIdsRef.current?.([]);
onDeleteRef.current?.(id);
@@ -540,6 +540,33 @@ export function useEditorState(
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
}
function deleteFeatures(ids: Array<FeatureProperties["id"]>) {
if (mode === "replay") {
return;
}
const idsSet = new Set(ids.map(String));
const nextFeatures: Feature[] = [];
const undoActions: UndoAction[] = [];
for (const feature of mainDraftRef.current.features) {
if (idsSet.has(String(feature.properties.id))) {
undoActions.push({ type: "delete", feature: deepClone(feature) });
} else {
nextFeatures.push(feature);
}
}
if (undoActions.length === 0) return;
pushMainUndo(
undoActions.length === 1
? undoActions[0]
: { type: "group", label: `Xóa ${undoActions.length} geometry`, actions: undoActions }
);
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
}
function buildPayload(): Change[] {
return Array.from(changes.values()).map((change) => deepClone(change));
}
@@ -695,6 +722,7 @@ export function useEditorState(
patchFeaturePropertiesBatch,
updateFeature,
deleteFeature,
deleteFeatures,
undo,
buildPayload,
clearChanges,
+92 -70
View File
@@ -5,7 +5,7 @@ import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
export function initSelect(
map: maplibregl.Map,
getMode: ModeGetter,
onDelete?: (id: string | number) => void,
onDelete?: (id: string | number | (string | number)[]) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onDuplicate?: (id: string | number) => void,
onHide?: (id: string | number) => void,
@@ -44,10 +44,13 @@ export function initSelect(
clearSelection();
}
if (additive && selectedIds.has(id)) {
const idToRemove = Array.from(selectedIds).find(sid => String(sid) === String(id));
const isAlreadySelected = idToRemove !== undefined;
if (additive && isAlreadySelected) {
// Alt + click on an already selected feature removes it from the selection
setSelectionStateForId(id, false);
selectedIds.delete(id);
setSelectionStateForId(idToRemove, false);
selectedIds.delete(idToRemove);
onSelectIds?.(Array.from(selectedIds));
return;
}
@@ -98,7 +101,7 @@ export function initSelect(
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
const isRightClickedItemAlreadySelected = selectedIds.has(id);
const isRightClickedItemAlreadySelected = Array.from(selectedIds).some(sid => String(sid) === String(id));
const hasSelection = selectedIds.size > 0;
// If the right-clicked item is not selected, and there is no active selection,
@@ -258,26 +261,30 @@ export function initSelect(
};
const selectedCount = selectedIds.size;
let hasMenuItems = false;
const effectiveCount = selectedCount || 1;
const targetId = clickedFeature.id ?? clickedFeature.properties?.id;
const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection;
if (!isRightClickedItemAlreadySelected && hasSelection) {
if (onBindGeometries) {
const targetId = clickedFeature.id ?? clickedFeature.properties?.id;
if (targetId !== undefined && targetId !== null) {
const sourceIds = Array.from(selectedIds);
menu.appendChild(
createItem(
`Bind ${selectedCount} geo đang chọn vào geo này`,
() => {
onBindGeometries(targetId, sourceIds);
}
)
);
hasMenuItems = true;
}
}
} else {
const effectiveCount = selectedCount || 1;
type MenuItem = {
label: string;
onClick: () => void;
group: "edit" | "bind" | "replay" | "delete";
};
const items: MenuItem[] = [];
if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) {
const sourceIds = Array.from(selectedIds);
items.push({
group: "bind",
label: `Bind ${selectedCount} geo đang chọn vào geo này`,
onClick: () => {
onBindGeometries(targetId, sourceIds);
},
});
}
if (!isClickOutsideSelection) {
if (
effectiveCount === 1 &&
clickedFeature.source === "countries" &&
@@ -285,59 +292,74 @@ export function initSelect(
onEdit
) {
const single = clickedFeature;
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
hasMenuItems = true;
items.push({
group: "edit",
label: "Chỉnh sửa",
onClick: () => onEdit(single),
});
}
if (effectiveCount === 1 && onDuplicate) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId !== undefined && featureId !== null) {
menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId)));
hasMenuItems = true;
}
if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) {
items.push({
group: "edit",
label: "Duplicate",
onClick: () => onDuplicate(targetId),
});
}
if (effectiveCount === 1 && onHide) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId !== undefined && featureId !== null) {
menu.appendChild(createItem("Hide", () => onHide(featureId)));
hasMenuItems = true;
}
}
if (onReplayEdit) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId) {
menu.appendChild(
createItem(
effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay",
() => onReplayEdit(featureId)
)
);
hasMenuItems = true;
}
}
if (onDelete) {
menu.appendChild(
createItem(
effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
() => {
const ids = selectedIds.size
? Array.from(selectedIds)
: [clickedFeature.id ?? clickedFeature.properties?.id];
ids.forEach((eachId) => {
if (eachId !== undefined && eachId !== null) onDelete(eachId);
});
clearSelection();
}
)
);
hasMenuItems = true;
if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) {
items.push({
group: "edit",
label: "Hide",
onClick: () => onHide(targetId),
});
}
}
if (!hasMenuItems) return;
if (onReplayEdit) {
const replayId = targetId;
if (replayId !== undefined && replayId !== null) {
const totalCount = isClickOutsideSelection ? selectedIds.size + 1 : effectiveCount;
items.push({
group: "replay",
label: totalCount > 1 ? `Vào replay (${totalCount} geo)` : "Vào replay",
onClick: () => onReplayEdit(replayId),
});
}
}
if (onDelete) {
items.push({
group: "delete",
label: effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
onClick: () => {
const ids = selectedIds.size
? Array.from(selectedIds)
: [targetId];
if (ids.length === 1) {
onDelete(ids[0]);
} else {
onDelete(ids);
}
clearSelection();
},
});
}
if (items.length === 0) return;
let lastGroup: string | null = null;
items.forEach((item) => {
if (lastGroup !== null && lastGroup !== item.group) {
const separator = document.createElement("div");
separator.style.height = "1px";
separator.style.background = "#374151";
separator.style.margin = "4px 0";
menu.appendChild(separator);
}
menu.appendChild(createItem(item.label, item.onClick));
lastGroup = item.group;
});
document.body.appendChild(menu);
contextMenu = menu;
@@ -0,0 +1,357 @@
import maplibregl from "maplibre-gl";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
// Khởi tạo engine chọn feature và context menu edit/delete.
export function initSelect(
map: maplibregl.Map,
getMode: ModeGetter,
onDelete?: (id: string | number) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onDuplicate?: (id: string | number) => void,
onHide?: (id: string | number) => void,
onSelectIds?: (ids: (string | number)[]) => void,
onReplayEdit?: (id: string | number) => void,
isEditSessionActive?: () => boolean,
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void
) {
const FEATURE_STATE_SOURCES = [
"countries",
"places",
"path-arrow-shapes",
] as const;
const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit || onBindGeometries);
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
function clearSelection(emit = true) {
if (!selectedIds.size) return;
selectedIds.forEach((id) => setSelectionStateForId(id, false));
selectedIds.clear();
if (emit) {
onSelectIds?.([]);
}
}
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
if (!additive) {
clearSelection();
}
const idToRemove = Array.from(selectedIds).find(sid => String(sid) === String(id));
const isAlreadySelected = idToRemove !== undefined;
if (additive && isAlreadySelected) {
// Alt + click on an already selected feature removes it from the selection
setSelectionStateForId(idToRemove, false);
selectedIds.delete(idToRemove);
onSelectIds?.(Array.from(selectedIds));
return;
}
setSelectionStateForId(id, true);
selectedIds.add(id);
onSelectIds?.(Array.from(selectedIds));
}
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select" && getMode() !== "replay") return;
if (isEditSessionActive?.()) return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return;
const features = map.queryRenderedFeatures(e.point, {
layers: selectableLayers,
}) as maplibregl.MapGeoJSONFeature[];
if (!features.length) {
clearSelection();
return;
}
const additive = !!e.originalEvent?.altKey;
selectFeature(pickPreferredFeature(features), additive);
}
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
// Mở menu thao tác khi click phải lên feature.
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return;
e.preventDefault(); // block browser menu
if (getMode() === "replay") return;
if (isEditSessionActive?.()) return;
const features = map.queryRenderedFeatures(e.point, {
layers: selectableLayers,
}) as maplibregl.MapGeoJSONFeature[];
if (!features.length) return;
const feature = pickPreferredFeature(features);
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
const isRightClickedItemAlreadySelected = Array.from(selectedIds).some(sid => String(sid) === String(id));
const hasSelection = selectedIds.size > 0;
// If the right-clicked item is not selected, and there is no active selection,
// make it the sole selection. If there is an active selection, do not clear it
// so we can bind the active selection to this target geometry.
if (!isRightClickedItemAlreadySelected && !hasSelection) {
clearSelection();
selectFeature(feature, false);
}
showContextMenu(
e.originalEvent?.clientX ?? e.point.x,
e.originalEvent?.clientY ?? e.point.y,
feature,
isRightClickedItemAlreadySelected,
hasSelection
);
}
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) {
map.getCanvas().style.cursor = "";
return;
}
const features = map.queryRenderedFeatures(e.point, {
layers: selectableLayers,
});
map.getCanvas().style.cursor = features.length ? "pointer" : "";
}
function getSelectableLayers(): string[] {
const style = map.getStyle();
if (!style || !style.layers) return [];
return style.layers
.filter((layer) =>
"source" in layer &&
typeof layer.source === "string" &&
FEATURE_STATE_SOURCES.includes(layer.source as (typeof FEATURE_STATE_SOURCES)[number])
)
.map((layer) => layer.id);
}
function setSelectionStateForId(id: string | number, selected: boolean) {
for (const source of FEATURE_STATE_SOURCES) {
if (!map.getSource(source)) continue;
map.setFeatureState({ source, id }, { selected });
}
}
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;
}
// Đồng bộ selection state từ React.
function syncSelection(ids: (string | number)[]) {
const nextSet = new Set(ids);
selectedIds.forEach((id) => {
if (!nextSet.has(id)) {
setSelectionStateForId(id, false);
}
});
selectedIds.clear();
ids.forEach((id) => {
setSelectionStateForId(id, true);
selectedIds.add(id);
});
}
map.on("click", onClick);
map.on("mousemove", onMove);
if (hasContextActions) {
map.on("contextmenu", onRightClick);
}
const cleanup = () => {
map.off("click", onClick);
map.off("mousemove", onMove);
if (hasContextActions) {
map.off("contextmenu", onRightClick);
}
clearSelection(false);
hideContextMenu();
};
return {
cleanup,
clearSelection,
syncSelection,
};
// Ẩn và dọn dẹp context menu hiện tại.
function hideContextMenu() {
if (contextMenu) {
contextMenu.remove();
contextMenu = null;
}
if (docClickHandler) {
document.removeEventListener("click", docClickHandler);
docClickHandler = null;
}
}
// Render menu ngữ cảnh tối giản gần vị trí con trỏ.
function showContextMenu(
x: number,
y: number,
clickedFeature: maplibregl.MapGeoJSONFeature,
isRightClickedItemAlreadySelected: boolean,
hasSelection: boolean
) {
hideContextMenu();
const menu = document.createElement("div");
menu.style.position = "fixed";
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.background = "#0f172a";
menu.style.color = "white";
menu.style.border = "1px solid #1f2937";
menu.style.borderRadius = "6px";
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
menu.style.zIndex = "9999";
menu.style.minWidth = "120px";
menu.style.fontSize = "14px";
menu.style.padding = "4px 0";
// Tạo một item thao tác trong context menu.
const createItem = (label: string, onClick: () => void) => {
const item = document.createElement("div");
item.textContent = label;
item.style.padding = "8px 12px";
item.style.cursor = "pointer";
item.onmouseenter = () => (item.style.background = "#1f2937");
item.onmouseleave = () => (item.style.background = "transparent");
item.onclick = () => {
onClick();
hideContextMenu();
};
return item;
};
const selectedCount = selectedIds.size;
let hasMenuItems = false;
const effectiveCount = selectedCount || 1;
const targetId = clickedFeature.id ?? clickedFeature.properties?.id;
const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection;
if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) {
const sourceIds = Array.from(selectedIds);
menu.appendChild(
createItem(
`Bind ${selectedCount} geo đang chọn vào geo này`,
() => {
onBindGeometries(targetId, sourceIds);
}
)
);
hasMenuItems = true;
const separator = document.createElement("div");
separator.style.height = "1px";
separator.style.background = "#374151";
separator.style.margin = "4px 0";
menu.appendChild(separator);
}
if (!isClickOutsideSelection) {
if (
effectiveCount === 1 &&
clickedFeature.source === "countries" &&
clickedFeature.geometry?.type === "Polygon" &&
onEdit
) {
const single = clickedFeature;
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
hasMenuItems = true;
}
if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) {
menu.appendChild(createItem("Duplicate", () => onDuplicate(targetId)));
hasMenuItems = true;
}
if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) {
menu.appendChild(createItem("Hide", () => onHide(targetId)));
hasMenuItems = true;
}
}
if (onReplayEdit) {
const replayId = isClickOutsideSelection ? Array.from(selectedIds)[0] : targetId;
if (replayId !== undefined && replayId !== null) {
menu.appendChild(
createItem(
effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay",
() => onReplayEdit(replayId)
)
);
hasMenuItems = true;
}
}
if (onDelete) {
menu.appendChild(
createItem(
effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
() => {
const ids = selectedIds.size
? Array.from(selectedIds)
: [targetId];
ids.forEach((eachId) => {
if (eachId !== undefined && eachId !== null) onDelete(eachId);
});
clearSelection();
}
)
);
hasMenuItems = true;
}
if (!hasMenuItems) return;
document.body.appendChild(menu);
contextMenu = menu;
// Đóng menu khi click ra ngoài vùng menu.
const onDocClick = (ev: MouseEvent) => {
if (!menu.contains(ev.target as Node)) {
hideContextMenu();
}
};
docClickHandler = onDocClick;
setTimeout(() => document.addEventListener("click", onDocClick), 0);
}
}