refactor(B05): 3D 뷰어 포인터 입력 분리 (803→678줄)
- `B05_Profile_UI_Viewer_Marker_Input.ts`(196줄) 신설 — 지형 지점 픽킹, 마커 끌기· 고르기, 끌어놓기(drop), 리스너 등록·해제를 `bindMarkerPointerControls` 로 묶음. - 뷰어는 `markerInput.blocked()`(회전 중심 양보 판정)·`terrainPointAt`(공개 API `modelPointAt`)·`beginMoveSelected` 만 받아 쓰고, dispose 에서 함께 해제. - 화면 검증: 공용 브라우저에서 BP 마커를 실제 클릭 — 선택 배율 1 → 1.35, 빈 지형 클릭으로 해제 1.0 복귀. 마커군 명중 시 이벤트 전파 차단도 유지됨. - `tsc --noEmit` 0, prettier 통과, pytest 359 passed.
This commit is contained in:
@@ -11,10 +11,10 @@ import {
|
|||||||
sceneToModel,
|
sceneToModel,
|
||||||
type ModelBounds,
|
type ModelBounds,
|
||||||
type RouteMarkers,
|
type RouteMarkers,
|
||||||
type RoutePointKind,
|
|
||||||
type SectionStationMarker,
|
type SectionStationMarker,
|
||||||
} from "./B05_Profile_UI_Markers";
|
} from "./B05_Profile_UI_Markers";
|
||||||
import { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera";
|
import { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera";
|
||||||
|
import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input";
|
||||||
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||||
import {
|
import {
|
||||||
createCorridorGroup,
|
createCorridorGroup,
|
||||||
@@ -184,10 +184,6 @@ export function createRouteViewer(): RouteViewer {
|
|||||||
let bounds: ModelBounds | null = null;
|
let bounds: ModelBounds | null = null;
|
||||||
let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null =
|
let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null =
|
||||||
null;
|
null;
|
||||||
let movingSelected = false;
|
|
||||||
let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null;
|
|
||||||
let draggingMarker = false;
|
|
||||||
let lastDragPoint: { x: number; y: number; z: number } | null = null;
|
|
||||||
/**
|
/**
|
||||||
* 모델 좌표 (x, y) 자리의 지형 표고. 지형 위에서 수직으로 광선을 쏴 맞은 높이를 돌려준다.
|
* 모델 좌표 (x, y) 자리의 지형 표고. 지형 위에서 수직으로 광선을 쏴 맞은 높이를 돌려준다.
|
||||||
* 계획노선 CSV로 만든 점처럼 표고가 없는 자리를 지형에 얹을 때 쓴다.
|
* 계획노선 CSV로 만든 점처럼 표고가 없는 자리를 지형에 얹을 때 쓴다.
|
||||||
@@ -218,6 +214,15 @@ export function createRouteViewer(): RouteViewer {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const markers = createRouteMarkers(scene, () => bounds, terrainElevation);
|
const markers = createRouteMarkers(scene, () => bounds, terrainElevation);
|
||||||
|
// 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한).
|
||||||
|
const markerInput = bindMarkerPointerControls({
|
||||||
|
canvas,
|
||||||
|
camera,
|
||||||
|
controls,
|
||||||
|
markers,
|
||||||
|
getTerrain: () => terrain,
|
||||||
|
getBounds: () => bounds,
|
||||||
|
});
|
||||||
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
|
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
|
||||||
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
|
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
|
||||||
const releaseCursorPivot = bindCursorPivotControls({
|
const releaseCursorPivot = bindCursorPivotControls({
|
||||||
@@ -225,7 +230,7 @@ export function createRouteViewer(): RouteViewer {
|
|||||||
controls,
|
controls,
|
||||||
element: canvas,
|
element: canvas,
|
||||||
pickables: () => (terrain ? [terrain] : []),
|
pickables: () => (terrain ? [terrain] : []),
|
||||||
blocked: () => dragCandidate !== null || draggingMarker || movingSelected,
|
blocked: () => markerInput.blocked(),
|
||||||
scene,
|
scene,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -310,132 +315,6 @@ export function createRouteViewer(): RouteViewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 화면(client) 좌표 아래 지형의 모델 좌표 — 우클릭 구조물 배치 등 외부 픽에도 쓴다. */
|
/** 화면(client) 좌표 아래 지형의 모델 좌표 — 우클릭 구조물 배치 등 외부 픽에도 쓴다. */
|
||||||
function terrainPointAt(
|
|
||||||
clientX: number,
|
|
||||||
clientY: number,
|
|
||||||
): { x: number; y: number; z: number } | null {
|
|
||||||
if (!terrain || !bounds) return null;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const pointer = new THREE.Vector2(
|
|
||||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
|
||||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
|
||||||
);
|
|
||||||
const raycaster = new THREE.Raycaster();
|
|
||||||
raycaster.setFromCamera(pointer, camera);
|
|
||||||
const hit = raycaster.intersectObject(terrain, true)[0];
|
|
||||||
return hit ? sceneToModel(hit.point, bounds) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function terrainPoint(
|
|
||||||
event: PointerEvent | DragEvent,
|
|
||||||
): { x: number; y: number; z: number } | null {
|
|
||||||
return terrainPointAt(event.clientX, event.clientY);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener("dragover", (event) => event.preventDefault());
|
|
||||||
canvas.addEventListener("drop", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const kind = event.dataTransfer?.getData("pointType") as RoutePointKind;
|
|
||||||
const point = terrainPoint(event);
|
|
||||||
if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point);
|
|
||||||
});
|
|
||||||
function markerHit(event: PointerEvent): THREE.Object3D | undefined {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const pointer = new THREE.Vector2(
|
|
||||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
|
||||||
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
|
||||||
);
|
|
||||||
const raycaster = new THREE.Raycaster();
|
|
||||||
raycaster.setFromCamera(pointer, camera);
|
|
||||||
return raycaster.intersectObject(markers.group, true)[0]?.object;
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishMarkerInteraction(selectCandidate: boolean): void {
|
|
||||||
if (dragCandidate && (draggingMarker || selectCandidate)) {
|
|
||||||
markers.selectPoint(dragCandidate.id);
|
|
||||||
}
|
|
||||||
if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) {
|
|
||||||
canvas.releasePointerCapture(dragCandidate.pointerId);
|
|
||||||
}
|
|
||||||
controls.enabled = true;
|
|
||||||
dragCandidate = null;
|
|
||||||
draggingMarker = false;
|
|
||||||
lastDragPoint = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent): void {
|
|
||||||
if (event.button !== 0) return;
|
|
||||||
const hit = markerHit(event);
|
|
||||||
const pointId = markers.pointIdForObject(hit);
|
|
||||||
if (pointId) {
|
|
||||||
dragCandidate = {
|
|
||||||
id: pointId,
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY,
|
|
||||||
};
|
|
||||||
draggingMarker = false;
|
|
||||||
lastDragPoint = null;
|
|
||||||
canvas.setPointerCapture(event.pointerId);
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hit) {
|
|
||||||
markers.selectObject(hit);
|
|
||||||
event.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (movingSelected) {
|
|
||||||
const point = terrainPoint(event);
|
|
||||||
if (point) markers.moveSelected(point);
|
|
||||||
movingSelected = false;
|
|
||||||
} else {
|
|
||||||
markers.selectObject(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerMove(event: PointerEvent): void {
|
|
||||||
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (
|
|
||||||
!draggingMarker &&
|
|
||||||
Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3
|
|
||||||
) {
|
|
||||||
draggingMarker = true;
|
|
||||||
controls.enabled = false;
|
|
||||||
}
|
|
||||||
if (!draggingMarker) return;
|
|
||||||
const point = terrainPoint(event);
|
|
||||||
if (!point) return;
|
|
||||||
lastDragPoint = point;
|
|
||||||
markers.movePoint(dragCandidate.id, point);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerUp(event: PointerEvent): void {
|
|
||||||
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (draggingMarker) {
|
|
||||||
const point = terrainPoint(event) ?? lastDragPoint;
|
|
||||||
if (point) markers.movePoint(dragCandidate.id, point);
|
|
||||||
}
|
|
||||||
finishMarkerInteraction(!draggingMarker);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerExit(event: PointerEvent): void {
|
|
||||||
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
finishMarkerInteraction(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener("pointerdown", handlePointerDown, true);
|
|
||||||
canvas.addEventListener("pointermove", handlePointerMove, true);
|
|
||||||
canvas.addEventListener("pointerup", handlePointerUp, true);
|
|
||||||
canvas.addEventListener("pointerleave", handlePointerExit, true);
|
|
||||||
canvas.addEventListener("pointercancel", handlePointerExit, true);
|
|
||||||
|
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
function animate(): void {
|
function animate(): void {
|
||||||
@@ -774,20 +653,16 @@ export function createRouteViewer(): RouteViewer {
|
|||||||
renderStationLines: markers.renderStationLines,
|
renderStationLines: markers.renderStationLines,
|
||||||
setView: fit,
|
setView: fit,
|
||||||
beginMoveSelected() {
|
beginMoveSelected() {
|
||||||
movingSelected = true;
|
markerInput.beginMoveSelected();
|
||||||
status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요.";
|
status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요.";
|
||||||
},
|
},
|
||||||
modelPointAt: terrainPointAt,
|
modelPointAt: markerInput.terrainPointAt,
|
||||||
dispose() {
|
dispose() {
|
||||||
|
markerInput.dispose();
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
themeObserver.disconnect();
|
themeObserver.disconnect();
|
||||||
systemDarkTheme.removeEventListener("change", updateSceneBackground);
|
systemDarkTheme.removeEventListener("change", updateSceneBackground);
|
||||||
canvas.removeEventListener("pointerdown", handlePointerDown, true);
|
|
||||||
canvas.removeEventListener("pointermove", handlePointerMove, true);
|
|
||||||
canvas.removeEventListener("pointerup", handlePointerUp, true);
|
|
||||||
canvas.removeEventListener("pointerleave", handlePointerExit, true);
|
|
||||||
canvas.removeEventListener("pointercancel", handlePointerExit, true);
|
|
||||||
releaseCursorPivot();
|
releaseCursorPivot();
|
||||||
markers.dispose();
|
markers.dispose();
|
||||||
clearContours();
|
clearContours();
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B05_Profile_UI_Viewer_Marker_Input.ts
|
||||||
|
* 3D 뷰어의 **포인터 입력** — 지형 위 좌표 얻기, 마커 끌기·고르기, 끌어놓기(drop).
|
||||||
|
*
|
||||||
|
* `B05_Profile_UI_Viewer` 에서 떼어낸 몫이다(700줄 제한, 2026-09-02). 카메라·씬 조립과
|
||||||
|
* 코리도 조립은 뷰어에 남고, 여기에는 캔버스 이벤트로 마커를 다루는 흐름만 둔다.
|
||||||
|
* 회전 중심 유틸(`bindCursorPivotControls`)이 "지금 마커를 잡고 있나"를 묻는 창구도
|
||||||
|
* 여기서 낸다(`blocked`).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
|
import {
|
||||||
|
sceneToModel,
|
||||||
|
type ModelBounds,
|
||||||
|
type RouteMarkers,
|
||||||
|
type RoutePointKind,
|
||||||
|
} from "./B05_Profile_UI_Markers";
|
||||||
|
|
||||||
|
export interface MarkerPointerControls {
|
||||||
|
/** 화면 좌표 아래 지형 지점(모델 좌표). 지형·bounds가 없으면 null. */
|
||||||
|
terrainPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null;
|
||||||
|
/** 다음 클릭으로 선택 포인트를 옮기는 모드로 들어간다. */
|
||||||
|
beginMoveSelected: () => void;
|
||||||
|
/** 마커를 잡고 있거나 이동 대기 중인가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */
|
||||||
|
blocked: () => boolean;
|
||||||
|
/** 등록한 캔버스 이벤트를 모두 떼어낸다. */
|
||||||
|
dispose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindMarkerPointerControls(options: {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
camera: THREE.Camera;
|
||||||
|
controls: OrbitControls;
|
||||||
|
markers: RouteMarkers;
|
||||||
|
getTerrain: () => THREE.Object3D | null;
|
||||||
|
getBounds: () => ModelBounds | null;
|
||||||
|
}): MarkerPointerControls {
|
||||||
|
const { canvas, camera, controls, markers, getTerrain, getBounds } = options;
|
||||||
|
|
||||||
|
let movingSelected = false;
|
||||||
|
let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null;
|
||||||
|
let draggingMarker = false;
|
||||||
|
let lastDragPoint: { x: number; y: number; z: number } | null = null;
|
||||||
|
|
||||||
|
/** 화면 좌표 → 정규화 장치 좌표(-1~1). */
|
||||||
|
function pointerOf(clientX: number, clientY: number): THREE.Vector2 {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return new THREE.Vector2(
|
||||||
|
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||||
|
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function terrainPointAt(
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
): { x: number; y: number; z: number } | null {
|
||||||
|
const terrain = getTerrain();
|
||||||
|
const bounds = getBounds();
|
||||||
|
if (!terrain || !bounds) return null;
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
raycaster.setFromCamera(pointerOf(clientX, clientY), camera);
|
||||||
|
const hit = raycaster.intersectObject(terrain, true)[0];
|
||||||
|
return hit ? sceneToModel(hit.point, bounds) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function terrainPoint(
|
||||||
|
event: PointerEvent | DragEvent,
|
||||||
|
): { x: number; y: number; z: number } | null {
|
||||||
|
return terrainPointAt(event.clientX, event.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: DragEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: DragEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
const kind = event.dataTransfer?.getData("pointType") as RoutePointKind;
|
||||||
|
const point = terrainPoint(event);
|
||||||
|
if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerHit(event: PointerEvent): THREE.Object3D | undefined {
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera);
|
||||||
|
return raycaster.intersectObject(markers.group, true)[0]?.object;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishMarkerInteraction(selectCandidate: boolean): void {
|
||||||
|
if (dragCandidate && (draggingMarker || selectCandidate)) {
|
||||||
|
markers.selectPoint(dragCandidate.id);
|
||||||
|
}
|
||||||
|
if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) {
|
||||||
|
canvas.releasePointerCapture(dragCandidate.pointerId);
|
||||||
|
}
|
||||||
|
controls.enabled = true;
|
||||||
|
dragCandidate = null;
|
||||||
|
draggingMarker = false;
|
||||||
|
lastDragPoint = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent): void {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
const hit = markerHit(event);
|
||||||
|
const pointId = markers.pointIdForObject(hit);
|
||||||
|
if (pointId) {
|
||||||
|
dragCandidate = {
|
||||||
|
id: pointId,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
};
|
||||||
|
draggingMarker = false;
|
||||||
|
lastDragPoint = null;
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hit) {
|
||||||
|
markers.selectObject(hit);
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (movingSelected) {
|
||||||
|
const point = terrainPoint(event);
|
||||||
|
if (point) markers.moveSelected(point);
|
||||||
|
movingSelected = false;
|
||||||
|
} else {
|
||||||
|
markers.selectObject(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent): void {
|
||||||
|
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (
|
||||||
|
!draggingMarker &&
|
||||||
|
Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3
|
||||||
|
) {
|
||||||
|
draggingMarker = true;
|
||||||
|
controls.enabled = false;
|
||||||
|
}
|
||||||
|
if (!draggingMarker) return;
|
||||||
|
const point = terrainPoint(event);
|
||||||
|
if (!point) return;
|
||||||
|
lastDragPoint = point;
|
||||||
|
markers.movePoint(dragCandidate.id, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent): void {
|
||||||
|
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (draggingMarker) {
|
||||||
|
const point = terrainPoint(event) ?? lastDragPoint;
|
||||||
|
if (point) markers.movePoint(dragCandidate.id, point);
|
||||||
|
}
|
||||||
|
finishMarkerInteraction(!draggingMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerExit(event: PointerEvent): void {
|
||||||
|
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
finishMarkerInteraction(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener("dragover", handleDragOver);
|
||||||
|
canvas.addEventListener("drop", handleDrop);
|
||||||
|
canvas.addEventListener("pointerdown", handlePointerDown, true);
|
||||||
|
canvas.addEventListener("pointermove", handlePointerMove, true);
|
||||||
|
canvas.addEventListener("pointerup", handlePointerUp, true);
|
||||||
|
canvas.addEventListener("pointerleave", handlePointerExit, true);
|
||||||
|
canvas.addEventListener("pointercancel", handlePointerExit, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
terrainPointAt,
|
||||||
|
beginMoveSelected: () => {
|
||||||
|
movingSelected = true;
|
||||||
|
},
|
||||||
|
blocked: () => dragCandidate !== null || draggingMarker || movingSelected,
|
||||||
|
dispose: () => {
|
||||||
|
canvas.removeEventListener("dragover", handleDragOver);
|
||||||
|
canvas.removeEventListener("drop", handleDrop);
|
||||||
|
canvas.removeEventListener("pointerdown", handlePointerDown, true);
|
||||||
|
canvas.removeEventListener("pointermove", handlePointerMove, true);
|
||||||
|
canvas.removeEventListener("pointerup", handlePointerUp, true);
|
||||||
|
canvas.removeEventListener("pointerleave", handlePointerExit, true);
|
||||||
|
canvas.removeEventListener("pointercancel", handlePointerExit, true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user