adds context menu for point-click waypoints and Gmaps
7 files changed,  +401, -5
A client/lib/es6/src/ui/ContextMenu.res.mjs
+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 */
M client/lib/es6/src/ui/FloatingMenu.res.mjs
+0, -3
 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;
M client/lib/es6/src/ui/MapInstance.res.mjs
+10, -2
 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 }
A client/lib/es6/test/ContextMenu_test.res.mjs
+45, -0
 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 */
A client/src/ui/ContextMenu.res
+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+}
M client/src/ui/MapInstance.res
+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 }
A client/test/ContextMenu_test.res
+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+})