refactor: enhance selectingEngine with multi-delete support and structured context menu items
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user