7 files changed,
+401,
-5
+173,
-0
1@@ -0,0 +1,173 @@
2+// Generated by ReScript, PLEASE EDIT WITH CARE
3+
4+import * as Log from "../utils/Log.res.mjs";
5+import * as State from "../State.res.mjs";
6+import * as Waldo from "../ffi/Waldo.res.mjs";
7+import * as NanoID from "../ffi/NanoID.res.mjs";
8+import * as Core__Option from "@rescript/core/lib/es6/src/Core__Option.res.mjs";
9+import * as LucidePreact from "lucide-preact";
10+import * as Signals from "@preact/signals";
11+import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
12+import * as JsxRuntime from "preact/jsx-runtime";
13+import * as ReactMaplibre from "@vis.gl/react-maplibre";
14+
15+let show = Signals.signal(undefined);
16+
17+async function handleWaypoint(lon, lat, editor) {
18+ let params = {
19+ lon: lon,
20+ lat: lat
21+ };
22+ let results = await Waldo.Reverse.geocode(params);
23+ switch (results.TAG) {
24+ case "Success" :
25+ let result = results._0[0];
26+ if (result !== undefined) {
27+ let waypointContent = {
28+ type: "waypoint",
29+ attrs: {
30+ id: NanoID.make(),
31+ point: [
32+ lon,
33+ lat
34+ ],
35+ label: result.name,
36+ country: result.country,
37+ gid: result.gid.toString(),
38+ from: undefined,
39+ cons: [],
40+ nonroutable: false,
41+ error: undefined
42+ }
43+ };
44+ let textContent = {
45+ type: "text",
46+ content: " "
47+ };
48+ if (editor !== undefined) {
49+ let ed = Primitive_option.valFromOption(editor);
50+ ed.commands.insertContent(waypointContent);
51+ ed.commands.insertContent(textContent);
52+ }
53+ } else {
54+ Log.warn("Reverse geocode returned no results", {
55+ lon: lon.toString(),
56+ lat: lat.toString()
57+ });
58+ }
59+ break;
60+ case "Error" :
61+ Log.error("Reverse geocode error", {
62+ error: results._0,
63+ lon: lon.toString(),
64+ lat: lat.toString()
65+ });
66+ break;
67+ case "NetworkError" :
68+ Log.error("Reverse geocode network error", {
69+ error: results._0,
70+ lon: lon.toString(),
71+ lat: lat.toString()
72+ });
73+ break;
74+ }
75+ show.value = undefined;
76+}
77+
78+function ContextMenu(props) {
79+ let editor = State.Editor.get(props.doc);
80+ let evt = show.value;
81+ let match = evt !== undefined ? [
82+ evt.lngLat.lng,
83+ evt.lngLat.lat
84+ ] : [
85+ 0.0,
86+ 0.0
87+ ];
88+ let lat = match[1];
89+ let lon = match[0];
90+ if (Core__Option.isSome(show.value)) {
91+ return JsxRuntime.jsxs(JsxRuntime.Fragment, {
92+ children: [
93+ JsxRuntime.jsx(ReactMaplibre.Marker, {
94+ longitude: lon,
95+ latitude: lat,
96+ children: JsxRuntime.jsx(LucidePreact.Dot, {
97+ strokeWidth: 6,
98+ className: "text-(--color-orange)"
99+ })
100+ }),
101+ JsxRuntime.jsxs(ReactMaplibre.Popup, {
102+ longitude: lon,
103+ latitude: lat,
104+ anchor: "top-left",
105+ children: [
106+ JsxRuntime.jsx("style", {
107+ children: "\n .maplibregl-popup-content {\n margin: 0;\n padding: 0;\n padding-top: 2px;\n }\n "
108+ }),
109+ JsxRuntime.jsxs("div", {
110+ children: [
111+ JsxRuntime.jsxs("div", {
112+ children: [
113+ JsxRuntime.jsx(LucidePreact.MapPinned, {
114+ className: "my-auto"
115+ }),
116+ JsxRuntime.jsx("a", {
117+ children: "Open Google Maps",
118+ className: "w-full px-2 py-1 text-md",
119+ href: `https://www.google.com/maps/@` + lat.toString() + `,` + lon.toString() + `,15z`,
120+ target: "_blank",
121+ onClick: param => {
122+ show.value = undefined;
123+ }
124+ })
125+ ],
126+ className: "flex flex-row gap-1 hover:bg-blue-100 justify-start px-1 w-full"
127+ }),
128+ JsxRuntime.jsxs("div", {
129+ children: [
130+ JsxRuntime.jsx(LucidePreact.LocateFixed, {
131+ className: "my-auto"
132+ }),
133+ JsxRuntime.jsx("button", {
134+ children: "Add waypoint",
135+ className: "w-full py-1 px-2 text-md",
136+ onClick: param => {
137+ handleWaypoint(lon, lat, editor);
138+ }
139+ })
140+ ],
141+ className: "flex flex-row gap-1 hover:bg-blue-100 justify-start px-1 w-full"
142+ })
143+ ],
144+ className: "flex flex-col m-0 p-0 rounded-b-sm rounded-r-sm gap-1 justify-start items-start"
145+ })
146+ ],
147+ style: {
148+ margin: 0,
149+ padding: 0,
150+ background: "#FFFFFF00"
151+ },
152+ className: "m-0 p-0 bg-[#FFFFFF00]",
153+ onClose: param => {
154+ show.value = undefined;
155+ },
156+ closeButton: false,
157+ closeOnClick: true,
158+ closeOnMove: true
159+ })
160+ ]
161+ });
162+ } else {
163+ return null;
164+ }
165+}
166+
167+let make = ContextMenu;
168+
169+export {
170+ show,
171+ handleWaypoint,
172+ make,
173+}
174+/* show Not a pure module */
1@@ -1,6 +1,5 @@
2 // Generated by ReScript, PLEASE EDIT WITH CARE
3
4-import * as Log from "../utils/Log.res.mjs";
5 import * as Editor from "./Editor.res.mjs";
6 import * as NanoID from "../ffi/NanoID.res.mjs";
7 import * as Preact from "../ffi/Preact.res.mjs";
8@@ -155,10 +154,8 @@ function FloatingMenu(props) {
9 });
10 Hooks.useEffect(() => {
11 let handleKeyDown = evt => {
12- Log.info(`key: ` + evt.key, undefined);
13 if (evt.metaKey && evt.shiftKey && evt.key === "P") {
14 evt.preventDefault();
15- Log.info("got menu open shortcut", undefined);
16 if (!show.value) {
17 show.value = true;
18 return;
1@@ -7,6 +7,7 @@ import * as Style from "../ffi/Style.res.mjs";
2 import * as Preact from "../ffi/Preact.res.mjs";
3 import * as Window from "../ffi/Window.res.mjs";
4 import * as MapEvents from "./MapEvents.res.mjs";
5+import * as ContextMenu from "./ContextMenu.res.mjs";
6 import * as Core__Option from "@rescript/core/lib/es6/src/Core__Option.res.mjs";
7 import * as Hooks from "preact/hooks";
8 import * as LucidePreact from "lucide-preact";
9@@ -63,7 +64,8 @@ let popup = Signals.signal(undefined);
10 let initialRender = Signals.signal(true);
11
12 function MapInstance(props) {
13- let waypoints = State.getOrCreateWaypointsSignal(props.doc);
14+ let doc = props.doc;
15+ let waypoints = State.getOrCreateWaypointsSignal(doc);
16 let cmap = Hooks.useRef(null);
17 let flyToPoint = (point, padding) => {
18 let match = cmap.current;
19@@ -216,6 +218,9 @@ function MapInstance(props) {
20 onMouseLeave: _event => {
21 popup.value = undefined;
22 },
23+ onContextMenu: evt => {
24+ ContextMenu.show.value = evt;
25+ },
26 onMoveEnd: event => {
27 State.center.value = [
28 event.viewState.longitude,
29@@ -248,7 +253,10 @@ function MapInstance(props) {
30 }, `route-fragment-` + hash);
31 }),
32 tmp,
33- Preact.option(popup.value)
34+ Preact.option(popup.value),
35+ JsxRuntime.jsx(ContextMenu.make, {
36+ doc: doc
37+ })
38 ]
39 });
40 }
1@@ -0,0 +1,45 @@
2+// Generated by ReScript, PLEASE EDIT WITH CARE
3+
4+import * as Vitest from "vitest";
5+import * as Waypoint from "../src/ffi/Waypoint.res.mjs";
6+
7+Vitest.describe("ContextMenu", () => {
8+ Vitest.test("waypoint content decodes correctly from Waldo result", ctx => {
9+ let waypointContent = {
10+ type: "waypoint",
11+ attrs: {
12+ id: "test-id",
13+ point: [
14+ -77.0369,
15+ 38.9072
16+ ],
17+ label: "Washington",
18+ country: "US",
19+ gid: (102191569).toString(),
20+ from: undefined,
21+ cons: [],
22+ nonroutable: false,
23+ error: undefined
24+ }
25+ };
26+ let attrs = Waypoint.fromJSON(waypointContent.attrs);
27+ if (attrs.TAG === "Ok") {
28+ let attrs$1 = attrs._0;
29+ ctx.expect(attrs$1.label).toBe("Washington");
30+ ctx.expect(attrs$1.country).toBe("US");
31+ ctx.expect(attrs$1.gid).toBe("102191569");
32+ ctx.expect(attrs$1.point).toEqual([
33+ -77.0369,
34+ 38.9072
35+ ]);
36+ ctx.expect(attrs$1.cons).toEqual([]);
37+ ctx.expect(attrs$1.nonroutable).toBe(false);
38+ ctx.expect(attrs$1.error).toEqual(undefined);
39+ ctx.expect(attrs$1.from).toEqual(undefined);
40+ return;
41+ }
42+ ctx.fail(`Waypoint decode failed: ` + attrs._0);
43+ });
44+});
45+
46+/* Not a pure module */
+123,
-0
1@@ -0,0 +1,123 @@
2+let show: Preact.Signal.t<option<Maplibre.mapLayerMouseEvent>> = Preact.Signal.make(None)
3+let handleWaypoint = async (lon: float, lat: float, editor: option<Tiptap.t>) => {
4+ let params: Waldo.Reverse.params = {lon, lat}
5+
6+ switch await Waldo.Reverse.geocode(~params) {
7+ | Success(results) => switch results->Array.get(0) {
8+ | Some(result) => {
9+ let waypointContent = Obj.magic({
10+ "type": "waypoint",
11+ "attrs": {
12+ "id": NanoID.make(),
13+ "point": (lon, lat),
14+ "label": result.name,
15+ "country": result.country,
16+ "gid": Int.toString(result.gid),
17+ "from": None,
18+ "cons": [],
19+ "nonroutable": false,
20+ "error": None,
21+ },
22+ })
23+
24+ let textContent = Obj.magic({"type": "text", "content": " "})
25+
26+ switch editor {
27+ | Some(ed) => {
28+ ed->Tiptap.Commands.insertContent(waypointContent)
29+ ed->Tiptap.Commands.insertContent(textContent)
30+ }
31+ | None => ()
32+ }
33+ }
34+ | None => Log.warn(
35+ "Reverse geocode returned no results",
36+ ~kv={
37+ "lon": Float.toString(lon),
38+ "lat": Float.toString(lat),
39+ },
40+ )
41+ }
42+ | Error(msg) => Log.error(
43+ "Reverse geocode error",
44+ ~kv={
45+ "error": msg,
46+ "lon": Float.toString(lon),
47+ "lat": Float.toString(lat),
48+ },
49+ )
50+ | NetworkError(msg) => Log.error(
51+ "Reverse geocode network error",
52+ ~kv={
53+ "error": msg,
54+ "lon": Float.toString(lon),
55+ "lat": Float.toString(lat),
56+ },
57+ )
58+ }
59+
60+ show->Preact.Signal.set(None)
61+}
62+
63[email protected]
64+let make = (~doc: string) => {
65+ let editor = State.Editor.get(doc)
66+ let (lon, lat) = switch show->Preact.Signal.get {
67+ | Some(evt) => (evt.lngLat.lng, evt.lngLat.lat)
68+ | None => (0.0, 0.0)
69+ }
70+ show->Preact.Signal.get->Option.isSome
71+ ? <>
72+ <Maplibre.Marker latitude={lat} longitude={lon}>
73+ <Icons.Dot className="text-(--color-orange)" strokeWidth={6} />
74+ </Maplibre.Marker>
75+ <Maplibre.Popup
76+ anchor={TopLeft}
77+ longitude={lon}
78+ latitude={lat}
79+ closeButton={false}
80+ onClose={_ => show->Preact.Signal.set(None)}
81+ closeOnClick={true}
82+ closeOnMove={true}
83+ className="m-0 p-0 bg-[#FFFFFF00]"
84+ style={{"margin": 0, "padding": 0, "background": "#FFFFFF00"}}
85+ >
86+ <style>
87+ {"
88+ .maplibregl-popup-content {
89+ margin: 0;
90+ padding: 0;
91+ padding-top: 2px;
92+ }
93+ "->Preact.string}
94+ </style>
95+ <div
96+ className="flex flex-col m-0 p-0 rounded-b-sm rounded-r-sm gap-1 justify-start items-start"
97+ >
98+ <div className="flex flex-row gap-1 hover:bg-blue-100 justify-start px-1 w-full">
99+ <Icons.MapPinned className="my-auto" />
100+ <a
101+ className="w-full px-2 py-1 text-md"
102+ href={`https://www.google.com/maps/@${lat->Float.toString},${lon->Float.toString},15z`}
103+ target="_blank"
104+ onClick={_ => show->Preact.Signal.set(None)}
105+ >
106+ {"Open Google Maps"->Preact.string}
107+ </a>
108+ </div>
109+ <div className="flex flex-row gap-1 hover:bg-blue-100 justify-start px-1 w-full">
110+ <Icons.Waypoint className="my-auto" />
111+ <button
112+ className="w-full py-1 px-2 text-md"
113+ onClick={_ => {
114+ let _ = handleWaypoint(lon, lat, editor)
115+ }}
116+ >
117+ {"Add waypoint"->Preact.string}
118+ </button>
119+ </div>
120+ </div>
121+ </Maplibre.Popup>
122+ </>
123+ : Preact.null
124+}
+2,
-0
1@@ -73,6 +73,7 @@ let make = (~doc: string) => {
2 longitude: 83.85382,
3 zoom: 8.0,
4 }
5+ onContextMenu={evt => ContextMenu.show->Preact.Signal.set(Some(evt))}
6 onMoveEnd={event => {
7 // update map state for other components read-only view
8 State.center->Preact.Signal.set((event.viewState.longitude, event.viewState.latitude))
9@@ -211,5 +212,6 @@ let make = (~doc: string) => {
10 ->Preact.array
11 }}
12 {popup->Preact.Signal.get->Preact.option}
13+ <ContextMenu doc={doc} />
14 </Maplibre.Map>
15 }
+48,
-0
1@@ -0,0 +1,48 @@
2+open VitestHelpers
3+
4+describe("ContextMenu", () => {
5+ test("waypoint content decodes correctly from Waldo result", ctx => {
6+ let result: Waldo.result = {
7+ gid: 102191569,
8+ name: "Washington",
9+ placetype: "region",
10+ lon: -77.0369,
11+ lat: 38.9072,
12+ distance: 0.0,
13+ bearing: 0.0,
14+ region: ["Washington", "District of Columbia", "United States", "North America"],
15+ country: "US",
16+ }
17+
18+ // Construct content using the same pattern as ContextMenu.handleWaypoint
19+ let waypointContent = Obj.magic({
20+ "type": "waypoint",
21+ "attrs": {
22+ "id": "test-id",
23+ "point": (result.lon, result.lat),
24+ "label": result.name,
25+ "country": result.country,
26+ "gid": Int.toString(result.gid),
27+ "from": None,
28+ "cons": [],
29+ "nonroutable": false,
30+ "error": None,
31+ },
32+ })
33+
34+ // Verify round-trip: JSON -> Waypoint.attributes decodes correctly
35+ switch Waypoint.fromJSON(Obj.magic(waypointContent)["attrs"]) {
36+ | Ok(attrs) => {
37+ ctx->expect(attrs.label)->toBe("Washington")
38+ ctx->expect(attrs.country)->toBe("US")
39+ ctx->expect(attrs.gid)->toBe("102191569")
40+ ctx->expect(attrs.point)->toEqual((-77.0369, 38.9072))
41+ ctx->expect(attrs.cons)->toEqual([])
42+ ctx->expect(attrs.nonroutable)->toBe(false)
43+ ctx->expect(attrs.error)->toEqual(None)
44+ ctx->expect(attrs.from)->toEqual(None)
45+ }
46+ | Error(msg) => ctx->fail(`Waypoint decode failed: ${msg}`)
47+ }
48+ })
49+})