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
+16 -3
View File
@@ -742,10 +742,12 @@ function EditorPageContent() {
if (m === "replay" && featureId) { if (m === "replay" && featureId) {
// QUY TẮC: Geo chọn đầu tiên là geo main. // QUY TẮC: Geo chọn đầu tiên là geo main.
const finalSelectedIds = Array.from(new Set([...selectedFeatureIds, featureId]));
const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId; const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId;
setReplayFeatureId(triggerId); setReplayFeatureId(triggerId);
setReplaySelection({ stageId: null, stepIndex: null }); setReplaySelection({ stageId: null, stepIndex: null });
editor.switchReplayContext(triggerId, selectedFeatureIds); editor.switchReplayContext(triggerId, finalSelectedIds);
setSelectedFeatureIds([]); setSelectedFeatureIds([]);
} else if (m !== "replay") { } else if (m !== "replay") {
if (mode === "replay") { if (mode === "replay") {
@@ -2037,7 +2039,13 @@ function EditorPageContent() {
selectedFeatureIds={selectedFeatureIds} selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={setSelectedFeatureIds} onSelectFeatureIds={setSelectedFeatureIds}
onCreateFeature={handleCreateFeature} onCreateFeature={handleCreateFeature}
onDeleteFeature={editor.deleteFeature} onDeleteFeature={(id) => {
if (Array.isArray(id)) {
editor.deleteFeatures(id);
} else {
editor.deleteFeature(id);
}
}}
onHideFeature={handleHideGeometryLocal} onHideFeature={handleHideGeometryLocal}
onUpdateFeature={editor.updateFeature} onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility} backgroundVisibility={backgroundVisibility}
@@ -2190,10 +2198,15 @@ function EditorPageContent() {
<EntityWikiBindingsPanel <EntityWikiBindingsPanel
setLinks={setSnapshotEntityWikiLinksUndoable} setLinks={setSnapshotEntityWikiLinksUndoable}
/> />
{selectedFeature ? ( {selectedFeatures.length > 0 ? (
<SelectedGeometryPanel <SelectedGeometryPanel
selectedFeatures={selectedFeatures} selectedFeatures={selectedFeatures}
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata} onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
onDeleteFeatures={(ids) => {
editor.deleteFeatures(ids);
setSelectedFeatureIds([]);
}}
onDeselectAll={() => setSelectedFeatureIds([])}
changeCount={editor.changeCount} changeCount={editor.changeCount}
onReplayEdit={(id) => setMode("replay", id)} onReplayEdit={(id) => setMode("replay", id)}
/> />
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -42,7 +42,7 @@ type MapProps = {
labelContextDraft?: FeatureCollection; labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null; labelTimelineYear?: number | null;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number) => void; onDeleteFeature?: (id: string | number | (string | number)[]) => void;
onHideFeature?: (id: string | number) => void; onHideFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean; allowGeometryEditing?: boolean;
@@ -18,6 +18,8 @@ type Props = {
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>; onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
changeCount: number; changeCount: number;
onReplayEdit?: (id: string | number) => void; onReplayEdit?: (id: string | number) => void;
onDeleteFeatures?: (ids: (string | number)[]) => void;
onDeselectAll?: () => void;
}; };
export default function SelectedGeometryPanel({ export default function SelectedGeometryPanel({
@@ -25,6 +27,8 @@ export default function SelectedGeometryPanel({
onApplyGeometryMetadata, onApplyGeometryMetadata,
changeCount, changeCount,
onReplayEdit, onReplayEdit,
onDeleteFeatures,
onDeselectAll,
}: Props) { }: Props) {
const { const {
geometryMetaForm, geometryMetaForm,
@@ -74,6 +78,13 @@ export default function SelectedGeometryPanel({
const visibleGeoApplyFeedback = const visibleGeoApplyFeedback =
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null; 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; if (!selectedFeatures || selectedFeatures.length === 0) return null;
const representativeFeature = selectedFeatures[0]; 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={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}> <div style={{ fontWeight: 700, fontSize: "14px" }}>
Geometry property {isBulkMode ? `Đang chọn ${selectedFeatures.length} Geometries` : "Geometry property"}
</div> </div>
<button <button
type="button" type="button"
@@ -125,7 +136,78 @@ export default function SelectedGeometryPanel({
</div> </div>
{collapsed ? null : ( {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 <div
style={{ style={{
display: "grid", display: "grid",
@@ -142,111 +224,113 @@ export default function SelectedGeometryPanel({
<div style={{ color: "#94a3b8", fontSize: "11px" }}> <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. Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
</div> </div>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Loại GEO {!isMultiEditValid ? (
</div> <div style={{ color: "#fca5a5", fontSize: "12px", padding: "8px", border: "1px solid #7f1d1d", borderRadius: "6px", background: "#450a0a", marginTop: "4px" }}>
<select 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).
value={geometryMetaForm.type_key} </div>
onChange={(event) => ) : (
setGeometryMetaForm((prev) => ({ <>
...prev, <div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
type_key: event.target.value, Loại GEO
})) </div>
} <select
disabled={isEntitySubmitting} value={geometryMetaForm.type_key}
style={entityInputStyle} onChange={(event) =>
> setGeometryMetaForm((prev) => ({
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? ( ...prev,
<option value={geometryMetaForm.type_key}> type_key: event.target.value,
Custom Type ({geometryMetaForm.type_key}) }))
</option> }
) : null} disabled={isEntitySubmitting}
{groupedGeoTypeOptions.map((group) => ( style={entityInputStyle}
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
> >
{group.options.map((option) => ( {!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
<option key={option.value} value={option.value}> <option value={geometryMetaForm.type_key}>
{option.label} Custom Type ({geometryMetaForm.type_key})
</option> </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 ? (
</select> <div style={{ color: "#cbd5e1", fontSize: "12px" }}>
{selectedTypeOption ? ( Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
<div style={{ color: "#cbd5e1", fontSize: "12px" }}> </div>
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel}) ) : geometryMetaForm.type_key ? (
</div> <div style={{ color: "#cbd5e1", fontSize: "12px" }}>
) : geometryMetaForm.type_key ? ( Đang chọn: <b>{geometryMetaForm.type_key}</b>
<div style={{ color: "#cbd5e1", fontSize: "12px" }}> </div>
Đang chọn: <b>{geometryMetaForm.type_key}</b> ) : null}
</div> <input
) : null} value={geometryMetaForm.time_start}
<input onChange={(event) =>
value={geometryMetaForm.time_start} setGeometryMetaForm((prev) => ({
onChange={(event) => ...prev,
setGeometryMetaForm((prev) => ({ time_start: event.target.value,
...prev, }))
time_start: event.target.value, }
})) placeholder="time_start"
} disabled={isEntitySubmitting}
placeholder="time_start" style={entityInputStyle}
disabled={isEntitySubmitting} />
style={entityInputStyle} <input
/> value={geometryMetaForm.time_end}
<input onChange={(event) =>
value={geometryMetaForm.time_end} setGeometryMetaForm((prev) => ({
onChange={(event) => ...prev,
setGeometryMetaForm((prev) => ({ time_end: event.target.value,
...prev, }))
time_end: event.target.value, }
})) placeholder="time_end"
} disabled={isEntitySubmitting}
placeholder="time_end" style={entityInputStyle}
disabled={isEntitySubmitting} />
style={entityInputStyle} <button
/> type="button"
{/*<input*/} onClick={handleApplyGeoMeta}
{/* value={geometryMetaForm.binding}*/} disabled={isEntitySubmitting}
{/* onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}*/} style={primaryGeometryButtonStyle}
{/* placeholder="binding (geometry ids, comma separated)"*/} >
{/* disabled={isEntitySubmitting}*/} {isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
{/* style={entityInputStyle}*/} </button>
{/*/>*/} {onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
<button <button
type="button" type="button"
onClick={handleApplyGeoMeta} onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
disabled={isEntitySubmitting} style={{
style={primaryGeometryButtonStyle} ...primaryGeometryButtonStyle,
> background: "#1e293b",
Apply border: "1px solid #334155",
</button> color: "#38bdf8",
{onReplayEdit && selectedFeatures.length > 0 && ( }}
<button >
type="button" Replay Edit
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)} </button>
style={{ )}
...primaryGeometryButtonStyle, {visibleGeoApplyFeedback ? (
background: "#1e293b", <div
border: "1px solid #334155", style={{
color: "#38bdf8", fontSize: "12px",
}} color:
> visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
Replay Edit }}
</button> >
{visibleGeoApplyFeedback.text}
</div>
) : null}
</>
)} )}
{visibleGeoApplyFeedback ? (
<div
style={{
fontSize: "12px",
color:
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
}}
>
{visibleGeoApplyFeedback.text}
</div>
) : null}
</div> </div>
{changeCount > 0 ? ( {changeCount > 0 ? (
@@ -254,7 +338,7 @@ export default function SelectedGeometryPanel({
Thay đi sẽ vào lịch sử khi Commit. Thay đi sẽ vào lịch sử khi Commit.
</div> </div>
) : null} ) : null}
</div> </div>
)} )}
</div> </div>
); );
+2 -2
View File
@@ -29,7 +29,7 @@ type UseMapInteractionProps = {
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>; onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>; onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>;
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][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>; onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>; onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>; onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
@@ -144,7 +144,7 @@ export function useMapInteraction({
map, map,
() => modeRef.current, () => modeRef.current,
allowGeometryEditing allowGeometryEditing
? (id: string | number) => { ? (id: string | number | (string | number)[]) => {
editingEngineRef.current?.clearEditing(); editingEngineRef.current?.clearEditing();
onSelectFeatureIdsRef.current?.([]); onSelectFeatureIdsRef.current?.([]);
onDeleteRef.current?.(id); onDeleteRef.current?.(id);
@@ -540,6 +540,33 @@ export function useEditorState(
commitMainDraft({ ...mainDraftRef.current, features: nextFeatures }); 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[] { function buildPayload(): Change[] {
return Array.from(changes.values()).map((change) => deepClone(change)); return Array.from(changes.values()).map((change) => deepClone(change));
} }
@@ -695,6 +722,7 @@ export function useEditorState(
patchFeaturePropertiesBatch, patchFeaturePropertiesBatch,
updateFeature, updateFeature,
deleteFeature, deleteFeature,
deleteFeatures,
undo, undo,
buildPayload, buildPayload,
clearChanges, clearChanges,
+92 -70
View File
@@ -5,7 +5,7 @@ import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
export function initSelect( export function initSelect(
map: maplibregl.Map, map: maplibregl.Map,
getMode: ModeGetter, getMode: ModeGetter,
onDelete?: (id: string | number) => void, onDelete?: (id: string | number | (string | number)[]) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onDuplicate?: (id: string | number) => void, onDuplicate?: (id: string | number) => void,
onHide?: (id: string | number) => void, onHide?: (id: string | number) => void,
@@ -44,10 +44,13 @@ export function initSelect(
clearSelection(); 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 // Alt + click on an already selected feature removes it from the selection
setSelectionStateForId(id, false); setSelectionStateForId(idToRemove, false);
selectedIds.delete(id); selectedIds.delete(idToRemove);
onSelectIds?.(Array.from(selectedIds)); onSelectIds?.(Array.from(selectedIds));
return; return;
} }
@@ -98,7 +101,7 @@ export function initSelect(
const id = feature.id ?? feature.properties?.id; const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return; 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; const hasSelection = selectedIds.size > 0;
// If the right-clicked item is not selected, and there is no active selection, // 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; 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) { type MenuItem = {
if (onBindGeometries) { label: string;
const targetId = clickedFeature.id ?? clickedFeature.properties?.id; onClick: () => void;
if (targetId !== undefined && targetId !== null) { group: "edit" | "bind" | "replay" | "delete";
const sourceIds = Array.from(selectedIds); };
menu.appendChild(
createItem( const items: MenuItem[] = [];
`Bind ${selectedCount} geo đang chọn vào geo này`,
() => { if (isClickOutsideSelection && onBindGeometries && targetId !== undefined && targetId !== null) {
onBindGeometries(targetId, sourceIds); const sourceIds = Array.from(selectedIds);
} items.push({
) group: "bind",
); label: `Bind ${selectedCount} geo đang chọn vào geo này`,
hasMenuItems = true; onClick: () => {
} onBindGeometries(targetId, sourceIds);
} },
} else { });
const effectiveCount = selectedCount || 1; }
if (!isClickOutsideSelection) {
if ( if (
effectiveCount === 1 && effectiveCount === 1 &&
clickedFeature.source === "countries" && clickedFeature.source === "countries" &&
@@ -285,59 +292,74 @@ export function initSelect(
onEdit onEdit
) { ) {
const single = clickedFeature; const single = clickedFeature;
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); items.push({
hasMenuItems = true; group: "edit",
label: "Chỉnh sửa",
onClick: () => onEdit(single),
});
} }
if (effectiveCount === 1 && onDuplicate) { if (effectiveCount === 1 && onDuplicate && targetId !== undefined && targetId !== null) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id; items.push({
if (featureId !== undefined && featureId !== null) { group: "edit",
menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId))); label: "Duplicate",
hasMenuItems = true; onClick: () => onDuplicate(targetId),
} });
} }
if (effectiveCount === 1 && onHide) { if (effectiveCount === 1 && onHide && targetId !== undefined && targetId !== null) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id; items.push({
if (featureId !== undefined && featureId !== null) { group: "edit",
menu.appendChild(createItem("Hide", () => onHide(featureId))); label: "Hide",
hasMenuItems = true; onClick: () => onHide(targetId),
} });
}
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 (!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); document.body.appendChild(menu);
contextMenu = 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);
}
}