@gramps-connect/story 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/editor.js CHANGED
@@ -1,19 +1,19 @@
1
1
  // src/editor/StoryEditor.tsx
2
- import { useCallback, useEffect as useEffect8, useMemo as useMemo3, useRef as useRef9, useState as useState8 } from "react";
2
+ import { useCallback, useEffect as useEffect9, useMemo as useMemo4, useRef as useRef11, useState as useState10 } from "react";
3
3
  import {
4
- ActionIcon as ActionIcon2,
4
+ ActionIcon as ActionIcon3,
5
5
  Badge,
6
- Box as Box2,
7
- Button as Button4,
8
- Divider,
9
- Group as Group5,
10
- Modal,
6
+ Box as Box4,
7
+ Button as Button6,
8
+ Divider as Divider2,
9
+ Group as Group7,
10
+ Modal as Modal2,
11
11
  Popover,
12
12
  Select,
13
- Stack as Stack6,
14
- Text as Text5,
15
- TextInput as TextInput4,
16
- Tooltip as Tooltip3,
13
+ Stack as Stack8,
14
+ Text as Text7,
15
+ TextInput as TextInput6,
16
+ Tooltip as Tooltip4,
17
17
  UnstyledButton as UnstyledButton3
18
18
  } from "@mantine/core";
19
19
  import { useMediaQuery } from "@mantine/hooks";
@@ -2057,7 +2057,9 @@ function MapBackground({ initialCenter, current, dark, colors, panelFraction, oh
2057
2057
  type: "fill",
2058
2058
  source: SHAPE_SOURCE,
2059
2059
  filter: ["==", ["geometry-type"], "Polygon"],
2060
- paint: { "fill-color": color, "fill-opacity": 0.25 }
2060
+ // And its own `opacity` (what the map editor sets on an area) over
2061
+ // a light default.
2062
+ paint: { "fill-color": color, "fill-opacity": ["coalesce", ["to-number", ["get", "opacity"], 0.25], 0.25] }
2061
2063
  });
2062
2064
  map.addLayer({
2063
2065
  id: LINE_LAYER,
@@ -3345,7 +3347,7 @@ function PlaceMap({ point, around, dark = false, label, onPick }) {
3345
3347
 
3346
3348
  // src/editor/PlaceField.tsx
3347
3349
  import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
3348
- function PlaceField({ place, choices, around, language, search, dark, strings, onChange }) {
3350
+ function PlaceField({ place, choices, around, language, search, dark, strings, onChange, onEditMap }) {
3349
3351
  const lat = place?.lat;
3350
3352
  const lon = place?.lon;
3351
3353
  const point = lat !== void 0 && lon !== void 0 ? [lon, lat] : null;
@@ -3469,7 +3471,1550 @@ function PlaceField({ place, choices, around, language, search, dark, strings, o
3469
3471
  }
3470
3472
  ),
3471
3473
  /* @__PURE__ */ jsx8(Text3, { size: "xs", c: "dimmed", children: strings.placeMapHint }),
3472
- keeps.length > 0 && /* @__PURE__ */ jsx8(Text3, { size: "xs", c: "dimmed", children: strings.placeKeeps(keeps.join(", ")) })
3474
+ keeps.length > 0 && /* @__PURE__ */ jsx8(Text3, { size: "xs", c: "dimmed", children: strings.placeKeeps(keeps.join(", ")) }),
3475
+ onEditMap && /* @__PURE__ */ jsxs6(Group3, { gap: "sm", wrap: "nowrap", align: "center", children: [
3476
+ /* @__PURE__ */ jsx8(Button2, { variant: "default", size: "xs", style: { flex: "none" }, onClick: onEditMap, children: strings.editMap }),
3477
+ /* @__PURE__ */ jsx8(Text3, { size: "xs", c: "dimmed", children: strings.editMapHint })
3478
+ ] })
3479
+ ] });
3480
+ }
3481
+
3482
+ // src/editor/PlaceMapDialog.tsx
3483
+ import { useMemo as useMemo3, useRef as useRef9, useState as useState8 } from "react";
3484
+ import { ActionIcon as ActionIcon2, Box as Box3, Button as Button4, Group as Group5, Modal, Stack as Stack5, Text as Text5, TextInput as TextInput5, Tooltip as Tooltip3 } from "@mantine/core";
3485
+
3486
+ // src/mapEdit/model.ts
3487
+ var EDITOR_OWNED = /* @__PURE__ */ new Set(["name", "color", "opacity"]);
3488
+ var DROPPED = /* @__PURE__ */ new Set(["coordinateProperties", "_gpxType", "styleUrl", "@geometry-type"]);
3489
+ function round2d(position) {
3490
+ return [Number(position[0].toFixed(9)), Number(position[1].toFixed(9))];
3491
+ }
3492
+ function singleParts(geometry) {
3493
+ switch (geometry.type) {
3494
+ case "Point":
3495
+ return [{ type: "Point", coordinates: round2d(geometry.coordinates) }];
3496
+ case "LineString":
3497
+ return [{ type: "LineString", coordinates: geometry.coordinates.map(round2d) }];
3498
+ case "Polygon":
3499
+ return [{ type: "Polygon", coordinates: geometry.coordinates.map((ring) => ring.map(round2d)) }];
3500
+ case "MultiPoint":
3501
+ return geometry.coordinates.map((c) => ({ type: "Point", coordinates: round2d(c) }));
3502
+ case "MultiLineString":
3503
+ return geometry.coordinates.map((line) => ({ type: "LineString", coordinates: line.map(round2d) }));
3504
+ case "MultiPolygon":
3505
+ return geometry.coordinates.map((poly) => ({ type: "Polygon", coordinates: poly.map((ring) => ring.map(round2d)) }));
3506
+ case "GeometryCollection":
3507
+ return geometry.geometries.flatMap(singleParts);
3508
+ }
3509
+ }
3510
+ var modeOf = (geometry) => geometry.type === "Point" ? "point" : geometry.type === "LineString" ? "linestring" : "polygon";
3511
+ function extraProperties(properties) {
3512
+ const extra = {};
3513
+ for (const [key, value] of Object.entries(properties)) {
3514
+ if (EDITOR_OWNED.has(key) || DROPPED.has(key) || value === void 0 || value === null) continue;
3515
+ extra[key] = value;
3516
+ }
3517
+ return extra;
3518
+ }
3519
+ function partTimes(feature, partCount) {
3520
+ const times = feature.properties?.coordinateProperties?.times;
3521
+ if (!Array.isArray(times) || times.length === 0) return [];
3522
+ if (feature.geometry?.type === "LineString" && times.every((t) => typeof t === "string")) {
3523
+ return [times];
3524
+ }
3525
+ if (feature.geometry?.type === "MultiLineString" && times.length === partCount && times.every(Array.isArray)) {
3526
+ return times;
3527
+ }
3528
+ return [];
3529
+ }
3530
+ function prepareForEditing(features, firstIndex = 0) {
3531
+ const session = { parts: [], sources: /* @__PURE__ */ new Map(), locked: /* @__PURE__ */ new Map() };
3532
+ features.forEach((feature, i) => {
3533
+ const f = firstIndex + i;
3534
+ if (!feature.geometry) return;
3535
+ const properties = feature.properties ?? {};
3536
+ const extra = extraProperties(properties);
3537
+ const geometries = singleParts(feature.geometry);
3538
+ const times = partTimes(feature, geometries.length);
3539
+ const opacityRaw = Number(properties.opacity);
3540
+ const opacity = Number.isFinite(opacityRaw) && opacityRaw >= 0 && opacityRaw <= 1 ? opacityRaw : void 0;
3541
+ geometries.forEach((geometry, index) => {
3542
+ const sourceKey = `f${f}p${index}`;
3543
+ const partTimesHere = times[index];
3544
+ const keepTimes = geometry.type === "LineString" && partTimesHere?.length === geometry.coordinates.length;
3545
+ session.sources.set(sourceKey, {
3546
+ group: `f${f}`,
3547
+ index,
3548
+ extra,
3549
+ ...keepTimes ? { times: partTimesHere, coords: geometry.coordinates } : {}
3550
+ });
3551
+ const part = {
3552
+ sourceKey,
3553
+ mode: modeOf(geometry),
3554
+ geometry,
3555
+ name: typeof properties.name === "string" ? properties.name : void 0,
3556
+ color: typeof properties.color === "string" ? properties.color : void 0,
3557
+ ...geometry.type === "Polygon" ? { opacity } : {}
3558
+ };
3559
+ if (geometry.type === "Polygon" && geometry.coordinates.length > 1) session.locked.set(sourceKey, part);
3560
+ else session.parts.push(part);
3561
+ });
3562
+ });
3563
+ return session;
3564
+ }
3565
+ function addToSession(session, features) {
3566
+ let next = 0;
3567
+ for (const source of session.sources.values()) next = Math.max(next, Number(source.group.slice(1)) + 1);
3568
+ const added = prepareForEditing(features, next);
3569
+ session.parts.push(...added.parts);
3570
+ for (const [key, source] of added.sources) session.sources.set(key, source);
3571
+ for (const [key, part] of added.locked) session.locked.set(key, part);
3572
+ return added;
3573
+ }
3574
+ function lockPart(session, sourceKey) {
3575
+ const index = session.parts.findIndex((part) => part.sourceKey === sourceKey);
3576
+ if (index < 0) return;
3577
+ session.locked.set(sourceKey, session.parts[index]);
3578
+ session.parts.splice(index, 1);
3579
+ }
3580
+ var samePositions = (a, b) => a.length === b.length && a.every((p, i) => p[0] === b[i][0] && p[1] === b[i][1]);
3581
+ function joinGeometries(geometries) {
3582
+ if (geometries.length === 1) return geometries[0];
3583
+ const kinds = new Set(geometries.map((g) => g.type));
3584
+ if (kinds.size === 1) {
3585
+ if (kinds.has("Point")) return { type: "MultiPoint", coordinates: geometries.map((g) => g.coordinates) };
3586
+ if (kinds.has("LineString")) return { type: "MultiLineString", coordinates: geometries.map((g) => g.coordinates) };
3587
+ return { type: "MultiPolygon", coordinates: geometries.map((g) => g.coordinates) };
3588
+ }
3589
+ return { type: "GeometryCollection", geometries };
3590
+ }
3591
+ function assembleForSaving(session, saved) {
3592
+ const entries = [
3593
+ ...saved.map((part) => ({ part, source: part.sourceKey ? session.sources.get(part.sourceKey) : void 0 })),
3594
+ ...[...session.locked.values()].map((part) => ({ part, source: session.sources.get(part.sourceKey) }))
3595
+ ];
3596
+ const buckets = /* @__PURE__ */ new Map();
3597
+ let newCount = 0;
3598
+ for (const entry of entries) {
3599
+ const { part, source } = entry;
3600
+ const key = source ? `${source.group}|${part.name ?? ""}|${part.color ?? ""}|${part.opacity ?? ""}` : `new${newCount++}`;
3601
+ const bucket = buckets.get(key);
3602
+ if (bucket) bucket.push(entry);
3603
+ else buckets.set(key, [entry]);
3604
+ }
3605
+ const groupOrder = (key) => key.startsWith("new") ? Infinity : Number(key.slice(1, key.indexOf("|")));
3606
+ return [...buckets.entries()].sort(([a], [b]) => groupOrder(a) - groupOrder(b)).map(([, bucket]) => {
3607
+ bucket.sort((a, b) => (a.source?.index ?? 0) - (b.source?.index ?? 0));
3608
+ const first = bucket[0];
3609
+ const geometry = joinGeometries(bucket.map((e) => e.part.geometry));
3610
+ const times = bucket.map(({ part, source }) => part.geometry.type === "LineString" && source?.times && source.coords && samePositions(part.geometry.coordinates, source.coords) ? source.times : void 0);
3611
+ const allTimed = bucket.every((e) => e.part.geometry.type === "LineString") && times.every(Boolean);
3612
+ return {
3613
+ type: "Feature",
3614
+ geometry,
3615
+ properties: {
3616
+ ...first.source?.extra ?? {},
3617
+ ...first.part.color !== void 0 ? { color: first.part.color } : {},
3618
+ ...first.part.name ? { name: first.part.name } : {},
3619
+ ...first.part.opacity !== void 0 ? { opacity: first.part.opacity } : {},
3620
+ ...allTimed ? { coordinateProperties: { times: bucket.length === 1 ? times[0] : times } } : {}
3621
+ }
3622
+ };
3623
+ });
3624
+ }
3625
+
3626
+ // src/mapEdit/mapFile.ts
3627
+ import { gpx, kml } from "@tmcw/togeojson";
3628
+ var MEDIA_HANDLE_PREFIX = "media-handle:";
3629
+ function applySimplestyle(feature) {
3630
+ const props = feature.properties ?? {};
3631
+ const isRegion = feature.geometry?.type === "Polygon" || feature.geometry?.type === "MultiPolygon";
3632
+ const color = props.color ?? (isRegion ? props.fill ?? props.stroke : props.stroke ?? props["marker-color"]);
3633
+ const opacity = props.opacity ?? (isRegion ? props["fill-opacity"] : void 0);
3634
+ if (color === props.color && opacity === props.opacity) return feature;
3635
+ return { ...feature, properties: { ...props, color, opacity } };
3636
+ }
3637
+ function geoJsonFeatures(doc) {
3638
+ if (doc.type === "FeatureCollection") return doc.features ?? [];
3639
+ if (doc.type === "Feature") return [doc];
3640
+ return [{ type: "Feature", geometry: doc, properties: {} }];
3641
+ }
3642
+ function asGroundOverlay(feature) {
3643
+ const props = feature.properties ?? {};
3644
+ if (props[IMAGE_OVERLAY_PROPERTY] !== true || typeof props.image !== "string") return feature;
3645
+ const { [IMAGE_OVERLAY_PROPERTY]: _marker, image, ...rest } = props;
3646
+ return {
3647
+ ...feature,
3648
+ properties: { ...rest, "@geometry-type": "groundoverlay", icon: `${MEDIA_HANDLE_PREFIX}${image}` }
3649
+ };
3650
+ }
3651
+ function parseMapFile(text) {
3652
+ const hasGeometry = (f) => f.geometry != null;
3653
+ if (text.trimStart().startsWith("{")) {
3654
+ return geoJsonFeatures(JSON.parse(text)).filter(hasGeometry).map(asGroundOverlay).map(applySimplestyle);
3655
+ }
3656
+ const dom = new DOMParser().parseFromString(text, "text/xml");
3657
+ if (dom.documentElement.localName === "gpx") {
3658
+ return gpx(dom).features.filter(hasGeometry).map(applySimplestyle);
3659
+ }
3660
+ return kml(dom).features.filter(hasGeometry);
3661
+ }
3662
+ var IMAGE_OVERLAY_PROPERTY = "gramps:image-overlay";
3663
+
3664
+ // src/mapEdit/geometry.ts
3665
+ var DEG_TO_RAD = Math.PI / 180;
3666
+ var RAD_TO_DEG = 180 / Math.PI;
3667
+ function mercatorFromLngLat([lng, lat]) {
3668
+ const x = (180 + lng) / 360;
3669
+ const y = (180 - RAD_TO_DEG * Math.log(Math.tan(Math.PI / 4 + lat * DEG_TO_RAD / 2))) / 360;
3670
+ return [x, y];
3671
+ }
3672
+ function lngLatFromMercator([x, y]) {
3673
+ const lng = x * 360 - 180;
3674
+ const lat = 90 - 2 * Math.atan(Math.exp((y * 360 - 180) * DEG_TO_RAD)) * RAD_TO_DEG;
3675
+ return [lng, lat];
3676
+ }
3677
+ function rotatePoint(point, center, rotationDeg) {
3678
+ const angle = rotationDeg * DEG_TO_RAD;
3679
+ const cos = Math.cos(angle);
3680
+ const sin = Math.sin(angle);
3681
+ const [px, py] = mercatorFromLngLat(point);
3682
+ const [cx, cy] = mercatorFromLngLat(center);
3683
+ const dx = px - cx;
3684
+ const dy = -(py - cy);
3685
+ const rx = dx * cos - dy * sin;
3686
+ const ry = dx * sin + dy * cos;
3687
+ return lngLatFromMercator([cx + rx, cy - ry]);
3688
+ }
3689
+ function scalePoint(point, center, scale) {
3690
+ const [px, py] = mercatorFromLngLat(point);
3691
+ const [cx, cy] = mercatorFromLngLat(center);
3692
+ return lngLatFromMercator([cx + (px - cx) * scale, cy + (py - cy) * scale]);
3693
+ }
3694
+ function mercatorDistance(a, b) {
3695
+ const [ax, ay] = mercatorFromLngLat(a);
3696
+ const [bx, by] = mercatorFromLngLat(b);
3697
+ return Math.hypot(bx - ax, by - ay);
3698
+ }
3699
+ function mercatorBearing(from, to) {
3700
+ const [fx, fy] = mercatorFromLngLat(from);
3701
+ const [tx, ty] = mercatorFromLngLat(to);
3702
+ return Math.atan2(-(ty - fy), tx - fx) * RAD_TO_DEG;
3703
+ }
3704
+
3705
+ // src/editor/MapEditor.tsx
3706
+ import { forwardRef, useEffect as useEffect8, useImperativeHandle, useRef as useRef8, useState as useState7 } from "react";
3707
+ import * as maplibregl3 from "maplibre-gl";
3708
+ import {
3709
+ TerraDraw,
3710
+ TerraDrawLineStringMode,
3711
+ TerraDrawPointMode,
3712
+ TerraDrawPolygonMode,
3713
+ TerraDrawRectangleMode,
3714
+ TerraDrawSelectMode
3715
+ } from "terra-draw";
3716
+ import { TerraDrawMapLibreGLAdapter } from "terra-draw-maplibre-gl-adapter";
3717
+ import {
3718
+ Alert,
3719
+ Box as Box2,
3720
+ Button as Button3,
3721
+ ColorInput,
3722
+ Divider,
3723
+ Group as Group4,
3724
+ Kbd,
3725
+ List,
3726
+ Loader as Loader3,
3727
+ NumberInput,
3728
+ SegmentedControl,
3729
+ Slider,
3730
+ Stack as Stack4,
3731
+ Text as Text4,
3732
+ TextInput as TextInput4
3733
+ } from "@mantine/core";
3734
+
3735
+ // src/editor/mapEditorStrings.ts
3736
+ var MAP_EDITOR_ENGLISH = {
3737
+ point: "Point",
3738
+ line: "Line",
3739
+ polygon: "Polygon",
3740
+ rectangle: "Rectangle",
3741
+ select: "Select",
3742
+ image: "Image",
3743
+ editOptions: "Edit Options",
3744
+ labelText: "Label text",
3745
+ regionName: "Region name (optional)",
3746
+ fillOpacity: "Fill opacity",
3747
+ scale: "Scale",
3748
+ transform: "Transform",
3749
+ overlayName: "Overlay name (optional)",
3750
+ opacity: "Opacity",
3751
+ stackingOrder: "Stacking order",
3752
+ removeImage: "Remove this image",
3753
+ resizeImage: "Drag to resize (keeps its exact shape, warped or not)",
3754
+ rotateImage: "Drag to rotate",
3755
+ warpCorner: "Drag to warp this corner",
3756
+ basemapFailed: "The basemap could not be loaded.",
3757
+ imageFailed: "The image failed to load.",
3758
+ lockedOne: "1 shape can't be edited here",
3759
+ lockedMany: (count) => `${count} shapes can't be edited here`,
3760
+ lockedExplain: "For example, a region with a hole in it, or one whose outline crosses itself. It's drawn dashed, and saved exactly as it was.",
3761
+ helpTitle: "How to use this editor",
3762
+ helpDrawing: "Drawing",
3763
+ helpPoint: "Point: click once to place it.",
3764
+ helpLine: "Line, Polygon: click to place each point, then click that first point's own marker again to finish -- it's styled differently once there's enough points to close the shape. Pressing",
3765
+ helpEnterFinishes: "finishes it too;",
3766
+ helpEscCancels: "cancels it.",
3767
+ helpRectangle: "Rectangle: click and drag from one corner to the opposite corner.",
3768
+ helpImage: "Image: pick a media object to overlay -- see",
3769
+ helpImages: "Images",
3770
+ helpBelow: "below.",
3771
+ helpEditing: "Editing a shape",
3772
+ helpSelect: "Select: click a shape to select it, then drag it or one of its handles.",
3773
+ helpVertex: "Drag a vertex to move it. To add a new point on a Line, Polygon, or Rectangle, drag the small midpoint marker between two existing vertices outward.",
3774
+ helpColor: "The color swatch sets the next shape's color -- or, with a shape selected, changes its color.",
3775
+ helpDelete: "Delete a selected shape or image with",
3776
+ helpOr: "or",
3777
+ helpRotate: "Rotate a selected Polygon, Rectangle, or Line: hold",
3778
+ helpRotateHandle: "while dragging one of its handles.",
3779
+ helpImageMove: "Drag anywhere on an image to move it.",
3780
+ helpImageResize: "Drag its \u2198 handle to resize it -- grows or shrinks from its own center, keeping its exact shape (works the same on a warped image, too).",
3781
+ helpImageRotate: "Drag its \u21BB handle to rotate it -- handy for aligning a scanned old map.",
3782
+ helpImageTransform: `Switch to "Transform" to also show 4 corner handles you can drag independently (rubber-sheeting) -- for a scanned map that isn't printed at a uniform scale/orientation. Switching back to "Scale" just hides those 4 handles again; move/resize/rotate keep working on the image's exact current shape either way.`,
3783
+ helpImageRemove: "Click its \xD7 handle to remove it."
3784
+ };
3785
+
3786
+ // src/editor/MapEditor.tsx
3787
+ import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
3788
+ var TOOLBAR = [
3789
+ { mode: "point", label: "point" },
3790
+ { mode: "linestring", label: "line" },
3791
+ { mode: "polygon", label: "polygon" },
3792
+ { mode: "rectangle", label: "rectangle" },
3793
+ { mode: "select", label: "select" }
3794
+ ];
3795
+ var COLOR_SWATCHES = [
3796
+ "#2a78d6",
3797
+ "#d64545",
3798
+ "#3aa657",
3799
+ "#e0a325",
3800
+ "#8654c9",
3801
+ "#2aa7a0",
3802
+ "#e0678a",
3803
+ "#555555"
3804
+ ];
3805
+ var EMPTY_LABEL_PREVIEW = { type: "FeatureCollection", features: [] };
3806
+ var OVERLAY_ANCHOR_LAYER = "image-overlay-anchor";
3807
+ var TERRA_DRAW_LAYERS = ["td-polygon", "td-polygon-outline", "td-linestring", "td-point", "td-point-marker"];
3808
+ var LOCKED_SOURCE = "locked-shapes";
3809
+ var LOCKED_FILL_LAYER = "locked-shapes-fill";
3810
+ var LOCKED_LINE_LAYER = "locked-shapes-line";
3811
+ var LABEL_PREVIEW_SOURCE = "label-preview";
3812
+ var LABEL_PREVIEW_LAYER = "label-preview-text";
3813
+ var TOOLBAR_CLASS = "gc-map-editor-toolbar";
3814
+ var TOOLBAR_CSS = `.${TOOLBAR_CLASS} { pointer-events: none; } .${TOOLBAR_CLASS} > * { pointer-events: auto; }`;
3815
+ function overlayCenter(corners) {
3816
+ let lng = 0, lat = 0;
3817
+ for (const [x, y] of corners) {
3818
+ lng += x;
3819
+ lat += y;
3820
+ }
3821
+ return [lng / 4, lat / 4];
3822
+ }
3823
+ function rotateHandleAnchor(corners, center) {
3824
+ const [topLeft, topRight] = corners;
3825
+ const topMid = [(topLeft[0] + topRight[0]) / 2, (topLeft[1] + topRight[1]) / 2];
3826
+ return [center[0] + (topMid[0] - center[0]) * 1.5, center[1] + (topMid[1] - center[1]) * 1.5];
3827
+ }
3828
+ function pointInQuad(point, corners) {
3829
+ let sign = 0;
3830
+ for (let i = 0; i < corners.length; i++) {
3831
+ const a = corners[i];
3832
+ const b = corners[(i + 1) % corners.length];
3833
+ const cross = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x);
3834
+ if (cross === 0) continue;
3835
+ const s = cross > 0 ? 1 : -1;
3836
+ if (sign === 0) sign = s;
3837
+ else if (s !== sign) return false;
3838
+ }
3839
+ return true;
3840
+ }
3841
+ var OVERLAY_DRAG_OPACITY = 0.5;
3842
+ var OVERLAY_HANDLE_STYLE = {
3843
+ width: "20px",
3844
+ height: "20px",
3845
+ borderRadius: "50%",
3846
+ background: "white",
3847
+ border: "2px solid #333",
3848
+ display: "flex",
3849
+ alignItems: "center",
3850
+ justifyContent: "center",
3851
+ fontSize: "12px",
3852
+ fontWeight: "700",
3853
+ color: "#333",
3854
+ cursor: "pointer",
3855
+ boxShadow: "0 1px 3px rgba(0,0,0,0.4)",
3856
+ userSelect: "none",
3857
+ zIndex: 4
3858
+ };
3859
+ function loadImageElement(src, failed) {
3860
+ return new Promise((resolve, reject) => {
3861
+ const img = new Image();
3862
+ img.onload = () => resolve(img);
3863
+ img.onerror = () => reject(new Error(failed));
3864
+ img.src = src;
3865
+ });
3866
+ }
3867
+ function extendBounds(bounds, geometry) {
3868
+ if (geometry.type === "Point") {
3869
+ bounds.extend(geometry.coordinates);
3870
+ } else if (geometry.type === "LineString") {
3871
+ for (const c of geometry.coordinates) bounds.extend(c);
3872
+ } else if (geometry.type === "Polygon") {
3873
+ for (const ring of geometry.coordinates) for (const c of ring) bounds.extend(c);
3874
+ }
3875
+ }
3876
+ var MapEditor = forwardRef(function MapEditor2({
3877
+ initial,
3878
+ initialView,
3879
+ resolveImage,
3880
+ releaseImage,
3881
+ onAddImage,
3882
+ onStatus,
3883
+ onError,
3884
+ dark = false,
3885
+ defaultColor,
3886
+ labelColors,
3887
+ toolbarTop = 12,
3888
+ overlayNames = true,
3889
+ renderOverlayOptions,
3890
+ strings: stringOverrides
3891
+ }, ref) {
3892
+ const strings = { ...MAP_EDITOR_ENGLISH, ...stringOverrides };
3893
+ const live = useRef8({ strings, resolveImage, releaseImage, onError });
3894
+ live.current = { strings, resolveImage, releaseImage, onError };
3895
+ const fail = (message) => live.current.onError?.(message);
3896
+ const release = (url) => live.current.releaseImage?.(url);
3897
+ const [containerEl, setContainerEl] = useState7(null);
3898
+ const mapRef = useRef8(null);
3899
+ const drawRef = useRef8(null);
3900
+ const overlayMountsRef = useRef8(/* @__PURE__ */ new Map());
3901
+ const [mode, setMode] = useState7("select");
3902
+ const modeRef = useRef8(mode);
3903
+ modeRef.current = mode;
3904
+ const [ready, setReady] = useState7(false);
3905
+ const [color, setColor] = useState7(() => defaultColor ?? defaultColors(dark).mark);
3906
+ const colorRef = useRef8(color);
3907
+ colorRef.current = color;
3908
+ const [opacity, setOpacity] = useState7(0.25);
3909
+ const opacityRef = useRef8(opacity);
3910
+ opacityRef.current = opacity;
3911
+ const [labelText, setLabelText] = useState7("");
3912
+ const refreshLabelPreviewRef = useRef8(null);
3913
+ const [selectedId, setSelectedId] = useState7(null);
3914
+ const selectedIdRef = useRef8(selectedId);
3915
+ selectedIdRef.current = selectedId;
3916
+ const labelColorsRef = useRef8(labelColors ?? defaultColors(dark));
3917
+ labelColorsRef.current = labelColors ?? defaultColors(dark);
3918
+ useEffect8(() => {
3919
+ const draw = drawRef.current;
3920
+ const map = mapRef.current;
3921
+ const source = map?.getSource(LABEL_PREVIEW_SOURCE);
3922
+ const refresh = () => {
3923
+ const currentDraw = drawRef.current;
3924
+ const currentSource = mapRef.current?.getSource(LABEL_PREVIEW_SOURCE);
3925
+ if (!currentDraw || !currentSource) return;
3926
+ const features = [];
3927
+ for (const f of currentDraw.getSnapshot()) {
3928
+ if (f.geometry?.type !== "Point") continue;
3929
+ const isSelected = String(f.id) === selectedId;
3930
+ const name = (isSelected ? labelText : f.properties?.name)?.trim();
3931
+ if (!name) continue;
3932
+ features.push({
3933
+ type: "Feature",
3934
+ geometry: f.geometry,
3935
+ properties: { name, color: f.properties?.color }
3936
+ });
3937
+ }
3938
+ currentSource.setData({ type: "FeatureCollection", features });
3939
+ };
3940
+ refreshLabelPreviewRef.current = refresh;
3941
+ if (draw && source) refresh();
3942
+ }, [labelText, selectedId]);
3943
+ const [overlays, setOverlays] = useState7([]);
3944
+ const overlaysRef = useRef8(overlays);
3945
+ overlaysRef.current = overlays;
3946
+ const [selectedOverlayId, setSelectedOverlayId] = useState7(null);
3947
+ const [warpHandleIds, setWarpHandleIds] = useState7(/* @__PURE__ */ new Set());
3948
+ const [hasFeatures, setHasFeatures] = useState7(false);
3949
+ const sessionRef = useRef8(null);
3950
+ const [lockedCount, setLockedCount] = useState7(0);
3951
+ const [lockedNoticeDismissed, setLockedNoticeDismissed] = useState7(false);
3952
+ const hasDrawn = hasFeatures || overlays.length > 0;
3953
+ const hasContent = hasDrawn || lockedCount > 0;
3954
+ const onStatusRef = useRef8(onStatus);
3955
+ onStatusRef.current = onStatus;
3956
+ useEffect8(() => {
3957
+ onStatusRef.current?.({ hasDrawn, hasContent });
3958
+ }, [hasDrawn, hasContent]);
3959
+ useEffect8(() => {
3960
+ if (!containerEl) return;
3961
+ const map = new maplibregl3.Map({
3962
+ container: containerEl,
3963
+ style: mapStyleUrl(dark, null),
3964
+ center: [0, 20],
3965
+ zoom: 1.5,
3966
+ attributionControl: { compact: true }
3967
+ });
3968
+ mapRef.current = map;
3969
+ map.addControl(new maplibregl3.NavigationControl({ showCompass: false }), "top-right");
3970
+ map.dragRotate.disable();
3971
+ map.touchZoomRotate.disableRotation();
3972
+ map.keyboard.disableRotation();
3973
+ const readColor = (feature) => feature.properties.color ?? colorRef.current;
3974
+ const readOpacity = (feature) => feature.properties.opacity ?? opacityRef.current;
3975
+ const draw = new TerraDraw({
3976
+ adapter: new TerraDrawMapLibreGLAdapter({ map }),
3977
+ modes: [
3978
+ new TerraDrawPointMode({ styles: { pointColor: readColor } }),
3979
+ new TerraDrawLineStringMode({ styles: { lineStringColor: readColor } }),
3980
+ new TerraDrawPolygonMode({ styles: { fillColor: readColor, fillOpacity: readOpacity, outlineColor: readColor } }),
3981
+ new TerraDrawRectangleMode({ styles: { fillColor: readColor, fillOpacity: readOpacity, outlineColor: readColor } }),
3982
+ // Dragging (both a whole feature and its individual vertices) and
3983
+ // deletion (terra-draw's own default keybinding, Delete/Backspace
3984
+ // on the current selection) are what this editor needs from
3985
+ // Select for every mode; `rotateable` additionally turns on
3986
+ // terra-draw's own built-in rotate gesture (hold R while dragging
3987
+ // a selected shape's vertex) for anything 2D enough for "rotate"
3988
+ // to mean something -- not `point`, a single vertex has nothing to
3989
+ // rotate around.
3990
+ new TerraDrawSelectMode({
3991
+ // While a feature is selected, terra-draw renders it through
3992
+ // *this* mode's own styling, not the drawing mode that created it
3993
+ // (see TerraDraw's own per-feature style dispatch: only the
3994
+ // selected feature goes through TerraDrawSelectMode.styleFeature;
3995
+ // every other feature keeps using its own mode's, which is why an
3996
+ // unselected shape's color/opacity already reflects its own
3997
+ // properties fine). Without these, the selected-state styling
3998
+ // falls back to terra-draw's fixed defaults regardless of
3999
+ // `feature.properties` -- so dragging the opacity slider (or
4000
+ // recoloring) while a shape is actively selected had no visible
4001
+ // effect until it was deselected. Found live.
4002
+ styles: {
4003
+ selectedPointColor: readColor,
4004
+ selectedLineStringColor: readColor,
4005
+ selectedPolygonColor: readColor,
4006
+ selectedPolygonOutlineColor: readColor,
4007
+ selectedPolygonFillOpacity: readOpacity
4008
+ },
4009
+ // Half terra-draw's own default (40px) -- with labels sitting
4010
+ // right next to their point (see LABEL_PREVIEW_LAYER's
4011
+ // text-offset below) and vertices sometimes placed close
4012
+ // together, the default hit-test radius made clicking one point
4013
+ // too likely to grab a neighboring one instead. Found live.
4014
+ pointerDistance: 20,
4015
+ // Every field restated (terra-draw's own type doesn't allow a
4016
+ // partial override), only `rotate` actually changed from its
4017
+ // default -- plain "r" instead of the default Ctrl+R, an
4018
+ // awkward two-handed chord to hold through an entire drag. Bare
4019
+ // "r" is safe here the same way Backspace-delete is (see the
4020
+ // canvas keydown listener below): terra-draw's own keydown
4021
+ // listener is bound to the map canvas specifically, so it never
4022
+ // fires while typing "r" into a text field.
4023
+ keyEvents: { deselect: "Escape", delete: "Delete", rotate: ["r"], scale: ["Control", "s"] },
4024
+ flags: {
4025
+ point: { feature: { draggable: true } },
4026
+ linestring: {
4027
+ feature: {
4028
+ draggable: true,
4029
+ rotateable: true,
4030
+ coordinates: { draggable: true, deletable: true, midpoints: true }
4031
+ }
4032
+ },
4033
+ polygon: {
4034
+ feature: {
4035
+ draggable: true,
4036
+ rotateable: true,
4037
+ coordinates: { draggable: true, deletable: true, midpoints: true }
4038
+ }
4039
+ },
4040
+ rectangle: {
4041
+ feature: {
4042
+ draggable: true,
4043
+ rotateable: true,
4044
+ coordinates: { draggable: true, deletable: true, midpoints: true }
4045
+ }
4046
+ }
4047
+ }
4048
+ })
4049
+ ]
4050
+ });
4051
+ drawRef.current = draw;
4052
+ draw.on("change", () => {
4053
+ setHasFeatures(
4054
+ draw.getSnapshot().some((f) => f.geometry != null && !f.properties?.selectionPoint && !f.properties?.midPoint)
4055
+ );
4056
+ refreshLabelPreviewRef.current?.();
4057
+ });
4058
+ function syncSelection(id) {
4059
+ setSelectedId(id);
4060
+ const feature = id ? draw.getSnapshot().find((f) => String(f.id) === id) : void 0;
4061
+ setLabelText(feature?.properties?.name ?? "");
4062
+ if (feature) {
4063
+ setColor(feature.properties?.color ?? colorRef.current);
4064
+ setOpacity(feature.properties?.opacity ?? 0.25);
4065
+ setSelectedOverlayId(null);
4066
+ }
4067
+ }
4068
+ draw.on("finish", (id) => {
4069
+ const isRegion = modeRef.current === "polygon" || modeRef.current === "rectangle";
4070
+ draw.updateFeatureProperties(
4071
+ id,
4072
+ isRegion ? { color: colorRef.current, opacity: opacityRef.current } : { color: colorRef.current }
4073
+ );
4074
+ draw.setMode("select");
4075
+ setMode("select");
4076
+ draw.selectFeature(id);
4077
+ syncSelection(String(id));
4078
+ });
4079
+ draw.on("select", (id) => syncSelection(String(id)));
4080
+ draw.on("deselect", () => syncSelection(null));
4081
+ const canvas = map.getCanvas();
4082
+ const onCanvasKeyDown = (e) => {
4083
+ if (e.key !== "Backspace" || !selectedIdRef.current) return;
4084
+ try {
4085
+ draw.removeFeatures([selectedIdRef.current]);
4086
+ } catch {
4087
+ }
4088
+ setSelectedId(null);
4089
+ };
4090
+ canvas.addEventListener("keydown", onCanvasKeyDown);
4091
+ let sawNativePointerdown = false;
4092
+ let pointerBridgeActive = false;
4093
+ let pendingFirstMouseDown = null;
4094
+ const onNativePointerDown = () => {
4095
+ sawNativePointerdown = true;
4096
+ };
4097
+ canvas.addEventListener("pointerdown", onNativePointerDown, { once: true });
4098
+ const dispatchAsPointerEvent = (type, e) => {
4099
+ const synthetic = new MouseEvent(type, {
4100
+ bubbles: true,
4101
+ cancelable: true,
4102
+ view: window,
4103
+ clientX: e.clientX,
4104
+ clientY: e.clientY,
4105
+ button: e.button,
4106
+ buttons: e.buttons
4107
+ });
4108
+ Object.defineProperty(synthetic, "isPrimary", { value: true });
4109
+ canvas.dispatchEvent(synthetic);
4110
+ };
4111
+ const onMouseDownBridge = (e) => {
4112
+ if (pointerBridgeActive) {
4113
+ dispatchAsPointerEvent("pointerdown", e);
4114
+ return;
4115
+ }
4116
+ if (sawNativePointerdown) return;
4117
+ pendingFirstMouseDown = e;
4118
+ requestAnimationFrame(() => {
4119
+ if (sawNativePointerdown || !pendingFirstMouseDown) return;
4120
+ pointerBridgeActive = true;
4121
+ dispatchAsPointerEvent("pointerdown", pendingFirstMouseDown);
4122
+ pendingFirstMouseDown = null;
4123
+ });
4124
+ };
4125
+ const onMouseMoveBridge = (e) => {
4126
+ if (pointerBridgeActive) dispatchAsPointerEvent("pointermove", e);
4127
+ };
4128
+ const onMouseUpBridge = (e) => {
4129
+ if (pointerBridgeActive) dispatchAsPointerEvent("pointerup", e);
4130
+ };
4131
+ canvas.addEventListener("mousedown", onMouseDownBridge);
4132
+ canvas.addEventListener("mousemove", onMouseMoveBridge);
4133
+ canvas.addEventListener("mouseup", onMouseUpBridge);
4134
+ map.on("mousedown", (e) => {
4135
+ const target = e.originalEvent.target;
4136
+ if (target instanceof Node) {
4137
+ for (const mount of overlayMountsRef.current.values()) {
4138
+ if (mount.deleteMarker.getElement().contains(target) || mount.resizeMarker.getElement().contains(target) || mount.rotateMarker.getElement().contains(target) || mount.cornerMarkers.some((marker) => marker.getElement().contains(target))) return;
4139
+ }
4140
+ }
4141
+ if (map.queryRenderedFeatures(e.point, { layers: TERRA_DRAW_LAYERS }).length > 0) return;
4142
+ const mounts = [...overlayMountsRef.current.entries()].reverse();
4143
+ for (const [id, mount] of mounts) {
4144
+ const corners = mount.box.corners.map((c) => map.project(c));
4145
+ if (pointInQuad(e.point, corners)) {
4146
+ if (selectedIdRef.current) draw.deselectFeature(selectedIdRef.current);
4147
+ setSelectedOverlayId(id);
4148
+ mount.startMove(e);
4149
+ return;
4150
+ }
4151
+ }
4152
+ setSelectedOverlayId(null);
4153
+ });
4154
+ map.on("error", (e) => {
4155
+ const message = e.error?.message ?? "";
4156
+ if (/style|sprite|tile|fetch|network|Failed/i.test(message)) fail(message || live.current.strings.basemapFailed);
4157
+ });
4158
+ map.on("style.load", () => {
4159
+ try {
4160
+ map.addLayer({ id: OVERLAY_ANCHOR_LAYER, type: "background", paint: { "background-opacity": 0 } });
4161
+ map.addSource(LOCKED_SOURCE, { type: "geojson", data: { type: "FeatureCollection", features: [] } });
4162
+ map.addLayer({
4163
+ id: LOCKED_FILL_LAYER,
4164
+ type: "fill",
4165
+ source: LOCKED_SOURCE,
4166
+ filter: ["==", ["geometry-type"], "Polygon"],
4167
+ paint: { "fill-color": ["coalesce", ["get", "color"], "#888888"], "fill-opacity": 0.15 }
4168
+ });
4169
+ map.addLayer({
4170
+ id: LOCKED_LINE_LAYER,
4171
+ type: "line",
4172
+ source: LOCKED_SOURCE,
4173
+ filter: ["any", ["==", ["geometry-type"], "Polygon"], ["==", ["geometry-type"], "LineString"]],
4174
+ paint: { "line-color": ["coalesce", ["get", "color"], "#888888"], "line-width": 2, "line-dasharray": [2, 2] }
4175
+ });
4176
+ draw.start();
4177
+ draw.setMode("select");
4178
+ const colors = labelColorsRef.current;
4179
+ map.addSource(LABEL_PREVIEW_SOURCE, { type: "geojson", data: EMPTY_LABEL_PREVIEW });
4180
+ map.addLayer({
4181
+ id: LABEL_PREVIEW_LAYER,
4182
+ type: "symbol",
4183
+ source: LABEL_PREVIEW_SOURCE,
4184
+ layout: {
4185
+ "text-field": ["get", "name"],
4186
+ "text-size": 12,
4187
+ "text-font": ["Noto Sans Regular"],
4188
+ // Offset right rather than centered on the point -- centered
4189
+ // text sat directly on top of the point, making it hard to see
4190
+ // (and, in the real map views, click) the point itself.
4191
+ "text-anchor": "left",
4192
+ "text-offset": [0.6, 0]
4193
+ },
4194
+ paint: {
4195
+ "text-color": ["coalesce", ["get", "color"], colors.text],
4196
+ "text-halo-color": colors.surface,
4197
+ "text-halo-width": 1.5
4198
+ }
4199
+ });
4200
+ refreshLabelPreviewRef.current?.();
4201
+ } catch (err) {
4202
+ fail(err instanceof Error ? err.message : String(err));
4203
+ } finally {
4204
+ setReady(true);
4205
+ }
4206
+ });
4207
+ return () => {
4208
+ draw.stop();
4209
+ canvas.removeEventListener("keydown", onCanvasKeyDown);
4210
+ canvas.removeEventListener("pointerdown", onNativePointerDown);
4211
+ canvas.removeEventListener("mousedown", onMouseDownBridge);
4212
+ canvas.removeEventListener("mousemove", onMouseMoveBridge);
4213
+ canvas.removeEventListener("mouseup", onMouseUpBridge);
4214
+ for (const mount of overlayMountsRef.current.values()) {
4215
+ mount.deleteMarker.remove();
4216
+ mount.resizeMarker.remove();
4217
+ mount.rotateMarker.remove();
4218
+ for (const marker of mount.cornerMarkers) marker.remove();
4219
+ release(mount.imageUrl);
4220
+ }
4221
+ overlayMountsRef.current.clear();
4222
+ map.remove();
4223
+ mapRef.current = null;
4224
+ drawRef.current = null;
4225
+ };
4226
+ }, [containerEl]);
4227
+ async function mountOverlay(overlay) {
4228
+ const map = mapRef.current;
4229
+ if (!map) return;
4230
+ const sourceId = `img-${overlay.id}`;
4231
+ const box = { corners: overlay.corners };
4232
+ const imageUrl = await live.current.resolveImage(overlay.image);
4233
+ map.addSource(sourceId, { type: "image", url: imageUrl, coordinates: box.corners });
4234
+ map.addLayer({ id: sourceId, type: "raster", source: sourceId }, OVERLAY_ANCHOR_LAYER);
4235
+ map.setPaintProperty(sourceId, "raster-opacity", overlay.opacity ?? 1);
4236
+ const setCorners = (next) => {
4237
+ box.corners = next;
4238
+ map.getSource(sourceId)?.setCoordinates(box.corners);
4239
+ };
4240
+ const commit = () => {
4241
+ setOverlays((prev) => prev.map((o) => o.id === overlay.id ? { ...o, corners: box.corners } : o));
4242
+ };
4243
+ const words = live.current.strings;
4244
+ const deleteEl = document.createElement("div");
4245
+ deleteEl.textContent = "\xD7";
4246
+ deleteEl.title = words.removeImage;
4247
+ Object.assign(deleteEl.style, OVERLAY_HANDLE_STYLE);
4248
+ const deleteMarker = new maplibregl3.Marker({ element: deleteEl, anchor: "center" });
4249
+ deleteEl.addEventListener("click", (e) => {
4250
+ e.stopPropagation();
4251
+ unmountOverlay(overlay.id);
4252
+ setOverlays((prev) => prev.filter((o) => o.id !== overlay.id));
4253
+ setSelectedOverlayId((current) => current === overlay.id ? null : current);
4254
+ });
4255
+ const resizeEl = document.createElement("div");
4256
+ resizeEl.textContent = "\u2198";
4257
+ resizeEl.title = words.resizeImage;
4258
+ Object.assign(resizeEl.style, OVERLAY_HANDLE_STYLE);
4259
+ const resizeMarker = new maplibregl3.Marker({ element: resizeEl, anchor: "center", draggable: true });
4260
+ let resizeStartCorners = box.corners;
4261
+ let resizeStartDist = 1;
4262
+ resizeMarker.on("dragstart", () => {
4263
+ resizeStartCorners = box.corners;
4264
+ const center = overlayCenter(resizeStartCorners);
4265
+ const p = resizeMarker.getLngLat();
4266
+ resizeStartDist = Math.max(1e-9, mercatorDistance(center, [p.lng, p.lat]));
4267
+ });
4268
+ resizeMarker.on("drag", () => {
4269
+ const center = overlayCenter(resizeStartCorners);
4270
+ const p = resizeMarker.getLngLat();
4271
+ const scale = Math.max(0.02, mercatorDistance(center, [p.lng, p.lat]) / resizeStartDist);
4272
+ setCorners(resizeStartCorners.map((c) => scalePoint(c, center, scale)));
4273
+ repositionHandles();
4274
+ });
4275
+ resizeMarker.on("dragend", commit);
4276
+ const rotateEl = document.createElement("div");
4277
+ rotateEl.textContent = "\u21BB";
4278
+ rotateEl.title = words.rotateImage;
4279
+ Object.assign(rotateEl.style, OVERLAY_HANDLE_STYLE);
4280
+ const rotateMarker = new maplibregl3.Marker({ element: rotateEl, anchor: "center", draggable: true });
4281
+ let rotateStartCorners = box.corners;
4282
+ let rotateStartAngle = 0;
4283
+ rotateMarker.on("dragstart", () => {
4284
+ rotateStartCorners = box.corners;
4285
+ const center = overlayCenter(rotateStartCorners);
4286
+ const p = rotateMarker.getLngLat();
4287
+ rotateStartAngle = mercatorBearing(center, [p.lng, p.lat]);
4288
+ });
4289
+ rotateMarker.on("drag", () => {
4290
+ const center = overlayCenter(rotateStartCorners);
4291
+ const p = rotateMarker.getLngLat();
4292
+ const delta = mercatorBearing(center, [p.lng, p.lat]) - rotateStartAngle;
4293
+ setCorners(rotateStartCorners.map((c) => rotatePoint(c, center, delta)));
4294
+ repositionHandles();
4295
+ });
4296
+ rotateMarker.on("dragend", commit);
4297
+ const cornerMarkers = [0, 1, 2, 3].map((i) => {
4298
+ const el = document.createElement("div");
4299
+ el.title = words.warpCorner;
4300
+ Object.assign(el.style, OVERLAY_HANDLE_STYLE);
4301
+ const marker = new maplibregl3.Marker({ element: el, anchor: "center", draggable: true });
4302
+ marker.on("drag", () => {
4303
+ const corners = [...box.corners];
4304
+ const p = marker.getLngLat();
4305
+ corners[i] = [p.lng, p.lat];
4306
+ setCorners(corners);
4307
+ repositionHandles();
4308
+ });
4309
+ marker.on("dragend", commit);
4310
+ return marker;
4311
+ });
4312
+ const repositionHandles = () => {
4313
+ const [topLeft, topRight, bottomRight, bottomLeft] = box.corners;
4314
+ const center = overlayCenter(box.corners);
4315
+ deleteMarker.setLngLat(center);
4316
+ resizeMarker.setLngLat(bottomRight);
4317
+ rotateMarker.setLngLat(rotateHandleAnchor(box.corners, center));
4318
+ cornerMarkers[0].setLngLat(topLeft);
4319
+ cornerMarkers[1].setLngLat(topRight);
4320
+ cornerMarkers[2].setLngLat(bottomRight);
4321
+ cornerMarkers[3].setLngLat(bottomLeft);
4322
+ };
4323
+ const startMove = (e) => {
4324
+ e.preventDefault();
4325
+ map.dragPan.disable();
4326
+ map.setPaintProperty(sourceId, "raster-opacity", OVERLAY_DRAG_OPACITY);
4327
+ const startPoint = e.point;
4328
+ const startScreenCorners = box.corners.map((corner) => map.project(corner));
4329
+ const container = map.getContainer();
4330
+ const onMove = (moveEvent) => {
4331
+ const rect = container.getBoundingClientRect();
4332
+ const dx = moveEvent.clientX - rect.left - startPoint.x;
4333
+ const dy = moveEvent.clientY - rect.top - startPoint.y;
4334
+ const newCorners = startScreenCorners.map((screenCorner) => {
4335
+ const lngLat = map.unproject([screenCorner.x + dx, screenCorner.y + dy]);
4336
+ return [lngLat.lng, lngLat.lat];
4337
+ });
4338
+ setCorners(newCorners);
4339
+ repositionHandles();
4340
+ };
4341
+ const onUp = () => {
4342
+ document.removeEventListener("mousemove", onMove);
4343
+ document.removeEventListener("mouseup", onUp);
4344
+ map.dragPan.enable();
4345
+ const current = overlaysRef.current.find((o) => o.id === overlay.id);
4346
+ map.setPaintProperty(sourceId, "raster-opacity", current?.opacity ?? 1);
4347
+ commit();
4348
+ };
4349
+ document.addEventListener("mousemove", onMove);
4350
+ document.addEventListener("mouseup", onUp);
4351
+ };
4352
+ repositionHandles();
4353
+ overlayMountsRef.current.set(overlay.id, {
4354
+ sourceId,
4355
+ box,
4356
+ deleteMarker,
4357
+ resizeMarker,
4358
+ rotateMarker,
4359
+ cornerMarkers,
4360
+ startMove,
4361
+ setCorners,
4362
+ repositionHandles,
4363
+ imageUrl
4364
+ });
4365
+ }
4366
+ function unmountOverlay(id) {
4367
+ const map = mapRef.current;
4368
+ const mount = overlayMountsRef.current.get(id);
4369
+ if (!map || !mount) return;
4370
+ mount.deleteMarker.remove();
4371
+ mount.resizeMarker.remove();
4372
+ mount.rotateMarker.remove();
4373
+ for (const marker of mount.cornerMarkers) marker.remove();
4374
+ if (map.getLayer(mount.sourceId)) map.removeLayer(mount.sourceId);
4375
+ if (map.getSource(mount.sourceId)) map.removeSource(mount.sourceId);
4376
+ release(mount.imageUrl);
4377
+ overlayMountsRef.current.delete(id);
4378
+ }
4379
+ function setCornerHandlesVisible(id, visible) {
4380
+ setWarpHandleIds((prev) => {
4381
+ const next = new Set(prev);
4382
+ if (visible) next.add(id);
4383
+ else next.delete(id);
4384
+ return next;
4385
+ });
4386
+ }
4387
+ const showCornerHandles = selectedOverlayId !== null && warpHandleIds.has(selectedOverlayId);
4388
+ useEffect8(() => {
4389
+ const map = mapRef.current;
4390
+ if (!map) return;
4391
+ for (const [id, mount] of overlayMountsRef.current) {
4392
+ if (id !== selectedOverlayId) {
4393
+ mount.deleteMarker.remove();
4394
+ mount.resizeMarker.remove();
4395
+ mount.rotateMarker.remove();
4396
+ for (const marker of mount.cornerMarkers) marker.remove();
4397
+ continue;
4398
+ }
4399
+ mount.deleteMarker.addTo(map);
4400
+ mount.resizeMarker.addTo(map);
4401
+ mount.rotateMarker.addTo(map);
4402
+ for (const marker of mount.cornerMarkers) showCornerHandles ? marker.addTo(map) : marker.remove();
4403
+ }
4404
+ }, [selectedOverlayId, showCornerHandles]);
4405
+ const selectedOverlay = overlays.find((o) => o.id === selectedOverlayId) ?? null;
4406
+ function updateSelectedOverlay(patch) {
4407
+ if (!selectedOverlayId) return;
4408
+ setOverlays((prev) => prev.map((o) => o.id === selectedOverlayId ? { ...o, ...patch } : o));
4409
+ if (patch.opacity !== void 0) {
4410
+ const mount = overlayMountsRef.current.get(selectedOverlayId);
4411
+ if (mount) mapRef.current?.setPaintProperty(mount.sourceId, "raster-opacity", patch.opacity);
4412
+ }
4413
+ }
4414
+ const loaded = useRef8(false);
4415
+ useEffect8(() => {
4416
+ if (!ready || !initial || loaded.current) return;
4417
+ const draw = drawRef.current;
4418
+ const map = mapRef.current;
4419
+ if (!draw || !map) return;
4420
+ loaded.current = true;
4421
+ const bounds = new maplibregl3.LngLatBounds();
4422
+ if (initial.features.length > 0) {
4423
+ const session = prepareForEditing(initial.features);
4424
+ sessionRef.current = session;
4425
+ for (const geometry of showShapes(session, session)) extendBounds(bounds, geometry);
4426
+ }
4427
+ const drafts = initial.overlays.map((overlay) => ({ ...overlay, id: crypto.randomUUID() }));
4428
+ setOverlays(drafts);
4429
+ for (const draft of drafts) {
4430
+ mountOverlay(draft).catch(() => {
4431
+ });
4432
+ for (const corner of draft.corners) bounds.extend(corner);
4433
+ }
4434
+ if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 60, maxZoom: 17, duration: 0 });
4435
+ else if (initialView) map.jumpTo(initialView);
4436
+ }, [ready, initial]);
4437
+ function showShapes(session, added) {
4438
+ const draw = drawRef.current;
4439
+ const map = mapRef.current;
4440
+ if (!draw || !map) return [];
4441
+ const parts = [...added.parts];
4442
+ const results = draw.addFeatures(parts.map((part) => ({
4443
+ type: "Feature",
4444
+ geometry: part.geometry,
4445
+ properties: {
4446
+ mode: part.mode,
4447
+ sourceKey: part.sourceKey,
4448
+ color: part.color,
4449
+ name: part.name,
4450
+ ...part.mode === "polygon" ? { opacity: part.opacity } : {}
4451
+ }
4452
+ })));
4453
+ results.forEach((result, i) => {
4454
+ if (!result.valid) lockPart(session, parts[i].sourceKey);
4455
+ });
4456
+ const locked = [...session.locked.values()];
4457
+ setLockedCount(locked.length);
4458
+ map.getSource(LOCKED_SOURCE)?.setData({
4459
+ type: "FeatureCollection",
4460
+ features: locked.map((part) => ({ type: "Feature", geometry: part.geometry, properties: { color: part.color } }))
4461
+ });
4462
+ return [...parts, ...added.locked.values()].map((part) => part.geometry);
4463
+ }
4464
+ function handleModeChange(next) {
4465
+ if (!ready) return;
4466
+ setMode(next);
4467
+ setSelectedOverlayId(null);
4468
+ drawRef.current?.setMode(next);
4469
+ }
4470
+ useImperativeHandle(ref, () => ({
4471
+ getResult() {
4472
+ const savedParts = (drawRef.current?.getSnapshot() ?? []).filter((f) => f.geometry != null && !f.properties?.selectionPoint && !f.properties?.midPoint).map((f) => {
4473
+ const name = f.properties?.name?.trim();
4474
+ const regionOpacity = f.geometry.type === "Polygon" ? f.properties?.opacity ?? opacityRef.current : void 0;
4475
+ const sourceKey = f.properties?.sourceKey;
4476
+ return {
4477
+ ...typeof sourceKey === "string" ? { sourceKey } : {},
4478
+ geometry: f.geometry,
4479
+ color: f.properties?.color ?? colorRef.current,
4480
+ ...name ? { name } : {},
4481
+ ...regionOpacity !== void 0 ? { opacity: regionOpacity } : {}
4482
+ };
4483
+ });
4484
+ const session = sessionRef.current ?? { parts: [], sources: /* @__PURE__ */ new Map(), locked: /* @__PURE__ */ new Map() };
4485
+ return {
4486
+ features: assembleForSaving(session, savedParts),
4487
+ overlays: overlaysRef.current.map(({ image, corners, name, opacity: opacity2, order }) => ({ image, corners, name, opacity: opacity2, order }))
4488
+ };
4489
+ },
4490
+ drawnBounds() {
4491
+ const bounds = new maplibregl3.LngLatBounds();
4492
+ for (const f of drawRef.current?.getSnapshot() ?? []) {
4493
+ if (f.geometry == null || f.properties?.selectionPoint || f.properties?.midPoint) continue;
4494
+ extendBounds(bounds, f.geometry);
4495
+ }
4496
+ for (const overlay of overlaysRef.current) for (const corner of overlay.corners) bounds.extend(corner);
4497
+ return bounds.isEmpty() ? null : bounds.toArray().flat();
4498
+ },
4499
+ async addImage(image) {
4500
+ if (overlaysRef.current.some((o) => o.image === image)) return false;
4501
+ const map = mapRef.current;
4502
+ if (!map) return false;
4503
+ const url = await live.current.resolveImage(image);
4504
+ const element = await loadImageElement(url, live.current.strings.imageFailed).finally(() => release(url));
4505
+ const aspect = element.naturalWidth / element.naturalHeight;
4506
+ const centerScreen = map.project(map.getCenter());
4507
+ const halfWidthPx = 130;
4508
+ const halfHeightPx = halfWidthPx / aspect;
4509
+ const topLeft = map.unproject([centerScreen.x - halfWidthPx, centerScreen.y - halfHeightPx]);
4510
+ const bottomRight = map.unproject([centerScreen.x + halfWidthPx, centerScreen.y + halfHeightPx]);
4511
+ const draft = {
4512
+ id: crypto.randomUUID(),
4513
+ image,
4514
+ corners: [
4515
+ [topLeft.lng, topLeft.lat],
4516
+ [bottomRight.lng, topLeft.lat],
4517
+ [bottomRight.lng, bottomRight.lat],
4518
+ [topLeft.lng, bottomRight.lat]
4519
+ ],
4520
+ // Leaves room to insert between existing overlays later without
4521
+ // renumbering.
4522
+ order: overlaysRef.current.length * 10
4523
+ };
4524
+ setOverlays((prev) => [...prev, draft]);
4525
+ await mountOverlay(draft);
4526
+ setSelectedOverlayId(draft.id);
4527
+ return true;
4528
+ },
4529
+ addFeatures(features) {
4530
+ const map = mapRef.current;
4531
+ if (!map || features.length === 0) return;
4532
+ const session = sessionRef.current ?? { parts: [], sources: /* @__PURE__ */ new Map(), locked: /* @__PURE__ */ new Map() };
4533
+ sessionRef.current = session;
4534
+ const bounds = new maplibregl3.LngLatBounds();
4535
+ for (const geometry of showShapes(session, addToSession(session, features))) extendBounds(bounds, geometry);
4536
+ if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 60, maxZoom: 17 });
4537
+ }
4538
+ // mountOverlay and friends read only refs: a fresh handle each render
4539
+ // is fine.
4540
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4541
+ }));
4542
+ const selectedFeature = selectedId ? drawRef.current?.getSnapshot().find((f) => String(f.id) === selectedId) : void 0;
4543
+ const selectedIsPoint = selectedFeature?.geometry?.type === "Point";
4544
+ const selectedIsRegion = selectedFeature?.geometry?.type === "Polygon";
4545
+ function renderModeButton(entry) {
4546
+ return /* @__PURE__ */ jsx9(
4547
+ Button3,
4548
+ {
4549
+ size: "xs",
4550
+ variant: mode === entry.mode ? "filled" : "default",
4551
+ disabled: !ready,
4552
+ onClick: () => handleModeChange(entry.mode),
4553
+ children: strings[entry.label]
4554
+ },
4555
+ entry.mode
4556
+ );
4557
+ }
4558
+ return /* @__PURE__ */ jsxs7(Box2, { style: { position: "absolute", inset: 0, overflow: "hidden" }, children: [
4559
+ /* @__PURE__ */ jsx9("style", { children: TOOLBAR_CSS }),
4560
+ /* @__PURE__ */ jsx9("div", { ref: setContainerEl, style: { position: "absolute", inset: 0 } }),
4561
+ /* @__PURE__ */ jsxs7(
4562
+ Stack4,
4563
+ {
4564
+ gap: 4,
4565
+ p: 6,
4566
+ className: TOOLBAR_CLASS,
4567
+ style: {
4568
+ position: "absolute",
4569
+ top: toolbarTop,
4570
+ left: 12,
4571
+ zIndex: 3,
4572
+ borderRadius: 8,
4573
+ background: dark ? "rgba(20,20,20,0.75)" : "rgba(255,255,255,0.85)"
4574
+ },
4575
+ children: [
4576
+ TOOLBAR.filter((entry) => entry.mode !== "select").map(renderModeButton),
4577
+ onAddImage && /* @__PURE__ */ jsx9(Button3, { size: "xs", variant: "default", disabled: !ready, onClick: onAddImage, children: strings.image }),
4578
+ TOOLBAR.filter((entry) => entry.mode === "select").map(renderModeButton),
4579
+ /* @__PURE__ */ jsx9(Divider, { my: 2 }),
4580
+ /* @__PURE__ */ jsx9(Text4, { size: "xs", fw: 600, c: "dimmed", children: strings.editOptions }),
4581
+ selectedFeature && /* @__PURE__ */ jsx9(
4582
+ ColorInput,
4583
+ {
4584
+ size: "xs",
4585
+ value: color,
4586
+ onChange: (next) => {
4587
+ setColor(next);
4588
+ const draw = drawRef.current;
4589
+ if (draw && selectedId) draw.updateFeatureProperties(selectedId, { color: next });
4590
+ },
4591
+ swatches: COLOR_SWATCHES,
4592
+ disabled: !ready,
4593
+ popoverProps: { withinPortal: true, zIndex: 1e3 }
4594
+ }
4595
+ ),
4596
+ (selectedIsPoint || selectedIsRegion) && /* @__PURE__ */ jsx9(
4597
+ TextInput4,
4598
+ {
4599
+ size: "xs",
4600
+ placeholder: selectedIsPoint ? strings.labelText : strings.regionName,
4601
+ value: labelText,
4602
+ onChange: (e) => {
4603
+ setLabelText(e.currentTarget.value);
4604
+ const draw = drawRef.current;
4605
+ if (draw && selectedId) draw.updateFeatureProperties(selectedId, { name: e.currentTarget.value });
4606
+ },
4607
+ disabled: !ready
4608
+ }
4609
+ ),
4610
+ selectedIsRegion && /* @__PURE__ */ jsxs7(Stack4, { gap: 2, children: [
4611
+ /* @__PURE__ */ jsx9(Text4, { size: "xs", c: "dimmed", children: strings.fillOpacity }),
4612
+ /* @__PURE__ */ jsx9(
4613
+ Slider,
4614
+ {
4615
+ size: "xs",
4616
+ min: 0.1,
4617
+ max: 1,
4618
+ step: 0.05,
4619
+ label: (v) => `${Math.round(v * 100)}%`,
4620
+ value: opacity,
4621
+ onChange: (v) => {
4622
+ setOpacity(v);
4623
+ const draw = drawRef.current;
4624
+ if (draw && selectedId) draw.updateFeatureProperties(selectedId, { opacity: v });
4625
+ },
4626
+ disabled: !ready
4627
+ }
4628
+ )
4629
+ ] }),
4630
+ selectedOverlayId && /* @__PURE__ */ jsx9(
4631
+ SegmentedControl,
4632
+ {
4633
+ size: "xs",
4634
+ value: showCornerHandles ? "transform" : "scale",
4635
+ onChange: (value) => setCornerHandlesVisible(selectedOverlayId, value === "transform"),
4636
+ disabled: !ready,
4637
+ data: [
4638
+ { label: strings.scale, value: "scale" },
4639
+ { label: strings.transform, value: "transform" }
4640
+ ]
4641
+ }
4642
+ ),
4643
+ selectedOverlay && /* @__PURE__ */ jsxs7(Fragment2, { children: [
4644
+ overlayNames && /* @__PURE__ */ jsx9(
4645
+ TextInput4,
4646
+ {
4647
+ size: "xs",
4648
+ placeholder: strings.overlayName,
4649
+ value: selectedOverlay.name ?? "",
4650
+ onChange: (e) => updateSelectedOverlay({ name: e.currentTarget.value }),
4651
+ disabled: !ready
4652
+ }
4653
+ ),
4654
+ /* @__PURE__ */ jsxs7(Stack4, { gap: 2, children: [
4655
+ /* @__PURE__ */ jsx9(Text4, { size: "xs", c: "dimmed", children: strings.opacity }),
4656
+ /* @__PURE__ */ jsx9(
4657
+ Slider,
4658
+ {
4659
+ size: "xs",
4660
+ min: 0.1,
4661
+ max: 1,
4662
+ step: 0.05,
4663
+ label: (v) => `${Math.round(v * 100)}%`,
4664
+ value: selectedOverlay.opacity ?? 1,
4665
+ onChange: (v) => updateSelectedOverlay({ opacity: v }),
4666
+ disabled: !ready
4667
+ }
4668
+ )
4669
+ ] }),
4670
+ /* @__PURE__ */ jsx9(
4671
+ NumberInput,
4672
+ {
4673
+ size: "xs",
4674
+ label: strings.stackingOrder,
4675
+ hideControls: false,
4676
+ value: selectedOverlay.order ?? 0,
4677
+ onChange: (v) => updateSelectedOverlay({ order: Number(v) || 0 }),
4678
+ disabled: !ready
4679
+ }
4680
+ ),
4681
+ renderOverlayOptions?.(selectedOverlay.image)
4682
+ ] })
4683
+ ]
4684
+ }
4685
+ ),
4686
+ !ready && /* @__PURE__ */ jsx9(Group4, { justify: "center", align: "center", style: { position: "absolute", inset: 0, zIndex: 2 }, children: /* @__PURE__ */ jsx9(Loader3, { size: "sm" }) }),
4687
+ lockedCount > 0 && !lockedNoticeDismissed && /* @__PURE__ */ jsx9(
4688
+ Alert,
4689
+ {
4690
+ color: "yellow",
4691
+ title: lockedCount === 1 ? strings.lockedOne : strings.lockedMany(lockedCount),
4692
+ withCloseButton: true,
4693
+ onClose: () => setLockedNoticeDismissed(true),
4694
+ style: { position: "absolute", bottom: 12, right: 12, zIndex: 3, maxWidth: 360 },
4695
+ children: strings.lockedExplain
4696
+ }
4697
+ )
4698
+ ] });
4699
+ });
4700
+ function MapEditorHelp({ strings: stringOverrides, images = true }) {
4701
+ const strings = { ...MAP_EDITOR_ENGLISH, ...stringOverrides };
4702
+ return /* @__PURE__ */ jsxs7(Stack4, { gap: "md", children: [
4703
+ /* @__PURE__ */ jsx9(Text4, { size: "sm", fw: 600, children: strings.helpDrawing }),
4704
+ /* @__PURE__ */ jsxs7(List, { spacing: 4, size: "sm", children: [
4705
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpPoint }),
4706
+ /* @__PURE__ */ jsxs7(List.Item, { children: [
4707
+ strings.helpLine,
4708
+ " ",
4709
+ /* @__PURE__ */ jsx9(Kbd, { children: "Enter" }),
4710
+ " ",
4711
+ strings.helpEnterFinishes,
4712
+ " ",
4713
+ /* @__PURE__ */ jsx9(Kbd, { children: "Esc" }),
4714
+ " ",
4715
+ strings.helpEscCancels
4716
+ ] }),
4717
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpRectangle }),
4718
+ images && /* @__PURE__ */ jsxs7(List.Item, { children: [
4719
+ strings.helpImage,
4720
+ " ",
4721
+ /* @__PURE__ */ jsx9(Text4, { span: true, fw: 600, children: strings.helpImages }),
4722
+ " ",
4723
+ strings.helpBelow
4724
+ ] })
4725
+ ] }),
4726
+ /* @__PURE__ */ jsx9(Text4, { size: "sm", fw: 600, children: strings.helpEditing }),
4727
+ /* @__PURE__ */ jsxs7(List, { spacing: 4, size: "sm", children: [
4728
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpSelect }),
4729
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpVertex }),
4730
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpColor }),
4731
+ /* @__PURE__ */ jsxs7(List.Item, { children: [
4732
+ strings.helpDelete,
4733
+ " ",
4734
+ /* @__PURE__ */ jsx9(Kbd, { children: "Delete" }),
4735
+ " ",
4736
+ strings.helpOr,
4737
+ " ",
4738
+ /* @__PURE__ */ jsx9(Kbd, { children: "Backspace" }),
4739
+ "."
4740
+ ] }),
4741
+ /* @__PURE__ */ jsxs7(List.Item, { children: [
4742
+ strings.helpRotate,
4743
+ " ",
4744
+ /* @__PURE__ */ jsx9(Kbd, { children: "R" }),
4745
+ " ",
4746
+ strings.helpRotateHandle
4747
+ ] })
4748
+ ] }),
4749
+ images && /* @__PURE__ */ jsxs7(Fragment2, { children: [
4750
+ /* @__PURE__ */ jsx9(Text4, { size: "sm", fw: 600, children: strings.helpImages }),
4751
+ /* @__PURE__ */ jsxs7(List, { spacing: 4, size: "sm", children: [
4752
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpImageMove }),
4753
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpImageResize }),
4754
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpImageRotate }),
4755
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpImageTransform }),
4756
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpImageRemove })
4757
+ ] })
4758
+ ] })
4759
+ ] });
4760
+ }
4761
+
4762
+ // src/editor/placeShapes.ts
4763
+ function placeFeatures(place) {
4764
+ const geometry = place?.geometry;
4765
+ if (!geometry) return [];
4766
+ if (geometry.type === "FeatureCollection") {
4767
+ return (geometry.features ?? []).filter((f) => f && f.geometry);
4768
+ }
4769
+ if (geometry.type === "Feature") return geometry.geometry ? [geometry] : [];
4770
+ return [{ type: "Feature", properties: {}, geometry }];
4771
+ }
4772
+ function placeToEditor(place) {
4773
+ return {
4774
+ features: placeFeatures(place),
4775
+ overlays: (place?.overlays ?? []).map((o) => ({
4776
+ image: o.media,
4777
+ corners: o.corners,
4778
+ ...o.opacity !== void 0 ? { opacity: o.opacity } : {},
4779
+ ...o.order !== void 0 ? { order: o.order } : {}
4780
+ }))
4781
+ };
4782
+ }
4783
+ function editorToPlace(place, result, dates) {
4784
+ const before = place?.geometry;
4785
+ const geometry = result.features.length === 0 ? void 0 : { ...before?.type === "FeatureCollection" ? before : {}, type: "FeatureCollection", features: result.features };
4786
+ const previous = new Map((place?.overlays ?? []).map((o) => [o.media, o]));
4787
+ const overlays = result.overlays.map((o) => {
4788
+ const { opacity: _o, order: _r, date: _d, ...kept } = previous.get(o.image) ?? {};
4789
+ const date = dates.has(o.image) ? dates.get(o.image) : previous.get(o.image)?.date;
4790
+ return {
4791
+ ...kept,
4792
+ media: o.image,
4793
+ corners: o.corners,
4794
+ ...o.opacity !== void 0 && o.opacity !== 1 ? { opacity: o.opacity } : {},
4795
+ ...o.order !== void 0 ? { order: Math.round(o.order) } : {},
4796
+ ...date ? { date } : {}
4797
+ };
4798
+ });
4799
+ return patchPlace(place, {
4800
+ geometry,
4801
+ overlays: overlays.length ? overlays : void 0
4802
+ });
4803
+ }
4804
+
4805
+ // src/editor/PlaceMapDialog.tsx
4806
+ import { jsx as jsx10, jsxs as jsxs8 } from "react/jsx-runtime";
4807
+ var BAR_HEIGHT2 = 56;
4808
+ var STORY_MAP_WORDS = {
4809
+ helpImage: "Image: add a picture file, such as a scan of an old map -- see"
4810
+ };
4811
+ async function prepareOverlay(file) {
4812
+ const image = await optimizeImage(file, { purpose: "overlay" });
4813
+ return { data: new Uint8Array(await image.blob.arrayBuffer()), mime: image.mime, ext: image.ext, width: image.width, height: image.height };
4814
+ }
4815
+ function OverlayDateField({ value, language, parse, strings, onChange }) {
4816
+ const [text, setText] = useState8(value?.text ?? "");
4817
+ const request = useRef9(0);
4818
+ const edtf = value?.edtf;
4819
+ return /* @__PURE__ */ jsxs8(Stack5, { gap: 2, maw: 220, children: [
4820
+ /* @__PURE__ */ jsx10(
4821
+ TextInput5,
4822
+ {
4823
+ size: "xs",
4824
+ label: strings.overlayDate,
4825
+ description: strings.overlayDateHint,
4826
+ value: text,
4827
+ onChange: (e) => {
4828
+ const typed = e.currentTarget.value;
4829
+ setText(typed);
4830
+ const id = ++request.current;
4831
+ if (!typed.trim()) {
4832
+ onChange(void 0);
4833
+ return;
4834
+ }
4835
+ void parse(typed, language).then((parsed) => {
4836
+ if (id === request.current) onChange(slideDate(value, typed, parsed));
4837
+ });
4838
+ }
4839
+ }
4840
+ ),
4841
+ text.trim() && /* @__PURE__ */ jsx10(Text5, { size: "xs", c: edtf && edtfYearRange(edtf) ? "dimmed" : "red", children: edtf && edtfYearRange(edtf) ? `EDTF ${edtf}` : strings.preciseDateNotUnderstood })
4842
+ ] });
4843
+ }
4844
+ function PlaceMapDialog({
4845
+ place,
4846
+ doc,
4847
+ files,
4848
+ dark,
4849
+ parseDateText,
4850
+ strings,
4851
+ mapStrings: mapStringOverrides,
4852
+ onCancel,
4853
+ onDone
4854
+ }) {
4855
+ const mapStrings = { ...STORY_MAP_WORDS, ...mapStringOverrides };
4856
+ const editorRef = useRef9(null);
4857
+ const initial = useMemo3(() => placeToEditor(place), [place]);
4858
+ const [work, setWork] = useState8({ doc, files });
4859
+ const workRef = useRef9(work);
4860
+ workRef.current = work;
4861
+ const [dates, setDates] = useState8(() => /* @__PURE__ */ new Map());
4862
+ const [message, setMessage] = useState8(null);
4863
+ const [helpOpen, setHelpOpen] = useState8(false);
4864
+ const imageInput = useRef9(null);
4865
+ const shapesInput = useRef9(null);
4866
+ const initialView = useMemo3(() => {
4867
+ if (place?.lat !== void 0 && place.lon !== void 0) return { center: [place.lon, place.lat], zoom: 12 };
4868
+ const all = storyPlaces(doc.slides.map((s2) => s2.place));
4869
+ if (!all) return void 0;
4870
+ const [w, s, e, n] = all.bounds;
4871
+ return { center: [(w + e) / 2, (s + n) / 2], zoom: 5 };
4872
+ }, [place, doc]);
4873
+ const resolveImage = async (key) => {
4874
+ const item = Object.prototype.hasOwnProperty.call(workRef.current.doc.media, key) ? workRef.current.doc.media[key] : void 0;
4875
+ const data = item && workRef.current.files.get(item.src);
4876
+ if (!item || !data) throw new Error(strings.photoFailed(key));
4877
+ return URL.createObjectURL(new Blob([data], { type: item.mime }));
4878
+ };
4879
+ const addImages = async (chosen) => {
4880
+ setMessage(null);
4881
+ for (const file of chosen) {
4882
+ let key;
4883
+ try {
4884
+ const added = addImage(workRef.current.doc, workRef.current.files, await prepareOverlay(file), "map");
4885
+ workRef.current = { doc: added.doc, files: added.files };
4886
+ setWork(workRef.current);
4887
+ key = added.key;
4888
+ } catch {
4889
+ setMessage(strings.photoFailed(file.name || "the image"));
4890
+ continue;
4891
+ }
4892
+ try {
4893
+ if (!await editorRef.current?.addImage(key)) setMessage(strings.overlayAlready);
4894
+ } catch (err) {
4895
+ setMessage(err instanceof Error ? err.message : String(err));
4896
+ }
4897
+ }
4898
+ };
4899
+ const importShapes = async (chosen) => {
4900
+ setMessage(null);
4901
+ for (const file of chosen) {
4902
+ try {
4903
+ const features = parseMapFile(await file.text()).filter((f) => f.properties?.["@geometry-type"] !== "groundoverlay");
4904
+ if (features.length === 0) setMessage(strings.importNothing(file.name));
4905
+ else editorRef.current?.addFeatures(features);
4906
+ } catch {
4907
+ setMessage(strings.importFailed(file.name));
4908
+ }
4909
+ }
4910
+ };
4911
+ const done = () => {
4912
+ const editor = editorRef.current;
4913
+ if (!editor) return;
4914
+ onDone(editorToPlace(place, editor.getResult(), dates), workRef.current.doc.media, workRef.current.files);
4915
+ };
4916
+ const bar = dark ? "rgba(20,20,20,0.85)" : "rgba(255,255,255,0.9)";
4917
+ return /* @__PURE__ */ jsxs8(Modal, { opened: true, onClose: onCancel, fullScreen: true, withCloseButton: false, closeOnEscape: false, padding: 0, children: [
4918
+ /* @__PURE__ */ jsxs8(Box3, { style: { position: "fixed", inset: 0, overflow: "hidden" }, children: [
4919
+ /* @__PURE__ */ jsx10(
4920
+ MapEditor,
4921
+ {
4922
+ ref: editorRef,
4923
+ initial,
4924
+ initialView,
4925
+ resolveImage,
4926
+ releaseImage: (url) => URL.revokeObjectURL(url),
4927
+ onAddImage: () => imageInput.current?.click(),
4928
+ onError: setMessage,
4929
+ dark,
4930
+ toolbarTop: BAR_HEIGHT2 + 12,
4931
+ overlayNames: false,
4932
+ strings: mapStrings,
4933
+ renderOverlayOptions: (key) => /* @__PURE__ */ jsx10(
4934
+ OverlayDateField,
4935
+ {
4936
+ value: dates.has(key) ? dates.get(key) : place?.overlays?.find((o) => o.media === key)?.date,
4937
+ language: doc.language,
4938
+ parse: parseDateText,
4939
+ strings,
4940
+ onChange: (date) => setDates((prev) => new Map(prev).set(key, date))
4941
+ },
4942
+ key
4943
+ )
4944
+ }
4945
+ ),
4946
+ /* @__PURE__ */ jsxs8(
4947
+ Group5,
4948
+ {
4949
+ justify: "space-between",
4950
+ wrap: "nowrap",
4951
+ px: "md",
4952
+ gap: "sm",
4953
+ style: { position: "absolute", top: 0, left: 0, right: 0, height: BAR_HEIGHT2, zIndex: 3, background: bar },
4954
+ children: [
4955
+ /* @__PURE__ */ jsxs8(Group5, { gap: 6, wrap: "nowrap", style: { minWidth: 0 }, children: [
4956
+ /* @__PURE__ */ jsx10(Text5, { fw: 600, truncate: true, dir: "auto", children: strings.mapDialogTitle(place?.name) }),
4957
+ /* @__PURE__ */ jsx10(Tooltip3, { label: strings.mapHelp, children: /* @__PURE__ */ jsx10(ActionIcon2, { variant: "subtle", radius: "xl", "aria-label": strings.mapHelp, onClick: () => setHelpOpen(true), children: "\u24D8" }) })
4958
+ ] }),
4959
+ /* @__PURE__ */ jsxs8(Group5, { gap: "xs", wrap: "nowrap", children: [
4960
+ /* @__PURE__ */ jsx10(Tooltip3, { label: strings.importShapesHint, multiline: true, w: 280, children: /* @__PURE__ */ jsx10(Button4, { variant: "default", size: "sm", onClick: () => shapesInput.current?.click(), children: strings.importShapes }) }),
4961
+ /* @__PURE__ */ jsx10(Button4, { variant: "default", size: "sm", onClick: onCancel, children: strings.cancel }),
4962
+ /* @__PURE__ */ jsx10(Button4, { size: "sm", onClick: done, children: strings.done })
4963
+ ] })
4964
+ ]
4965
+ }
4966
+ ),
4967
+ message && /* @__PURE__ */ jsx10(
4968
+ Text5,
4969
+ {
4970
+ size: "sm",
4971
+ role: "alert",
4972
+ style: {
4973
+ position: "absolute",
4974
+ bottom: 12,
4975
+ left: 12,
4976
+ zIndex: 3,
4977
+ maxWidth: 480,
4978
+ padding: "6px 10px",
4979
+ borderRadius: 6,
4980
+ background: bar,
4981
+ color: "var(--mantine-color-red-filled)"
4982
+ },
4983
+ children: message
4984
+ }
4985
+ ),
4986
+ /* @__PURE__ */ jsx10(
4987
+ "input",
4988
+ {
4989
+ ref: imageInput,
4990
+ type: "file",
4991
+ accept: "image/*",
4992
+ multiple: true,
4993
+ hidden: true,
4994
+ onChange: (e) => {
4995
+ const chosen = [...e.currentTarget.files ?? []];
4996
+ e.currentTarget.value = "";
4997
+ void addImages(chosen);
4998
+ }
4999
+ }
5000
+ ),
5001
+ /* @__PURE__ */ jsx10(
5002
+ "input",
5003
+ {
5004
+ ref: shapesInput,
5005
+ type: "file",
5006
+ accept: ".kml,.gpx,.geojson,.json,application/vnd.google-earth.kml+xml,application/gpx+xml,application/geo+json",
5007
+ multiple: true,
5008
+ hidden: true,
5009
+ onChange: (e) => {
5010
+ const chosen = [...e.currentTarget.files ?? []];
5011
+ e.currentTarget.value = "";
5012
+ void importShapes(chosen);
5013
+ }
5014
+ }
5015
+ )
5016
+ ] }),
5017
+ /* @__PURE__ */ jsx10(Modal, { opened: helpOpen, onClose: () => setHelpOpen(false), title: strings.mapHelp, size: "md", zIndex: 1e3, children: /* @__PURE__ */ jsx10(MapEditorHelp, { strings: mapStrings }) })
3473
5018
  ] });
3474
5019
  }
3475
5020
 
@@ -3512,20 +5057,20 @@ async function wikidataPoint(result, signal) {
3512
5057
  var WIKIDATA_PLACE_SEARCH = { search: searchWikidataPlaces, point: wikidataPoint };
3513
5058
 
3514
5059
  // src/editor/SlideList.tsx
3515
- import { useState as useState7 } from "react";
3516
- import { Stack as Stack4, UnstyledButton as UnstyledButton2 } from "@mantine/core";
3517
- import { jsx as jsx9 } from "react/jsx-runtime";
5060
+ import { useState as useState9 } from "react";
5061
+ import { Stack as Stack6, UnstyledButton as UnstyledButton2 } from "@mantine/core";
5062
+ import { jsx as jsx11 } from "react/jsx-runtime";
3518
5063
  function SlideList({ items, selected, onSelect, onMove, getKey, renderItem, label }) {
3519
- const [dragging, setDragging] = useState7(null);
3520
- const [over, setOver] = useState7(null);
5064
+ const [dragging, setDragging] = useState9(null);
5065
+ const [over, setOver] = useState9(null);
3521
5066
  const end = () => {
3522
5067
  setDragging(null);
3523
5068
  setOver(null);
3524
5069
  };
3525
- return /* @__PURE__ */ jsx9(Stack4, { gap: 4, role: "listbox", "aria-label": label, children: items.map((item, i) => {
5070
+ return /* @__PURE__ */ jsx11(Stack6, { gap: 4, role: "listbox", "aria-label": label, children: items.map((item, i) => {
3526
5071
  const isSelected = i === selected;
3527
5072
  const dropLine = over === i && dragging !== null && dragging !== i ? dragging < i ? { borderBottom: "2px solid var(--mantine-primary-color-filled)" } : { borderTop: "2px solid var(--mantine-primary-color-filled)" } : {};
3528
- return /* @__PURE__ */ jsx9(
5073
+ return /* @__PURE__ */ jsx11(
3529
5074
  UnstyledButton2,
3530
5075
  {
3531
5076
  role: "option",
@@ -3565,9 +5110,9 @@ function SlideList({ items, selected, onSelect, onMove, getKey, renderItem, labe
3565
5110
  }
3566
5111
 
3567
5112
  // src/editor/StoryTextField.tsx
3568
- import { useRef as useRef8 } from "react";
3569
- import { Button as Button3, Group as Group4, Stack as Stack5, Text as Text4, Textarea } from "@mantine/core";
3570
- import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
5113
+ import { useRef as useRef10 } from "react";
5114
+ import { Button as Button5, Group as Group6, Stack as Stack7, Text as Text6, Textarea } from "@mantine/core";
5115
+ import { jsx as jsx12, jsxs as jsxs9 } from "react/jsx-runtime";
3571
5116
  function applyMarker(el, marker, value, onChange) {
3572
5117
  const start = el.selectionStart ?? value.length;
3573
5118
  const end = el.selectionEnd ?? value.length;
@@ -3585,12 +5130,12 @@ function applyMarker(el, marker, value, onChange) {
3585
5130
  onChange(value.slice(0, start) + replacement + value.slice(end));
3586
5131
  }
3587
5132
  function StoryTextField({ label, value, onChange, boldLabel = "Bold", italicLabel = "Italic", minRows = 3 }) {
3588
- const ref = useRef8(null);
3589
- return /* @__PURE__ */ jsxs7(Stack5, { gap: 4, children: [
3590
- /* @__PURE__ */ jsxs7(Group4, { gap: "xs", children: [
3591
- /* @__PURE__ */ jsx10(Text4, { size: "sm", fw: 500, children: label }),
3592
- /* @__PURE__ */ jsx10(
3593
- Button3,
5133
+ const ref = useRef10(null);
5134
+ return /* @__PURE__ */ jsxs9(Stack7, { gap: 4, children: [
5135
+ /* @__PURE__ */ jsxs9(Group6, { gap: "xs", children: [
5136
+ /* @__PURE__ */ jsx12(Text6, { size: "sm", fw: 500, children: label }),
5137
+ /* @__PURE__ */ jsx12(
5138
+ Button5,
3594
5139
  {
3595
5140
  variant: "default",
3596
5141
  size: "compact-xs",
@@ -3601,8 +5146,8 @@ function StoryTextField({ label, value, onChange, boldLabel = "Bold", italicLabe
3601
5146
  children: "B"
3602
5147
  }
3603
5148
  ),
3604
- /* @__PURE__ */ jsx10(
3605
- Button3,
5149
+ /* @__PURE__ */ jsx12(
5150
+ Button5,
3606
5151
  {
3607
5152
  variant: "default",
3608
5153
  size: "compact-xs",
@@ -3614,7 +5159,7 @@ function StoryTextField({ label, value, onChange, boldLabel = "Bold", italicLabe
3614
5159
  }
3615
5160
  )
3616
5161
  ] }),
3617
- /* @__PURE__ */ jsx10(
5162
+ /* @__PURE__ */ jsx12(
3618
5163
  Textarea,
3619
5164
  {
3620
5165
  ref,
@@ -3675,7 +5220,20 @@ var EDITOR_ENGLISH = {
3675
5220
  placeMapHint: "Click the map to put the pin there, or drag the pin.",
3676
5221
  placeShape: "a shape",
3677
5222
  placeOverlays: (count) => count === 1 ? "a map overlay" : `${count} map overlays`,
3678
- placeKeeps: (what) => `This place also has ${what}, kept as they are.`,
5223
+ placeKeeps: (what) => `This place also has ${what}.`,
5224
+ editMap: "Edit map",
5225
+ editMapHint: "Shapes under the pin (a parish, a route) and scans of old maps laid over it.",
5226
+ mapDialogTitle: (placeName) => placeName ? `Map: ${placeName}` : "Map",
5227
+ mapHelp: "How to use this editor",
5228
+ importShapes: "Import shapes",
5229
+ importShapesHint: "Adds the points, lines and areas in a KML, GPX or GeoJSON file to this map.",
5230
+ importFailed: (fileName) => `Couldn't read ${fileName} as a KML, GPX or GeoJSON file.`,
5231
+ importNothing: (fileName) => `${fileName} has no shapes in it.`,
5232
+ overlayAlready: "That image is already on this map.",
5233
+ overlayDate: "Years it shows",
5234
+ overlayDateHint: "e.g. 1850, or 1850 to 1880. With historical maps, it's hidden on slides outside them.",
5235
+ cancel: "Cancel",
5236
+ done: "Done",
3679
5237
  photos: "Photos",
3680
5238
  photosHint: "Add, drop or paste a photo. It's resized for the screen, and hidden details like camera and location are left out.",
3681
5239
  addPhoto: "Add photo",
@@ -3698,8 +5256,8 @@ var EDITOR_ENGLISH = {
3698
5256
  };
3699
5257
 
3700
5258
  // src/editor/StoryEditor.tsx
3701
- import { jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
3702
- var BAR_HEIGHT2 = 56;
5259
+ import { jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
5260
+ var BAR_HEIGHT3 = 56;
3703
5261
  var STORY = -1;
3704
5262
  function issueSlide(issue) {
3705
5263
  const match = /^\/slides\/(\d+)/.exec(issue.path);
@@ -3712,8 +5270,8 @@ async function prepareImage(file) {
3712
5270
  function Thumb({ doc, slide, resolveMedia }) {
3713
5271
  const ref = slide.media?.[0]?.ref;
3714
5272
  const item = ref && Object.prototype.hasOwnProperty.call(doc.media, ref) ? doc.media[ref] : void 0;
3715
- const [url, setUrl] = useState8(null);
3716
- useEffect8(() => {
5273
+ const [url, setUrl] = useState10(null);
5274
+ useEffect9(() => {
3717
5275
  let live = true;
3718
5276
  setUrl(null);
3719
5277
  if (ref && item) void resolveMedia(ref, item).then((u) => {
@@ -3723,13 +5281,13 @@ function Thumb({ doc, slide, resolveMedia }) {
3723
5281
  live = false;
3724
5282
  };
3725
5283
  }, [ref, item, resolveMedia]);
3726
- return /* @__PURE__ */ jsx11(
3727
- Box2,
5284
+ return /* @__PURE__ */ jsx13(
5285
+ Box4,
3728
5286
  {
3729
5287
  w: 40,
3730
5288
  h: 40,
3731
5289
  style: { flex: "none", borderRadius: 4, background: "var(--mantine-color-default-hover)", overflow: "hidden" },
3732
- children: url && /* @__PURE__ */ jsx11("img", { src: url, alt: "", style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })
5290
+ children: url && /* @__PURE__ */ jsx13("img", { src: url, alt: "", style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })
3733
5291
  }
3734
5292
  );
3735
5293
  }
@@ -3744,24 +5302,25 @@ function StoryEditor({
3744
5302
  strings: stringOverrides,
3745
5303
  playerStrings,
3746
5304
  parseDateText = suggestEdtf,
3747
- placeSearch = WIKIDATA_PLACE_SEARCH
5305
+ placeSearch = WIKIDATA_PLACE_SEARCH,
5306
+ mapStrings
3748
5307
  }) {
3749
- const strings = useMemo3(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
3750
- const [history, setHistory] = useState8(() => startHistory(initialStory));
3751
- const [selected, setSelected] = useState8(0);
3752
- const [presenting, setPresenting] = useState8(false);
3753
- const [problemsOpen, setProblemsOpen] = useState8(false);
5308
+ const strings = useMemo4(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
5309
+ const [history, setHistory] = useState10(() => startHistory(initialStory));
5310
+ const [selected, setSelected] = useState10(0);
5311
+ const [presenting, setPresenting] = useState10(false);
5312
+ const [problemsOpen, setProblemsOpen] = useState10(false);
3754
5313
  const wide = useMediaQuery("(min-width: 1000px)", true);
3755
5314
  const doc = history.present;
3756
- const [files, setFiles] = useState8(() => new Map(initialFiles ?? []));
3757
- const [busy, setBusy] = useState8(false);
3758
- const [photoError, setPhotoError] = useState8(null);
3759
- const docRef = useRef9(doc);
5315
+ const [files, setFiles] = useState10(() => new Map(initialFiles ?? []));
5316
+ const [busy, setBusy] = useState10(false);
5317
+ const [photoError, setPhotoError] = useState10(null);
5318
+ const docRef = useRef11(doc);
3760
5319
  docRef.current = doc;
3761
- const filesRef = useRef9(files);
5320
+ const filesRef = useRef11(files);
3762
5321
  filesRef.current = files;
3763
- const urls = useRef9(/* @__PURE__ */ new Map());
3764
- useEffect8(() => () => {
5322
+ const urls = useRef11(/* @__PURE__ */ new Map());
5323
+ useEffect9(() => () => {
3765
5324
  for (const url of urls.current.values()) URL.revokeObjectURL(url);
3766
5325
  }, []);
3767
5326
  const resolveMedia = useCallback(async (_key, item) => {
@@ -3780,7 +5339,7 @@ function StoryEditor({
3780
5339
  const apply = useCallback((next, key) => {
3781
5340
  setHistory((h) => record(h, next, key));
3782
5341
  }, []);
3783
- useEffect8(() => {
5342
+ useEffect9(() => {
3784
5343
  onChange?.(doc, files);
3785
5344
  }, [doc, files, onChange]);
3786
5345
  const index = selected === STORY ? STORY : Math.min(selected, doc.slides.length - 1);
@@ -3797,9 +5356,11 @@ function StoryEditor({
3797
5356
  return after === before ? h : record(h, updateSlide(h.present, at, { place: after }), `${slideId2}:${key}`);
3798
5357
  });
3799
5358
  };
3800
- const placeChoices = useMemo3(() => storyPlaceChoices(doc, index), [doc, index]);
3801
- const around = useMemo3(() => storyPlaces(doc.slides.map((s) => s.place))?.bounds ?? null, [doc.slides]);
3802
- const issues = useMemo3(() => validate2(doc, files).errors, [doc, files]);
5359
+ const [mapSlideId, setMapSlideId] = useState10(null);
5360
+ const mapSlide = mapSlideId ? doc.slides.find((s) => s.id === mapSlideId) : void 0;
5361
+ const placeChoices = useMemo4(() => storyPlaceChoices(doc, index), [doc, index]);
5362
+ const around = useMemo4(() => storyPlaces(doc.slides.map((s) => s.place))?.bounds ?? null, [doc.slides]);
5363
+ const issues = useMemo4(() => validate2(doc, files).errors, [doc, files]);
3803
5364
  const addPhotos = useCallback(async (chosen, slideId2, replace) => {
3804
5365
  setBusy(true);
3805
5366
  setPhotoError(null);
@@ -3836,7 +5397,7 @@ function StoryEditor({
3836
5397
  apply(setSlidePhotos(next, at, photos));
3837
5398
  }, [apply, prepare, strings]);
3838
5399
  const slideId = slide?.id;
3839
- useEffect8(() => {
5400
+ useEffect9(() => {
3840
5401
  if (!slideId) return;
3841
5402
  const onPaste = (e) => {
3842
5403
  if (e.target?.closest("input, textarea, [contenteditable=true]")) return;
@@ -3848,7 +5409,7 @@ function StoryEditor({
3848
5409
  window.addEventListener("paste", onPaste);
3849
5410
  return () => window.removeEventListener("paste", onPaste);
3850
5411
  }, [slideId, addPhotos]);
3851
- useEffect8(() => {
5412
+ useEffect9(() => {
3852
5413
  const onKey = (e) => {
3853
5414
  if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
3854
5415
  const target = e.target;
@@ -3865,9 +5426,9 @@ function StoryEditor({
3865
5426
  window.addEventListener("keydown", onKey);
3866
5427
  return () => window.removeEventListener("keydown", onKey);
3867
5428
  }, []);
3868
- const slideActions = slide && /* @__PURE__ */ jsxs8(Group5, { gap: 6, children: [
3869
- /* @__PURE__ */ jsx11(Tooltip3, { label: strings.moveUp, children: /* @__PURE__ */ jsx11(
3870
- ActionIcon2,
5429
+ const slideActions = slide && /* @__PURE__ */ jsxs10(Group7, { gap: 6, children: [
5430
+ /* @__PURE__ */ jsx13(Tooltip4, { label: strings.moveUp, children: /* @__PURE__ */ jsx13(
5431
+ ActionIcon3,
3871
5432
  {
3872
5433
  variant: "default",
3873
5434
  "aria-label": strings.moveUp,
@@ -3879,8 +5440,8 @@ function StoryEditor({
3879
5440
  children: "\u2191"
3880
5441
  }
3881
5442
  ) }),
3882
- /* @__PURE__ */ jsx11(Tooltip3, { label: strings.moveDown, children: /* @__PURE__ */ jsx11(
3883
- ActionIcon2,
5443
+ /* @__PURE__ */ jsx13(Tooltip4, { label: strings.moveDown, children: /* @__PURE__ */ jsx13(
5444
+ ActionIcon3,
3884
5445
  {
3885
5446
  variant: "default",
3886
5447
  "aria-label": strings.moveDown,
@@ -3892,13 +5453,13 @@ function StoryEditor({
3892
5453
  children: "\u2193"
3893
5454
  }
3894
5455
  ) }),
3895
- /* @__PURE__ */ jsx11(Button4, { variant: "default", size: "xs", onClick: () => {
5456
+ /* @__PURE__ */ jsx13(Button6, { variant: "default", size: "xs", onClick: () => {
3896
5457
  const r = duplicateSlide(doc, index);
3897
5458
  apply(r.doc);
3898
5459
  setSelected(r.index);
3899
5460
  }, children: strings.duplicateSlide }),
3900
- /* @__PURE__ */ jsx11(
3901
- Button4,
5461
+ /* @__PURE__ */ jsx13(
5462
+ Button6,
3902
5463
  {
3903
5464
  variant: "default",
3904
5465
  size: "xs",
@@ -3913,10 +5474,10 @@ function StoryEditor({
3913
5474
  }
3914
5475
  )
3915
5476
  ] });
3916
- const storyForm = /* @__PURE__ */ jsxs8(Stack6, { gap: "md", children: [
3917
- /* @__PURE__ */ jsx11(Text5, { fw: 600, size: "lg", children: strings.story }),
3918
- /* @__PURE__ */ jsx11(
3919
- TextInput4,
5477
+ const storyForm = /* @__PURE__ */ jsxs10(Stack8, { gap: "md", children: [
5478
+ /* @__PURE__ */ jsx13(Text7, { fw: 600, size: "lg", children: strings.story }),
5479
+ /* @__PURE__ */ jsx13(
5480
+ TextInput6,
3920
5481
  {
3921
5482
  label: strings.title,
3922
5483
  required: true,
@@ -3924,8 +5485,8 @@ function StoryEditor({
3924
5485
  onChange: (e) => apply(updateStory(doc, { title: e.currentTarget.value }), "story:title")
3925
5486
  }
3926
5487
  ),
3927
- /* @__PURE__ */ jsx11(
3928
- TextInput4,
5488
+ /* @__PURE__ */ jsx13(
5489
+ TextInput6,
3929
5490
  {
3930
5491
  label: strings.language,
3931
5492
  description: strings.languageHint,
@@ -3934,7 +5495,7 @@ function StoryEditor({
3934
5495
  onChange: (e) => apply(updateStory(doc, { language: e.currentTarget.value.trim() || void 0 }), "story:language")
3935
5496
  }
3936
5497
  ),
3937
- /* @__PURE__ */ jsx11(
5498
+ /* @__PURE__ */ jsx13(
3938
5499
  Select,
3939
5500
  {
3940
5501
  label: strings.map,
@@ -3953,20 +5514,20 @@ function StoryEditor({
3953
5514
  }
3954
5515
  )
3955
5516
  ] });
3956
- const slideForm = slide && /* @__PURE__ */ jsxs8(Stack6, { gap: "md", children: [
3957
- /* @__PURE__ */ jsxs8(Group5, { justify: "space-between", wrap: "wrap", gap: "xs", children: [
3958
- /* @__PURE__ */ jsx11(Text5, { fw: 600, size: "lg", children: index === 0 ? strings.openingSlide : `${index + 1} / ${doc.slides.length}` }),
5517
+ const slideForm = slide && /* @__PURE__ */ jsxs10(Stack8, { gap: "md", children: [
5518
+ /* @__PURE__ */ jsxs10(Group7, { justify: "space-between", wrap: "wrap", gap: "xs", children: [
5519
+ /* @__PURE__ */ jsx13(Text7, { fw: 600, size: "lg", children: index === 0 ? strings.openingSlide : `${index + 1} / ${doc.slides.length}` }),
3959
5520
  slideActions
3960
5521
  ] }),
3961
- /* @__PURE__ */ jsx11(
3962
- TextInput4,
5522
+ /* @__PURE__ */ jsx13(
5523
+ TextInput6,
3963
5524
  {
3964
5525
  label: strings.slideTitle,
3965
5526
  value: slide.title ?? "",
3966
5527
  onChange: (e) => setSlide({ title: e.currentTarget.value || void 0 }, "title")
3967
5528
  }
3968
5529
  ),
3969
- /* @__PURE__ */ jsx11(
5530
+ /* @__PURE__ */ jsx13(
3970
5531
  DateField,
3971
5532
  {
3972
5533
  value: slide.date,
@@ -3976,7 +5537,7 @@ function StoryEditor({
3976
5537
  onChange: (date) => setSlide({ date }, "date")
3977
5538
  }
3978
5539
  ),
3979
- /* @__PURE__ */ jsx11(
5540
+ /* @__PURE__ */ jsx13(
3980
5541
  StoryTextField,
3981
5542
  {
3982
5543
  label: strings.text,
@@ -3987,7 +5548,7 @@ function StoryEditor({
3987
5548
  onChange: (text) => setSlide({ text: text || void 0 }, "text")
3988
5549
  }
3989
5550
  ),
3990
- /* @__PURE__ */ jsx11(
5551
+ /* @__PURE__ */ jsx13(
3991
5552
  PlaceField,
3992
5553
  {
3993
5554
  place: slide.place,
@@ -3997,10 +5558,11 @@ function StoryEditor({
3997
5558
  search: placeSearch,
3998
5559
  dark,
3999
5560
  strings,
4000
- onChange: (update, key) => setPlace(slide.id, update, key)
5561
+ onChange: (update, key) => setPlace(slide.id, update, key),
5562
+ onEditMap: () => setMapSlideId(slide.id)
4001
5563
  }
4002
5564
  ),
4003
- /* @__PURE__ */ jsx11(
5565
+ /* @__PURE__ */ jsx13(
4004
5566
  PhotosField,
4005
5567
  {
4006
5568
  photos: slide.media ?? [],
@@ -4013,22 +5575,22 @@ function StoryEditor({
4013
5575
  onDescription: (key, alt) => apply(updateMedia(doc, key, { alt: alt || void 0 }), `media:${key}:alt`)
4014
5576
  }
4015
5577
  ),
4016
- photoError && /* @__PURE__ */ jsx11(Text5, { size: "xs", c: "red", role: "alert", children: photoError })
5578
+ photoError && /* @__PURE__ */ jsx13(Text7, { size: "xs", c: "red", role: "alert", children: photoError })
4017
5579
  ] }, slide.id);
4018
- const list = /* @__PURE__ */ jsxs8(Stack6, { gap: "xs", children: [
4019
- /* @__PURE__ */ jsxs8(
5580
+ const list = /* @__PURE__ */ jsxs10(Stack8, { gap: "xs", children: [
5581
+ /* @__PURE__ */ jsxs10(
4020
5582
  UnstyledButton3,
4021
5583
  {
4022
5584
  onClick: () => setSelected(STORY),
4023
5585
  style: { padding: "8px", borderRadius: 6, background: index === STORY ? "var(--mantine-primary-color-light)" : void 0 },
4024
5586
  children: [
4025
- /* @__PURE__ */ jsx11(Text5, { fw: 600, size: "sm", children: strings.story }),
4026
- /* @__PURE__ */ jsx11(Text5, { size: "xs", c: "dimmed", children: strings.storySettings })
5587
+ /* @__PURE__ */ jsx13(Text7, { fw: 600, size: "sm", children: strings.story }),
5588
+ /* @__PURE__ */ jsx13(Text7, { size: "xs", c: "dimmed", children: strings.storySettings })
4027
5589
  ]
4028
5590
  }
4029
5591
  ),
4030
- /* @__PURE__ */ jsx11(Divider, { label: strings.slides, labelPosition: "left" }),
4031
- /* @__PURE__ */ jsx11(
5592
+ /* @__PURE__ */ jsx13(Divider2, { label: strings.slides, labelPosition: "left" }),
5593
+ /* @__PURE__ */ jsx13(
4032
5594
  SlideList,
4033
5595
  {
4034
5596
  label: strings.slides,
@@ -4040,17 +5602,17 @@ function StoryEditor({
4040
5602
  setSelected(to);
4041
5603
  },
4042
5604
  getKey: (s) => s.id,
4043
- renderItem: (s, i) => /* @__PURE__ */ jsxs8(Group5, { gap: "sm", wrap: "nowrap", children: [
4044
- /* @__PURE__ */ jsx11(Thumb, { doc, slide: s, resolveMedia }),
4045
- /* @__PURE__ */ jsxs8(Box2, { style: { minWidth: 0 }, children: [
4046
- /* @__PURE__ */ jsx11(Text5, { size: "sm", fw: 500, truncate: true, dir: "auto", c: s.title ? void 0 : "dimmed", children: s.title || strings.untitledSlide }),
4047
- /* @__PURE__ */ jsx11(Text5, { size: "xs", c: "dimmed", truncate: true, children: i === 0 ? strings.openingSlide : s.date?.text ?? "" })
5605
+ renderItem: (s, i) => /* @__PURE__ */ jsxs10(Group7, { gap: "sm", wrap: "nowrap", children: [
5606
+ /* @__PURE__ */ jsx13(Thumb, { doc, slide: s, resolveMedia }),
5607
+ /* @__PURE__ */ jsxs10(Box4, { style: { minWidth: 0 }, children: [
5608
+ /* @__PURE__ */ jsx13(Text7, { size: "sm", fw: 500, truncate: true, dir: "auto", c: s.title ? void 0 : "dimmed", children: s.title || strings.untitledSlide }),
5609
+ /* @__PURE__ */ jsx13(Text7, { size: "xs", c: "dimmed", truncate: true, children: i === 0 ? strings.openingSlide : s.date?.text ?? "" })
4048
5610
  ] })
4049
5611
  ] })
4050
5612
  }
4051
5613
  ),
4052
- /* @__PURE__ */ jsxs8(
4053
- Button4,
5614
+ /* @__PURE__ */ jsxs10(
5615
+ Button6,
4054
5616
  {
4055
5617
  variant: "light",
4056
5618
  size: "xs",
@@ -4067,7 +5629,7 @@ function StoryEditor({
4067
5629
  }
4068
5630
  )
4069
5631
  ] });
4070
- const preview = /* @__PURE__ */ jsx11(Box2, { style: { position: "relative", height: wide ? "100%" : 460, borderRadius: 8, overflow: "hidden", border: "1px solid var(--mantine-color-default-border)" }, children: /* @__PURE__ */ jsx11(
5632
+ const preview = /* @__PURE__ */ jsx13(Box4, { style: { position: "relative", height: wide ? "100%" : 460, borderRadius: 8, overflow: "hidden", border: "1px solid var(--mantine-color-default-border)" }, children: /* @__PURE__ */ jsx13(
4071
5633
  StoryPlayer,
4072
5634
  {
4073
5635
  story: doc,
@@ -4081,8 +5643,8 @@ function StoryEditor({
4081
5643
  }
4082
5644
  }
4083
5645
  ) });
4084
- const column = (children, width) => /* @__PURE__ */ jsx11(
4085
- Box2,
5646
+ const column = (children, width) => /* @__PURE__ */ jsx13(
5647
+ Box4,
4086
5648
  {
4087
5649
  style: {
4088
5650
  width,
@@ -4095,32 +5657,32 @@ function StoryEditor({
4095
5657
  children
4096
5658
  }
4097
5659
  );
4098
- return /* @__PURE__ */ jsxs8(Box2, { style: { height: "100%", display: "flex", flexDirection: "column" }, children: [
4099
- /* @__PURE__ */ jsxs8(
4100
- Group5,
5660
+ return /* @__PURE__ */ jsxs10(Box4, { style: { height: "100%", display: "flex", flexDirection: "column" }, children: [
5661
+ /* @__PURE__ */ jsxs10(
5662
+ Group7,
4101
5663
  {
4102
- h: BAR_HEIGHT2,
5664
+ h: BAR_HEIGHT3,
4103
5665
  px: "md",
4104
5666
  gap: "sm",
4105
5667
  wrap: "nowrap",
4106
5668
  justify: "space-between",
4107
5669
  style: { flex: "none", borderBottom: "1px solid var(--mantine-color-default-border)" },
4108
5670
  children: [
4109
- /* @__PURE__ */ jsxs8(Group5, { gap: "sm", wrap: "nowrap", style: { minWidth: 0 }, children: [
5671
+ /* @__PURE__ */ jsxs10(Group7, { gap: "sm", wrap: "nowrap", style: { minWidth: 0 }, children: [
4110
5672
  brand,
4111
- /* @__PURE__ */ jsx11(Text5, { fw: 600, truncate: true, dir: "auto", children: doc.title })
5673
+ /* @__PURE__ */ jsx13(Text7, { fw: 600, truncate: true, dir: "auto", children: doc.title })
4112
5674
  ] }),
4113
- /* @__PURE__ */ jsxs8(Group5, { gap: "xs", wrap: "nowrap", children: [
4114
- issues.length > 0 && /* @__PURE__ */ jsxs8(Popover, { opened: problemsOpen, onChange: setProblemsOpen, width: 360, position: "bottom-end", withArrow: true, children: [
4115
- /* @__PURE__ */ jsx11(Popover.Target, { children: /* @__PURE__ */ jsx11(Badge, { color: "red", variant: "light", style: { cursor: "pointer" }, onClick: () => setProblemsOpen((o) => !o), children: strings.problems(issues.length) }) }),
4116
- /* @__PURE__ */ jsx11(Popover.Dropdown, { children: /* @__PURE__ */ jsx11(Stack6, { gap: 6, children: issues.map((issue, i) => {
5675
+ /* @__PURE__ */ jsxs10(Group7, { gap: "xs", wrap: "nowrap", children: [
5676
+ issues.length > 0 && /* @__PURE__ */ jsxs10(Popover, { opened: problemsOpen, onChange: setProblemsOpen, width: 360, position: "bottom-end", withArrow: true, children: [
5677
+ /* @__PURE__ */ jsx13(Popover.Target, { children: /* @__PURE__ */ jsx13(Badge, { color: "red", variant: "light", style: { cursor: "pointer" }, onClick: () => setProblemsOpen((o) => !o), children: strings.problems(issues.length) }) }),
5678
+ /* @__PURE__ */ jsx13(Popover.Dropdown, { children: /* @__PURE__ */ jsx13(Stack8, { gap: 6, children: issues.map((issue, i) => {
4117
5679
  const at = issueSlide(issue);
4118
- return /* @__PURE__ */ jsxs8(UnstyledButton3, { onClick: () => {
5680
+ return /* @__PURE__ */ jsxs10(UnstyledButton3, { onClick: () => {
4119
5681
  setSelected(at);
4120
5682
  setProblemsOpen(false);
4121
5683
  }, children: [
4122
- /* @__PURE__ */ jsx11(Text5, { size: "sm", fw: 500, children: at === STORY ? strings.story : doc.slides[at]?.title || `${strings.untitledSlide} ${at + 1}` }),
4123
- /* @__PURE__ */ jsxs8(Text5, { size: "xs", c: "dimmed", children: [
5684
+ /* @__PURE__ */ jsx13(Text7, { size: "sm", fw: 500, children: at === STORY ? strings.story : doc.slides[at]?.title || `${strings.untitledSlide} ${at + 1}` }),
5685
+ /* @__PURE__ */ jsxs10(Text7, { size: "xs", c: "dimmed", children: [
4124
5686
  issue.path,
4125
5687
  " ",
4126
5688
  issue.message
@@ -4128,16 +5690,16 @@ function StoryEditor({
4128
5690
  ] }, i);
4129
5691
  }) }) })
4130
5692
  ] }),
4131
- /* @__PURE__ */ jsx11(Tooltip3, { label: strings.undo, children: /* @__PURE__ */ jsx11(ActionIcon2, { variant: "default", size: "lg", "aria-label": strings.undo, disabled: history.past.length === 0, onClick: () => setHistory(undo), children: "\u21B6" }) }),
4132
- /* @__PURE__ */ jsx11(Tooltip3, { label: strings.redo, children: /* @__PURE__ */ jsx11(ActionIcon2, { variant: "default", size: "lg", "aria-label": strings.redo, disabled: history.future.length === 0, onClick: () => setHistory(redo), children: "\u21B7" }) }),
4133
- /* @__PURE__ */ jsx11(Button4, { variant: "default", onClick: () => setPresenting(true), children: strings.present }),
5693
+ /* @__PURE__ */ jsx13(Tooltip4, { label: strings.undo, children: /* @__PURE__ */ jsx13(ActionIcon3, { variant: "default", size: "lg", "aria-label": strings.undo, disabled: history.past.length === 0, onClick: () => setHistory(undo), children: "\u21B6" }) }),
5694
+ /* @__PURE__ */ jsx13(Tooltip4, { label: strings.redo, children: /* @__PURE__ */ jsx13(ActionIcon3, { variant: "default", size: "lg", "aria-label": strings.redo, disabled: history.future.length === 0, onClick: () => setHistory(redo), children: "\u21B7" }) }),
5695
+ /* @__PURE__ */ jsx13(Button6, { variant: "default", onClick: () => setPresenting(true), children: strings.present }),
4134
5696
  toolbar
4135
5697
  ] })
4136
5698
  ]
4137
5699
  }
4138
5700
  ),
4139
- /* @__PURE__ */ jsxs8(
4140
- Box2,
5701
+ /* @__PURE__ */ jsxs10(
5702
+ Box4,
4141
5703
  {
4142
5704
  style: {
4143
5705
  flex: 1,
@@ -4153,7 +5715,27 @@ function StoryEditor({
4153
5715
  ]
4154
5716
  }
4155
5717
  ),
4156
- /* @__PURE__ */ jsx11(Modal, { opened: presenting, onClose: () => setPresenting(false), fullScreen: true, withCloseButton: false, padding: 0, children: /* @__PURE__ */ jsx11(Box2, { style: { position: "fixed", inset: 0 }, children: /* @__PURE__ */ jsx11(
5718
+ mapSlide && /* @__PURE__ */ jsx13(
5719
+ PlaceMapDialog,
5720
+ {
5721
+ place: mapSlide.place,
5722
+ doc,
5723
+ files,
5724
+ dark,
5725
+ parseDateText,
5726
+ strings,
5727
+ mapStrings,
5728
+ onCancel: () => setMapSlideId(null),
5729
+ onDone: (place, media, nextFiles) => {
5730
+ const at = doc.slides.findIndex((s) => s.id === mapSlide.id);
5731
+ setMapSlideId(null);
5732
+ if (at < 0) return;
5733
+ setFiles(nextFiles);
5734
+ apply(updateSlide({ ...doc, media }, at, { place }));
5735
+ }
5736
+ }
5737
+ ),
5738
+ /* @__PURE__ */ jsx13(Modal2, { opened: presenting, onClose: () => setPresenting(false), fullScreen: true, withCloseButton: false, padding: 0, children: /* @__PURE__ */ jsx13(Box4, { style: { position: "fixed", inset: 0 }, children: /* @__PURE__ */ jsx13(
4157
5739
  StoryPlayer,
4158
5740
  {
4159
5741
  story: doc,
@@ -4170,8 +5752,12 @@ export {
4170
5752
  COALESCE_MS,
4171
5753
  DateField,
4172
5754
  EDITOR_ENGLISH,
5755
+ MAP_EDITOR_ENGLISH,
5756
+ MapEditor,
5757
+ MapEditorHelp,
4173
5758
  PhotosField,
4174
5759
  PlaceField,
5760
+ PlaceMapDialog,
4175
5761
  SlideList,
4176
5762
  StoryEditor,
4177
5763
  StoryTextField,
@@ -4181,12 +5767,15 @@ export {
4181
5767
  blankStory,
4182
5768
  deleteSlide,
4183
5769
  duplicateSlide,
5770
+ editorToPlace,
4184
5771
  findMedia,
4185
5772
  formatLatLon,
4186
5773
  moveSlide,
4187
5774
  nextSlideId,
4188
5775
  parseLatLon,
4189
5776
  patchPlace,
5777
+ placeFeatures,
5778
+ placeToEditor,
4190
5779
  prepareImage,
4191
5780
  pruneMedia,
4192
5781
  record,