@haklex/rich-plugin-image-editor 0.42.1 → 0.43.1

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.
Files changed (39) hide show
  1. package/dist/ImageEditModal.d.ts +5 -2
  2. package/dist/ImageEditModal.d.ts.map +1 -1
  3. package/dist/ImageEditModalPlugin.d.ts +5 -1
  4. package/dist/ImageEditModalPlugin.d.ts.map +1 -1
  5. package/dist/ImageInsertSheet.d.ts +12 -0
  6. package/dist/ImageInsertSheet.d.ts.map +1 -0
  7. package/dist/MosaicOptionsBar.d.ts +12 -0
  8. package/dist/MosaicOptionsBar.d.ts.map +1 -0
  9. package/dist/OutputSizeControl.d.ts +18 -0
  10. package/dist/OutputSizeControl.d.ts.map +1 -0
  11. package/dist/SheetRowItem.d.ts +10 -0
  12. package/dist/SheetRowItem.d.ts.map +1 -0
  13. package/dist/ToolRail.d.ts +9 -0
  14. package/dist/ToolRail.d.ts.map +1 -0
  15. package/dist/annotation.d.ts +1 -1
  16. package/dist/annotation.d.ts.map +1 -1
  17. package/dist/edit-summary.d.ts +9 -0
  18. package/dist/edit-summary.d.ts.map +1 -0
  19. package/dist/index.d.ts +4 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.mjs +853 -144
  22. package/dist/insert-sheet-model.d.ts +22 -0
  23. package/dist/insert-sheet-model.d.ts.map +1 -0
  24. package/dist/mosaic.d.ts +14 -0
  25. package/dist/mosaic.d.ts.map +1 -0
  26. package/dist/pipeline.d.ts +2 -1
  27. package/dist/pipeline.d.ts.map +1 -1
  28. package/dist/resize.d.ts +9 -0
  29. package/dist/resize.d.ts.map +1 -0
  30. package/dist/rich-plugin-image-editor.css +1 -1
  31. package/dist/sheet.css.d.ts +22 -0
  32. package/dist/sheet.css.d.ts.map +1 -0
  33. package/dist/styles.css.d.ts +9 -0
  34. package/dist/styles.css.d.ts.map +1 -1
  35. package/dist/useEscapeDismissesTopDialog.d.ts +2 -0
  36. package/dist/useEscapeDismissesTopDialog.d.ts.map +1 -0
  37. package/dist/useImageEditorState.d.ts +6 -1
  38. package/dist/useImageEditorState.d.ts.map +1 -1
  39. package/package.json +4 -4
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
- import { Circle, Crop, Hash, ImageOff, MoveUpRight, PaintBucket, Pen, Redo2, Square, Type, Undo2 } from "lucide-react";
1
+ import { Circle, Crop, Grid3x3, Hash, ImageOff, MapPin, MoveUpRight, PaintBucket, Pen, Pencil, Redo2, Square, Type, Undo2, X } from "lucide-react";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { ReactCrop } from "react-image-crop";
4
4
  import { MarkerArea, Renderer, TextMarkerEditor } from "@markerjs/markerjs3";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ import { dismissTopDialog, presentDialog } from "@haklex/rich-editor-ui";
6
7
  import { useImagePreprocess } from "@haklex/rich-editor/plugins";
7
- import { presentDialog } from "@haklex/rich-editor-ui";
8
8
  import { usePortalTheme } from "@haklex/rich-style-token";
9
9
  //#region src/annotation.ts
10
10
  var MARKER_TYPE_BY_TOOL = {
@@ -17,7 +17,7 @@ var MARKER_TYPE_BY_TOOL = {
17
17
  text: "TextMarker"
18
18
  };
19
19
  function isAnnotationTool(tool) {
20
- return tool !== "crop";
20
+ return tool !== "crop" && tool !== "mosaic";
21
21
  }
22
22
  var SWATCH_COLORS = [
23
23
  "#ef4444",
@@ -71,19 +71,29 @@ var toolRail = "_1257qq29";
71
71
  var toolButton = "_1257qq2a";
72
72
  var toolButtonActive = "_1257qq2b";
73
73
  var canvasArea = "_1257qq2d";
74
+ var cropSurface = "_1257qq2e";
75
+ var cropImage = "_1257qq2f";
74
76
  var annotationSurface = "_1257qq2g";
75
77
  var optionsBar = "_1257qq2h";
76
78
  var swatch = "_1257qq2i";
77
79
  var swatchActive = "_1257qq2j";
78
80
  var optionButton = "_1257qq2k";
81
+ var optionButtonActive = "_1257qq2l";
79
82
  var optionDivider = "_1257qq2n";
80
83
  var errorState = "_1257qq2o";
81
84
  var attribution = "_1257qq2p";
82
- var footer = "_1257qq2q";
85
+ var footer$1 = "_1257qq2q";
83
86
  var footerSpacer = "_1257qq2r";
84
- var ghostButton = "_1257qq2t _1257qq2s";
85
- var secondaryButton = "_1257qq2v _1257qq2s";
86
- var primaryButton = "_1257qq2w _1257qq2s";
87
+ var ghostButton = "_1257qq2u _1257qq2t";
88
+ var topBarGhostButton = "_1257qq2v _1257qq2u _1257qq2t";
89
+ var secondaryButton = "_1257qq2w _1257qq2t";
90
+ var primaryButton = "_1257qq2x _1257qq2t";
91
+ var segmentButton = "_1257qq2y _1257qq2k";
92
+ var rangeField = "_1257qq2z";
93
+ var rangeInput = "_1257qq210";
94
+ var rangeValue = "_1257qq211";
95
+ var sizeControl = "_1257qq212";
96
+ var sizeSelect = "_1257qq213";
87
97
  //#endregion
88
98
  //#region src/AnnotationSurface.tsx
89
99
  var TEXT_TOOLS = /* @__PURE__ */ new Set(["counter", "text"]);
@@ -325,6 +335,228 @@ async function canvasToObjectUrl(canvas) {
325
335
  return URL.createObjectURL(blob);
326
336
  }
327
337
  //#endregion
338
+ //#region src/edit-summary.ts
339
+ function buildEditSummary({ cropped, marks, mosaics, resizedTo }) {
340
+ const parts = [];
341
+ if (cropped) parts.push("Cropped");
342
+ if (marks > 0) parts.push(`${marks} mark${marks > 1 ? "s" : ""}`);
343
+ if (mosaics > 0) parts.push(`${mosaics} mosaic`);
344
+ if (resizedTo) parts.push(`${resizedTo.width} × ${resizedTo.height}`);
345
+ return parts.join(" · ");
346
+ }
347
+ //#endregion
348
+ //#region src/mosaic.ts
349
+ var MOSAIC_STRENGTH_RANGE = {
350
+ blur: {
351
+ max: 48,
352
+ min: 12
353
+ },
354
+ pixelate: {
355
+ max: 48,
356
+ min: 8
357
+ }
358
+ };
359
+ var MOSAIC_DEFAULT_STRENGTH = {
360
+ blur: 20,
361
+ pixelate: 16
362
+ };
363
+ function clampMosaicStrength(mode, value) {
364
+ const { max, min } = MOSAIC_STRENGTH_RANGE[mode];
365
+ return Math.min(Math.max(Math.round(value), min), max);
366
+ }
367
+ function mosaicSampleSize(rect, block) {
368
+ return {
369
+ height: Math.max(1, Math.round(rect.height / block)),
370
+ width: Math.max(1, Math.round(rect.width / block))
371
+ };
372
+ }
373
+ async function applyMosaic(sourceUrl, area, mode, block) {
374
+ const canvas = await sourceToCanvas(sourceUrl);
375
+ const rect = clampCropRect(area, canvas.width, canvas.height);
376
+ if (rect.width === 0 || rect.height === 0) throw new Error("Mosaic region is empty");
377
+ const sample = mosaicSampleSize(rect, block);
378
+ const small = document.createElement("canvas");
379
+ small.width = sample.width;
380
+ small.height = sample.height;
381
+ const smallCtx = small.getContext("2d");
382
+ const ctx = canvas.getContext("2d");
383
+ if (!smallCtx || !ctx) throw new Error("Canvas 2d context unavailable");
384
+ const smooth = mode === "blur";
385
+ smallCtx.imageSmoothingEnabled = smooth;
386
+ smallCtx.imageSmoothingQuality = "high";
387
+ smallCtx.drawImage(canvas, rect.x, rect.y, rect.width, rect.height, 0, 0, sample.width, sample.height);
388
+ ctx.imageSmoothingEnabled = smooth;
389
+ ctx.imageSmoothingQuality = "high";
390
+ ctx.drawImage(small, 0, 0, sample.width, sample.height, rect.x, rect.y, rect.width, rect.height);
391
+ return canvasToObjectUrl(canvas);
392
+ }
393
+ //#endregion
394
+ //#region src/MosaicOptionsBar.tsx
395
+ var MODES = [{
396
+ id: "pixelate",
397
+ label: "Pixelate"
398
+ }, {
399
+ id: "blur",
400
+ label: "Blur"
401
+ }];
402
+ var MosaicOptionsBar = ({ canUndo, mode, onModeChange, onStrengthChange, onUndo, strength }) => {
403
+ const range = MOSAIC_STRENGTH_RANGE[mode];
404
+ return /* @__PURE__ */ jsxs("div", {
405
+ className: optionsBar,
406
+ children: [
407
+ MODES.map((item) => /* @__PURE__ */ jsx("button", {
408
+ "aria-pressed": mode === item.id,
409
+ type: "button",
410
+ className: mode === item.id ? `${segmentButton} ${optionButtonActive}` : segmentButton,
411
+ onClick: () => onModeChange(item.id),
412
+ children: item.label
413
+ }, item.id)),
414
+ /* @__PURE__ */ jsx("div", { className: optionDivider }),
415
+ /* @__PURE__ */ jsxs("label", {
416
+ className: rangeField,
417
+ children: [
418
+ mode === "blur" ? "Radius" : "Block size",
419
+ /* @__PURE__ */ jsx("input", {
420
+ className: rangeInput,
421
+ max: range.max,
422
+ min: range.min,
423
+ type: "range",
424
+ value: strength,
425
+ onChange: (event) => onStrengthChange(Number(event.currentTarget.value))
426
+ }),
427
+ /* @__PURE__ */ jsxs("span", {
428
+ className: rangeValue,
429
+ children: [strength, " px"]
430
+ })
431
+ ]
432
+ }),
433
+ /* @__PURE__ */ jsx("div", { className: optionDivider }),
434
+ /* @__PURE__ */ jsx("button", {
435
+ "aria-label": "Undo",
436
+ className: optionButton,
437
+ disabled: !canUndo,
438
+ title: "Undo",
439
+ type: "button",
440
+ onClick: onUndo,
441
+ children: /* @__PURE__ */ jsx(Undo2, { size: 16 })
442
+ })
443
+ ]
444
+ });
445
+ };
446
+ //#endregion
447
+ //#region src/resize.ts
448
+ var LONG_EDGE_PRESETS = [
449
+ 3840,
450
+ 2560,
451
+ 1920,
452
+ 1280
453
+ ];
454
+ function canShrinkTo(size, longEdge) {
455
+ return longEdge < Math.max(size.width, size.height);
456
+ }
457
+ function scaleToLongEdge(size, longEdge) {
458
+ if (!canShrinkTo(size, longEdge)) return size;
459
+ const ratio = longEdge / Math.max(size.width, size.height);
460
+ return {
461
+ height: Math.max(1, Math.round(size.height * ratio)),
462
+ width: Math.max(1, Math.round(size.width * ratio))
463
+ };
464
+ }
465
+ function scaleCanvasToLongEdge(source, longEdge) {
466
+ const target = scaleToLongEdge({
467
+ height: source.height,
468
+ width: source.width
469
+ }, longEdge);
470
+ if (target.width === source.width && target.height === source.height) return source;
471
+ const canvas = document.createElement("canvas");
472
+ canvas.width = target.width;
473
+ canvas.height = target.height;
474
+ const ctx = canvas.getContext("2d");
475
+ if (!ctx) throw new Error("Canvas 2d context unavailable");
476
+ ctx.imageSmoothingEnabled = true;
477
+ ctx.imageSmoothingQuality = "high";
478
+ ctx.drawImage(source, 0, 0, target.width, target.height);
479
+ return canvas;
480
+ }
481
+ //#endregion
482
+ //#region src/OutputSizeControl.tsx
483
+ var CUSTOM = "custom";
484
+ function useOutputSize(size) {
485
+ const [mode, setMode] = useState("original");
486
+ const [presetEdge, setPresetEdge] = useState(null);
487
+ const [customEdge, setCustomEdge] = useState("");
488
+ const requested = mode === "custom" ? Number(customEdge) : presetEdge;
489
+ const maxLongEdge = requested && size && canShrinkTo(size, requested) ? requested : null;
490
+ return {
491
+ controlProps: {
492
+ customEdge,
493
+ maxLongEdge,
494
+ mode,
495
+ size,
496
+ onCustomEdgeChange: setCustomEdge,
497
+ onModeChange: (nextMode, edge) => {
498
+ setMode(nextMode);
499
+ setPresetEdge(edge);
500
+ }
501
+ },
502
+ maxLongEdge
503
+ };
504
+ }
505
+ var OutputSizeControl = ({ customEdge, maxLongEdge, mode, onCustomEdgeChange, onModeChange, size }) => {
506
+ const output = size && maxLongEdge !== null ? scaleToLongEdge(size, maxLongEdge) : size;
507
+ return /* @__PURE__ */ jsxs("div", {
508
+ className: sizeControl,
509
+ children: [
510
+ /* @__PURE__ */ jsxs("label", {
511
+ className: rangeField,
512
+ children: ["Long edge", /* @__PURE__ */ jsxs("select", {
513
+ className: sizeSelect,
514
+ disabled: !size,
515
+ value: mode === "preset" ? String(maxLongEdge) : mode,
516
+ onChange: (event) => {
517
+ const { value } = event.currentTarget;
518
+ if (value === "original") onModeChange("original", null);
519
+ else if (value === CUSTOM) onModeChange("custom", null);
520
+ else onModeChange("preset", Number(value));
521
+ },
522
+ children: [
523
+ /* @__PURE__ */ jsx("option", {
524
+ value: "original",
525
+ children: "Original"
526
+ }),
527
+ LONG_EDGE_PRESETS.map((edge) => /* @__PURE__ */ jsxs("option", {
528
+ disabled: !size || !canShrinkTo(size, edge),
529
+ value: edge,
530
+ children: [edge, " px"]
531
+ }, edge)),
532
+ /* @__PURE__ */ jsx("option", {
533
+ value: CUSTOM,
534
+ children: "Custom…"
535
+ })
536
+ ]
537
+ })]
538
+ }),
539
+ mode === "custom" && /* @__PURE__ */ jsx("input", {
540
+ "aria-label": "Custom long edge in pixels",
541
+ className: "_1257qq214",
542
+ inputMode: "numeric",
543
+ placeholder: "1600",
544
+ value: customEdge,
545
+ onChange: (event) => onCustomEdgeChange(event.currentTarget.value.replaceAll(/\D/g, ""))
546
+ }),
547
+ output && /* @__PURE__ */ jsxs("span", {
548
+ className: "_1257qq215",
549
+ children: [
550
+ "→ ",
551
+ output.width,
552
+ " × ",
553
+ output.height
554
+ ]
555
+ })
556
+ ]
557
+ });
558
+ };
559
+ //#endregion
328
560
  //#region src/build-result.ts
329
561
  async function buildResult(original, canvas) {
330
562
  if (!canvas) return original;
@@ -357,14 +589,15 @@ async function applyCropRebase(sourceUrl, rect, markerState) {
357
589
  } : null
358
590
  };
359
591
  }
360
- async function exportResult({ hasRebasedBitmap, markerState, original, pendingCropRect, sourceUrl }) {
592
+ async function exportResult({ hasRebasedBitmap, markerState, maxLongEdge, original, pendingCropRect, sourceUrl }) {
361
593
  const hasMarkers = markerState !== null && markerState.markers.length > 0;
362
594
  let canvas = null;
363
595
  if (hasMarkers && markerState) {
364
596
  canvas = await rasterizeAnnotations(sourceUrl, markerState);
365
597
  if (pendingCropRect) canvas = cropCanvas(canvas, pendingCropRect);
366
598
  } else if (pendingCropRect) canvas = await extractCrop(sourceUrl, pendingCropRect);
367
- else if (hasRebasedBitmap) canvas = await sourceToCanvas(sourceUrl);
599
+ else if (hasRebasedBitmap || maxLongEdge !== null) canvas = await sourceToCanvas(sourceUrl);
600
+ if (canvas && maxLongEdge !== null) canvas = scaleCanvasToLongEdge(canvas, maxLongEdge);
368
601
  return buildResult(original, canvas);
369
602
  }
370
603
  //#endregion
@@ -418,6 +651,85 @@ var ToolOptionsBar = ({ canRedo, canUndo, onRedo, onStrokeColorChange, onStrokeW
418
651
  ]
419
652
  });
420
653
  //#endregion
654
+ //#region src/ToolRail.tsx
655
+ var TOOLS = [
656
+ {
657
+ icon: Crop,
658
+ id: "crop",
659
+ label: "Crop"
660
+ },
661
+ {
662
+ icon: MoveUpRight,
663
+ id: "arrow",
664
+ label: "Arrow"
665
+ },
666
+ {
667
+ icon: Pen,
668
+ id: "pen",
669
+ label: "Pen"
670
+ },
671
+ {
672
+ icon: Square,
673
+ id: "rect",
674
+ label: "Rectangle"
675
+ },
676
+ {
677
+ icon: Circle,
678
+ id: "ellipse",
679
+ label: "Ellipse"
680
+ },
681
+ {
682
+ icon: Type,
683
+ id: "text",
684
+ label: "Text"
685
+ },
686
+ {
687
+ icon: Hash,
688
+ id: "counter",
689
+ label: "Counter"
690
+ },
691
+ {
692
+ icon: PaintBucket,
693
+ id: "cover",
694
+ label: "Cover"
695
+ },
696
+ {
697
+ icon: Grid3x3,
698
+ id: "mosaic",
699
+ label: "Mosaic"
700
+ }
701
+ ];
702
+ var ToolRail = ({ activeTool, disabled, onSelect }) => /* @__PURE__ */ jsx("div", {
703
+ className: toolRail,
704
+ children: TOOLS.map((tool, index) => /* @__PURE__ */ jsxs("span", {
705
+ className: toolGroup,
706
+ children: [/* @__PURE__ */ jsx("button", {
707
+ "aria-label": tool.label,
708
+ "aria-pressed": activeTool === tool.id,
709
+ disabled,
710
+ title: tool.label,
711
+ type: "button",
712
+ className: activeTool === tool.id ? `${toolButton} ${toolButtonActive}` : toolButton,
713
+ onClick: () => onSelect(tool.id),
714
+ children: /* @__PURE__ */ jsx(tool.icon, { size: 18 })
715
+ }), index === 0 && /* @__PURE__ */ jsx("div", { className: "_1257qq2c" })]
716
+ }, tool.id))
717
+ });
718
+ //#endregion
719
+ //#region src/useEscapeDismissesTopDialog.ts
720
+ function useEscapeDismissesTopDialog() {
721
+ useEffect(() => {
722
+ const handleKeyDown = (event) => {
723
+ if (event.key !== "Escape") return;
724
+ event.preventDefault();
725
+ event.stopImmediatePropagation();
726
+ dismissTopDialog();
727
+ };
728
+ document.addEventListener("keydown", handleKeyDown, true);
729
+ return () => document.removeEventListener("keydown", handleKeyDown, true);
730
+ }, []);
731
+ }
732
+ //#endregion
421
733
  //#region src/useImageEditorState.ts
422
734
  var FULL_IMAGE_CROP = {
423
735
  height: 100,
@@ -433,12 +745,25 @@ function useImageEditorState(objectUrl) {
433
745
  const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
434
746
  const [naturalSize, setNaturalSize] = useState(null);
435
747
  const markerStateRef = useRef(null);
748
+ const [mosaicHistory, setMosaicHistory] = useState([]);
436
749
  const rebasedUrlRef = useRef(null);
437
750
  rebasedUrlRef.current = rebasedBitmapUrl;
438
- useEffect(() => () => {
439
- if (rebasedUrlRef.current) URL.revokeObjectURL(rebasedUrlRef.current);
751
+ const mosaicHistoryRef = useRef(mosaicHistory);
752
+ mosaicHistoryRef.current = mosaicHistory;
753
+ const mountedRef = useRef(false);
754
+ useEffect(() => {
755
+ mountedRef.current = true;
756
+ return () => {
757
+ mountedRef.current = false;
758
+ if (rebasedUrlRef.current) URL.revokeObjectURL(rebasedUrlRef.current);
759
+ for (const url of mosaicHistoryRef.current) if (url) URL.revokeObjectURL(url);
760
+ };
440
761
  }, []);
441
762
  const sourceUrl = rebasedBitmapUrl ?? objectUrl;
763
+ const sourceUrlRef = useRef(sourceUrl);
764
+ sourceUrlRef.current = sourceUrl;
765
+ const [mosaicBusy, setMosaicBusy] = useState(false);
766
+ const mosaicBusyRef = useRef(false);
442
767
  const hasPendingCrop = croppedAreaPixels !== null && naturalSize !== null && croppedAreaPixels.width >= 1 && croppedAreaPixels.height >= 1 && !isFullImageCrop(croppedAreaPixels, naturalSize.width, naturalSize.height);
443
768
  const resetCrop = () => {
444
769
  setCrop(FULL_IMAGE_CROP);
@@ -450,6 +775,8 @@ function useImageEditorState(objectUrl) {
450
775
  try {
451
776
  const rebase = await applyCropRebase(sourceUrl, rect, markerStateRef.current);
452
777
  if (rebasedBitmapUrl) URL.revokeObjectURL(rebasedBitmapUrl);
778
+ for (const url of mosaicHistory) if (url) URL.revokeObjectURL(url);
779
+ setMosaicHistory([]);
453
780
  setRebasedBitmapUrl(rebase.bitmapUrl);
454
781
  markerStateRef.current = rebase.markerState;
455
782
  resetCrop();
@@ -460,13 +787,44 @@ function useImageEditorState(objectUrl) {
460
787
  setActiveTool(tool);
461
788
  } catch {}
462
789
  };
790
+ const applyMosaicArea = async (area, mode, block) => {
791
+ if (!naturalSize || mosaicBusyRef.current) return;
792
+ const rect = clampCropRect(area, naturalSize.width, naturalSize.height);
793
+ if (rect.width < 1 || rect.height < 1) return;
794
+ mosaicBusyRef.current = true;
795
+ setMosaicBusy(true);
796
+ try {
797
+ const bitmapUrl = await applyMosaic(sourceUrlRef.current, rect, mode, block);
798
+ if (!mountedRef.current) {
799
+ URL.revokeObjectURL(bitmapUrl);
800
+ return;
801
+ }
802
+ const previous = rebasedUrlRef.current;
803
+ rebasedUrlRef.current = bitmapUrl;
804
+ setMosaicHistory((history) => [...history, previous]);
805
+ setRebasedBitmapUrl(bitmapUrl);
806
+ } finally {
807
+ mosaicBusyRef.current = false;
808
+ if (mountedRef.current) setMosaicBusy(false);
809
+ }
810
+ };
811
+ const undoMosaic = () => {
812
+ if (mosaicHistory.length === 0 || mosaicBusyRef.current) return;
813
+ const previous = mosaicHistory.at(-1) ?? null;
814
+ if (rebasedBitmapUrl) URL.revokeObjectURL(rebasedBitmapUrl);
815
+ setMosaicHistory(mosaicHistory.slice(0, -1));
816
+ setRebasedBitmapUrl(previous);
817
+ };
463
818
  return {
464
819
  activeTool,
820
+ applyMosaicArea,
465
821
  confirmCropAndSwitch,
466
822
  crop,
467
823
  croppedAreaPixels,
468
824
  hasPendingCrop,
469
825
  markerStateRef,
826
+ mosaicBusy,
827
+ mosaicCount: mosaicHistory.length,
470
828
  naturalSize,
471
829
  rebasedBitmapUrl,
472
830
  resetCrop,
@@ -474,84 +832,82 @@ function useImageEditorState(objectUrl) {
474
832
  setCrop,
475
833
  setCroppedAreaPixels,
476
834
  setNaturalSize,
477
- sourceUrl
835
+ sourceUrl,
836
+ undoMosaic
478
837
  };
479
838
  }
480
839
  //#endregion
481
840
  //#region src/ImageEditModal.tsx
482
- var TOOLS = [
483
- {
484
- icon: Crop,
485
- id: "crop",
486
- label: "Crop"
487
- },
488
- {
489
- icon: MoveUpRight,
490
- id: "arrow",
491
- label: "Arrow"
492
- },
493
- {
494
- icon: Pen,
495
- id: "pen",
496
- label: "Pen"
497
- },
498
- {
499
- icon: Square,
500
- id: "rect",
501
- label: "Rectangle"
502
- },
503
- {
504
- icon: Circle,
505
- id: "ellipse",
506
- label: "Ellipse"
507
- },
508
- {
509
- icon: Type,
510
- id: "text",
511
- label: "Text"
512
- },
513
- {
514
- icon: Hash,
515
- id: "counter",
516
- label: "Counter"
517
- },
518
- {
519
- icon: PaintBucket,
520
- id: "cover",
521
- label: "Cover"
522
- }
523
- ];
524
- var ImageEditModal = ({ file, onCancel, onConfirm, onSkip }) => {
841
+ var EMPTY_MOSAIC_CROP = {
842
+ height: 0,
843
+ unit: "px",
844
+ width: 0,
845
+ x: 0,
846
+ y: 0
847
+ };
848
+ var ImageEditModal = ({ file, onCancel, onConfirm }) => {
525
849
  const [objectUrl, setObjectUrl] = useState("");
526
850
  const [decodeError, setDecodeError] = useState(false);
527
851
  const [exporting, setExporting] = useState(false);
852
+ const [errorMessage, setErrorMessage] = useState(null);
528
853
  const [strokeColor, setStrokeColor] = useState(SWATCH_COLORS[0]);
529
854
  const [strokeWidth, setStrokeWidth] = useState(STROKE_WIDTHS[1]);
530
855
  const [history, setHistory] = useState({
531
856
  canRedo: false,
532
857
  canUndo: false
533
858
  });
859
+ const [mosaicMode, setMosaicMode] = useState("pixelate");
860
+ const [mosaicStrength, setMosaicStrength] = useState(MOSAIC_DEFAULT_STRENGTH.pixelate);
861
+ const [mosaicCrop, setMosaicCrop] = useState(EMPTY_MOSAIC_CROP);
862
+ const [originalSize, setOriginalSize] = useState(null);
534
863
  const imgRef = useRef(null);
535
864
  const counterRef = useRef(1);
536
865
  const annotationApiRef = useRef(null);
537
866
  const editor = useImageEditorState(objectUrl);
867
+ useEscapeDismissesTopDialog();
538
868
  const { hasPendingCrop, markerStateRef, sourceUrl } = editor;
539
869
  useEffect(() => {
540
870
  const url = URL.createObjectURL(file);
541
871
  setObjectUrl(url);
542
872
  return () => URL.revokeObjectURL(url);
543
873
  }, [file]);
874
+ const pendingCropRect = editor.activeTool === "crop" && hasPendingCrop ? editor.croppedAreaPixels : null;
875
+ const { controlProps: outputSizeProps, maxLongEdge } = useOutputSize(pendingCropRect ?? editor.naturalSize);
544
876
  const handleImageLoad = (event) => {
545
877
  const { naturalHeight, naturalWidth } = event.currentTarget;
546
878
  editor.setNaturalSize({
547
879
  height: naturalHeight,
548
880
  width: naturalWidth
549
881
  });
882
+ setOriginalSize((size) => size ?? {
883
+ height: naturalHeight,
884
+ width: naturalWidth
885
+ });
550
886
  };
551
- const handleCropComplete = (pixelCrop) => {
887
+ const toNatural = (pixelCrop) => {
552
888
  const img = imgRef.current;
553
- if (!img || img.width === 0 || img.height === 0) return;
554
- editor.setCroppedAreaPixels(displayedToNatural(pixelCrop, img.naturalWidth / img.width, img.naturalHeight / img.height));
889
+ if (!img || img.width === 0 || img.height === 0) return null;
890
+ return {
891
+ rect: displayedToNatural(pixelCrop, img.naturalWidth / img.width, img.naturalHeight / img.height),
892
+ scale: img.naturalWidth / img.width
893
+ };
894
+ };
895
+ const handleCropComplete = (pixelCrop) => {
896
+ const natural = toNatural(pixelCrop);
897
+ if (natural) editor.setCroppedAreaPixels(natural.rect);
898
+ };
899
+ const handleMosaicComplete = (pixelCrop) => {
900
+ const natural = toNatural(pixelCrop);
901
+ setMosaicCrop(EMPTY_MOSAIC_CROP);
902
+ if (!natural || pixelCrop.width < 2 || pixelCrop.height < 2) return;
903
+ editor.applyMosaicArea(natural.rect, mosaicMode, mosaicStrength * natural.scale).then(() => setErrorMessage(null)).catch((error) => {
904
+ console.error("[ImageEditModal] mosaic failed", error);
905
+ setErrorMessage("Could not apply mosaic to this area");
906
+ });
907
+ };
908
+ const handleMosaicModeChange = (mode) => {
909
+ setMosaicMode(mode);
910
+ setMosaicStrength(clampMosaicStrength(mode, mosaicStrength));
555
911
  };
556
912
  const handleToolClick = (tool) => {
557
913
  if (tool === editor.activeTool) return;
@@ -561,23 +917,105 @@ var ImageEditModal = ({ file, onCancel, onConfirm, onSkip }) => {
561
917
  }
562
918
  editor.setActiveTool(tool);
563
919
  };
920
+ const buildSummary = (output) => {
921
+ if (output === file) return "";
922
+ const size = pendingCropRect ?? editor.naturalSize;
923
+ return buildEditSummary({
924
+ cropped: pendingCropRect !== null || originalSize !== null && size !== null && (size.width !== originalSize.width || size.height !== originalSize.height),
925
+ marks: markerStateRef.current?.markers.length ?? 0,
926
+ mosaics: editor.mosaicCount,
927
+ resizedTo: maxLongEdge !== null && size ? scaleToLongEdge(size, maxLongEdge) : null
928
+ });
929
+ };
564
930
  const handleConfirm = async () => {
565
931
  setExporting(true);
932
+ setErrorMessage(null);
566
933
  try {
567
- const pendingCropRect = editor.activeTool === "crop" && hasPendingCrop ? editor.croppedAreaPixels : null;
568
- onConfirm(await exportResult({
934
+ const output = await exportResult({
569
935
  hasRebasedBitmap: editor.rebasedBitmapUrl !== null,
570
936
  markerState: markerStateRef.current,
937
+ maxLongEdge,
571
938
  original: file,
572
939
  pendingCropRect,
573
940
  sourceUrl
574
- }));
575
- } catch {
576
- onSkip();
577
- } finally {
941
+ });
942
+ onConfirm({
943
+ file: output,
944
+ summary: buildSummary(output)
945
+ });
946
+ } catch (error) {
947
+ console.error("[ImageEditModal] export failed", error);
948
+ setErrorMessage("Could not export the edited image. Try again or discard edits.");
578
949
  setExporting(false);
579
950
  }
580
951
  };
952
+ const renderOptions = () => {
953
+ if (decodeError) return null;
954
+ if (editor.activeTool === "crop") return /* @__PURE__ */ jsx("button", {
955
+ className: topBarGhostButton,
956
+ disabled: !hasPendingCrop,
957
+ type: "button",
958
+ onClick: editor.resetCrop,
959
+ children: "Reset crop"
960
+ });
961
+ if (editor.activeTool === "mosaic") return /* @__PURE__ */ jsx(MosaicOptionsBar, {
962
+ canUndo: editor.mosaicCount > 0 && !editor.mosaicBusy,
963
+ mode: mosaicMode,
964
+ strength: mosaicStrength,
965
+ onModeChange: handleMosaicModeChange,
966
+ onStrengthChange: (value) => setMosaicStrength(clampMosaicStrength(mosaicMode, value)),
967
+ onUndo: editor.undoMosaic
968
+ });
969
+ return /* @__PURE__ */ jsx(ToolOptionsBar, {
970
+ canRedo: history.canRedo,
971
+ canUndo: history.canUndo,
972
+ showStrokeWidth: STROKE_TOOLS.has(editor.activeTool),
973
+ strokeColor,
974
+ strokeWidth,
975
+ onRedo: () => annotationApiRef.current?.redo(),
976
+ onStrokeColorChange: setStrokeColor,
977
+ onStrokeWidthChange: setStrokeWidth,
978
+ onUndo: () => annotationApiRef.current?.undo()
979
+ });
980
+ };
981
+ const renderCanvas = () => {
982
+ if (decodeError) return /* @__PURE__ */ jsxs("div", {
983
+ className: errorState,
984
+ children: [/* @__PURE__ */ jsx(ImageOff, { size: 24 }), /* @__PURE__ */ jsx("span", { children: "This image could not be displayed for editing." })]
985
+ });
986
+ if (!sourceUrl) return null;
987
+ if (isAnnotationTool(editor.activeTool)) return /* @__PURE__ */ jsx(AnnotationSurface, {
988
+ apiRef: annotationApiRef,
989
+ counterRef,
990
+ sourceUrl,
991
+ stateRef: markerStateRef,
992
+ strokeColor,
993
+ strokeWidth,
994
+ tool: editor.activeTool,
995
+ onDecodeError: () => setDecodeError(true),
996
+ onHistoryChange: (canUndo, canRedo) => setHistory({
997
+ canRedo,
998
+ canUndo
999
+ })
1000
+ });
1001
+ const isMosaic = editor.activeTool === "mosaic";
1002
+ return /* @__PURE__ */ jsx(ReactCrop, {
1003
+ className: cropSurface,
1004
+ crop: isMosaic ? mosaicCrop : editor.crop,
1005
+ disabled: isMosaic && editor.mosaicBusy,
1006
+ keepSelection: !isMosaic,
1007
+ onComplete: isMosaic ? handleMosaicComplete : handleCropComplete,
1008
+ onChange: (_, percentCrop) => isMosaic ? setMosaicCrop(percentCrop) : editor.setCrop(percentCrop),
1009
+ children: /* @__PURE__ */ jsx("img", {
1010
+ alt: file.name,
1011
+ className: cropImage,
1012
+ ref: imgRef,
1013
+ src: sourceUrl,
1014
+ onError: () => setDecodeError(true),
1015
+ onLoad: handleImageLoad
1016
+ })
1017
+ });
1018
+ };
581
1019
  return /* @__PURE__ */ jsxs("div", {
582
1020
  className: root,
583
1021
  children: [
@@ -588,75 +1026,18 @@ var ImageEditModal = ({ file, onCancel, onConfirm, onSkip }) => {
588
1026
  children: "Edit image"
589
1027
  }), /* @__PURE__ */ jsx("div", {
590
1028
  className: topBarControls,
591
- children: !decodeError && (editor.activeTool === "crop" ? /* @__PURE__ */ jsx("button", {
592
- className: "_1257qq2u _1257qq2t _1257qq2s",
593
- disabled: !hasPendingCrop,
594
- type: "button",
595
- onClick: editor.resetCrop,
596
- children: "Reset crop"
597
- }) : /* @__PURE__ */ jsx(ToolOptionsBar, {
598
- canRedo: history.canRedo,
599
- canUndo: history.canUndo,
600
- showStrokeWidth: STROKE_TOOLS.has(editor.activeTool),
601
- strokeColor,
602
- strokeWidth,
603
- onRedo: () => annotationApiRef.current?.redo(),
604
- onStrokeColorChange: setStrokeColor,
605
- onStrokeWidthChange: setStrokeWidth,
606
- onUndo: () => annotationApiRef.current?.undo()
607
- }))
1029
+ children: renderOptions()
608
1030
  })]
609
1031
  }),
610
1032
  /* @__PURE__ */ jsxs("div", {
611
1033
  className: body,
612
- children: [/* @__PURE__ */ jsx("div", {
613
- className: toolRail,
614
- children: TOOLS.map((tool, index) => /* @__PURE__ */ jsxs("span", {
615
- className: toolGroup,
616
- children: [/* @__PURE__ */ jsx("button", {
617
- "aria-label": tool.label,
618
- "aria-pressed": editor.activeTool === tool.id,
619
- disabled: decodeError,
620
- title: tool.label,
621
- type: "button",
622
- className: editor.activeTool === tool.id ? `${toolButton} ${toolButtonActive}` : toolButton,
623
- onClick: () => handleToolClick(tool.id),
624
- children: /* @__PURE__ */ jsx(tool.icon, { size: 18 })
625
- }), index === 0 && /* @__PURE__ */ jsx("div", { className: "_1257qq2c" })]
626
- }, tool.id))
1034
+ children: [/* @__PURE__ */ jsx(ToolRail, {
1035
+ activeTool: editor.activeTool,
1036
+ disabled: decodeError,
1037
+ onSelect: handleToolClick
627
1038
  }), /* @__PURE__ */ jsxs("div", {
628
1039
  className: canvasArea,
629
- children: [decodeError ? /* @__PURE__ */ jsxs("div", {
630
- className: errorState,
631
- children: [/* @__PURE__ */ jsx(ImageOff, { size: 24 }), /* @__PURE__ */ jsx("span", { children: "This image could not be displayed for editing." })]
632
- }) : sourceUrl && (isAnnotationTool(editor.activeTool) ? /* @__PURE__ */ jsx(AnnotationSurface, {
633
- apiRef: annotationApiRef,
634
- counterRef,
635
- sourceUrl,
636
- stateRef: markerStateRef,
637
- strokeColor,
638
- strokeWidth,
639
- tool: editor.activeTool,
640
- onDecodeError: () => setDecodeError(true),
641
- onHistoryChange: (canUndo, canRedo) => setHistory({
642
- canRedo,
643
- canUndo
644
- })
645
- }) : /* @__PURE__ */ jsx(ReactCrop, {
646
- keepSelection: true,
647
- className: "_1257qq2e",
648
- crop: editor.crop,
649
- onChange: (_, percentCrop) => editor.setCrop(percentCrop),
650
- onComplete: handleCropComplete,
651
- children: /* @__PURE__ */ jsx("img", {
652
- alt: file.name,
653
- className: "_1257qq2f",
654
- ref: imgRef,
655
- src: sourceUrl,
656
- onError: () => setDecodeError(true),
657
- onLoad: handleImageLoad
658
- })
659
- })), /* @__PURE__ */ jsx("a", {
1040
+ children: [renderCanvas(), /* @__PURE__ */ jsx("a", {
660
1041
  className: attribution,
661
1042
  href: "https://markerjs.com",
662
1043
  rel: "noreferrer",
@@ -666,27 +1047,326 @@ var ImageEditModal = ({ file, onCancel, onConfirm, onSkip }) => {
666
1047
  })]
667
1048
  }),
668
1049
  /* @__PURE__ */ jsxs("div", {
669
- className: footer,
1050
+ className: footer$1,
670
1051
  children: [
1052
+ /* @__PURE__ */ jsx(OutputSizeControl, { ...outputSizeProps }),
1053
+ /* @__PURE__ */ jsx("div", {
1054
+ className: footerSpacer,
1055
+ children: errorMessage && /* @__PURE__ */ jsx("span", {
1056
+ className: "_1257qq2s",
1057
+ role: "alert",
1058
+ children: errorMessage
1059
+ })
1060
+ }),
671
1061
  /* @__PURE__ */ jsx("button", {
672
- className: ghostButton,
1062
+ className: secondaryButton,
673
1063
  type: "button",
674
- onClick: onSkip,
675
- children: "Upload without editing"
1064
+ onClick: onCancel,
1065
+ children: "Discard edits"
676
1066
  }),
677
- /* @__PURE__ */ jsx("div", { className: footerSpacer }),
678
1067
  /* @__PURE__ */ jsx("button", {
679
- className: secondaryButton,
1068
+ className: primaryButton,
1069
+ disabled: decodeError || exporting || editor.mosaicBusy,
1070
+ type: "button",
1071
+ onClick: handleConfirm,
1072
+ children: "Done"
1073
+ })
1074
+ ]
1075
+ })
1076
+ ]
1077
+ });
1078
+ };
1079
+ //#endregion
1080
+ //#region src/insert-sheet-model.ts
1081
+ var FLATTENED_BY_CANVAS = /* @__PURE__ */ new Set(["image/gif", "image/svg+xml"]);
1082
+ function isEditableImage(file) {
1083
+ return !FLATTENED_BY_CANVAS.has(file.type);
1084
+ }
1085
+ function formatBytes(bytes) {
1086
+ if (bytes < 1024) return `${bytes} B`;
1087
+ if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
1088
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
1089
+ }
1090
+ async function finalizeRows(rows, removeGps, stripGps) {
1091
+ const failedIds = [];
1092
+ return {
1093
+ failedIds,
1094
+ files: (await Promise.all(rows.map(async (row) => {
1095
+ if (row.current !== row.original || !removeGps || !row.gps) return row.current;
1096
+ try {
1097
+ if (!stripGps) throw new Error("No GPS stripper configured");
1098
+ return await stripGps(row.original);
1099
+ } catch {
1100
+ failedIds.push(row.id);
1101
+ return null;
1102
+ }
1103
+ }))).filter((file) => file !== null)
1104
+ };
1105
+ }
1106
+ //#endregion
1107
+ //#region src/sheet.css.ts
1108
+ var sheetPopup = "_1us6jj40";
1109
+ var sheet = "_1us6jj41";
1110
+ var rows = "_1us6jj42";
1111
+ var row = "_1us6jj43";
1112
+ var rowFailed = "_1us6jj44";
1113
+ var thumb = "_1us6jj45";
1114
+ var rowBody = "_1us6jj46";
1115
+ var rowTitle = "_1us6jj47";
1116
+ var fileName = "_1us6jj48";
1117
+ var meta = "_1us6jj4a";
1118
+ var footer = "_1us6jj4h";
1119
+ var hint = "_1us6jj4i";
1120
+ var iconButton = "_1us6jj4j";
1121
+ //#endregion
1122
+ //#region src/SheetRowItem.tsx
1123
+ function useImageInfo(file) {
1124
+ const [info, setInfo] = useState(null);
1125
+ const [failed, setFailed] = useState(false);
1126
+ useEffect(() => {
1127
+ const url = URL.createObjectURL(file);
1128
+ let active = true;
1129
+ setFailed(false);
1130
+ const image = new Image();
1131
+ image.onload = () => {
1132
+ if (active) setInfo({
1133
+ height: image.naturalHeight,
1134
+ url,
1135
+ width: image.naturalWidth
1136
+ });
1137
+ };
1138
+ image.onerror = () => {
1139
+ if (active) setFailed(true);
1140
+ };
1141
+ image.src = url;
1142
+ return () => {
1143
+ active = false;
1144
+ URL.revokeObjectURL(url);
1145
+ };
1146
+ }, [file]);
1147
+ return {
1148
+ failed,
1149
+ info
1150
+ };
1151
+ }
1152
+ var SheetRowItem = ({ failed, onEdit, onRemove, row: row$1 }) => {
1153
+ const { failed: decodeFailed, info } = useImageInfo(row$1.current);
1154
+ const edited = row$1.current !== row$1.original;
1155
+ const editable = isEditableImage(row$1.current) && !decodeFailed;
1156
+ const type = row$1.current.type.replace("image/", "").toUpperCase();
1157
+ const meta$1 = [
1158
+ info ? `${info.width} × ${info.height}` : null,
1159
+ formatBytes(row$1.current.size),
1160
+ type || null
1161
+ ].filter(Boolean).join(" · ");
1162
+ return /* @__PURE__ */ jsxs("div", {
1163
+ className: failed ? `${row} ${rowFailed}` : row,
1164
+ children: [
1165
+ info ? /* @__PURE__ */ jsx("img", {
1166
+ alt: "",
1167
+ className: thumb,
1168
+ src: info.url
1169
+ }) : /* @__PURE__ */ jsx("div", { className: thumb }),
1170
+ /* @__PURE__ */ jsxs("div", {
1171
+ className: rowBody,
1172
+ children: [
1173
+ /* @__PURE__ */ jsxs("div", {
1174
+ className: rowTitle,
1175
+ children: [/* @__PURE__ */ jsx("span", {
1176
+ className: fileName,
1177
+ title: row$1.original.name,
1178
+ children: row$1.original.name
1179
+ }), edited && /* @__PURE__ */ jsx("span", {
1180
+ className: "_1us6jj49",
1181
+ children: "Edited"
1182
+ })]
1183
+ }),
1184
+ /* @__PURE__ */ jsx("span", {
1185
+ className: meta,
1186
+ children: meta$1
1187
+ }),
1188
+ edited && row$1.summary && /* @__PURE__ */ jsx("span", {
1189
+ className: "_1us6jj4a",
1190
+ children: row$1.summary
1191
+ }),
1192
+ !edited && row$1.gps && /* @__PURE__ */ jsxs("span", {
1193
+ className: "_1us6jj4b",
1194
+ children: [/* @__PURE__ */ jsx(MapPin, {
1195
+ "aria-hidden": true,
1196
+ size: 13
1197
+ }), "Has location"]
1198
+ }),
1199
+ !isEditableImage(row$1.current) && /* @__PURE__ */ jsx("span", {
1200
+ className: "_1us6jj4a",
1201
+ children: "Animated or vector — editing would flatten it"
1202
+ }),
1203
+ failed && /* @__PURE__ */ jsx("span", {
1204
+ className: "_1us6jj4c",
1205
+ children: "Could not remove location from this image"
1206
+ })
1207
+ ]
1208
+ }),
1209
+ /* @__PURE__ */ jsxs("button", {
1210
+ className: secondaryButton,
1211
+ disabled: !editable,
1212
+ type: "button",
1213
+ onClick: onEdit,
1214
+ children: [/* @__PURE__ */ jsx(Pencil, {
1215
+ "aria-hidden": true,
1216
+ size: 14,
1217
+ style: { marginRight: 6 }
1218
+ }), edited ? "Re-edit" : "Edit…"]
1219
+ }),
1220
+ /* @__PURE__ */ jsx("button", {
1221
+ "aria-label": `Remove ${row$1.original.name}`,
1222
+ className: iconButton,
1223
+ title: "Remove",
1224
+ type: "button",
1225
+ onClick: onRemove,
1226
+ children: /* @__PURE__ */ jsx(X, {
1227
+ "aria-hidden": true,
1228
+ size: 16
1229
+ })
1230
+ })
1231
+ ]
1232
+ });
1233
+ };
1234
+ //#endregion
1235
+ //#region src/ImageInsertSheet.tsx
1236
+ function mapUrl({ latitude, longitude }) {
1237
+ return `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`;
1238
+ }
1239
+ function withDetectedGps(rows, files, found) {
1240
+ return rows.map((row) => {
1241
+ const index = files.indexOf(row.original);
1242
+ return index === -1 ? row : {
1243
+ ...row,
1244
+ gps: found[index] ?? null
1245
+ };
1246
+ });
1247
+ }
1248
+ var ImageInsertSheet = ({ files, onCancel, onEdit, onInsert, privacy }) => {
1249
+ const [rows$1, setRows] = useState(() => files.map((file, index) => ({
1250
+ current: file,
1251
+ gps: null,
1252
+ id: `${index}-${file.name}`,
1253
+ original: file,
1254
+ summary: ""
1255
+ })));
1256
+ const [removeGps, setRemoveGps] = useState(true);
1257
+ const [failedIds, setFailedIds] = useState([]);
1258
+ const [inserting, setInserting] = useState(false);
1259
+ useEscapeDismissesTopDialog();
1260
+ const detectionRef = useRef(Promise.resolve([]));
1261
+ useEffect(() => {
1262
+ if (!privacy) return;
1263
+ let active = true;
1264
+ const detection = Promise.all(files.map((file) => privacy.detectGps(file).catch(() => null)));
1265
+ detectionRef.current = detection;
1266
+ detection.then((found) => {
1267
+ if (active) setRows((current) => withDetectedGps(current, files, found));
1268
+ });
1269
+ return () => {
1270
+ active = false;
1271
+ };
1272
+ }, [files, privacy]);
1273
+ const gpsRows = rows$1.filter((row) => row.gps && row.current === row.original);
1274
+ const blocked = removeGps && failedIds.some((id) => gpsRows.some((row) => row.id === id));
1275
+ const handleRemove = (id) => {
1276
+ const next = rows$1.filter((row) => row.id !== id);
1277
+ if (next.length === 0) {
1278
+ onCancel();
1279
+ return;
1280
+ }
1281
+ setRows(next);
1282
+ };
1283
+ const handleEdit = async (target) => {
1284
+ const result = await onEdit(target.current);
1285
+ if (!result || result.file === target.current) return;
1286
+ setRows((current) => current.map((row) => row.id === target.id ? {
1287
+ ...row,
1288
+ current: result.file,
1289
+ summary: [row.summary, result.summary].filter(Boolean).join(" · ")
1290
+ } : row));
1291
+ };
1292
+ const handleInsert = async () => {
1293
+ setInserting(true);
1294
+ const found = await detectionRef.current;
1295
+ const result = await finalizeRows(withDetectedGps(rows$1, files, found), removeGps, privacy?.stripGps);
1296
+ setInserting(false);
1297
+ if (result.failedIds.length > 0) {
1298
+ setFailedIds(result.failedIds);
1299
+ return;
1300
+ }
1301
+ onInsert(result.files);
1302
+ };
1303
+ const single = rows$1.length === 1 ? gpsRows[0] : void 0;
1304
+ return /* @__PURE__ */ jsxs("div", {
1305
+ className: sheet,
1306
+ children: [
1307
+ /* @__PURE__ */ jsx("div", {
1308
+ className: rows,
1309
+ children: rows$1.map((row) => /* @__PURE__ */ jsx(SheetRowItem, {
1310
+ failed: removeGps && failedIds.includes(row.id),
1311
+ row,
1312
+ onEdit: () => void handleEdit(row),
1313
+ onRemove: () => handleRemove(row.id)
1314
+ }, row.id))
1315
+ }),
1316
+ gpsRows.length > 0 && /* @__PURE__ */ jsxs("div", {
1317
+ className: "_1us6jj4d",
1318
+ children: [
1319
+ /* @__PURE__ */ jsxs("div", {
1320
+ className: "_1us6jj4e",
1321
+ children: [/* @__PURE__ */ jsx(MapPin, {
1322
+ "aria-hidden": true,
1323
+ color: "#d97706",
1324
+ size: 16
1325
+ }), single ? "This photo records where it was taken" : `${gpsRows.length} of ${rows$1.length} images record where they were taken`]
1326
+ }),
1327
+ single?.gps && /* @__PURE__ */ jsxs("div", {
1328
+ className: "_1us6jj4f",
1329
+ children: [/* @__PURE__ */ jsxs("span", { children: [
1330
+ single.gps.latitude.toFixed(4),
1331
+ ", ",
1332
+ single.gps.longitude.toFixed(4)
1333
+ ] }), /* @__PURE__ */ jsx("a", {
1334
+ href: mapUrl(single.gps),
1335
+ rel: "noreferrer",
1336
+ target: "_blank",
1337
+ children: "View on map"
1338
+ })]
1339
+ }),
1340
+ /* @__PURE__ */ jsxs("label", {
1341
+ className: "_1us6jj4g",
1342
+ children: [/* @__PURE__ */ jsx("input", {
1343
+ checked: removeGps,
1344
+ type: "checkbox",
1345
+ onChange: (event) => setRemoveGps(event.currentTarget.checked)
1346
+ }), "Remove location before upload"]
1347
+ })
1348
+ ]
1349
+ }),
1350
+ /* @__PURE__ */ jsxs("div", {
1351
+ className: footer,
1352
+ children: [
1353
+ /* @__PURE__ */ jsx("span", {
1354
+ className: hint,
1355
+ children: blocked ? "Remove the flagged images or keep their location to continue" : "Edited images drop all metadata"
1356
+ }),
1357
+ /* @__PURE__ */ jsx("button", {
1358
+ className: ghostButton,
680
1359
  type: "button",
681
1360
  onClick: onCancel,
682
1361
  children: "Cancel"
683
1362
  }),
684
1363
  /* @__PURE__ */ jsx("button", {
1364
+ autoFocus: true,
685
1365
  className: primaryButton,
686
- disabled: decodeError || exporting,
1366
+ disabled: inserting || blocked,
687
1367
  type: "button",
688
- onClick: handleConfirm,
689
- children: "Upload"
1368
+ onClick: () => void handleInsert(),
1369
+ children: rows$1.length > 1 ? `Insert ${rows$1.length} images` : "Insert"
690
1370
  })
691
1371
  ]
692
1372
  })
@@ -695,14 +1375,16 @@ var ImageEditModal = ({ file, onCancel, onConfirm, onSkip }) => {
695
1375
  };
696
1376
  //#endregion
697
1377
  //#region src/ImageEditModalPlugin.tsx
698
- var ImageEditModalPlugin = () => {
1378
+ var ImageEditModalPlugin = ({ privacy }) => {
699
1379
  const preprocess = useImagePreprocess();
700
1380
  const portalTheme = usePortalTheme();
701
1381
  const portalThemeRef = useRef(portalTheme);
702
1382
  portalThemeRef.current = portalTheme;
1383
+ const privacyRef = useRef(privacy);
1384
+ privacyRef.current = privacy;
703
1385
  useEffect(() => {
704
1386
  if (!preprocess) return;
705
- const fn = (file) => new Promise((resolve) => {
1387
+ const openEditor = (file) => new Promise((resolve) => {
706
1388
  let settled = false;
707
1389
  const settle = (result) => {
708
1390
  if (settled) return;
@@ -717,19 +1399,46 @@ var ImageEditModalPlugin = () => {
717
1399
  settle(null);
718
1400
  dismiss();
719
1401
  },
720
- onConfirm: (edited) => {
721
- settle(edited);
1402
+ onConfirm: (result) => {
1403
+ settle(result);
1404
+ dismiss();
1405
+ }
1406
+ }),
1407
+ onClose: () => settle(null),
1408
+ portalClassName: portalThemeRef.current.className,
1409
+ showCloseButton: false,
1410
+ theme: portalThemeRef.current.theme
1411
+ });
1412
+ });
1413
+ const fn = (files) => new Promise((resolve) => {
1414
+ let settled = false;
1415
+ const settle = (result) => {
1416
+ if (settled) return;
1417
+ settled = true;
1418
+ resolve(result);
1419
+ };
1420
+ const dismiss = presentDialog({
1421
+ className: sheetPopup,
1422
+ clickOutsideToDismiss: false,
1423
+ content: () => /* @__PURE__ */ jsx(ImageInsertSheet, {
1424
+ files,
1425
+ privacy: privacyRef.current,
1426
+ onEdit: openEditor,
1427
+ onCancel: () => {
1428
+ settle(null);
722
1429
  dismiss();
723
1430
  },
724
- onSkip: () => {
725
- settle("skip");
1431
+ onInsert: (result) => {
1432
+ settle(result);
726
1433
  dismiss();
727
1434
  }
728
1435
  }),
1436
+ description: "Check before it goes up. Nothing is uploaded yet.",
729
1437
  onClose: () => settle(null),
730
1438
  portalClassName: portalThemeRef.current.className,
731
1439
  showCloseButton: false,
732
- theme: portalThemeRef.current.theme
1440
+ theme: portalThemeRef.current.theme,
1441
+ title: files.length > 1 ? "Insert images" : "Insert image"
733
1442
  });
734
1443
  });
735
1444
  return preprocess.register(fn);
@@ -737,4 +1446,4 @@ var ImageEditModalPlugin = () => {
737
1446
  return null;
738
1447
  };
739
1448
  //#endregion
740
- export { ImageEditModal, ImageEditModalPlugin };
1449
+ export { ImageEditModal, ImageEditModalPlugin, ImageInsertSheet };