refactor: enhance selectingEngine with multi-delete support and structured context menu items
This commit is contained in:
@@ -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
@@ -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"
|
||||||
@@ -126,6 +137,77 @@ export default function SelectedGeometryPanel({
|
|||||||
|
|
||||||
{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,6 +224,13 @@ 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>
|
||||||
|
|
||||||
|
{!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" }}>
|
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||||
Loại GEO
|
Loại GEO
|
||||||
</div>
|
</div>
|
||||||
@@ -207,22 +296,15 @@ export default function SelectedGeometryPanel({
|
|||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
/>
|
/>
|
||||||
{/*<input*/}
|
|
||||||
{/* value={geometryMetaForm.binding}*/}
|
|
||||||
{/* onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}*/}
|
|
||||||
{/* placeholder="binding (geometry ids, comma separated)"*/}
|
|
||||||
{/* disabled={isEntitySubmitting}*/}
|
|
||||||
{/* style={entityInputStyle}*/}
|
|
||||||
{/*/>*/}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleApplyGeoMeta}
|
onClick={handleApplyGeoMeta}
|
||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={primaryGeometryButtonStyle}
|
style={primaryGeometryButtonStyle}
|
||||||
>
|
>
|
||||||
Apply
|
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
|
||||||
</button>
|
</button>
|
||||||
{onReplayEdit && selectedFeatures.length > 0 && (
|
{onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
|
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
|
||||||
@@ -247,6 +329,8 @@ export default function SelectedGeometryPanel({
|
|||||||
{visibleGeoApplyFeedback.text}
|
{visibleGeoApplyFeedback.text}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{changeCount > 0 ? (
|
{changeCount > 0 ? (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
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;
|
const effectiveCount = selectedCount || 1;
|
||||||
|
const targetId = clickedFeature.id ?? clickedFeature.properties?.id;
|
||||||
|
const isClickOutsideSelection = !isRightClickedItemAlreadySelected && hasSelection;
|
||||||
|
|
||||||
|
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 (
|
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) {
|
if (onReplayEdit) {
|
||||||
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
|
const replayId = targetId;
|
||||||
if (featureId) {
|
if (replayId !== undefined && replayId !== null) {
|
||||||
menu.appendChild(
|
const totalCount = isClickOutsideSelection ? selectedIds.size + 1 : effectiveCount;
|
||||||
createItem(
|
items.push({
|
||||||
effectiveCount > 1 ? `Vào replay (${effectiveCount} geo)` : "Vào replay",
|
group: "replay",
|
||||||
() => onReplayEdit(featureId)
|
label: totalCount > 1 ? `Vào replay (${totalCount} geo)` : "Vào replay",
|
||||||
)
|
onClick: () => onReplayEdit(replayId),
|
||||||
);
|
});
|
||||||
hasMenuItems = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onDelete) {
|
if (onDelete) {
|
||||||
menu.appendChild(
|
items.push({
|
||||||
createItem(
|
group: "delete",
|
||||||
effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
|
label: effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
|
||||||
() => {
|
onClick: () => {
|
||||||
const ids = selectedIds.size
|
const ids = selectedIds.size
|
||||||
? Array.from(selectedIds)
|
? Array.from(selectedIds)
|
||||||
: [clickedFeature.id ?? clickedFeature.properties?.id];
|
: [targetId];
|
||||||
ids.forEach((eachId) => {
|
if (ids.length === 1) {
|
||||||
if (eachId !== undefined && eachId !== null) onDelete(eachId);
|
onDelete(ids[0]);
|
||||||
});
|
} else {
|
||||||
|
onDelete(ids);
|
||||||
|
}
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
},
|
||||||
)
|
});
|
||||||
);
|
|
||||||
hasMenuItems = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasMenuItems) return;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user