@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.
- package/dist/ImageEditModal.d.ts +5 -2
- package/dist/ImageEditModal.d.ts.map +1 -1
- package/dist/ImageEditModalPlugin.d.ts +5 -1
- package/dist/ImageEditModalPlugin.d.ts.map +1 -1
- package/dist/ImageInsertSheet.d.ts +12 -0
- package/dist/ImageInsertSheet.d.ts.map +1 -0
- package/dist/MosaicOptionsBar.d.ts +12 -0
- package/dist/MosaicOptionsBar.d.ts.map +1 -0
- package/dist/OutputSizeControl.d.ts +18 -0
- package/dist/OutputSizeControl.d.ts.map +1 -0
- package/dist/SheetRowItem.d.ts +10 -0
- package/dist/SheetRowItem.d.ts.map +1 -0
- package/dist/ToolRail.d.ts +9 -0
- package/dist/ToolRail.d.ts.map +1 -0
- package/dist/annotation.d.ts +1 -1
- package/dist/annotation.d.ts.map +1 -1
- package/dist/edit-summary.d.ts +9 -0
- package/dist/edit-summary.d.ts.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +853 -144
- package/dist/insert-sheet-model.d.ts +22 -0
- package/dist/insert-sheet-model.d.ts.map +1 -0
- package/dist/mosaic.d.ts +14 -0
- package/dist/mosaic.d.ts.map +1 -0
- package/dist/pipeline.d.ts +2 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/resize.d.ts +9 -0
- package/dist/resize.d.ts.map +1 -0
- package/dist/rich-plugin-image-editor.css +1 -1
- package/dist/sheet.css.d.ts +22 -0
- package/dist/sheet.css.d.ts.map +1 -0
- package/dist/styles.css.d.ts +9 -0
- package/dist/styles.css.d.ts.map +1 -1
- package/dist/useEscapeDismissesTopDialog.d.ts +2 -0
- package/dist/useEscapeDismissesTopDialog.d.ts.map +1 -0
- package/dist/useImageEditorState.d.ts +6 -1
- package/dist/useImageEditorState.d.ts.map +1 -1
- 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
|
|
85
|
-
var
|
|
86
|
-
var
|
|
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
|
-
|
|
439
|
-
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
|
887
|
+
const toNatural = (pixelCrop) => {
|
|
552
888
|
const img = imgRef.current;
|
|
553
|
-
if (!img || img.width === 0 || img.height === 0) return;
|
|
554
|
-
|
|
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
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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:
|
|
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(
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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: [
|
|
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:
|
|
1062
|
+
className: secondaryButton,
|
|
673
1063
|
type: "button",
|
|
674
|
-
onClick:
|
|
675
|
-
children: "
|
|
1064
|
+
onClick: onCancel,
|
|
1065
|
+
children: "Discard edits"
|
|
676
1066
|
}),
|
|
677
|
-
/* @__PURE__ */ jsx("div", { className: footerSpacer }),
|
|
678
1067
|
/* @__PURE__ */ jsx("button", {
|
|
679
|
-
className:
|
|
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:
|
|
1366
|
+
disabled: inserting || blocked,
|
|
687
1367
|
type: "button",
|
|
688
|
-
onClick:
|
|
689
|
-
children: "
|
|
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
|
|
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: (
|
|
721
|
-
settle(
|
|
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
|
-
|
|
725
|
-
settle(
|
|
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 };
|