@lmb-it/kitsconcerto-native 0.1.285 → 0.1.287

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 (55) hide show
  1. package/apps/mobile/src/ui/accordion/index.js +2 -2
  2. package/components/ImageCropper/compression.cjs +63 -0
  3. package/components/ImageCropper/compression.cjs.map +1 -0
  4. package/components/ImageCropper/compression.js +58 -0
  5. package/components/ImageCropper/compression.js.map +1 -0
  6. package/components/ImageCropper/constants.cjs +47 -0
  7. package/components/ImageCropper/constants.cjs.map +1 -0
  8. package/components/ImageCropper/constants.js +29 -0
  9. package/components/ImageCropper/constants.js.map +1 -0
  10. package/components/ImageCropper/cropperState.cjs +129 -0
  11. package/components/ImageCropper/cropperState.cjs.map +1 -0
  12. package/components/ImageCropper/cropperState.js +124 -0
  13. package/components/ImageCropper/cropperState.js.map +1 -0
  14. package/components/ImageCropper/encode.cjs +45 -0
  15. package/components/ImageCropper/encode.cjs.map +1 -0
  16. package/components/ImageCropper/encode.js +42 -0
  17. package/components/ImageCropper/encode.js.map +1 -0
  18. package/components/ImageCropper/encoderRegistry.cjs +13 -0
  19. package/components/ImageCropper/encoderRegistry.cjs.map +1 -0
  20. package/components/ImageCropper/encoderRegistry.js +9 -0
  21. package/components/ImageCropper/encoderRegistry.js.map +1 -0
  22. package/components/ImageCropper/encoders/rnImageEncoder.cjs +54 -0
  23. package/components/ImageCropper/encoders/rnImageEncoder.cjs.map +1 -0
  24. package/components/ImageCropper/encoders/rnImageEncoder.js +51 -0
  25. package/components/ImageCropper/encoders/rnImageEncoder.js.map +1 -0
  26. package/components/ImageCropper/geometry.cjs +208 -0
  27. package/components/ImageCropper/geometry.cjs.map +1 -0
  28. package/components/ImageCropper/geometry.js +186 -0
  29. package/components/ImageCropper/geometry.js.map +1 -0
  30. package/components/ImageCropper/index.cjs +640 -0
  31. package/components/ImageCropper/index.cjs.map +1 -0
  32. package/components/ImageCropper/index.js +638 -0
  33. package/components/ImageCropper/index.js.map +1 -0
  34. package/components/ImageCropper/pinchZoom.cjs +8 -0
  35. package/components/ImageCropper/pinchZoom.cjs.map +1 -0
  36. package/components/ImageCropper/pinchZoom.js +5 -0
  37. package/components/ImageCropper/pinchZoom.js.map +1 -0
  38. package/components/ImageCropper/useImageSource.cjs +101 -0
  39. package/components/ImageCropper/useImageSource.cjs.map +1 -0
  40. package/components/ImageCropper/useImageSource.js +97 -0
  41. package/components/ImageCropper/useImageSource.js.map +1 -0
  42. package/hooks/useComponentDefaults.cjs +12 -0
  43. package/hooks/useComponentDefaults.cjs.map +1 -1
  44. package/hooks/useComponentDefaults.js +12 -0
  45. package/hooks/useComponentDefaults.js.map +1 -1
  46. package/index.cjs +120 -115
  47. package/index.cjs.map +1 -1
  48. package/index.d.ts +394 -3
  49. package/index.js +2 -0
  50. package/index.js.map +1 -1
  51. package/package.json +13 -1
  52. package/packages/types/src/Theme/defaultTheme.cjs +189 -0
  53. package/packages/types/src/Theme/defaultTheme.cjs.map +1 -1
  54. package/packages/types/src/Theme/defaultTheme.js +189 -0
  55. package/packages/types/src/Theme/defaultTheme.js.map +1 -1
@@ -1,8 +1,8 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import React from 'react';
3
3
  import { createAccordion } from '@gluestack-ui/core/accordion/creator';
4
- import { View, Platform, Text, Pressable } from 'react-native';
5
- import { tva, withStyleContext, useStyleContext } from '@gluestack-ui/utils/nativewind-utils';
4
+ import { View, Text, Pressable, Platform } from 'react-native';
5
+ import { useStyleContext, tva, withStyleContext } from '@gluestack-ui/utils/nativewind-utils';
6
6
  import { H3 } from '@expo/html-elements';
7
7
  import { cssInterop } from 'nativewind';
8
8
  import { UIIcon, PrimitiveIcon } from '@gluestack-ui/core/icon/creator';
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ var constants = require('./constants.cjs');
4
+ var geometry = require('./geometry.cjs');
5
+
6
+ const isLossless = (format) => format === "png";
7
+ const bisect = (low, high) => (low + high) / 2;
8
+ function* encodePlan(natural, max, format) {
9
+ let size = geometry.fitWithin(natural, { width: max.width, height: max.height });
10
+ for (let pass = 0; pass <= constants.MAX_DOWNSCALE_PASSES; pass++) {
11
+ if (isLossless(format)) {
12
+ const outcome = yield { size, quality: 1 };
13
+ if (outcome.bytes <= max.bytes) return;
14
+ } else {
15
+ let low = constants.QUALITY_MIN;
16
+ let high = constants.QUALITY_MAX;
17
+ const best = yield { size, quality: high };
18
+ if (best.bytes <= max.bytes) return;
19
+ let fitted = false;
20
+ for (let step = 0; step < constants.QUALITY_STEPS; step++) {
21
+ const quality = bisect(low, high);
22
+ const outcome = yield { size, quality };
23
+ if (outcome.bytes <= max.bytes) {
24
+ fitted = true;
25
+ low = quality;
26
+ } else {
27
+ high = quality;
28
+ }
29
+ }
30
+ if (fitted) return;
31
+ }
32
+ const next = Math.max(size.width, size.height) * constants.DOWNSCALE_FACTOR;
33
+ size = geometry.fitWithin(size, { width: next, height: next });
34
+ if (size.width < 1 || size.height < 1) return;
35
+ }
36
+ }
37
+ async function runEncodePlan(natural, max, format, encode) {
38
+ const plan = encodePlan(natural, max, format);
39
+ let best;
40
+ let smallest;
41
+ let outcome;
42
+ for (; ; ) {
43
+ const step = outcome === void 0 ? plan.next() : plan.next(outcome);
44
+ if (step.done === true) break;
45
+ const attempt = step.value;
46
+ const { bytes, value } = await encode(attempt);
47
+ const record = { value, bytes, size: attempt.size, quality: attempt.quality };
48
+ if (bytes <= max.bytes) best = record;
49
+ if (!smallest || bytes < smallest.bytes) smallest = record;
50
+ outcome = { bytes };
51
+ }
52
+ const chosen = best ?? smallest;
53
+ if (!chosen) {
54
+ throw new Error("[ImageCropper] the encode plan produced no attempts at all");
55
+ }
56
+ return { ...chosen, withinBudget: chosen.bytes <= max.bytes };
57
+ }
58
+
59
+ exports.bisect = bisect;
60
+ exports.encodePlan = encodePlan;
61
+ exports.isLossless = isLossless;
62
+ exports.runEncodePlan = runEncodePlan;
63
+ //# sourceMappingURL=compression.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compression.cjs","sources":["../../../../src/components/ImageCropper/compression.ts"],"sourcesContent":["import type {CropOutputFormat, ICropSize} from \"@lmb-it/kitsconcerto-types\";\nimport {\n DOWNSCALE_FACTOR,\n MAX_DOWNSCALE_PASSES,\n QUALITY_MAX,\n QUALITY_MIN,\n QUALITY_STEPS,\n} from \"./constants\";\nimport {fitWithin} from \"./geometry\";\n\n/**\n * How hard to squeeze, and when to stop squeezing and shrink instead.\n *\n * ⛔ THE POLICY IS HERE AND THE ENCODER IS NOT, ON PURPOSE. Web encodes with\n * `canvas.toBlob`, native with expo-image-manipulator; neither can run in the\n * other's runtime, and neither can be unit-tested without one. What CAN be\n * tested — and what decides whether a 4 MB photo comes back at 480 KB or at\n * 2 MB — is the search: how the quality band is walked, when the band is\n * exhausted, and how far to scale down after that. Both platforms drive the\n * same plan from here and only supply `encode`.\n */\n\n/** One attempt: what the caller's encoder is asked to produce. */\nexport interface IEncodeAttempt {\n size: ICropSize;\n /** 0–1. Ignored by lossless formats. */\n quality: number;\n}\n\n/** What an encoder reports back. */\nexport interface IEncodeOutcome {\n bytes: number;\n}\n\n/** Lossless formats cannot trade quality for bytes — only dimensions. */\nexport const isLossless = (format: CropOutputFormat): boolean => format === \"png\";\n\n/**\n * The quality to try on step `step` of a binary search that has so far bracketed\n * the answer to [`low`, `high`].\n *\n * ⚠️ A PLAIN MIDPOINT, NOT A GUESS FROM THE BYTE RATIO. Encoded size is not\n * linear in quality — it is flat near the top of the band and falls off a cliff\n * near the bottom — so a ratio-based jump overshoots into the blocky region on\n * the first move. Bisection is slower by a few encodes and lands where it says.\n */\nexport const bisect = (low: number, high: number): number => (low + high) / 2;\n\n/**\n * The plan for one encode pass, as a sequence of (size, quality) attempts.\n *\n * Walks the quality band from {@link QUALITY_MAX} down by bisection for\n * {@link QUALITY_STEPS} steps, keeping the best attempt that fits. When the\n * whole band is over the ceiling it scales the image down by\n * {@link DOWNSCALE_FACTOR} and walks it again, up to\n * {@link MAX_DOWNSCALE_PASSES} times.\n *\n * ⚠️ IT IS A GENERATOR, NOT A LOOP THAT CALLS YOU BACK. The caller owns the\n * encoder, the async, and the cancellation; this owns only the order to try\n * things in. That is what lets the same policy be driven by `canvas.toBlob` on\n * one platform and a native module on the other, and be tested with neither.\n */\nexport function* encodePlan(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n): Generator<IEncodeAttempt, void, IEncodeOutcome> {\n let size = fitWithin(natural, {width: max.width, height: max.height});\n\n for (let pass = 0; pass <= MAX_DOWNSCALE_PASSES; pass++) {\n if (isLossless(format)) {\n // ⛔ NO QUALITY KNOB TO TURN. A single attempt per size; if it is\n // over, the only lever left is the next downscale pass.\n const outcome = yield {size, quality: 1};\n if (outcome.bytes <= max.bytes) return;\n } else {\n let low = QUALITY_MIN;\n let high = QUALITY_MAX;\n\n // The top of the band first: most images fit at best quality, and\n // when they do nothing else should be tried.\n const best = yield {size, quality: high};\n if (best.bytes <= max.bytes) return;\n\n let fitted = false;\n for (let step = 0; step < QUALITY_STEPS; step++) {\n const quality = bisect(low, high);\n const outcome = yield {size, quality};\n if (outcome.bytes <= max.bytes) {\n fitted = true;\n low = quality; // it fits — try to buy quality back\n } else {\n high = quality;\n }\n }\n if (fitted) return;\n }\n\n // ⚠️ THE LONG EDGE SHRINKS, AND THE RATIO IS KEPT BY `fitWithin`, so a\n // downscale pass can never change the crop's shape. A crop that came\n // back a different aspect than the window showed would be the worst\n // kind of silent failure here.\n const next = Math.max(size.width, size.height) * DOWNSCALE_FACTOR;\n size = fitWithin(size, {width: next, height: next});\n if (size.width < 1 || size.height < 1) return;\n }\n}\n\n/**\n * Drives {@link encodePlan} with a real encoder and returns the best result\n * that met the ceiling — or the smallest one produced, if none did.\n *\n * ⚠️ IT RETURNS THE LAST ATTEMPT EVEN WHEN NOTHING FIT, with `withinBudget`\n * false. Handing back nothing would mean a user who cropped a huge PNG gets a\n * spinner and no file; handing back the smallest we managed, clearly flagged,\n * lets the caller decide between warning and refusing.\n */\nexport async function runEncodePlan<T>(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n encode: (attempt: IEncodeAttempt) => Promise<{bytes: number; value: T}>,\n): Promise<{value: T; bytes: number; size: ICropSize; quality: number; withinBudget: boolean}> {\n const plan = encodePlan(natural, max, format);\n let best: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n let smallest: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n\n // ⚠️ `step` IS A `const` DECLARED INSIDE THE LOOP, AND THAT IS NOT STYLE.\n // Hoisted to a reassigned `let`, TypeScript stops narrowing it across the\n // back edge and `step.value` comes out as `IEncodeAttempt | void` — which\n // then needs a cast, and a cast here would be asserting exactly the thing\n // the `done` check is supposed to prove.\n let outcome: IEncodeOutcome | undefined;\n for (;;) {\n const step = outcome === undefined ? plan.next() : plan.next(outcome);\n if (step.done === true) break;\n\n const attempt = step.value;\n const {bytes, value} = await encode(attempt);\n const record = {value, bytes, size: attempt.size, quality: attempt.quality};\n\n if (bytes <= max.bytes) best = record;\n if (!smallest || bytes < smallest.bytes) smallest = record;\n\n outcome = {bytes};\n }\n\n const chosen = best ?? smallest;\n if (!chosen) {\n throw new Error(\"[ImageCropper] the encode plan produced no attempts at all\");\n }\n return {...chosen, withinBudget: chosen.bytes <= max.bytes};\n}\n"],"names":["fitWithin","MAX_DOWNSCALE_PASSES","QUALITY_MIN","QUALITY_MAX","QUALITY_STEPS","DOWNSCALE_FACTOR"],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ import { MAX_DOWNSCALE_PASSES, QUALITY_MAX, QUALITY_STEPS, DOWNSCALE_FACTOR, QUALITY_MIN } from './constants.js';
2
+ import { fitWithin } from './geometry.js';
3
+
4
+ const isLossless = (format) => format === "png";
5
+ const bisect = (low, high) => (low + high) / 2;
6
+ function* encodePlan(natural, max, format) {
7
+ let size = fitWithin(natural, { width: max.width, height: max.height });
8
+ for (let pass = 0; pass <= MAX_DOWNSCALE_PASSES; pass++) {
9
+ if (isLossless(format)) {
10
+ const outcome = yield { size, quality: 1 };
11
+ if (outcome.bytes <= max.bytes) return;
12
+ } else {
13
+ let low = QUALITY_MIN;
14
+ let high = QUALITY_MAX;
15
+ const best = yield { size, quality: high };
16
+ if (best.bytes <= max.bytes) return;
17
+ let fitted = false;
18
+ for (let step = 0; step < QUALITY_STEPS; step++) {
19
+ const quality = bisect(low, high);
20
+ const outcome = yield { size, quality };
21
+ if (outcome.bytes <= max.bytes) {
22
+ fitted = true;
23
+ low = quality;
24
+ } else {
25
+ high = quality;
26
+ }
27
+ }
28
+ if (fitted) return;
29
+ }
30
+ const next = Math.max(size.width, size.height) * DOWNSCALE_FACTOR;
31
+ size = fitWithin(size, { width: next, height: next });
32
+ if (size.width < 1 || size.height < 1) return;
33
+ }
34
+ }
35
+ async function runEncodePlan(natural, max, format, encode) {
36
+ const plan = encodePlan(natural, max, format);
37
+ let best;
38
+ let smallest;
39
+ let outcome;
40
+ for (; ; ) {
41
+ const step = outcome === void 0 ? plan.next() : plan.next(outcome);
42
+ if (step.done === true) break;
43
+ const attempt = step.value;
44
+ const { bytes, value } = await encode(attempt);
45
+ const record = { value, bytes, size: attempt.size, quality: attempt.quality };
46
+ if (bytes <= max.bytes) best = record;
47
+ if (!smallest || bytes < smallest.bytes) smallest = record;
48
+ outcome = { bytes };
49
+ }
50
+ const chosen = best ?? smallest;
51
+ if (!chosen) {
52
+ throw new Error("[ImageCropper] the encode plan produced no attempts at all");
53
+ }
54
+ return { ...chosen, withinBudget: chosen.bytes <= max.bytes };
55
+ }
56
+
57
+ export { bisect, encodePlan, isLossless, runEncodePlan };
58
+ //# sourceMappingURL=compression.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compression.js","sources":["../../../../src/components/ImageCropper/compression.ts"],"sourcesContent":["import type {CropOutputFormat, ICropSize} from \"@lmb-it/kitsconcerto-types\";\nimport {\n DOWNSCALE_FACTOR,\n MAX_DOWNSCALE_PASSES,\n QUALITY_MAX,\n QUALITY_MIN,\n QUALITY_STEPS,\n} from \"./constants\";\nimport {fitWithin} from \"./geometry\";\n\n/**\n * How hard to squeeze, and when to stop squeezing and shrink instead.\n *\n * ⛔ THE POLICY IS HERE AND THE ENCODER IS NOT, ON PURPOSE. Web encodes with\n * `canvas.toBlob`, native with expo-image-manipulator; neither can run in the\n * other's runtime, and neither can be unit-tested without one. What CAN be\n * tested — and what decides whether a 4 MB photo comes back at 480 KB or at\n * 2 MB — is the search: how the quality band is walked, when the band is\n * exhausted, and how far to scale down after that. Both platforms drive the\n * same plan from here and only supply `encode`.\n */\n\n/** One attempt: what the caller's encoder is asked to produce. */\nexport interface IEncodeAttempt {\n size: ICropSize;\n /** 0–1. Ignored by lossless formats. */\n quality: number;\n}\n\n/** What an encoder reports back. */\nexport interface IEncodeOutcome {\n bytes: number;\n}\n\n/** Lossless formats cannot trade quality for bytes — only dimensions. */\nexport const isLossless = (format: CropOutputFormat): boolean => format === \"png\";\n\n/**\n * The quality to try on step `step` of a binary search that has so far bracketed\n * the answer to [`low`, `high`].\n *\n * ⚠️ A PLAIN MIDPOINT, NOT A GUESS FROM THE BYTE RATIO. Encoded size is not\n * linear in quality — it is flat near the top of the band and falls off a cliff\n * near the bottom — so a ratio-based jump overshoots into the blocky region on\n * the first move. Bisection is slower by a few encodes and lands where it says.\n */\nexport const bisect = (low: number, high: number): number => (low + high) / 2;\n\n/**\n * The plan for one encode pass, as a sequence of (size, quality) attempts.\n *\n * Walks the quality band from {@link QUALITY_MAX} down by bisection for\n * {@link QUALITY_STEPS} steps, keeping the best attempt that fits. When the\n * whole band is over the ceiling it scales the image down by\n * {@link DOWNSCALE_FACTOR} and walks it again, up to\n * {@link MAX_DOWNSCALE_PASSES} times.\n *\n * ⚠️ IT IS A GENERATOR, NOT A LOOP THAT CALLS YOU BACK. The caller owns the\n * encoder, the async, and the cancellation; this owns only the order to try\n * things in. That is what lets the same policy be driven by `canvas.toBlob` on\n * one platform and a native module on the other, and be tested with neither.\n */\nexport function* encodePlan(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n): Generator<IEncodeAttempt, void, IEncodeOutcome> {\n let size = fitWithin(natural, {width: max.width, height: max.height});\n\n for (let pass = 0; pass <= MAX_DOWNSCALE_PASSES; pass++) {\n if (isLossless(format)) {\n // ⛔ NO QUALITY KNOB TO TURN. A single attempt per size; if it is\n // over, the only lever left is the next downscale pass.\n const outcome = yield {size, quality: 1};\n if (outcome.bytes <= max.bytes) return;\n } else {\n let low = QUALITY_MIN;\n let high = QUALITY_MAX;\n\n // The top of the band first: most images fit at best quality, and\n // when they do nothing else should be tried.\n const best = yield {size, quality: high};\n if (best.bytes <= max.bytes) return;\n\n let fitted = false;\n for (let step = 0; step < QUALITY_STEPS; step++) {\n const quality = bisect(low, high);\n const outcome = yield {size, quality};\n if (outcome.bytes <= max.bytes) {\n fitted = true;\n low = quality; // it fits — try to buy quality back\n } else {\n high = quality;\n }\n }\n if (fitted) return;\n }\n\n // ⚠️ THE LONG EDGE SHRINKS, AND THE RATIO IS KEPT BY `fitWithin`, so a\n // downscale pass can never change the crop's shape. A crop that came\n // back a different aspect than the window showed would be the worst\n // kind of silent failure here.\n const next = Math.max(size.width, size.height) * DOWNSCALE_FACTOR;\n size = fitWithin(size, {width: next, height: next});\n if (size.width < 1 || size.height < 1) return;\n }\n}\n\n/**\n * Drives {@link encodePlan} with a real encoder and returns the best result\n * that met the ceiling — or the smallest one produced, if none did.\n *\n * ⚠️ IT RETURNS THE LAST ATTEMPT EVEN WHEN NOTHING FIT, with `withinBudget`\n * false. Handing back nothing would mean a user who cropped a huge PNG gets a\n * spinner and no file; handing back the smallest we managed, clearly flagged,\n * lets the caller decide between warning and refusing.\n */\nexport async function runEncodePlan<T>(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n encode: (attempt: IEncodeAttempt) => Promise<{bytes: number; value: T}>,\n): Promise<{value: T; bytes: number; size: ICropSize; quality: number; withinBudget: boolean}> {\n const plan = encodePlan(natural, max, format);\n let best: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n let smallest: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n\n // ⚠️ `step` IS A `const` DECLARED INSIDE THE LOOP, AND THAT IS NOT STYLE.\n // Hoisted to a reassigned `let`, TypeScript stops narrowing it across the\n // back edge and `step.value` comes out as `IEncodeAttempt | void` — which\n // then needs a cast, and a cast here would be asserting exactly the thing\n // the `done` check is supposed to prove.\n let outcome: IEncodeOutcome | undefined;\n for (;;) {\n const step = outcome === undefined ? plan.next() : plan.next(outcome);\n if (step.done === true) break;\n\n const attempt = step.value;\n const {bytes, value} = await encode(attempt);\n const record = {value, bytes, size: attempt.size, quality: attempt.quality};\n\n if (bytes <= max.bytes) best = record;\n if (!smallest || bytes < smallest.bytes) smallest = record;\n\n outcome = {bytes};\n }\n\n const chosen = best ?? smallest;\n if (!chosen) {\n throw new Error(\"[ImageCropper] the encode plan produced no attempts at all\");\n }\n return {...chosen, withinBudget: chosen.bytes <= max.bytes};\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_ASPECT = [1, 1];
4
+ const DEFAULT_MAX_BYTES = 500 * 1024;
5
+ const DEFAULT_FORMAT = "jpeg";
6
+ const DEFAULT_MAX_ZOOM = 8;
7
+ const MIN_CROP_SIZE = { width: 48, height: 48 };
8
+ const QUALITY_MAX = 0.92;
9
+ const QUALITY_MIN = 0.4;
10
+ const QUALITY_STEPS = 7;
11
+ const DOWNSCALE_FACTOR = 0.8;
12
+ const MAX_DOWNSCALE_PASSES = 6;
13
+ const DEFAULT_LABELS = {
14
+ cancel: "Cancel",
15
+ save: "Save photo",
16
+ rotate: "Rotate",
17
+ reset: "Reset",
18
+ zoom: "Zoom",
19
+ preview: "Preview",
20
+ dropZone: "Add a photo",
21
+ processing: "Saving\u2026"
22
+ };
23
+ const STAGE_ASPECT_NATIVE = 390 / 404;
24
+ const STAGE_INSET_NATIVE = 22;
25
+ const SQUARE_CROP_FACTOR = 0.92;
26
+ const CROP_ACCENT = "#20AAB0";
27
+ const CROP_ACCENT_SOFT = "#5FD0D5";
28
+ const CROP_DANGER = "#FCA5A5";
29
+
30
+ exports.CROP_ACCENT = CROP_ACCENT;
31
+ exports.CROP_ACCENT_SOFT = CROP_ACCENT_SOFT;
32
+ exports.CROP_DANGER = CROP_DANGER;
33
+ exports.DEFAULT_ASPECT = DEFAULT_ASPECT;
34
+ exports.DEFAULT_FORMAT = DEFAULT_FORMAT;
35
+ exports.DEFAULT_LABELS = DEFAULT_LABELS;
36
+ exports.DEFAULT_MAX_BYTES = DEFAULT_MAX_BYTES;
37
+ exports.DEFAULT_MAX_ZOOM = DEFAULT_MAX_ZOOM;
38
+ exports.DOWNSCALE_FACTOR = DOWNSCALE_FACTOR;
39
+ exports.MAX_DOWNSCALE_PASSES = MAX_DOWNSCALE_PASSES;
40
+ exports.MIN_CROP_SIZE = MIN_CROP_SIZE;
41
+ exports.QUALITY_MAX = QUALITY_MAX;
42
+ exports.QUALITY_MIN = QUALITY_MIN;
43
+ exports.QUALITY_STEPS = QUALITY_STEPS;
44
+ exports.SQUARE_CROP_FACTOR = SQUARE_CROP_FACTOR;
45
+ exports.STAGE_ASPECT_NATIVE = STAGE_ASPECT_NATIVE;
46
+ exports.STAGE_INSET_NATIVE = STAGE_INSET_NATIVE;
47
+ //# sourceMappingURL=constants.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.cjs","sources":["../../../../src/components/ImageCropper/constants.ts"],"sourcesContent":["import type {CropAspect, CropOutputFormat} from \"@lmb-it/kitsconcerto-types\";\n\n/** A square, which is what an avatar wants. */\nexport const DEFAULT_ASPECT: CropAspect = [1, 1];\n\n/**\n * 500 KB.\n *\n * ⚠️ BYTES, NOT \"KB\" — a number named `500` that turns out to mean kilobytes is\n * how an upload limit gets enforced a thousand times too loosely. Every byte\n * ceiling in this component is in bytes, and `maxBytes` says so in its name.\n */\nexport const DEFAULT_MAX_BYTES = 500 * 1024;\n\nexport const DEFAULT_FORMAT: CropOutputFormat = \"jpeg\";\n\n/** Zoom ceiling, as a multiple of the scale that just covers the window. */\nexport const DEFAULT_MAX_ZOOM = 8;\n\n/** How much one wheel notch moves the zoom. */\nexport const WHEEL_ZOOM_STEP = 0.0015;\n\n/** The smallest crop window the grips may produce, in viewport units. */\nexport const MIN_CROP_SIZE = {width: 48, height: 48};\n\n/** Hit size of a resize grip on web, in viewport units. The mockup's `HIT`. */\nexport const HANDLE_HIT_SIZE = 30;\n\n/* ─── Encoder search ──────────────────────────────────────────────────────── */\n\n/**\n * Quality band for the lossy encoders.\n *\n * ⚠️ THE FLOOR IS 0.4, NOT 0.1. Below roughly 0.4 a JPEG of a face stops being\n * a smaller picture and becomes a different, blocky one — and a cropper that\n * silently returns that has not honoured the ceiling, it has destroyed the\n * photo to meet a number. When the floor is not enough the search scales the\n * image DOWN instead, which loses detail evenly rather than in 8×8 squares.\n */\nexport const QUALITY_MAX = 0.92;\nexport const QUALITY_MIN = 0.4;\n\n/** Binary-search steps over the quality band. Seven gets within ~0.004. */\nexport const QUALITY_STEPS = 7;\n\n/** Each downscale pass after the quality floor, as a factor on the long edge. */\nexport const DOWNSCALE_FACTOR = 0.8;\n\n/** How many downscale passes before giving up and reporting `too-large`. */\nexport const MAX_DOWNSCALE_PASSES = 6;\n\n/** The labels the component falls back to when a consumer passes none. */\nexport const DEFAULT_LABELS = {\n cancel: \"Cancel\",\n save: \"Save photo\",\n rotate: \"Rotate\",\n reset: \"Reset\",\n zoom: \"Zoom\",\n preview: \"Preview\",\n dropZone: \"Add a photo\",\n processing: \"Saving…\",\n} as const;\n\n/* ─── The mockup's stage ──────────────────────────────────────────────────────\n Measured off `UML/UI/ClientPortal/AlphaMatch - Image Cropper.dc.html`, which\n is the design source for every number below.\n\n ⚠️ THE STAGE IS A SHAPE, NOT A SIZE. The mockup draws 588×404 on desktop and\n 390×404 on a phone, but a component that hard-codes 588 cannot sit in a\n consumer's column. What carries over is the RATIO; the stage then measures\n itself and the geometry runs against whatever it really got. */\n\n/** 588 ÷ 404 — the desktop stage's shape. */\nexport const STAGE_ASPECT_WEB = 588 / 404;\n\n/** 390 ÷ 404 — the phone stage's shape, very nearly square. */\nexport const STAGE_ASPECT_NATIVE = 390 / 404;\n\n/**\n * The dark margin the crop window is inset by, in viewport units.\n *\n * ⛔ IT IS NOT DECORATION — WITHOUT IT THE CORNER GRIPS ARE HALF OFF-STAGE. A\n * window that fills the viewport puts its eight grips exactly on the boundary,\n * and the stage clips `overflow`, so half of every knob is invisible and the\n * corner hit areas run off the edge. The mockup's padding is what makes them\n * reachable.\n */\nexport const STAGE_INSET_WEB = 30;\nexport const STAGE_INSET_NATIVE = 22;\n\n/**\n * The square window is drawn at 92% of the largest square that fits.\n *\n * ⚠️ SQUARE ONLY, AND THE MOCKUP IS EXPLICIT ABOUT IT: `* (a === 1 ? 0.92 : 1)`.\n * A 1:1 window inset by the padding alone is tall enough to touch both margins\n * and reads as though it is jammed in; a cover at 8:3 is limited by WIDTH and\n * already has air above and below, so shrinking it would only waste stage.\n */\nexport const SQUARE_CROP_FACTOR = 0.92;\n\n/* ─── The mockup's palette ───────────────────────────────────────────────────\n ⚠️ LITERALS, NOT TOKENS, AND ON PURPOSE. The stage is dark in BOTH colour\n modes — a photo has to read against it either way — so these must not invert.\n The themeable surfaces go through `defaultTheme`; what is here is the chrome\n the mockup pins to a specific colour. */\n\n/** The teal every primary affordance on the stage is drawn in. */\nexport const CROP_ACCENT = \"#20AAB0\";\n\n/** The lighter teal for icons and tertiary text on the dark stage. */\nexport const CROP_ACCENT_SOFT = \"#5FD0D5\";\n\n/** The flattened teal a disabled Save wears on web. */\nexport const CROP_ACCENT_MUTED = \"#A7DADD\";\n\n/** Hairlines and button borders on the light chrome. */\nexport const CROP_BORDER = \"#E5E7EB\";\n\n/** The lighter hairline under the header and over the footer. */\nexport const CROP_DIVIDER = \"#F0F1F3\";\n\n/** Body text on the light chrome. */\nexport const CROP_INK = \"#374151\";\n\n/** Secondary text on the light chrome. */\nexport const CROP_INK_SUBTLE = \"#9CA3AF\";\n\n/** The error sentence's colour on the dark stage. */\nexport const CROP_DANGER = \"#FCA5A5\";\n\n/* ─── Grip geometry ─────────────────────────────────────────────────────────\n ⚠️ TWO SHAPES, NOT EIGHT OF ONE. The mockup draws corners as ringed circles\n and edges as short bars, which is also what tells a user which axis a grip\n moves. Eight identical knobs lose that. */\n\n/** Corner knob diameter. */\nexport const HANDLE_KNOB_WEB = 16;\nexport const HANDLE_KNOB_NATIVE = 20;\n\n/** An edge bar's long and short sides. */\nexport const HANDLE_BAR_LONG = 28;\nexport const HANDLE_BAR_SHORT = 7;\n\n/** The ring drawn around a corner knob. */\nexport const HANDLE_RING_WIDTH = 3;\n\n/**\n * A grip's touch target, which is larger than the thing drawn.\n *\n * ⚠️ 44 ON NATIVE BECAUSE A FINGER IS NOT A CURSOR — the mockup's own `HIT`\n * splits the same way, and 30 under a thumb is a grip that cannot be grabbed.\n */\nexport const HANDLE_HIT_SIZE_NATIVE = 44;\n"],"names":[],"mappings":";;AAGO,MAAM,cAAA,GAA6B,CAAC,CAAA,EAAG,CAAC;AASxC,MAAM,oBAAoB,GAAA,GAAM;AAEhC,MAAM,cAAA,GAAmC;AAGzC,MAAM,gBAAA,GAAmB;AAMzB,MAAM,aAAA,GAAgB,EAAC,KAAA,EAAO,EAAA,EAAI,QAAQ,EAAA;AAgB1C,MAAM,WAAA,GAAc;AACpB,MAAM,WAAA,GAAc;AAGpB,MAAM,aAAA,GAAgB;AAGtB,MAAM,gBAAA,GAAmB;AAGzB,MAAM,oBAAA,GAAuB;AAG7B,MAAM,cAAA,GAAiB;AAAA,EAC1B,MAAA,EAAQ,QAAA;AAAA,EACR,IAAA,EAAM,YAAA;AAAA,EACN,MAAA,EAAQ,QAAA;AAAA,EACR,KAAA,EAAO,OAAA;AAAA,EACP,IAAA,EAAM,MAAA;AAAA,EACN,OAAA,EAAS,SAAA;AAAA,EACT,QAAA,EAAU,aAAA;AAAA,EACV,UAAA,EAAY;AAChB;AAeO,MAAM,sBAAsB,GAAA,GAAM;AAYlC,MAAM,kBAAA,GAAqB;AAU3B,MAAM,kBAAA,GAAqB;AAS3B,MAAM,WAAA,GAAc;AAGpB,MAAM,gBAAA,GAAmB;AAkBzB,MAAM,WAAA,GAAc;;;;;;;;;;;;;;;;;;;;"}
@@ -0,0 +1,29 @@
1
+ const DEFAULT_ASPECT = [1, 1];
2
+ const DEFAULT_MAX_BYTES = 500 * 1024;
3
+ const DEFAULT_FORMAT = "jpeg";
4
+ const DEFAULT_MAX_ZOOM = 8;
5
+ const MIN_CROP_SIZE = { width: 48, height: 48 };
6
+ const QUALITY_MAX = 0.92;
7
+ const QUALITY_MIN = 0.4;
8
+ const QUALITY_STEPS = 7;
9
+ const DOWNSCALE_FACTOR = 0.8;
10
+ const MAX_DOWNSCALE_PASSES = 6;
11
+ const DEFAULT_LABELS = {
12
+ cancel: "Cancel",
13
+ save: "Save photo",
14
+ rotate: "Rotate",
15
+ reset: "Reset",
16
+ zoom: "Zoom",
17
+ preview: "Preview",
18
+ dropZone: "Add a photo",
19
+ processing: "Saving\u2026"
20
+ };
21
+ const STAGE_ASPECT_NATIVE = 390 / 404;
22
+ const STAGE_INSET_NATIVE = 22;
23
+ const SQUARE_CROP_FACTOR = 0.92;
24
+ const CROP_ACCENT = "#20AAB0";
25
+ const CROP_ACCENT_SOFT = "#5FD0D5";
26
+ const CROP_DANGER = "#FCA5A5";
27
+
28
+ export { CROP_ACCENT, CROP_ACCENT_SOFT, CROP_DANGER, DEFAULT_ASPECT, DEFAULT_FORMAT, DEFAULT_LABELS, DEFAULT_MAX_BYTES, DEFAULT_MAX_ZOOM, DOWNSCALE_FACTOR, MAX_DOWNSCALE_PASSES, MIN_CROP_SIZE, QUALITY_MAX, QUALITY_MIN, QUALITY_STEPS, SQUARE_CROP_FACTOR, STAGE_ASPECT_NATIVE, STAGE_INSET_NATIVE };
29
+ //# sourceMappingURL=constants.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.js","sources":["../../../../src/components/ImageCropper/constants.ts"],"sourcesContent":["import type {CropAspect, CropOutputFormat} from \"@lmb-it/kitsconcerto-types\";\n\n/** A square, which is what an avatar wants. */\nexport const DEFAULT_ASPECT: CropAspect = [1, 1];\n\n/**\n * 500 KB.\n *\n * ⚠️ BYTES, NOT \"KB\" — a number named `500` that turns out to mean kilobytes is\n * how an upload limit gets enforced a thousand times too loosely. Every byte\n * ceiling in this component is in bytes, and `maxBytes` says so in its name.\n */\nexport const DEFAULT_MAX_BYTES = 500 * 1024;\n\nexport const DEFAULT_FORMAT: CropOutputFormat = \"jpeg\";\n\n/** Zoom ceiling, as a multiple of the scale that just covers the window. */\nexport const DEFAULT_MAX_ZOOM = 8;\n\n/** How much one wheel notch moves the zoom. */\nexport const WHEEL_ZOOM_STEP = 0.0015;\n\n/** The smallest crop window the grips may produce, in viewport units. */\nexport const MIN_CROP_SIZE = {width: 48, height: 48};\n\n/** Hit size of a resize grip on web, in viewport units. The mockup's `HIT`. */\nexport const HANDLE_HIT_SIZE = 30;\n\n/* ─── Encoder search ──────────────────────────────────────────────────────── */\n\n/**\n * Quality band for the lossy encoders.\n *\n * ⚠️ THE FLOOR IS 0.4, NOT 0.1. Below roughly 0.4 a JPEG of a face stops being\n * a smaller picture and becomes a different, blocky one — and a cropper that\n * silently returns that has not honoured the ceiling, it has destroyed the\n * photo to meet a number. When the floor is not enough the search scales the\n * image DOWN instead, which loses detail evenly rather than in 8×8 squares.\n */\nexport const QUALITY_MAX = 0.92;\nexport const QUALITY_MIN = 0.4;\n\n/** Binary-search steps over the quality band. Seven gets within ~0.004. */\nexport const QUALITY_STEPS = 7;\n\n/** Each downscale pass after the quality floor, as a factor on the long edge. */\nexport const DOWNSCALE_FACTOR = 0.8;\n\n/** How many downscale passes before giving up and reporting `too-large`. */\nexport const MAX_DOWNSCALE_PASSES = 6;\n\n/** The labels the component falls back to when a consumer passes none. */\nexport const DEFAULT_LABELS = {\n cancel: \"Cancel\",\n save: \"Save photo\",\n rotate: \"Rotate\",\n reset: \"Reset\",\n zoom: \"Zoom\",\n preview: \"Preview\",\n dropZone: \"Add a photo\",\n processing: \"Saving…\",\n} as const;\n\n/* ─── The mockup's stage ──────────────────────────────────────────────────────\n Measured off `UML/UI/ClientPortal/AlphaMatch - Image Cropper.dc.html`, which\n is the design source for every number below.\n\n ⚠️ THE STAGE IS A SHAPE, NOT A SIZE. The mockup draws 588×404 on desktop and\n 390×404 on a phone, but a component that hard-codes 588 cannot sit in a\n consumer's column. What carries over is the RATIO; the stage then measures\n itself and the geometry runs against whatever it really got. */\n\n/** 588 ÷ 404 — the desktop stage's shape. */\nexport const STAGE_ASPECT_WEB = 588 / 404;\n\n/** 390 ÷ 404 — the phone stage's shape, very nearly square. */\nexport const STAGE_ASPECT_NATIVE = 390 / 404;\n\n/**\n * The dark margin the crop window is inset by, in viewport units.\n *\n * ⛔ IT IS NOT DECORATION — WITHOUT IT THE CORNER GRIPS ARE HALF OFF-STAGE. A\n * window that fills the viewport puts its eight grips exactly on the boundary,\n * and the stage clips `overflow`, so half of every knob is invisible and the\n * corner hit areas run off the edge. The mockup's padding is what makes them\n * reachable.\n */\nexport const STAGE_INSET_WEB = 30;\nexport const STAGE_INSET_NATIVE = 22;\n\n/**\n * The square window is drawn at 92% of the largest square that fits.\n *\n * ⚠️ SQUARE ONLY, AND THE MOCKUP IS EXPLICIT ABOUT IT: `* (a === 1 ? 0.92 : 1)`.\n * A 1:1 window inset by the padding alone is tall enough to touch both margins\n * and reads as though it is jammed in; a cover at 8:3 is limited by WIDTH and\n * already has air above and below, so shrinking it would only waste stage.\n */\nexport const SQUARE_CROP_FACTOR = 0.92;\n\n/* ─── The mockup's palette ───────────────────────────────────────────────────\n ⚠️ LITERALS, NOT TOKENS, AND ON PURPOSE. The stage is dark in BOTH colour\n modes — a photo has to read against it either way — so these must not invert.\n The themeable surfaces go through `defaultTheme`; what is here is the chrome\n the mockup pins to a specific colour. */\n\n/** The teal every primary affordance on the stage is drawn in. */\nexport const CROP_ACCENT = \"#20AAB0\";\n\n/** The lighter teal for icons and tertiary text on the dark stage. */\nexport const CROP_ACCENT_SOFT = \"#5FD0D5\";\n\n/** The flattened teal a disabled Save wears on web. */\nexport const CROP_ACCENT_MUTED = \"#A7DADD\";\n\n/** Hairlines and button borders on the light chrome. */\nexport const CROP_BORDER = \"#E5E7EB\";\n\n/** The lighter hairline under the header and over the footer. */\nexport const CROP_DIVIDER = \"#F0F1F3\";\n\n/** Body text on the light chrome. */\nexport const CROP_INK = \"#374151\";\n\n/** Secondary text on the light chrome. */\nexport const CROP_INK_SUBTLE = \"#9CA3AF\";\n\n/** The error sentence's colour on the dark stage. */\nexport const CROP_DANGER = \"#FCA5A5\";\n\n/* ─── Grip geometry ─────────────────────────────────────────────────────────\n ⚠️ TWO SHAPES, NOT EIGHT OF ONE. The mockup draws corners as ringed circles\n and edges as short bars, which is also what tells a user which axis a grip\n moves. Eight identical knobs lose that. */\n\n/** Corner knob diameter. */\nexport const HANDLE_KNOB_WEB = 16;\nexport const HANDLE_KNOB_NATIVE = 20;\n\n/** An edge bar's long and short sides. */\nexport const HANDLE_BAR_LONG = 28;\nexport const HANDLE_BAR_SHORT = 7;\n\n/** The ring drawn around a corner knob. */\nexport const HANDLE_RING_WIDTH = 3;\n\n/**\n * A grip's touch target, which is larger than the thing drawn.\n *\n * ⚠️ 44 ON NATIVE BECAUSE A FINGER IS NOT A CURSOR — the mockup's own `HIT`\n * splits the same way, and 30 under a thumb is a grip that cannot be grabbed.\n */\nexport const HANDLE_HIT_SIZE_NATIVE = 44;\n"],"names":[],"mappings":"AAGO,MAAM,cAAA,GAA6B,CAAC,CAAA,EAAG,CAAC;AASxC,MAAM,oBAAoB,GAAA,GAAM;AAEhC,MAAM,cAAA,GAAmC;AAGzC,MAAM,gBAAA,GAAmB;AAMzB,MAAM,aAAA,GAAgB,EAAC,KAAA,EAAO,EAAA,EAAI,QAAQ,EAAA;AAgB1C,MAAM,WAAA,GAAc;AACpB,MAAM,WAAA,GAAc;AAGpB,MAAM,aAAA,GAAgB;AAGtB,MAAM,gBAAA,GAAmB;AAGzB,MAAM,oBAAA,GAAuB;AAG7B,MAAM,cAAA,GAAiB;AAAA,EAC1B,MAAA,EAAQ,QAAA;AAAA,EACR,IAAA,EAAM,YAAA;AAAA,EACN,MAAA,EAAQ,QAAA;AAAA,EACR,KAAA,EAAO,OAAA;AAAA,EACP,IAAA,EAAM,MAAA;AAAA,EACN,OAAA,EAAS,SAAA;AAAA,EACT,QAAA,EAAU,aAAA;AAAA,EACV,UAAA,EAAY;AAChB;AAeO,MAAM,sBAAsB,GAAA,GAAM;AAYlC,MAAM,kBAAA,GAAqB;AAU3B,MAAM,kBAAA,GAAqB;AAS3B,MAAM,WAAA,GAAc;AAGpB,MAAM,gBAAA,GAAmB;AAkBzB,MAAM,WAAA,GAAc;;;;"}
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ var geometry = require('./geometry.cjs');
4
+ var constants = require('./constants.cjs');
5
+
6
+ const scaleRange = (state) => {
7
+ const oriented = geometry.orientedSize(state.config.source, state.rotation);
8
+ const min = geometry.minScaleToCover(oriented, state.crop);
9
+ return { min, max: min * state.config.maxZoom };
10
+ };
11
+ const zoomFraction = (state) => {
12
+ const { min, max } = scaleRange(state);
13
+ if (!Number.isFinite(min) || max <= min) return 0;
14
+ return geometry.clamp((state.transform.scale - min) / (max - min), 0, 1);
15
+ };
16
+ const settle = (state) => {
17
+ const oriented = geometry.orientedSize(state.config.source, state.rotation);
18
+ const stage = { x: 0, y: 0, ...state.config.viewport };
19
+ const crop = geometry.clampRectInside(state.crop, stage);
20
+ const min = geometry.minScaleToCover(oriented, crop);
21
+ const scale = geometry.clamp(state.transform.scale, min, min * state.config.maxZoom);
22
+ const scaled = geometry.zoomAbout(
23
+ state.transform,
24
+ { x: crop.x + crop.width / 2, y: crop.y + crop.height / 2 },
25
+ scale
26
+ );
27
+ return { ...state, crop, transform: geometry.clampTransformToCover(scaled, oriented, crop) };
28
+ };
29
+ const restingSize = (config) => {
30
+ const pad = config.inset ?? 0;
31
+ const bounds = {
32
+ width: Math.max(config.viewport.width - pad * 2, 1),
33
+ height: Math.max(config.viewport.height - pad * 2, 1)
34
+ };
35
+ const fitted = geometry.largestRectOfRatio(bounds, config.ratio);
36
+ const factor = config.ratio === 1 ? config.squareFactor ?? 1 : 1;
37
+ return { width: fitted.width * factor, height: fitted.height * factor };
38
+ };
39
+ const reframe = (config, rotation) => {
40
+ const oriented = geometry.orientedSize(config.source, rotation);
41
+ const crop = geometry.centerRect(restingSize(config), config.viewport);
42
+ const scale = geometry.minScaleToCover(oriented, crop);
43
+ const img = geometry.imageRect(oriented, { scale, x: 0, y: 0 });
44
+ return settle({
45
+ config,
46
+ rotation,
47
+ crop,
48
+ transform: {
49
+ scale,
50
+ x: crop.x + (crop.width - img.width) / 2,
51
+ y: crop.y + (crop.height - img.height) / 2
52
+ }
53
+ });
54
+ };
55
+ const initialCropperState = (config) => reframe(config, 0);
56
+ const cropperReducer = (state, action) => {
57
+ switch (action.type) {
58
+ case "configure": {
59
+ const config = { ...state.config, ...action.config };
60
+ const previous = state.config.viewport;
61
+ const next = config.viewport;
62
+ const framingChanged = config.ratio !== state.config.ratio || config.source.width !== state.config.source.width || config.source.height !== state.config.source.height;
63
+ if (framingChanged) return reframe(config, state.rotation);
64
+ const resized = next.width !== previous.width || next.height !== previous.height;
65
+ if (!resized) return settle({ ...state, config });
66
+ if (previous.width <= 1 || previous.height <= 1) return reframe(config, state.rotation);
67
+ const k = Math.min(next.width / previous.width, next.height / previous.height);
68
+ return settle({
69
+ ...state,
70
+ config,
71
+ crop: {
72
+ x: state.crop.x * k,
73
+ y: state.crop.y * k,
74
+ width: state.crop.width * k,
75
+ height: state.crop.height * k
76
+ },
77
+ transform: {
78
+ scale: state.transform.scale * k,
79
+ x: state.transform.x * k,
80
+ y: state.transform.y * k
81
+ }
82
+ });
83
+ }
84
+ case "pan":
85
+ return settle({
86
+ ...state,
87
+ transform: {
88
+ ...state.transform,
89
+ x: state.transform.x + action.dx,
90
+ y: state.transform.y + action.dy
91
+ }
92
+ });
93
+ case "zoomAt": {
94
+ const { min, max } = scaleRange(state);
95
+ const scale = geometry.clamp(action.scale, min, max);
96
+ return settle({ ...state, transform: geometry.zoomAbout(state.transform, action.anchor, scale) });
97
+ }
98
+ case "setZoom": {
99
+ const { min, max } = scaleRange(state);
100
+ const scale = min + geometry.clamp(action.value, 0, 1) * (max - min);
101
+ const anchor = {
102
+ x: state.crop.x + state.crop.width / 2,
103
+ y: state.crop.y + state.crop.height / 2
104
+ };
105
+ return settle({ ...state, transform: geometry.zoomAbout(state.transform, anchor, scale) });
106
+ }
107
+ case "resize": {
108
+ if (!state.config.resizable) return state;
109
+ const crop = geometry.resizeRect(state.crop, action.handle, action.pointer, {
110
+ ratio: state.config.ratio,
111
+ min: constants.MIN_CROP_SIZE,
112
+ bounds: { x: 0, y: 0, ...state.config.viewport }
113
+ });
114
+ return settle({ ...state, crop });
115
+ }
116
+ case "rotate":
117
+ return reframe(state.config, geometry.rotateCw(state.rotation));
118
+ case "reset":
119
+ return reframe(state.config, 0);
120
+ default:
121
+ return state;
122
+ }
123
+ };
124
+
125
+ exports.cropperReducer = cropperReducer;
126
+ exports.initialCropperState = initialCropperState;
127
+ exports.scaleRange = scaleRange;
128
+ exports.zoomFraction = zoomFraction;
129
+ //# sourceMappingURL=cropperState.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cropperState.cjs","sources":["../../../../src/components/ImageCropper/cropperState.ts"],"sourcesContent":["import type {\n CropHandle,\n CropRotation,\n ICropPoint,\n ICropRect,\n ICropSize,\n ICropTransform,\n} from \"@lmb-it/kitsconcerto-types\";\nimport {\n clamp,\n clampRectInside,\n clampTransformToCover,\n imageRect,\n largestRectOfRatio,\n centerRect,\n minScaleToCover,\n orientedSize,\n resizeRect,\n rotateCw,\n zoomAbout,\n} from \"./geometry\";\nimport {HANDLE_HIT_SIZE, MIN_CROP_SIZE} from \"./constants\";\n\n/**\n * The cropper's behaviour, as a pure reducer.\n *\n * ⛔ A REDUCER AND NOT A HOOK, BECAUSE THIS REPO'S VITEST HAS NO DOM. The\n * config is `environment: 'node'` and jsdom is deliberately not installed —\n * its own comment says the first test that renders a component is what earns\n * it. A `useCropperState` hook would therefore have been shipped untested,\n * which for the part that decides where a crop lands is not a trade worth\n * making. As a reducer every transition is an ordinary function call, and the\n * platform files are left holding nothing but `useReducer` and the events.\n *\n * ⚠️ TWO INVARIANTS HOLD AFTER EVERY ACTION, not just after the ones that\n * obviously threaten them:\n * 1. the image covers the crop window — no empty corners, ever;\n * 2. the scale is inside [cover, cover × maxZoom] — you can never zoom out\n * past the point where the window is filled.\n * They are re-applied in one place, `settle`, rather than at each call site,\n * because \"I added an action and forgot the clamp\" is the shape this bug takes.\n */\n\nexport interface ICropperConfig {\n /** The image's natural size, in source pixels. */\n source: ICropSize;\n /** The stage, in viewport units. */\n viewport: ICropSize;\n /** Locked crop ratio, width ÷ height. */\n ratio: number;\n /** Zoom ceiling, as a multiple of the cover scale. */\n maxZoom: number;\n /** Whether the eight grips may resize the window. */\n resizable: boolean;\n /**\n * The dark margin the resting crop window is inset by, in viewport units.\n *\n * ⚠️ DEFAULTS TO 0, WHICH IS THE OLD BEHAVIOUR. The platform files pass the\n * mockup's padding; every existing caller and test that omits it still gets\n * a window that fills the stage.\n */\n inset?: number;\n /**\n * A factor applied to a SQUARE window only, after the inset.\n *\n * ⚠️ ALSO DEFAULTS TO 1. See `SQUARE_CROP_FACTOR` for why the mockup\n * shrinks 1:1 and leaves every other ratio alone.\n */\n squareFactor?: number;\n}\n\nexport interface ICropperState {\n config: ICropperConfig;\n crop: ICropRect;\n transform: ICropTransform;\n rotation: CropRotation;\n}\n\nexport type CropperAction =\n /** The stage was measured, or re-measured on resize. */\n | {type: \"configure\"; config: Partial<ICropperConfig>}\n /** Drag the image behind a fixed window. */\n | {type: \"pan\"; dx: number; dy: number}\n /** Wheel or pinch, anchored so the pixel under the pointer stays there. */\n | {type: \"zoomAt\"; anchor: ICropPoint; scale: number}\n /** The zoom slider, in 0–1 over the legal band. */\n | {type: \"setZoom\"; value: number}\n /** Drag one of the eight grips. */\n | {type: \"resize\"; handle: CropHandle; pointer: ICropPoint}\n /** The rotate button: one quarter turn clockwise. */\n | {type: \"rotate\"}\n /** Back to how it opened. */\n | {type: \"reset\"};\n\n/* ─── Derived values ──────────────────────────────────────────────────────── */\n\n/** The scale band: cover at the bottom, `maxZoom` times that at the top. */\nexport const scaleRange = (state: ICropperState): {min: number; max: number} => {\n const oriented = orientedSize(state.config.source, state.rotation);\n const min = minScaleToCover(oriented, state.crop);\n return {min, max: min * state.config.maxZoom};\n};\n\n/**\n * Where the slider sits, 0–1 over the legal band.\n *\n * ⚠️ LINEAR IN SCALE, NOT LOGARITHMIC. A log mapping feels better over a wide\n * band and is what a photo editor would use; it is also a different control to\n * the one the pinch gesture drives, and two controls that disagree about what\n * \"half way\" means is worse than one that is slightly bottom-heavy. Change both\n * or neither.\n */\nexport const zoomFraction = (state: ICropperState): number => {\n const {min, max} = scaleRange(state);\n if (!Number.isFinite(min) || max <= min) return 0;\n return clamp((state.transform.scale - min) / (max - min), 0, 1);\n};\n\n/** The grips a renderer should draw — empty when resizing is off. */\nexport const activeHandles = (state: ICropperState): CropHandle[] =>\n state.config.resizable ? [\"nw\", \"n\", \"ne\", \"e\", \"se\", \"s\", \"sw\", \"w\"] : [];\n\n/** Hit size of a grip, so web and native agree on how fat a target is. */\nexport const handleHitSize = (): number => HANDLE_HIT_SIZE;\n\n/* ─── The two invariants, applied in one place ────────────────────────────── */\n\nconst settle = (state: ICropperState): ICropperState => {\n const oriented = orientedSize(state.config.source, state.rotation);\n\n // The window itself may never leave the stage.\n const stage: ICropRect = {x: 0, y: 0, ...state.config.viewport};\n const crop = clampRectInside(state.crop, stage);\n\n // Scale into the legal band…\n const min = minScaleToCover(oriented, crop);\n const scale = clamp(state.transform.scale, min, min * state.config.maxZoom);\n\n // …then slide the image until the window is filled. ⚠️ ORDER MATTERS: a\n // cover clamp computed against a scale that is about to change puts the\n // image where it needed to be a moment ago.\n const scaled = zoomAbout(\n state.transform,\n {x: crop.x + crop.width / 2, y: crop.y + crop.height / 2},\n scale,\n );\n return {...state, crop, transform: clampTransformToCover(scaled, oriented, crop)};\n};\n\n/* ─── Start ───────────────────────────────────────────────────────────────── */\n\n/**\n * How big the window sits when it is framed from scratch.\n *\n * ⛔ THE INSET IS TAKEN OFF THE BOUNDS, NOT OFF THE RESULT. Shrinking the\n * finished rect would change its ASPECT on whichever axis was not the limiting\n * one; taking the padding off both axes first and then fitting the ratio inside\n * what is left keeps the shape exactly `ratio` — which is the one thing the\n * crop window may never get wrong.\n *\n * ⚠️ AND IT CANNOT GO BELOW ZERO. A stage narrower than twice the padding — a\n * phone in a split view, a story at 200px — would otherwise produce a negative\n * bound and a window with a negative width, which renders as nothing at all.\n */\nconst restingSize = (config: ICropperConfig): ICropSize => {\n const pad = config.inset ?? 0;\n const bounds = {\n width: Math.max(config.viewport.width - pad * 2, 1),\n height: Math.max(config.viewport.height - pad * 2, 1),\n };\n const fitted = largestRectOfRatio(bounds, config.ratio);\n const factor = config.ratio === 1 ? (config.squareFactor ?? 1) : 1;\n return {width: fitted.width * factor, height: fitted.height * factor};\n};\n\n/**\n * The natural framing for a config and a rotation: the biggest window of the\n * ratio the stage allows, with the image just covering it, centred.\n *\n * ⚠️ ONE FUNCTION FOR OPEN, RESET AND ROTATE, because all three mean the same\n * thing — \"frame this from scratch\" — and three copies of it is three chances\n * for one of them to forget `settle`.\n */\nconst reframe = (config: ICropperConfig, rotation: CropRotation): ICropperState => {\n const oriented = orientedSize(config.source, rotation);\n const crop = centerRect(restingSize(config), config.viewport);\n const scale = minScaleToCover(oriented, crop);\n const img = imageRect(oriented, {scale, x: 0, y: 0});\n return settle({\n config,\n rotation,\n crop,\n transform: {\n scale,\n x: crop.x + (crop.width - img.width) / 2,\n y: crop.y + (crop.height - img.height) / 2,\n },\n });\n};\n\nexport const initialCropperState = (config: ICropperConfig): ICropperState =>\n reframe(config, 0);\n\n/* ─── Transitions ─────────────────────────────────────────────────────────── */\n\nexport const cropperReducer = (state: ICropperState, action: CropperAction): ICropperState => {\n switch (action.type) {\n case \"configure\": {\n const config = {...state.config, ...action.config};\n const previous = state.config.viewport;\n const next = config.viewport;\n\n // ⛔ A NEW IMAGE OR A NEW RATIO IS GENUINELY A NEW FRAMING. Keeping\n // the old window across a ratio change hands back a window of the\n // wrong shape; across a new image it frames pixels that are not\n // there any more. Rotation is the user's choice about the PHOTO, so\n // it survives.\n const framingChanged =\n config.ratio !== state.config.ratio ||\n config.source.width !== state.config.source.width ||\n config.source.height !== state.config.source.height;\n if (framingChanged) return reframe(config, state.rotation);\n\n const resized = next.width !== previous.width || next.height !== previous.height;\n if (!resized) return settle({...state, config});\n\n // ⛔ A RE-MEASURED STAGE MUST NOT THROW THE USER'S WORK AWAY, AND\n // FOR ONE BUILD IT DID. Saving a crop made the result preview\n // appear below the component, the page grew a scrollbar, the stage\n // lost ~15px of width, the ResizeObserver fired — and the cropper\n // silently snapped back to 1.0× and unrotated, in the same frame\n // the user pressed Save. Nothing about that is a new framing: the\n // same photo is being cropped the same way on a slightly narrower\n // stage. Scaling the whole framing by the stage's own factor keeps\n // it, and `settle` fixes up whatever no longer fits.\n //\n // ⚠️ THE FIRST MEASURE IS NOT A RESIZE. The stage starts at the 1×1\n // placeholder the component uses before the ResizeObserver has\n // reported, and scaling by a factor of several hundred from that is\n // not preserving anything.\n if (previous.width <= 1 || previous.height <= 1) return reframe(config, state.rotation);\n\n const k = Math.min(next.width / previous.width, next.height / previous.height);\n return settle({\n ...state,\n config,\n crop: {\n x: state.crop.x * k,\n y: state.crop.y * k,\n width: state.crop.width * k,\n height: state.crop.height * k,\n },\n transform: {\n scale: state.transform.scale * k,\n x: state.transform.x * k,\n y: state.transform.y * k,\n },\n });\n }\n\n case \"pan\":\n return settle({\n ...state,\n transform: {\n ...state.transform,\n x: state.transform.x + action.dx,\n y: state.transform.y + action.dy,\n },\n });\n\n case \"zoomAt\": {\n const {min, max} = scaleRange(state);\n const scale = clamp(action.scale, min, max);\n return settle({...state, transform: zoomAbout(state.transform, action.anchor, scale)});\n }\n\n case \"setZoom\": {\n const {min, max} = scaleRange(state);\n const scale = min + clamp(action.value, 0, 1) * (max - min);\n // The slider has no pointer to anchor on, so it zooms about the\n // window's centre — the one point the user is certainly looking at.\n const anchor = {\n x: state.crop.x + state.crop.width / 2,\n y: state.crop.y + state.crop.height / 2,\n };\n return settle({...state, transform: zoomAbout(state.transform, anchor, scale)});\n }\n\n case \"resize\": {\n if (!state.config.resizable) return state;\n const crop = resizeRect(state.crop, action.handle, action.pointer, {\n ratio: state.config.ratio,\n min: MIN_CROP_SIZE,\n bounds: {x: 0, y: 0, ...state.config.viewport},\n });\n // ⚠️ SETTLE AFTER A RESIZE TOO. A bigger window can need a bigger\n // scale to stay covered, and the grip is exactly the control that\n // makes the window bigger.\n return settle({...state, crop});\n }\n\n case \"rotate\":\n // ⛔ THE WINDOW IS REBUILT, NOT KEPT. A quarter turn transposes the\n // image, so a scale that covered the old window may not cover the\n // new one at all, and the old offset points somewhere else entirely.\n // Reopening at the natural framing for the new orientation is what\n // every other cropper does, and it is the only state guaranteed to\n // satisfy both invariants.\n return reframe(state.config, rotateCw(state.rotation));\n\n case \"reset\":\n return reframe(state.config, 0);\n\n default:\n return state;\n }\n};\n"],"names":["orientedSize","minScaleToCover","clamp","clampRectInside","zoomAbout","clampTransformToCover","largestRectOfRatio","centerRect","imageRect","resizeRect","MIN_CROP_SIZE","rotateCw"],"mappings":"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"}
@@ -0,0 +1,124 @@
1
+ import { rotateCw, resizeRect, clamp, zoomAbout, orientedSize, minScaleToCover, centerRect, imageRect, clampRectInside, clampTransformToCover, largestRectOfRatio } from './geometry.js';
2
+ import { MIN_CROP_SIZE } from './constants.js';
3
+
4
+ const scaleRange = (state) => {
5
+ const oriented = orientedSize(state.config.source, state.rotation);
6
+ const min = minScaleToCover(oriented, state.crop);
7
+ return { min, max: min * state.config.maxZoom };
8
+ };
9
+ const zoomFraction = (state) => {
10
+ const { min, max } = scaleRange(state);
11
+ if (!Number.isFinite(min) || max <= min) return 0;
12
+ return clamp((state.transform.scale - min) / (max - min), 0, 1);
13
+ };
14
+ const settle = (state) => {
15
+ const oriented = orientedSize(state.config.source, state.rotation);
16
+ const stage = { x: 0, y: 0, ...state.config.viewport };
17
+ const crop = clampRectInside(state.crop, stage);
18
+ const min = minScaleToCover(oriented, crop);
19
+ const scale = clamp(state.transform.scale, min, min * state.config.maxZoom);
20
+ const scaled = zoomAbout(
21
+ state.transform,
22
+ { x: crop.x + crop.width / 2, y: crop.y + crop.height / 2 },
23
+ scale
24
+ );
25
+ return { ...state, crop, transform: clampTransformToCover(scaled, oriented, crop) };
26
+ };
27
+ const restingSize = (config) => {
28
+ const pad = config.inset ?? 0;
29
+ const bounds = {
30
+ width: Math.max(config.viewport.width - pad * 2, 1),
31
+ height: Math.max(config.viewport.height - pad * 2, 1)
32
+ };
33
+ const fitted = largestRectOfRatio(bounds, config.ratio);
34
+ const factor = config.ratio === 1 ? config.squareFactor ?? 1 : 1;
35
+ return { width: fitted.width * factor, height: fitted.height * factor };
36
+ };
37
+ const reframe = (config, rotation) => {
38
+ const oriented = orientedSize(config.source, rotation);
39
+ const crop = centerRect(restingSize(config), config.viewport);
40
+ const scale = minScaleToCover(oriented, crop);
41
+ const img = imageRect(oriented, { scale, x: 0, y: 0 });
42
+ return settle({
43
+ config,
44
+ rotation,
45
+ crop,
46
+ transform: {
47
+ scale,
48
+ x: crop.x + (crop.width - img.width) / 2,
49
+ y: crop.y + (crop.height - img.height) / 2
50
+ }
51
+ });
52
+ };
53
+ const initialCropperState = (config) => reframe(config, 0);
54
+ const cropperReducer = (state, action) => {
55
+ switch (action.type) {
56
+ case "configure": {
57
+ const config = { ...state.config, ...action.config };
58
+ const previous = state.config.viewport;
59
+ const next = config.viewport;
60
+ const framingChanged = config.ratio !== state.config.ratio || config.source.width !== state.config.source.width || config.source.height !== state.config.source.height;
61
+ if (framingChanged) return reframe(config, state.rotation);
62
+ const resized = next.width !== previous.width || next.height !== previous.height;
63
+ if (!resized) return settle({ ...state, config });
64
+ if (previous.width <= 1 || previous.height <= 1) return reframe(config, state.rotation);
65
+ const k = Math.min(next.width / previous.width, next.height / previous.height);
66
+ return settle({
67
+ ...state,
68
+ config,
69
+ crop: {
70
+ x: state.crop.x * k,
71
+ y: state.crop.y * k,
72
+ width: state.crop.width * k,
73
+ height: state.crop.height * k
74
+ },
75
+ transform: {
76
+ scale: state.transform.scale * k,
77
+ x: state.transform.x * k,
78
+ y: state.transform.y * k
79
+ }
80
+ });
81
+ }
82
+ case "pan":
83
+ return settle({
84
+ ...state,
85
+ transform: {
86
+ ...state.transform,
87
+ x: state.transform.x + action.dx,
88
+ y: state.transform.y + action.dy
89
+ }
90
+ });
91
+ case "zoomAt": {
92
+ const { min, max } = scaleRange(state);
93
+ const scale = clamp(action.scale, min, max);
94
+ return settle({ ...state, transform: zoomAbout(state.transform, action.anchor, scale) });
95
+ }
96
+ case "setZoom": {
97
+ const { min, max } = scaleRange(state);
98
+ const scale = min + clamp(action.value, 0, 1) * (max - min);
99
+ const anchor = {
100
+ x: state.crop.x + state.crop.width / 2,
101
+ y: state.crop.y + state.crop.height / 2
102
+ };
103
+ return settle({ ...state, transform: zoomAbout(state.transform, anchor, scale) });
104
+ }
105
+ case "resize": {
106
+ if (!state.config.resizable) return state;
107
+ const crop = resizeRect(state.crop, action.handle, action.pointer, {
108
+ ratio: state.config.ratio,
109
+ min: MIN_CROP_SIZE,
110
+ bounds: { x: 0, y: 0, ...state.config.viewport }
111
+ });
112
+ return settle({ ...state, crop });
113
+ }
114
+ case "rotate":
115
+ return reframe(state.config, rotateCw(state.rotation));
116
+ case "reset":
117
+ return reframe(state.config, 0);
118
+ default:
119
+ return state;
120
+ }
121
+ };
122
+
123
+ export { cropperReducer, initialCropperState, scaleRange, zoomFraction };
124
+ //# sourceMappingURL=cropperState.js.map