@lmb-it/kitsconcerto-native 0.1.285 → 0.1.286
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/apps/mobile/src/ui/accordion/index.js +2 -2
- package/components/ImageCropper/compression.cjs +63 -0
- package/components/ImageCropper/compression.cjs.map +1 -0
- package/components/ImageCropper/compression.js +58 -0
- package/components/ImageCropper/compression.js.map +1 -0
- package/components/ImageCropper/constants.cjs +35 -0
- package/components/ImageCropper/constants.cjs.map +1 -0
- package/components/ImageCropper/constants.js +23 -0
- package/components/ImageCropper/constants.js.map +1 -0
- package/components/ImageCropper/cropperState.cjs +119 -0
- package/components/ImageCropper/cropperState.cjs.map +1 -0
- package/components/ImageCropper/cropperState.js +114 -0
- package/components/ImageCropper/cropperState.js.map +1 -0
- package/components/ImageCropper/encode.cjs +45 -0
- package/components/ImageCropper/encode.cjs.map +1 -0
- package/components/ImageCropper/encode.js +42 -0
- package/components/ImageCropper/encode.js.map +1 -0
- package/components/ImageCropper/encoderRegistry.cjs +13 -0
- package/components/ImageCropper/encoderRegistry.cjs.map +1 -0
- package/components/ImageCropper/encoderRegistry.js +9 -0
- package/components/ImageCropper/encoderRegistry.js.map +1 -0
- package/components/ImageCropper/encoders/rnImageEncoder.cjs +54 -0
- package/components/ImageCropper/encoders/rnImageEncoder.cjs.map +1 -0
- package/components/ImageCropper/encoders/rnImageEncoder.js +51 -0
- package/components/ImageCropper/encoders/rnImageEncoder.js.map +1 -0
- package/components/ImageCropper/geometry.cjs +208 -0
- package/components/ImageCropper/geometry.cjs.map +1 -0
- package/components/ImageCropper/geometry.js +186 -0
- package/components/ImageCropper/geometry.js.map +1 -0
- package/components/ImageCropper/index.cjs +436 -0
- package/components/ImageCropper/index.cjs.map +1 -0
- package/components/ImageCropper/index.js +434 -0
- package/components/ImageCropper/index.js.map +1 -0
- package/components/ImageCropper/pinchZoom.cjs +8 -0
- package/components/ImageCropper/pinchZoom.cjs.map +1 -0
- package/components/ImageCropper/pinchZoom.js +5 -0
- package/components/ImageCropper/pinchZoom.js.map +1 -0
- package/components/ImageCropper/useImageSource.cjs +87 -0
- package/components/ImageCropper/useImageSource.cjs.map +1 -0
- package/components/ImageCropper/useImageSource.js +84 -0
- package/components/ImageCropper/useImageSource.js.map +1 -0
- package/hooks/useComponentDefaults.cjs +12 -0
- package/hooks/useComponentDefaults.cjs.map +1 -1
- package/hooks/useComponentDefaults.js +12 -0
- package/hooks/useComponentDefaults.js.map +1 -1
- package/index.cjs +120 -115
- package/index.cjs.map +1 -1
- package/index.d.ts +394 -3
- package/index.js +2 -0
- package/index.js.map +1 -1
- package/package.json +13 -1
- package/packages/types/src/Theme/defaultTheme.cjs +111 -0
- package/packages/types/src/Theme/defaultTheme.cjs.map +1 -1
- package/packages/types/src/Theme/defaultTheme.js +111 -0
- 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,
|
|
5
|
-
import { tva, withStyleContext
|
|
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,35 @@
|
|
|
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",
|
|
16
|
+
rotate: "Rotate",
|
|
17
|
+
reset: "Reset",
|
|
18
|
+
zoom: "Zoom",
|
|
19
|
+
preview: "Preview",
|
|
20
|
+
dropZone: "Choose an image",
|
|
21
|
+
processing: "Processing\u2026"
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
exports.DEFAULT_ASPECT = DEFAULT_ASPECT;
|
|
25
|
+
exports.DEFAULT_FORMAT = DEFAULT_FORMAT;
|
|
26
|
+
exports.DEFAULT_LABELS = DEFAULT_LABELS;
|
|
27
|
+
exports.DEFAULT_MAX_BYTES = DEFAULT_MAX_BYTES;
|
|
28
|
+
exports.DEFAULT_MAX_ZOOM = DEFAULT_MAX_ZOOM;
|
|
29
|
+
exports.DOWNSCALE_FACTOR = DOWNSCALE_FACTOR;
|
|
30
|
+
exports.MAX_DOWNSCALE_PASSES = MAX_DOWNSCALE_PASSES;
|
|
31
|
+
exports.MIN_CROP_SIZE = MIN_CROP_SIZE;
|
|
32
|
+
exports.QUALITY_MAX = QUALITY_MAX;
|
|
33
|
+
exports.QUALITY_MIN = QUALITY_MIN;
|
|
34
|
+
exports.QUALITY_STEPS = QUALITY_STEPS;
|
|
35
|
+
//# 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, in viewport units. */\nexport const HANDLE_HIT_SIZE = 20;\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\",\n rotate: \"Rotate\",\n reset: \"Reset\",\n zoom: \"Zoom\",\n preview: \"Preview\",\n dropZone: \"Choose an image\",\n processing: \"Processing…\",\n} as const;\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,MAAA;AAAA,EACN,MAAA,EAAQ,QAAA;AAAA,EACR,KAAA,EAAO,OAAA;AAAA,EACP,IAAA,EAAM,MAAA;AAAA,EACN,OAAA,EAAS,SAAA;AAAA,EACT,QAAA,EAAU,iBAAA;AAAA,EACV,UAAA,EAAY;AAChB;;;;;;;;;;;;;;"}
|
|
@@ -0,0 +1,23 @@
|
|
|
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",
|
|
14
|
+
rotate: "Rotate",
|
|
15
|
+
reset: "Reset",
|
|
16
|
+
zoom: "Zoom",
|
|
17
|
+
preview: "Preview",
|
|
18
|
+
dropZone: "Choose an image",
|
|
19
|
+
processing: "Processing\u2026"
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export { 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 };
|
|
23
|
+
//# 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, in viewport units. */\nexport const HANDLE_HIT_SIZE = 20;\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\",\n rotate: \"Rotate\",\n reset: \"Reset\",\n zoom: \"Zoom\",\n preview: \"Preview\",\n dropZone: \"Choose an image\",\n processing: \"Processing…\",\n} as const;\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,MAAA;AAAA,EACN,MAAA,EAAQ,QAAA;AAAA,EACR,KAAA,EAAO,OAAA;AAAA,EACP,IAAA,EAAM,MAAA;AAAA,EACN,OAAA,EAAS,SAAA;AAAA,EACT,QAAA,EAAU,iBAAA;AAAA,EACV,UAAA,EAAY;AAChB;;;;"}
|
|
@@ -0,0 +1,119 @@
|
|
|
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 reframe = (config, rotation) => {
|
|
30
|
+
const oriented = geometry.orientedSize(config.source, rotation);
|
|
31
|
+
const crop = geometry.centerRect(geometry.largestRectOfRatio(config.viewport, config.ratio), config.viewport);
|
|
32
|
+
const scale = geometry.minScaleToCover(oriented, crop);
|
|
33
|
+
const img = geometry.imageRect(oriented, { scale, x: 0, y: 0 });
|
|
34
|
+
return settle({
|
|
35
|
+
config,
|
|
36
|
+
rotation,
|
|
37
|
+
crop,
|
|
38
|
+
transform: {
|
|
39
|
+
scale,
|
|
40
|
+
x: crop.x + (crop.width - img.width) / 2,
|
|
41
|
+
y: crop.y + (crop.height - img.height) / 2
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const initialCropperState = (config) => reframe(config, 0);
|
|
46
|
+
const cropperReducer = (state, action) => {
|
|
47
|
+
switch (action.type) {
|
|
48
|
+
case "configure": {
|
|
49
|
+
const config = { ...state.config, ...action.config };
|
|
50
|
+
const previous = state.config.viewport;
|
|
51
|
+
const next = config.viewport;
|
|
52
|
+
const framingChanged = config.ratio !== state.config.ratio || config.source.width !== state.config.source.width || config.source.height !== state.config.source.height;
|
|
53
|
+
if (framingChanged) return reframe(config, state.rotation);
|
|
54
|
+
const resized = next.width !== previous.width || next.height !== previous.height;
|
|
55
|
+
if (!resized) return settle({ ...state, config });
|
|
56
|
+
if (previous.width <= 1 || previous.height <= 1) return reframe(config, state.rotation);
|
|
57
|
+
const k = Math.min(next.width / previous.width, next.height / previous.height);
|
|
58
|
+
return settle({
|
|
59
|
+
...state,
|
|
60
|
+
config,
|
|
61
|
+
crop: {
|
|
62
|
+
x: state.crop.x * k,
|
|
63
|
+
y: state.crop.y * k,
|
|
64
|
+
width: state.crop.width * k,
|
|
65
|
+
height: state.crop.height * k
|
|
66
|
+
},
|
|
67
|
+
transform: {
|
|
68
|
+
scale: state.transform.scale * k,
|
|
69
|
+
x: state.transform.x * k,
|
|
70
|
+
y: state.transform.y * k
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
case "pan":
|
|
75
|
+
return settle({
|
|
76
|
+
...state,
|
|
77
|
+
transform: {
|
|
78
|
+
...state.transform,
|
|
79
|
+
x: state.transform.x + action.dx,
|
|
80
|
+
y: state.transform.y + action.dy
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
case "zoomAt": {
|
|
84
|
+
const { min, max } = scaleRange(state);
|
|
85
|
+
const scale = geometry.clamp(action.scale, min, max);
|
|
86
|
+
return settle({ ...state, transform: geometry.zoomAbout(state.transform, action.anchor, scale) });
|
|
87
|
+
}
|
|
88
|
+
case "setZoom": {
|
|
89
|
+
const { min, max } = scaleRange(state);
|
|
90
|
+
const scale = min + geometry.clamp(action.value, 0, 1) * (max - min);
|
|
91
|
+
const anchor = {
|
|
92
|
+
x: state.crop.x + state.crop.width / 2,
|
|
93
|
+
y: state.crop.y + state.crop.height / 2
|
|
94
|
+
};
|
|
95
|
+
return settle({ ...state, transform: geometry.zoomAbout(state.transform, anchor, scale) });
|
|
96
|
+
}
|
|
97
|
+
case "resize": {
|
|
98
|
+
if (!state.config.resizable) return state;
|
|
99
|
+
const crop = geometry.resizeRect(state.crop, action.handle, action.pointer, {
|
|
100
|
+
ratio: state.config.ratio,
|
|
101
|
+
min: constants.MIN_CROP_SIZE,
|
|
102
|
+
bounds: { x: 0, y: 0, ...state.config.viewport }
|
|
103
|
+
});
|
|
104
|
+
return settle({ ...state, crop });
|
|
105
|
+
}
|
|
106
|
+
case "rotate":
|
|
107
|
+
return reframe(state.config, geometry.rotateCw(state.rotation));
|
|
108
|
+
case "reset":
|
|
109
|
+
return reframe(state.config, 0);
|
|
110
|
+
default:
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
exports.cropperReducer = cropperReducer;
|
|
116
|
+
exports.initialCropperState = initialCropperState;
|
|
117
|
+
exports.scaleRange = scaleRange;
|
|
118
|
+
exports.zoomFraction = zoomFraction;
|
|
119
|
+
//# 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\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 * 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(largestRectOfRatio(config.viewport, config.ratio), 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","centerRect","largestRectOfRatio","imageRect","resizeRect","MIN_CROP_SIZE","rotateCw"],"mappings":"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"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { rotateCw, resizeRect, clamp, zoomAbout, orientedSize, minScaleToCover, centerRect, largestRectOfRatio, imageRect, clampRectInside, clampTransformToCover } 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 reframe = (config, rotation) => {
|
|
28
|
+
const oriented = orientedSize(config.source, rotation);
|
|
29
|
+
const crop = centerRect(largestRectOfRatio(config.viewport, config.ratio), config.viewport);
|
|
30
|
+
const scale = minScaleToCover(oriented, crop);
|
|
31
|
+
const img = imageRect(oriented, { scale, x: 0, y: 0 });
|
|
32
|
+
return settle({
|
|
33
|
+
config,
|
|
34
|
+
rotation,
|
|
35
|
+
crop,
|
|
36
|
+
transform: {
|
|
37
|
+
scale,
|
|
38
|
+
x: crop.x + (crop.width - img.width) / 2,
|
|
39
|
+
y: crop.y + (crop.height - img.height) / 2
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
const initialCropperState = (config) => reframe(config, 0);
|
|
44
|
+
const cropperReducer = (state, action) => {
|
|
45
|
+
switch (action.type) {
|
|
46
|
+
case "configure": {
|
|
47
|
+
const config = { ...state.config, ...action.config };
|
|
48
|
+
const previous = state.config.viewport;
|
|
49
|
+
const next = config.viewport;
|
|
50
|
+
const framingChanged = config.ratio !== state.config.ratio || config.source.width !== state.config.source.width || config.source.height !== state.config.source.height;
|
|
51
|
+
if (framingChanged) return reframe(config, state.rotation);
|
|
52
|
+
const resized = next.width !== previous.width || next.height !== previous.height;
|
|
53
|
+
if (!resized) return settle({ ...state, config });
|
|
54
|
+
if (previous.width <= 1 || previous.height <= 1) return reframe(config, state.rotation);
|
|
55
|
+
const k = Math.min(next.width / previous.width, next.height / previous.height);
|
|
56
|
+
return settle({
|
|
57
|
+
...state,
|
|
58
|
+
config,
|
|
59
|
+
crop: {
|
|
60
|
+
x: state.crop.x * k,
|
|
61
|
+
y: state.crop.y * k,
|
|
62
|
+
width: state.crop.width * k,
|
|
63
|
+
height: state.crop.height * k
|
|
64
|
+
},
|
|
65
|
+
transform: {
|
|
66
|
+
scale: state.transform.scale * k,
|
|
67
|
+
x: state.transform.x * k,
|
|
68
|
+
y: state.transform.y * k
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
case "pan":
|
|
73
|
+
return settle({
|
|
74
|
+
...state,
|
|
75
|
+
transform: {
|
|
76
|
+
...state.transform,
|
|
77
|
+
x: state.transform.x + action.dx,
|
|
78
|
+
y: state.transform.y + action.dy
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
case "zoomAt": {
|
|
82
|
+
const { min, max } = scaleRange(state);
|
|
83
|
+
const scale = clamp(action.scale, min, max);
|
|
84
|
+
return settle({ ...state, transform: zoomAbout(state.transform, action.anchor, scale) });
|
|
85
|
+
}
|
|
86
|
+
case "setZoom": {
|
|
87
|
+
const { min, max } = scaleRange(state);
|
|
88
|
+
const scale = min + clamp(action.value, 0, 1) * (max - min);
|
|
89
|
+
const anchor = {
|
|
90
|
+
x: state.crop.x + state.crop.width / 2,
|
|
91
|
+
y: state.crop.y + state.crop.height / 2
|
|
92
|
+
};
|
|
93
|
+
return settle({ ...state, transform: zoomAbout(state.transform, anchor, scale) });
|
|
94
|
+
}
|
|
95
|
+
case "resize": {
|
|
96
|
+
if (!state.config.resizable) return state;
|
|
97
|
+
const crop = resizeRect(state.crop, action.handle, action.pointer, {
|
|
98
|
+
ratio: state.config.ratio,
|
|
99
|
+
min: MIN_CROP_SIZE,
|
|
100
|
+
bounds: { x: 0, y: 0, ...state.config.viewport }
|
|
101
|
+
});
|
|
102
|
+
return settle({ ...state, crop });
|
|
103
|
+
}
|
|
104
|
+
case "rotate":
|
|
105
|
+
return reframe(state.config, rotateCw(state.rotation));
|
|
106
|
+
case "reset":
|
|
107
|
+
return reframe(state.config, 0);
|
|
108
|
+
default:
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export { cropperReducer, initialCropperState, scaleRange, zoomFraction };
|
|
114
|
+
//# sourceMappingURL=cropperState.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cropperState.js","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\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 * 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(largestRectOfRatio(config.viewport, config.ratio), 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":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var compression = require('./compression.cjs');
|
|
4
|
+
var encoderRegistry = require('./encoderRegistry.cjs');
|
|
5
|
+
|
|
6
|
+
const encodeCropNative = async (uri, sourceRect, sourceSize, rotation, max, format) => {
|
|
7
|
+
const encoder = encoderRegistry.getCropEncoder();
|
|
8
|
+
if (!encoder) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"[ImageCropper] No native encoder is registered, so the crop cannot be saved. Call setCropEncoder(\u2026) once at app start \u2014 see encode.native.ts for the one-function contract. (expo-image-manipulator, the library the plan named, needs the Expo modules runtime, which this bare React Native app does not have.)"
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
const quarter = rotation === 90 || rotation === 270;
|
|
14
|
+
const natural = quarter ? { width: sourceRect.height, height: sourceRect.width } : { width: sourceRect.width, height: sourceRect.height };
|
|
15
|
+
const run = await compression.runEncodePlan(natural, max, format, async (attempt) => {
|
|
16
|
+
const out = await encoder({
|
|
17
|
+
uri,
|
|
18
|
+
sourceRect,
|
|
19
|
+
sourceSize,
|
|
20
|
+
rotation,
|
|
21
|
+
size: attempt.size,
|
|
22
|
+
quality: attempt.quality,
|
|
23
|
+
format
|
|
24
|
+
});
|
|
25
|
+
return { bytes: out.bytes, value: { uri: out.uri, width: out.width, height: out.height } };
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
uri: run.value.uri,
|
|
29
|
+
// ⚠️ WHAT THE ENCODER ACTUALLY MADE, falling back to what the search
|
|
30
|
+
// asked for. The bare-React-Native encoder drives its resizer with a
|
|
31
|
+
// square `contain` ceiling — the only orientation-safe way to use it —
|
|
32
|
+
// so the result can be a pixel or two off the request, and a consumer
|
|
33
|
+
// sizing a layout from this number should get the true one.
|
|
34
|
+
width: Math.max(1, Math.round(run.value.width ?? run.size.width)),
|
|
35
|
+
height: Math.max(1, Math.round(run.value.height ?? run.size.height)),
|
|
36
|
+
size: run.bytes,
|
|
37
|
+
format,
|
|
38
|
+
withinBudget: run.withinBudget
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
exports.hasCropEncoder = encoderRegistry.hasCropEncoder;
|
|
43
|
+
exports.setCropEncoder = encoderRegistry.setCropEncoder;
|
|
44
|
+
exports.encodeCropNative = encodeCropNative;
|
|
45
|
+
//# sourceMappingURL=encode.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encode.cjs","sources":["../../../../src/components/ImageCropper/encode.native.ts"],"sourcesContent":["import type {CropOutputFormat, ICropRect, ICropResult, ICropSize} from \"@lmb-it/kitsconcerto-types\";\nimport {runEncodePlan} from \"./compression\";\nimport {getCropEncoder} from \"./encoderRegistry\";\n\nexport {setCropEncoder, hasCropEncoder, type ICropEncoder} from \"./encoderRegistry\";\n\n/**\n * The one thing the native cropper cannot do by itself.\n *\n * ⛔ THERE IS NO CANVAS ON A DEVICE. Every other part of this component —\n * geometry, state, gestures, the compression SEARCH — runs identically on both\n * platforms out of the shared modules. Turning the chosen rect into an encoded\n * file is the exception: it needs a native module, and this repository does not\n * currently ship one.\n *\n * ⚠️ WHAT IS ACTUALLY MISSING, MEASURED RATHER THAN ASSUMED. The approved plan\n * named `expo-image-manipulator`. It is not installed, and `apps/mobile` is a\n * BARE React Native app — there is no `expo`, no `expo-modules-core`, and\n * `expo-image-manipulator` cannot run without them. Adding it means running\n * `install-expo-modules`, which edits the iOS and Android projects, the Podfile\n * and the app delegate, and needs a native rebuild. That is a decision about\n * the app's native setup, not a line of component code, so this file does not\n * make it quietly.\n *\n * ── HOW TO FINISH IT, WHICHEVER LIBRARY IS CHOSEN ──\n * Call `setCropEncoder` once at app start with an implementation of\n * {@link ICropEncoder}. One function, four inputs, one output; the search that\n * decides WHICH size and quality to ask for stays in `compression.ts` and is\n * already tested. For expo-image-manipulator that body is a `manipulateAsync`\n * with a `crop` action, a `rotate` action, a `resize` action and a\n * `SaveOptions` — roughly fifteen lines.\n */\n\n\n/**\n * Crops, rotates, resizes and encodes — the whole output path on native.\n *\n * ⚠️ THE SEARCH IS NOT HERE, exactly as on web. Which (size, quality) to try,\n * in what order, and when to stop squeezing and start shrinking all live in\n * `compression.ts`. This drives the registered encoder with that plan, so both\n * platforms reach a byte ceiling by the same route.\n */\nexport const encodeCropNative = async (\n uri: string,\n sourceRect: ICropRect,\n sourceSize: ICropSize,\n rotation: number,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n): Promise<ICropResult & {withinBudget: boolean}> => {\n const encoder = getCropEncoder();\n if (!encoder) {\n throw new Error(\n \"[ImageCropper] No native encoder is registered, so the crop cannot be saved. \" +\n \"Call setCropEncoder(…) once at app start — see encode.native.ts for the \" +\n \"one-function contract. (expo-image-manipulator, the library the plan named, \" +\n \"needs the Expo modules runtime, which this bare React Native app does not have.)\",\n );\n }\n\n const quarter = rotation === 90 || rotation === 270;\n // The crop at 1:1, in the orientation it will be SAVED in.\n const natural: ICropSize = quarter\n ? {width: sourceRect.height, height: sourceRect.width}\n : {width: sourceRect.width, height: sourceRect.height};\n\n const run = await runEncodePlan(natural, max, format, async (attempt) => {\n const out = await encoder({\n uri,\n sourceRect,\n sourceSize,\n rotation,\n size: attempt.size,\n quality: attempt.quality,\n format,\n });\n return {bytes: out.bytes, value: {uri: out.uri, width: out.width, height: out.height}};\n });\n\n return {\n uri: run.value.uri,\n // ⚠️ WHAT THE ENCODER ACTUALLY MADE, falling back to what the search\n // asked for. The bare-React-Native encoder drives its resizer with a\n // square `contain` ceiling — the only orientation-safe way to use it —\n // so the result can be a pixel or two off the request, and a consumer\n // sizing a layout from this number should get the true one.\n width: Math.max(1, Math.round(run.value.width ?? run.size.width)),\n height: Math.max(1, Math.round(run.value.height ?? run.size.height)),\n size: run.bytes,\n format,\n withinBudget: run.withinBudget,\n };\n};\n"],"names":["getCropEncoder","runEncodePlan"],"mappings":";;;;;AA0CO,MAAM,mBAAmB,OAC5B,GAAA,EACA,YACA,UAAA,EACA,QAAA,EACA,KACA,MAAA,KACiD;AACjD,EAAA,MAAM,UAAUA,8BAAA,EAAe;AAC/B,EAAA,IAAI,CAAC,OAAA,EAAS;AACV,IAAA,MAAM,IAAI,KAAA;AAAA,MACN;AAAA,KAIJ;AAAA,EACJ;AAEA,EAAA,MAAM,OAAA,GAAU,QAAA,KAAa,EAAA,IAAM,QAAA,KAAa,GAAA;AAEhD,EAAA,MAAM,UAAqB,OAAA,GACrB,EAAC,KAAA,EAAO,UAAA,CAAW,QAAQ,MAAA,EAAQ,UAAA,CAAW,KAAA,EAAK,GACnD,EAAC,KAAA,EAAO,UAAA,CAAW,KAAA,EAAO,MAAA,EAAQ,WAAW,MAAA,EAAM;AAEzD,EAAA,MAAM,MAAM,MAAMC,yBAAA,CAAc,SAAS,GAAA,EAAK,MAAA,EAAQ,OAAO,OAAA,KAAY;AACrE,IAAA,MAAM,GAAA,GAAM,MAAM,OAAA,CAAQ;AAAA,MACtB,GAAA;AAAA,MACA,UAAA;AAAA,MACA,UAAA;AAAA,MACA,QAAA;AAAA,MACA,MAAM,OAAA,CAAQ,IAAA;AAAA,MACd,SAAS,OAAA,CAAQ,OAAA;AAAA,MACjB;AAAA,KACH,CAAA;AACD,IAAA,OAAO,EAAC,KAAA,EAAO,GAAA,CAAI,KAAA,EAAO,OAAO,EAAC,GAAA,EAAK,GAAA,CAAI,GAAA,EAAK,OAAO,GAAA,CAAI,KAAA,EAAO,MAAA,EAAQ,GAAA,CAAI,QAAM,EAAC;AAAA,EACzF,CAAC,CAAA;AAED,EAAA,OAAO;AAAA,IACH,GAAA,EAAK,IAAI,KAAA,CAAM,GAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMf,KAAA,EAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,KAAA,CAAM,KAAA,IAAS,GAAA,CAAI,IAAA,CAAK,KAAK,CAAC,CAAA;AAAA,IAChE,MAAA,EAAQ,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,KAAA,CAAM,MAAA,IAAU,GAAA,CAAI,IAAA,CAAK,MAAM,CAAC,CAAA;AAAA,IACnE,MAAM,GAAA,CAAI,KAAA;AAAA,IACV,MAAA;AAAA,IACA,cAAc,GAAA,CAAI;AAAA,GACtB;AACJ;;;;;;"}
|