refactor
This commit is contained in:
248
lib/engine/circleEngine.ts
Normal file
248
lib/engine/circleEngine.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371008.8;
|
||||
const CIRCLE_SEGMENTS = 72;
|
||||
const MIN_RADIUS_METERS = 1;
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ circle bằng thao tác kéo chuột từ tâm ra biên.
|
||||
export function initCircle(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let center: [number, number] | null = null;
|
||||
let radiusMeters = 0;
|
||||
let isDragging = false;
|
||||
let dragPanDisabledByCircle = false;
|
||||
|
||||
// Xóa dữ liệu preview circle trên map.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Bật lại drag pan nếu trước đó bị tắt khi đang kéo vẽ circle.
|
||||
const releaseDragPan = () => {
|
||||
if (!dragPanDisabledByCircle) return;
|
||||
dragPanDisabledByCircle = false;
|
||||
if (!map.dragPan.isEnabled()) {
|
||||
map.dragPan.enable();
|
||||
}
|
||||
};
|
||||
|
||||
// Reset toàn bộ trạng thái vẽ circle tạm thời.
|
||||
const resetDrawingState = () => {
|
||||
center = null;
|
||||
radiusMeters = 0;
|
||||
isDragging = false;
|
||||
clearPreview();
|
||||
releaseDragPan();
|
||||
};
|
||||
|
||||
// Cập nhật polygon preview theo tâm và bán kính hiện tại.
|
||||
const updatePreview = () => {
|
||||
if (!center || radiusMeters < MIN_RADIUS_METERS) {
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
// Bắt đầu phiên vẽ circle khi nhấn chuột trái.
|
||||
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||
|
||||
center = [e.lngLat.lng, e.lngLat.lat];
|
||||
radiusMeters = 0;
|
||||
isDragging = true;
|
||||
clearPreview();
|
||||
|
||||
if (map.dragPan.isEnabled()) {
|
||||
map.dragPan.disable();
|
||||
dragPanDisabledByCircle = true;
|
||||
} else {
|
||||
dragPanDisabledByCircle = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật bán kính theo vị trí chuột trong lúc kéo.
|
||||
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
if (getMode() !== "add-circle") {
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
if (isDragging) {
|
||||
resetDrawingState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.style.cursor = "crosshair";
|
||||
if (!isDragging || !center) return;
|
||||
|
||||
radiusMeters = distanceMeters(center, [e.lngLat.lng, e.lngLat.lat]);
|
||||
updatePreview();
|
||||
};
|
||||
|
||||
// Hoàn tất circle và trả geometry cho callback.
|
||||
const finishCircle = () => {
|
||||
if (!isDragging || !center) {
|
||||
resetDrawingState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (radiusMeters < MIN_RADIUS_METERS) {
|
||||
resetDrawingState();
|
||||
return;
|
||||
}
|
||||
|
||||
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
||||
onComplete({
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
});
|
||||
resetDrawingState();
|
||||
};
|
||||
|
||||
// Kết thúc thao tác kéo bằng mouseup chuột trái.
|
||||
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||
finishCircle();
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ circle khi nhấn Escape.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
resetDrawingState();
|
||||
};
|
||||
|
||||
map.on("mousedown", onMouseDown);
|
||||
map.on("mousemove", onMouseMove);
|
||||
map.on("mouseup", onMouseUp);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
map.off("mousedown", onMouseDown);
|
||||
map.off("mousemove", onMouseMove);
|
||||
map.off("mouseup", onMouseUp);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
resetDrawingState();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: resetDrawingState,
|
||||
};
|
||||
}
|
||||
|
||||
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
|
||||
function buildCircleRing(
|
||||
center: [number, number],
|
||||
radiusMeters: number,
|
||||
segments: number
|
||||
): [number, number][] {
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= segments; i += 1) {
|
||||
const bearingDeg = (i / segments) * 360; // Chia đều 360 do quanh tâm để tạo các điểm trên vòng tròn.
|
||||
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
|
||||
function distanceMeters(a: [number, number], b: [number, number]): number {
|
||||
const lat1 = toRad(a[1]);
|
||||
const lat2 = toRad(b[1]);
|
||||
const dLat = lat2 - lat1; // Delta vĩ độ (radian).
|
||||
const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian).
|
||||
|
||||
const sinLat = Math.sin(dLat / 2); // Thành phần sin(dLat/2) của công thức Haversine.
|
||||
const sinLng = Math.sin(dLng / 2); // Thành phần sin(dLng/2) của công thức Haversine.
|
||||
const h =
|
||||
sinLat * sinLat +
|
||||
Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; // h = haversine(d/R), độ lớn cung tròn chuẩn hóa.
|
||||
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); // Góc tâm (radian) giữa hai điểm trên mặt cầu.
|
||||
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
|
||||
}
|
||||
|
||||
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
|
||||
function destinationPoint(
|
||||
center: [number, number],
|
||||
distance: number,
|
||||
bearingDeg: number
|
||||
): [number, number] {
|
||||
const lat1 = toRad(center[1]);
|
||||
const lng1 = toRad(center[0]);
|
||||
const bearing = toRad(bearingDeg);
|
||||
const angularDistance = distance / EARTH_RADIUS_METERS; // d/R: khoảng cách góc trên mặt cầu.
|
||||
|
||||
const sinLat1 = Math.sin(lat1);
|
||||
const cosLat1 = Math.cos(lat1);
|
||||
const sinAngular = Math.sin(angularDistance);
|
||||
const cosAngular = Math.cos(angularDistance);
|
||||
|
||||
const sinLat2 =
|
||||
sinLat1 * cosAngular +
|
||||
cosLat1 * sinAngular * Math.cos(bearing); // Công thức vĩ độ điểm đích theo great-circle.
|
||||
const lat2 = Math.asin(clamp(sinLat2, -1, 1)); // Kẹp [-1,1] để tránh sai số số học trước khi asin.
|
||||
|
||||
const y = Math.sin(bearing) * sinAngular * cosLat1; // Tử số atan2 cho biến thiên kinh độ.
|
||||
const x = cosAngular - sinLat1 * Math.sin(lat2); // Mẫu số atan2 cho biến thiên kinh độ.
|
||||
const lng2 = lng1 + Math.atan2(y, x); // Kinh độ đích = kinh độ gốc + delta kinh độ.
|
||||
|
||||
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
||||
}
|
||||
|
||||
// Chuẩn hóa kinh độ về miền [-180, 180].
|
||||
function normalizeLng(lng: number): number {
|
||||
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
|
||||
if (normalized === -180) normalized = 180;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Kẹp giá trị trong đoạn [min, max].
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Đổi đơn vị góc từ độ sang radian.
|
||||
function toRad(value: number): number {
|
||||
return (value * Math.PI) / 180; // Đổi độ sang radian.
|
||||
}
|
||||
|
||||
// Đổi đơn vị góc từ radian sang độ.
|
||||
function toDeg(value: number): number {
|
||||
return (value * 180) / Math.PI; // Đổi radian sang độ.
|
||||
}
|
||||
128
lib/engine/drawingEngine.ts
Normal file
128
lib/engine/drawingEngine.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||
export function initDrawing(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
};
|
||||
|
||||
const cancelDrawing = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
|
||||
function closePolygon(c: [number, number][]) {
|
||||
if (c.length < 3) return c;
|
||||
const first = c[0];
|
||||
const last = c[c.length - 1];
|
||||
|
||||
if (first[0] !== last[0] || first[1] !== last[1]) {
|
||||
return [...c, first];
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// Cập nhật layer preview trong lúc đang vẽ.
|
||||
function update(c: [number, number][]) {
|
||||
const closed = closePolygon(c);
|
||||
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [closed],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Ghi nhận đỉnh polygon mới khi click map.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "draw") return;
|
||||
|
||||
coords.push([e.lngLat.lng, e.lngLat.lat] as [number, number]);
|
||||
update(coords);
|
||||
}
|
||||
|
||||
// Render preview polygon với điểm chuột hiện tại.
|
||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "draw" || coords.length === 0) return;
|
||||
|
||||
const preview: [number, number][] = [
|
||||
...coords,
|
||||
[e.lngLat.lng, e.lngLat.lat] as [number, number],
|
||||
];
|
||||
update(preview);
|
||||
}
|
||||
|
||||
// Hoàn tất polygon, trả geometry ra ngoài và reset preview.
|
||||
function finishDrawing() {
|
||||
if (getMode() !== "draw" || coords.length < 3) return;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "Polygon",
|
||||
coordinates: [closePolygon(coords)],
|
||||
};
|
||||
|
||||
onComplete(geometry);
|
||||
cancelDrawing();
|
||||
}
|
||||
|
||||
// Lắng nghe Enter để chốt polygon.
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (getMode() !== "draw") return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishDrawing();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelDrawing();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
coords = coords.slice(0, -1);
|
||||
if (coords.length) {
|
||||
update(coords);
|
||||
} else {
|
||||
clearPreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
cancelDrawing();
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelDrawing,
|
||||
};
|
||||
}
|
||||
226
lib/engine/editingEngine.ts
Normal file
226
lib/engine/editingEngine.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
export type EditingHandle = {
|
||||
id: string | number;
|
||||
ring: [number, number][];
|
||||
original: Geometry;
|
||||
};
|
||||
|
||||
export type EditingAPI = {
|
||||
beginEditing: (feature: maplibregl.MapGeoJSONFeature) => void;
|
||||
clearEditing: () => void;
|
||||
bindEditEvents: (map: maplibregl.Map) => void;
|
||||
};
|
||||
|
||||
// Tạo engine chỉnh sửa polygon đã có (kéo đỉnh, thêm đỉnh, commit/cancel).
|
||||
export function createEditingEngine(options: {
|
||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||
onUpdate: (id: string | number, geometry: Geometry) => void;
|
||||
}) {
|
||||
const { mapRef, onUpdate } = options;
|
||||
const editingRef = { current: null as EditingHandle | null };
|
||||
const dragStateRef = { current: null as { idx: number } | null };
|
||||
const modifierRef = { current: { ctrl: false, meta: false } };
|
||||
|
||||
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
|
||||
const clearEditing = () => {
|
||||
editingRef.current = null;
|
||||
dragStateRef.current = null;
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const empty: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||
};
|
||||
|
||||
// Đồng bộ polygon tạm và các handle point lên map source.
|
||||
const updateEditSources = () => {
|
||||
const editing = editingRef.current;
|
||||
const map = mapRef.current;
|
||||
if (!editing || !map) return;
|
||||
|
||||
const closedRing = [...editing.ring, editing.ring[0]];
|
||||
const shape: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [closedRing] },
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const handles: GeoJSON.FeatureCollection<GeoJSON.Point> = {
|
||||
type: "FeatureCollection",
|
||||
features: editing.ring.map((c, idx) => ({
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: c },
|
||||
properties: { idx },
|
||||
})),
|
||||
};
|
||||
|
||||
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
|
||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
|
||||
};
|
||||
|
||||
// Chốt chỉnh sửa và emit geometry mới cho caller.
|
||||
const finishEditing = () => {
|
||||
const editing = editingRef.current;
|
||||
if (!editing) return;
|
||||
const geometry: Geometry = {
|
||||
type: "Polygon",
|
||||
coordinates: [[...editing.ring, editing.ring[0]]],
|
||||
};
|
||||
onUpdate(editing.id, geometry);
|
||||
clearEditing();
|
||||
};
|
||||
|
||||
// Thoát chế độ chỉnh sửa mà không lưu thay đổi.
|
||||
const cancelEditing = () => {
|
||||
clearEditing();
|
||||
};
|
||||
|
||||
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
|
||||
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
|
||||
if (feature.geometry.type !== "Polygon") return;
|
||||
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
|
||||
if (coords.length < 4) return;
|
||||
|
||||
// remove duplicated closing point
|
||||
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
|
||||
editingRef.current = {
|
||||
id: feature.id ?? feature.properties?.id,
|
||||
ring,
|
||||
original: feature.geometry as Geometry,
|
||||
};
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh.
|
||||
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
|
||||
const oe = e?.originalEvent as MouseEvent | undefined;
|
||||
return (
|
||||
modifierRef.current.ctrl ||
|
||||
modifierRef.current.meta ||
|
||||
!!oe?.ctrlKey ||
|
||||
!!oe?.metaKey
|
||||
);
|
||||
};
|
||||
|
||||
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
|
||||
const bindEditEvents = (map: maplibregl.Map) => {
|
||||
// Bắt đầu kéo một handle point.
|
||||
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (!editingRef.current) return;
|
||||
const feature = e.features?.[0];
|
||||
const idx = feature?.properties?.idx;
|
||||
if (idx === undefined) return;
|
||||
e.preventDefault();
|
||||
dragStateRef.current = { idx };
|
||||
map.getCanvas().style.cursor = "grabbing";
|
||||
map.dragPan.disable();
|
||||
};
|
||||
|
||||
// Cập nhật vị trí đỉnh trong lúc kéo chuột.
|
||||
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
|
||||
const drag = dragStateRef.current;
|
||||
const editing = editingRef.current;
|
||||
if (!drag || !editing) return;
|
||||
|
||||
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map.
|
||||
const stopDragging = () => {
|
||||
dragStateRef.current = null;
|
||||
map.getCanvas().style.cursor = "";
|
||||
map.dragPan.enable();
|
||||
};
|
||||
|
||||
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags).
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Control") {
|
||||
modifierRef.current.ctrl = true;
|
||||
} else if (e.key === "Meta") {
|
||||
modifierRef.current.meta = true;
|
||||
}
|
||||
if (!editingRef.current) return;
|
||||
if (e.key === "Enter") {
|
||||
finishEditing();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelEditing();
|
||||
}
|
||||
};
|
||||
|
||||
// Hạ cờ modifier khi nhả phím.
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Control") {
|
||||
modifierRef.current.ctrl = false;
|
||||
} else if (e.key === "Meta") {
|
||||
modifierRef.current.meta = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
|
||||
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (!editingRef.current) return;
|
||||
if (!isModifierPressed(e)) return;
|
||||
e.preventDefault();
|
||||
const editing = editingRef.current;
|
||||
const ring = editing.ring;
|
||||
const click = [e.lngLat.lng, e.lngLat.lat] as [number, number];
|
||||
let nearestIdx = 0;
|
||||
let bestDist = Number.POSITIVE_INFINITY;
|
||||
ring.forEach((pt, idx) => {
|
||||
const dx = pt[0] - click[0];
|
||||
const dy = pt[1] - click[1];
|
||||
const d = dx * dx + dy * dy; // Dùng khoảng cách Euclid bình phương để so sánh nhanh, không cần sqrt.
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
nearestIdx = idx;
|
||||
}
|
||||
});
|
||||
const insertIdx = nearestIdx + 1;
|
||||
ring.splice(insertIdx, 0, click);
|
||||
dragStateRef.current = { idx: insertIdx };
|
||||
map.getCanvas().style.cursor = "grabbing";
|
||||
map.dragPan.disable();
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Ngắt kéo nếu con trỏ rời canvas.
|
||||
const onCanvasLeave = () => {
|
||||
stopDragging();
|
||||
};
|
||||
|
||||
map.on("mousedown", "edit-handles-circle", onHandleDown);
|
||||
map.on("mousedown", "edit-shape-line", onInsertHandle);
|
||||
map.on("mousemove", onHandleMove);
|
||||
map.on("mouseup", stopDragging);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
|
||||
|
||||
map.on("remove", () => {
|
||||
map.off("mousedown", "edit-handles-circle", onHandleDown);
|
||||
map.off("mousedown", "edit-shape-line", onInsertHandle);
|
||||
map.off("mousemove", onHandleMove);
|
||||
map.off("mouseup", stopDragging);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
beginEditing,
|
||||
clearEditing,
|
||||
bindEditEvents,
|
||||
updateEditSources,
|
||||
editingRef,
|
||||
dragStateRef,
|
||||
};
|
||||
}
|
||||
141
lib/engine/lineEngine.ts
Normal file
141
lib/engine/lineEngine.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ line (gấp khúc, không mũi tên).
|
||||
export function initLine(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
// Xóa dữ liệu preview line.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ line hiện tại.
|
||||
const cancelLine = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Cập nhật line preview theo danh sách tọa độ tạm.
|
||||
const updatePreview = (lineCoords: [number, number][]) => {
|
||||
if (lineCoords.length < 2) {
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: lineCoords,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
// Chốt line khi đủ số đỉnh tối thiểu.
|
||||
const finishLine = () => {
|
||||
if (getMode() !== "add-line" || coords.length < 2) return;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "LineString",
|
||||
coordinates: [...coords],
|
||||
};
|
||||
|
||||
onComplete(geometry);
|
||||
cancelLine();
|
||||
};
|
||||
|
||||
// Xóa đỉnh cuối cùng trong line đang vẽ.
|
||||
const removeLastVertex = () => {
|
||||
if (!coords.length) return;
|
||||
coords = coords.slice(0, -1);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Thêm một đỉnh line khi click map.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Cập nhật preview động theo vị trí chuột.
|
||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
if (getMode() !== "add-line") {
|
||||
if (coords.length) {
|
||||
cancelLine();
|
||||
}
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.style.cursor = "crosshair";
|
||||
if (coords.length === 0) return;
|
||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||
};
|
||||
|
||||
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
removeLastVertex();
|
||||
}
|
||||
};
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
cancelLine();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelLine,
|
||||
};
|
||||
}
|
||||
143
lib/engine/pathEngine.ts
Normal file
143
lib/engine/pathEngine.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ path (gấp khúc, sẽ render có mũi tên ở layer path).
|
||||
export function initPath(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
// Xóa dữ liệu preview path.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Cập nhật path preview theo danh sách tọa độ tạm.
|
||||
const updatePreview = (lineCoords: [number, number][]) => {
|
||||
if (lineCoords.length < 2) {
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: lineCoords,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
// Chốt path khi đủ số đỉnh tối thiểu.
|
||||
const finishPath = () => {
|
||||
if (getMode() !== "add-path" || coords.length < 2) return;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "LineString",
|
||||
coordinates: [...coords],
|
||||
};
|
||||
|
||||
onComplete(geometry);
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ path hiện tại.
|
||||
const cancelPath = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Xóa đỉnh cuối cùng của path đang vẽ.
|
||||
const removeLastVertex = () => {
|
||||
if (coords.length === 0) return;
|
||||
coords = coords.slice(0, -1);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Thêm một đỉnh path khi click map.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-path") return;
|
||||
|
||||
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Cập nhật preview path động theo vị trí chuột.
|
||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
if (getMode() !== "add-path") {
|
||||
if (coords.length) {
|
||||
cancelPath();
|
||||
}
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.style.cursor = "crosshair";
|
||||
if (coords.length === 0) return;
|
||||
|
||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||
};
|
||||
|
||||
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-path") return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishPath();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelPath();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
removeLastVertex();
|
||||
}
|
||||
};
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
cancelPath();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelPath,
|
||||
};
|
||||
}
|
||||
46
lib/engine/pointEngine.ts
Normal file
46
lib/engine/pointEngine.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
// Khởi tạo engine thêm point bằng click đơn.
|
||||
export function initPoint(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
// Thêm point mới khi đang ở chế độ add-point.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "add-point") return;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "Point",
|
||||
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||
};
|
||||
|
||||
onComplete?.(geometry);
|
||||
}
|
||||
|
||||
// Cập nhật trạng thái con trỏ theo mode add-point.
|
||||
function onMove() {
|
||||
const canvas = map.getCanvas();
|
||||
if (getMode() === "add-point") {
|
||||
canvas.style.cursor = "crosshair";
|
||||
return;
|
||||
}
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
}
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
|
||||
return () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
}
|
||||
259
lib/engine/selectingEngine.ts
Normal file
259
lib/engine/selectingEngine.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
// 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,
|
||||
onSelectId?: (id: string | number | null) => void
|
||||
) {
|
||||
const SELECTABLE_LAYERS = [
|
||||
"countries-fill",
|
||||
"countries-line",
|
||||
"routes-line",
|
||||
"routes-path-arrow-fill",
|
||||
"routes-path-arrow-line",
|
||||
"routes-path-hit",
|
||||
"places-circle",
|
||||
"places-symbol",
|
||||
] as const;
|
||||
const FEATURE_STATE_SOURCES = [
|
||||
"countries",
|
||||
"places",
|
||||
"path-arrow-shapes",
|
||||
] as const;
|
||||
const selectedIds = new Set<number | string>();
|
||||
const hasContextActions = Boolean(onDelete || onEdit);
|
||||
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) {
|
||||
onSelectId?.(null);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
if (additive && selectedIds.has(id)) {
|
||||
// Alt + click on an already selected feature removes it from the selection
|
||||
setSelectionStateForId(id, false);
|
||||
selectedIds.delete(id);
|
||||
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionStateForId(id, true);
|
||||
selectedIds.add(id);
|
||||
onSelectId?.(selectedIds.size === 1 ? id : null);
|
||||
}
|
||||
|
||||
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select") 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(features[0], 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") return;
|
||||
const selectableLayers = getSelectableLayers();
|
||||
if (!selectableLayers.length) return;
|
||||
|
||||
e.preventDefault(); // block browser menu
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: selectableLayers,
|
||||
}) as maplibregl.MapGeoJSONFeature[];
|
||||
|
||||
if (!features.length) return;
|
||||
|
||||
const feature = features[0];
|
||||
const id = feature.id ?? feature.properties?.id;
|
||||
if (id === undefined || id === null) return;
|
||||
|
||||
// if right-clicked item not selected, make it the sole selection
|
||||
if (!selectedIds.has(id)) {
|
||||
clearSelection();
|
||||
selectFeature(feature, false);
|
||||
}
|
||||
|
||||
showContextMenu(
|
||||
e.originalEvent?.clientX ?? e.point.x,
|
||||
e.originalEvent?.clientY ?? e.point.y,
|
||||
feature
|
||||
);
|
||||
}
|
||||
|
||||
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
|
||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select") 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[] {
|
||||
return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId)));
|
||||
}
|
||||
|
||||
function setSelectionStateForId(id: string | number, selected: boolean) {
|
||||
for (const source of FEATURE_STATE_SOURCES) {
|
||||
if (!map.getSource(source)) continue;
|
||||
map.setFeatureState({ source, id }, { selected });
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// Ẩ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
|
||||
) {
|
||||
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 || 1;
|
||||
let hasMenuItems = false;
|
||||
|
||||
if (
|
||||
selectedCount === 1 &&
|
||||
clickedFeature.source === "countries" &&
|
||||
clickedFeature.geometry?.type === "Polygon" &&
|
||||
onEdit
|
||||
) {
|
||||
const single = clickedFeature;
|
||||
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||
hasMenuItems = true;
|
||||
}
|
||||
|
||||
if (onDelete) {
|
||||
menu.appendChild(
|
||||
createItem(
|
||||
selectedCount > 1 ? `Xóa ${selectedCount} 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;
|
||||
|
||||
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