diff --git a/packages/e2e/src/index.ts b/packages/e2e/src/index.ts index eeb36acf..92fa2483 100644 --- a/packages/e2e/src/index.ts +++ b/packages/e2e/src/index.ts @@ -131,7 +131,9 @@ const example = { }, }, }), - new TerraDrawPointMode(), + new TerraDrawPointMode({ + editable: this.config?.includes("pointEditable"), + }), new TerraDrawLineStringMode({ snapping: { toCoordinate: this.config?.includes("snappingCoordinate"), diff --git a/packages/e2e/tests/leaflet.spec.ts b/packages/e2e/tests/leaflet.spec.ts index 77b3cd28..4973f997 100644 --- a/packages/e2e/tests/leaflet.spec.ts +++ b/packages/e2e/tests/leaflet.spec.ts @@ -71,6 +71,28 @@ test.describe("point mode", () => { await expectPaths({ page, count: 3 }); }); + + test("mode can set with editable set to true and points can be moved", async ({ + page, + }) => { + const mapDiv = await setupMap({ + page, + configQueryParam: ["pointEditable"], + }); + await changeMode({ page, mode }); + + await page.mouse.click(mapDiv.width / 2, mapDiv.height / 2); + await expectGroupPosition({ page, x: 633, y: 353 }); + + await page.mouse.move(mapDiv.width / 2, mapDiv.height / 2); + await page.mouse.down(); + await page.mouse.move(mapDiv.width / 3, mapDiv.height / 3); + await page.mouse.up(); + + await expectPaths({ page, count: 1 }); + + await expectGroupPosition({ page, x: 419, y: 233 }); + }); }); test.describe("linestring mode", () => { diff --git a/packages/e2e/tests/setup.ts b/packages/e2e/tests/setup.ts index 18268d67..509678e2 100644 --- a/packages/e2e/tests/setup.ts +++ b/packages/e2e/tests/setup.ts @@ -3,6 +3,7 @@ import { Page, expect } from "@playwright/test"; export const pageUrl = "http://localhost:3000/"; export type TestConfigOptions = + | "pointEditable" | "validationSuccess" | "validationFailure" | "insertCoordinates" diff --git a/packages/terra-draw/src/common.ts b/packages/terra-draw/src/common.ts index e72cfaa9..cff35073 100644 --- a/packages/terra-draw/src/common.ts +++ b/packages/terra-draw/src/common.ts @@ -172,6 +172,7 @@ export const SELECT_PROPERTIES = { } as const; export const COMMON_PROPERTIES = { + EDITED: "edited", CLOSING_POINT: "closingPoint", SNAPPING_POINT: "snappingPoint", }; diff --git a/packages/terra-draw/src/modes/point/point.mode.spec.ts b/packages/terra-draw/src/modes/point/point.mode.spec.ts index 6ec7f936..0c77875e 100644 --- a/packages/terra-draw/src/modes/point/point.mode.spec.ts +++ b/packages/terra-draw/src/modes/point/point.mode.spec.ts @@ -14,6 +14,11 @@ describe("TerraDrawPointMode", () => { it("constructs with options", () => { const pointMode = new TerraDrawPointMode({ + cursors: { + create: "crosshair", + dragStart: "grabbing", + dragEnd: "crosshair", + }, styles: { pointOutlineColor: "#ffffff" }, }); expect(pointMode.styles).toStrictEqual({ @@ -180,7 +185,7 @@ describe("TerraDrawPointMode", () => { }); describe("cleanUp", () => { - it("does nothing", () => { + it("does not throw", () => { const pointMode = new TerraDrawPointMode(); expect(() => { @@ -189,36 +194,241 @@ describe("TerraDrawPointMode", () => { }); }); - describe("onDrag", () => { - it("does nothing", () => { - const pointMode = new TerraDrawPointMode(); + describe("onDragStart", () => { + it("does not set cursor on drag starting if editable false", () => { + const pointMode = new TerraDrawPointMode({ + editable: false, + }); - expect(() => { - pointMode.onDrag(); - }).not.toThrow(); + const mockConfig = MockModeConfig("point"); + + pointMode.register(mockConfig); + + pointMode.onClick(MockCursorEvent({ lng: 0, lat: 0 })); + + const setMapDraggability = jest.fn(); + pointMode.onDragStart( + MockCursorEvent({ lng: 0, lat: 0 }), + setMapDraggability, + ); + + expect(mockConfig.setCursor).toHaveBeenCalledTimes(0); + expect(setMapDraggability).toHaveBeenCalledTimes(0); + }); + + it("sets the cursor on drag starting when editable true", () => { + const pointMode = new TerraDrawPointMode({ + editable: true, + }); + + const mockConfig = MockModeConfig("point"); + + // Trigger the codepath which ignores none point geometries + mockConfig.store.create([ + { + geometry: { + type: "Polygon", + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + [0, 0], + ], + ], + }, + properties: { mode: "polygon" }, + }, + ]); + + mockConfig.store.create([ + { + geometry: { + type: "Point", + coordinates: [0.1, 0.1], + }, + properties: { mode: "point" }, + }, + ]); + + mockConfig.store.create([ + { + geometry: { + type: "Point", + coordinates: [0.2, 0.2], + }, + properties: { mode: "point" }, + }, + ]); + + pointMode.register(mockConfig); + + pointMode.onClick(MockCursorEvent({ lng: 0, lat: 0 })); + + const setMapDraggability = jest.fn(); + pointMode.onDragStart( + MockCursorEvent({ lng: 0, lat: 0 }), + setMapDraggability, + ); + + expect(mockConfig.setCursor).toHaveBeenCalledTimes(1); + expect(setMapDraggability).toHaveBeenCalledWith(false); }); }); - describe("onDragStart", () => { - it("does nothing", () => { - const pointMode = new TerraDrawPointMode(); + describe("onDrag", () => { + it("does nothing if nothing currently edited when editable true", () => { + const pointMode = new TerraDrawPointMode({ + editable: true, + }); + const mockConfig = MockModeConfig("point"); - expect(() => { - pointMode.onDragStart(); - }).not.toThrow(); + pointMode.register(mockConfig); + + const setMapDraggability = jest.fn(); + pointMode.onDrag(MockCursorEvent({ lng: 0, lat: 0 }), setMapDraggability); + + expect(mockConfig.onChange).toHaveBeenCalledTimes(0); + }); + + it("updates the point geometry on drag when editable true", () => { + const pointMode = new TerraDrawPointMode({ + editable: true, + }); + + const mockConfig = MockModeConfig("point"); + + pointMode.register(mockConfig); + + pointMode.onClick(MockCursorEvent({ lng: 0, lat: 0 })); + + const setMapDraggability = jest.fn(); + pointMode.onDragStart( + MockCursorEvent({ lng: 0, lat: 0 }), + setMapDraggability, + ); + + pointMode.onDrag(MockCursorEvent({ lng: 0, lat: 1 }), setMapDraggability); + + expect(mockConfig.onChange).toHaveBeenCalledTimes(3); + expect(mockConfig.onChange).toHaveBeenNthCalledWith( + 2, + [expect.any(String)], + "update", + ); + + // On finished called from onClick and is then only called after onDragEnd + expect(mockConfig.onFinish).toHaveBeenCalledTimes(1); + }); + + it("handles the falsy validation when editable true", () => { + let validations = 0; + const pointMode = new TerraDrawPointMode({ + editable: true, + validation: () => { + validations++; + return { + valid: validations === 1, + }; + }, + }); + + const mockConfig = MockModeConfig("point"); + + pointMode.register(mockConfig); + + pointMode.onClick(MockCursorEvent({ lng: 0, lat: 0 })); + + const setMapDraggability = jest.fn(); + pointMode.onDragStart( + MockCursorEvent({ lng: 0, lat: 0 }), + setMapDraggability, + ); + + pointMode.onDrag(MockCursorEvent({ lng: 0, lat: 1 }), setMapDraggability); + + expect(mockConfig.onChange).toHaveBeenCalledTimes(1); + expect(mockConfig.onFinish).toHaveBeenCalledTimes(1); + }); + + it("handles the truthy validation when editable true", () => { + const pointMode = new TerraDrawPointMode({ + editable: true, + validation: () => ({ valid: true }), + }); + + const mockConfig = MockModeConfig("point"); + + pointMode.register(mockConfig); + + pointMode.onClick(MockCursorEvent({ lng: 0, lat: 0 })); + + const setMapDraggability = jest.fn(); + pointMode.onDragStart( + MockCursorEvent({ lng: 0, lat: 0 }), + setMapDraggability, + ); + + pointMode.onDrag(MockCursorEvent({ lng: 0, lat: 1 }), setMapDraggability); + + expect(mockConfig.onChange).toHaveBeenCalledTimes(3); + // On finished called from onClick and is then only called after onDragEnd + expect(mockConfig.onFinish).toHaveBeenCalledTimes(1); }); }); describe("onDragEnd", () => { - it("does nothing", () => { - const pointMode = new TerraDrawPointMode(); + it("doesn't set the cursor on drag ending if nothing currently edited", () => { + const pointMode = new TerraDrawPointMode({ + editable: true, + }); - expect(() => { - pointMode.onDragEnd(); - }).not.toThrow(); + const mockConfig = MockModeConfig("point"); + + pointMode.register(mockConfig); + + const setMapDraggability = jest.fn(); + pointMode.onDragEnd( + MockCursorEvent({ lng: 0, lat: 0 }), + setMapDraggability, + ); + + expect(mockConfig.setCursor).toHaveBeenCalledTimes(0); + expect(setMapDraggability).toHaveBeenCalledTimes(0); }); - }); + it("sets the cursor on drag ending", () => { + const pointMode = new TerraDrawPointMode({ + editable: true, + }); + + const mockConfig = MockModeConfig("point"); + + pointMode.register(mockConfig); + + pointMode.onClick(MockCursorEvent({ lng: 0, lat: 0 })); + + const setMapDraggability = jest.fn(); + pointMode.onDragStart( + MockCursorEvent({ lng: 0, lat: 0 }), + setMapDraggability, + ); + + pointMode.onDrag(MockCursorEvent({ lng: 1, lat: 0 }), setMapDraggability); + + pointMode.onDragEnd( + MockCursorEvent({ lng: 1, lat: 0 }), + setMapDraggability, + ); + + expect(mockConfig.setCursor).toHaveBeenCalledTimes(2); + expect(mockConfig.setCursor).toHaveBeenNthCalledWith(1, "grabbing"); + expect(mockConfig.setCursor).toHaveBeenNthCalledWith(2, "crosshair"); + expect(setMapDraggability).toHaveBeenNthCalledWith(1, false); + expect(setMapDraggability).toHaveBeenNthCalledWith(2, true); + }); + }); describe("styling", () => { it("gets", () => { const pointMode = new TerraDrawPointMode(); @@ -299,6 +509,42 @@ describe("TerraDrawPointMode", () => { pointOutlineWidth: 2, }); }); + + it("returns the correct styles for edited point", () => { + const pointMode = new TerraDrawPointMode({ + editable: true, + styles: { + editedPointColor: "#222222", + editedPointWidth: 3, + editedPointOutlineColor: "#555555", + editedPointOutlineWidth: 3, + }, + }); + + const mockConfig = MockModeConfig("point"); + + pointMode.register(mockConfig); + + pointMode.onClick(MockCursorEvent({ lng: 0, lat: 0 })); + + pointMode.onDragStart(MockCursorEvent({ lng: 0, lat: 0 }), jest.fn()); + + const id = mockConfig.onChange.mock.calls[0][0][0]; + + expect( + pointMode.styleFeature({ + type: "Feature", + id, + geometry: { type: "Point", coordinates: [] }, + properties: { mode: "point", edited: true }, + }), + ).toMatchObject({ + pointColor: "#222222", + pointWidth: 3, + pointOutlineColor: "#555555", + pointOutlineWidth: 3, + }); + }); }); describe("validateFeature", () => { diff --git a/packages/terra-draw/src/modes/point/point.mode.ts b/packages/terra-draw/src/modes/point/point.mode.ts index c7174b63..7f41a09a 100644 --- a/packages/terra-draw/src/modes/point/point.mode.ts +++ b/packages/terra-draw/src/modes/point/point.mode.ts @@ -5,8 +5,14 @@ import { HexColorStyling, Cursor, UpdateTypes, + COMMON_PROPERTIES, } from "../../common"; -import { GeoJSONStoreFeatures, StoreValidation } from "../../store/store"; +import { + BBoxPolygon, + FeatureId, + GeoJSONStoreFeatures, + StoreValidation, +} from "../../store/store"; import { getDefaultStyling } from "../../util/styling"; import { BaseModeOptions, @@ -14,33 +20,51 @@ import { TerraDrawBaseDrawMode, } from "../base.mode"; import { ValidatePointFeature } from "../../validations/point.validation"; -import { Point } from "geojson"; +import { Point, Position } from "geojson"; +import { BehaviorConfig } from "../base.behavior"; +import { ClickBoundingBoxBehavior } from "../click-bounding-box.behavior"; +import { PixelDistanceBehavior } from "../pixel-distance.behavior"; type PointModeStyling = { pointWidth: NumericStyling; pointColor: HexColorStyling; pointOutlineColor: HexColorStyling; pointOutlineWidth: NumericStyling; + editedPointColor: HexColorStyling; + editedPointWidth: NumericStyling; + editedPointOutlineColor: HexColorStyling; + editedPointOutlineWidth: NumericStyling; }; interface Cursors { create?: Cursor; + dragStart?: Cursor; + dragEnd?: Cursor; } interface TerraDrawPointModeOptions extends BaseModeOptions { cursors?: Cursors; + editable?: boolean; } export class TerraDrawPointMode extends TerraDrawBaseDrawMode { mode = "point"; private cursors: Required; + private editable: boolean; + private editedFeatureId: FeatureId | undefined; + + // Behaviors + private pixelDistance!: PixelDistanceBehavior; + private clickBoundingBox!: ClickBoundingBoxBehavior; constructor(options?: TerraDrawPointModeOptions) { super(options); const defaultCursors = { create: "crosshair", + dragStart: "grabbing", + dragEnd: "crosshair", } as Required; if (options && options.cursors) { @@ -48,6 +72,12 @@ export class TerraDrawPointMode extends TerraDrawBaseDrawMode } else { this.cursors = defaultCursors; } + + if (options && options.editable) { + this.editable = options.editable; + } else { + this.editable = false; + } } /** @internal */ @@ -112,16 +142,147 @@ export class TerraDrawPointMode extends TerraDrawBaseDrawMode onKeyUp() {} /** @internal */ - cleanUp() {} + cleanUp() { + this.editedFeatureId = undefined; + } - /** @internal */ - onDragStart() {} + onDragStart( + event: TerraDrawMouseEvent, + setMapDraggability: (enabled: boolean) => void, + ) { + if (this.editable) { + const bbox = this.clickBoundingBox.create(event) as BBoxPolygon; + const features = this.store.search(bbox); + + let distance = Infinity; + let clickedFeature: GeoJSONStoreFeatures | undefined = undefined; + + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + const isPoint = + feature.geometry.type === "Point" && + feature.properties.mode === this.mode; + + if (!isPoint) { + continue; + } + + const position = feature.geometry.coordinates as Position; + const distanceToFeature = this.pixelDistance.measure(event, position); + + if ( + distanceToFeature > distance || + distanceToFeature > this.pointerDistance + ) { + continue; + } + + distance = distanceToFeature; + clickedFeature = feature; + } + + if (clickedFeature) { + this.editedFeatureId = clickedFeature.id; + } + } + + // We only need to stop the map dragging if + // we actually have something selected + if (!this.editedFeatureId) { + return; + } + + // Drag Feature + this.setCursor(this.cursors.dragStart); + + setMapDraggability(false); + } /** @internal */ - onDrag() {} + onDrag( + event: TerraDrawMouseEvent, + setMapDraggability: (enabled: boolean) => void, + ) { + if (this.editedFeatureId === undefined) { + return; + } + + const newGeometry = { + type: "Point", + coordinates: [event.lng, event.lat], + }; + + if (this.validate) { + const validationResult = this.validate( + { + type: "Feature", + geometry: newGeometry, + properties: this.store.getPropertiesCopy(this.editedFeatureId), + } as GeoJSONStoreFeatures, + { + project: this.project, + unproject: this.unproject, + coordinatePrecision: this.coordinatePrecision, + updateType: UpdateTypes.Finish, + }, + ); + + if (!validationResult.valid) { + return; + } + } + + // For cursor points we can simply move it + // to the dragged position + this.store.updateGeometry([ + { + id: this.editedFeatureId, + geometry: { + type: "Point", + coordinates: [event.lng, event.lat], + }, + }, + ]); + + this.store.updateProperty([ + { + id: this.editedFeatureId, + property: COMMON_PROPERTIES.EDITED, + value: true, + }, + ]); + + setMapDraggability(true); + } /** @internal */ - onDragEnd() {} + onDragEnd( + _: TerraDrawMouseEvent, + setMapDraggability: (enabled: boolean) => void, + ) { + if (this.editedFeatureId === undefined) { + return; + } + + this.onFinish(this.editedFeatureId, { mode: this.mode, action: "edit" }); + + this.setCursor(this.cursors.dragEnd); + + this.store.updateProperty([ + { + id: this.editedFeatureId, + property: COMMON_PROPERTIES.EDITED, + value: false, + }, + ]); + this.editedFeatureId = undefined; + setMapDraggability(true); + } + + registerBehaviors(config: BehaviorConfig) { + this.pixelDistance = new PixelDistanceBehavior(config); + this.clickBoundingBox = new ClickBoundingBoxBehavior(config); + } /** @internal */ styleFeature(feature: GeoJSONStoreFeatures): TerraDrawAdapterStyling { @@ -132,26 +293,34 @@ export class TerraDrawPointMode extends TerraDrawBaseDrawMode feature.geometry.type === "Point" && feature.properties.mode === this.mode ) { + const isEdited = Boolean( + feature.id && this.editedFeatureId === feature.id, + ); + styles.pointWidth = this.getNumericStylingValue( - this.styles.pointWidth, + isEdited ? this.styles.editedPointWidth : this.styles.pointWidth, styles.pointWidth, feature, ); styles.pointColor = this.getHexColorStylingValue( - this.styles.pointColor, + isEdited ? this.styles.editedPointColor : this.styles.pointColor, styles.pointColor, feature, ); styles.pointOutlineColor = this.getHexColorStylingValue( - this.styles.pointOutlineColor, + isEdited + ? this.styles.editedPointOutlineColor + : this.styles.pointOutlineColor, styles.pointOutlineColor, feature, ); styles.pointOutlineWidth = this.getNumericStylingValue( - this.styles.pointOutlineWidth, + isEdited + ? this.styles.editedPointOutlineWidth + : this.styles.pointOutlineWidth, 2, feature, );