@octane-xplat/image-crop 0.0.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +121 -3
- package/dist/native/Gesture.js +12 -0
- package/dist/native/Guide.js +24 -0
- package/dist/native/ImageCrop.js +295 -0
- package/dist/native/Source.js +83 -0
- package/dist/native/geometry.js +102 -0
- package/dist/native/index.js +3 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/web/Gesture.web.js +17 -0
- package/dist/web/Guide.web.js +15 -0
- package/dist/web/ImageCrop.js +199 -0
- package/dist/web/Source.web.js +50 -0
- package/dist/web/geometry.js +102 -0
- package/dist/web/index.js +3 -0
- package/dist/web/octane-client-build.json +8 -0
- package/package.json +81 -3
- package/src/Gesture.macos.tsrx +6 -0
- package/src/Gesture.tsrx +6 -0
- package/src/Gesture.web.tsrx +5 -0
- package/src/Guide.macos.tsrx +6 -0
- package/src/Guide.tsrx +6 -0
- package/src/Guide.web.tsrx +5 -0
- package/src/ImageCrop.tsrx +70 -0
- package/src/Source.macos.tsrx +6 -0
- package/src/Source.tsrx +8 -0
- package/src/Source.web.tsrx +5 -0
- package/src/geometry.ts +163 -0
- package/src/index.macos.ts +12 -0
- package/src/index.ts +12 -0
- package/src/index.web.ts +12 -0
- package/src/props.ts +57 -0
- package/types/generated/.tsrx-typegen-manifest.json +20 -0
- package/types/generated/Gesture.d.ts +2 -0
- package/types/generated/Gesture.macos.d.ts +2 -0
- package/types/generated/Gesture.web.d.ts +2 -0
- package/types/generated/Guide.d.ts +2 -0
- package/types/generated/Guide.macos.d.ts +2 -0
- package/types/generated/Guide.web.d.ts +2 -0
- package/types/generated/ImageCrop.d.ts +3 -0
- package/types/generated/Source.d.ts +2 -0
- package/types/generated/Source.macos.d.ts +2 -0
- package/types/generated/Source.web.d.ts +2 -0
- package/types/generated/geometry.d.ts +12 -0
- package/types/generated/index.d.ts +3 -0
- package/types/generated/index.macos.d.ts +3 -0
- package/types/generated/index.web.d.ts +3 -0
- package/types/generated/props.d.ts +51 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { componentSlot } from "octane";
|
|
2
|
+
import { View } from "@octane-xplat/ui";
|
|
3
|
+
//#region src/Guide.web.tsrx
|
|
4
|
+
var Guide = function Guide(props, __s, __extra) {
|
|
5
|
+
const __block = __s.block;
|
|
6
|
+
componentSlot(__s, 0, __block.parentNode, View, {
|
|
7
|
+
...props,
|
|
8
|
+
"style": {
|
|
9
|
+
...props.style,
|
|
10
|
+
pointerEvents: "none"
|
|
11
|
+
}
|
|
12
|
+
}, __block.endMarker, void 0, void 0, true, void 0, "c:de4db464");
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { Guide };
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { constrainCrop, containFrame, convertToPercentCrop, dragCrop } from "./geometry.js";
|
|
2
|
+
import { Gesture } from "./Gesture.web.js";
|
|
3
|
+
import { Guide } from "./Guide.web.js";
|
|
4
|
+
import { Source } from "./Source.web.js";
|
|
5
|
+
import { compilerMemoRegion, hookMemoCreate, hookMemoEqual, hookMemoPublish2 } from "octane/internal/client";
|
|
6
|
+
import { componentSlot, componentSlotVoid, forBlock, hookSlots, ifBlock, markChildrenBlock, useRef, withSlot } from "octane";
|
|
7
|
+
import { Absolute, View, useMeasure } from "@octane-xplat/ui";
|
|
8
|
+
//#region src/ImageCrop.tsrx
|
|
9
|
+
var _hs$ = /* @__PURE__ */ hookSlots(6);
|
|
10
|
+
var _h$0 = /* @__PURE__ */ Symbol(_hs$);
|
|
11
|
+
var _key$7 = (fraction) => fraction;
|
|
12
|
+
function __item$8(fraction, __s, __extra) {
|
|
13
|
+
const __block = __s.block;
|
|
14
|
+
const crop = __extra[0];
|
|
15
|
+
const left = __extra[1];
|
|
16
|
+
const top = __extra[2];
|
|
17
|
+
componentSlotVoid(__s, 0, __block.parentNode, Guide, {
|
|
18
|
+
"left": left + crop.width * fraction,
|
|
19
|
+
"top": top,
|
|
20
|
+
"style": {
|
|
21
|
+
width: 1,
|
|
22
|
+
height: crop.height,
|
|
23
|
+
backgroundColor: "rgba(255,255,255,0.53)"
|
|
24
|
+
}
|
|
25
|
+
}, __block.endMarker, void 0, 2, void 0, void 0, "c:0cff140c");
|
|
26
|
+
componentSlotVoid(__s, 1, __block.parentNode, Guide, {
|
|
27
|
+
"left": left,
|
|
28
|
+
"top": top + crop.height * fraction,
|
|
29
|
+
"style": {
|
|
30
|
+
width: crop.width,
|
|
31
|
+
height: 1,
|
|
32
|
+
backgroundColor: "rgba(255,255,255,0.53)"
|
|
33
|
+
}
|
|
34
|
+
}, __block.endMarker, void 0, 2, void 0, void 0, "c:fa385af7");
|
|
35
|
+
}
|
|
36
|
+
function __then$6(__props, __s, __extra) {
|
|
37
|
+
const __block = __s.block;
|
|
38
|
+
const crop = __extra[0];
|
|
39
|
+
const left = __extra[1];
|
|
40
|
+
const top = __extra[2];
|
|
41
|
+
forBlock(__s, 0, __block.parentNode, [1 / 3, 2 / 3], _key$7, __item$8, 8, [
|
|
42
|
+
crop,
|
|
43
|
+
left,
|
|
44
|
+
top,
|
|
45
|
+
Guide
|
|
46
|
+
], null, __block.endMarker);
|
|
47
|
+
}
|
|
48
|
+
var _key$13 = (handle) => handle;
|
|
49
|
+
function __item$14(handle, __s, __extra) {
|
|
50
|
+
const __block = __s.block;
|
|
51
|
+
const crop = __extra[0];
|
|
52
|
+
const frame = __extra[1];
|
|
53
|
+
const left = __extra[2];
|
|
54
|
+
const pan = __extra[3];
|
|
55
|
+
const props = __extra[4];
|
|
56
|
+
const top = __extra[5];
|
|
57
|
+
const hx = handle.includes("w") ? 0 : handle.includes("e") ? crop.width : crop.width / 2;
|
|
58
|
+
const hy = handle.includes("n") ? 0 : handle.includes("s") ? crop.height : crop.height / 2;
|
|
59
|
+
const x = Math.max(frame.x, Math.min(left + hx - 12, frame.x + frame.width - 24));
|
|
60
|
+
const y = Math.max(frame.y, Math.min(top + hy - 12, frame.y + frame.height - 24));
|
|
61
|
+
if (__s.block.pending && !__s.block.crossRenderUpdate) return;
|
|
62
|
+
componentSlotVoid(__s, 0, __block.parentNode, Gesture, {
|
|
63
|
+
"id": props.id ? props.id + "-" + handle : void 0,
|
|
64
|
+
"left": x,
|
|
65
|
+
"top": y,
|
|
66
|
+
"accessibilityLabel": "Resize crop " + handle,
|
|
67
|
+
"accessibilityState": { disabled: props.disabled },
|
|
68
|
+
"style": {
|
|
69
|
+
width: Math.min(24, frame.width),
|
|
70
|
+
height: Math.min(24, frame.height),
|
|
71
|
+
backgroundColor: props.disabled ? "rgba(255,255,255,0.27)" : "rgba(255,255,255,0.53)",
|
|
72
|
+
borderWidth: 1,
|
|
73
|
+
borderColor: "#ffffff"
|
|
74
|
+
},
|
|
75
|
+
"onPan": props.disabled ? void 0 : (e) => pan(handle, e)
|
|
76
|
+
}, __block.endMarker, void 0, 2, void 0, void 0, "c:69decbd7");
|
|
77
|
+
}
|
|
78
|
+
var __childrenBody = {};
|
|
79
|
+
var __childrenBody$ = {};
|
|
80
|
+
var handles = [
|
|
81
|
+
"nw",
|
|
82
|
+
"n",
|
|
83
|
+
"ne",
|
|
84
|
+
"e",
|
|
85
|
+
"se",
|
|
86
|
+
"s",
|
|
87
|
+
"sw",
|
|
88
|
+
"w"
|
|
89
|
+
];
|
|
90
|
+
var ImageCrop = function ImageCrop(props, __s, __extra) {
|
|
91
|
+
const __block = __s.block;
|
|
92
|
+
const __memoRegion = compilerMemoRegion(__s, _hs$ + 0);
|
|
93
|
+
let __hk = __memoRegion.hooks;
|
|
94
|
+
if (__hk === void 0) __memoRegion.hooks = __hk = hookMemoCreate(8);
|
|
95
|
+
let __hkd0, __hkd1, __hkd0$, __hkd1$;
|
|
96
|
+
const measure = withSlot(_h$0, useMeasure);
|
|
97
|
+
const viewport = {
|
|
98
|
+
width: Math.min(props.width, measure.bounds?.width ?? props.width),
|
|
99
|
+
height: Math.min(props.height, measure.bounds?.height ?? props.height)
|
|
100
|
+
};
|
|
101
|
+
const frame = (__hkd0 = viewport, __hkd1 = props, __hk[0] !== true || !hookMemoEqual(__hk[1], __hkd0) || !hookMemoEqual(__hk[2], __hkd1) ? hookMemoPublish2(__hk, 0, containFrame(viewport, {
|
|
102
|
+
width: props.imageWidth,
|
|
103
|
+
height: props.imageHeight
|
|
104
|
+
}), __hkd0, __hkd1) : __hk[3]);
|
|
105
|
+
const crop = (__hkd0$ = props, __hkd1$ = frame, __hk[4] !== true || !hookMemoEqual(__hk[5], __hkd0$) || !hookMemoEqual(__hk[6], __hkd1$) ? hookMemoPublish2(__hk, 4, constrainCrop(props.crop, frame, props), __hkd0$, __hkd1$) : __hk[7]);
|
|
106
|
+
const gesture = useRef(null, _hs$ + 1);
|
|
107
|
+
const pan = (handle, event) => {
|
|
108
|
+
if (props.disabled || frame.width <= 0 || frame.height <= 0) {
|
|
109
|
+
gesture.current = null;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (event.state === "began") {
|
|
113
|
+
gesture.current = {
|
|
114
|
+
handle,
|
|
115
|
+
start: crop,
|
|
116
|
+
size: frame,
|
|
117
|
+
dx: event.dx,
|
|
118
|
+
dy: event.dy,
|
|
119
|
+
src: props.src
|
|
120
|
+
};
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const active = gesture.current;
|
|
124
|
+
if (!active || active.handle !== handle) return;
|
|
125
|
+
if (event.state === "cancelled") {
|
|
126
|
+
gesture.current = null;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (active.src !== props.src || active.size.width !== frame.width || active.size.height !== frame.height) {
|
|
130
|
+
gesture.current = null;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const next = dragCrop(active.start, handle, event.dx - active.dx, event.dy - active.dy, frame, props);
|
|
134
|
+
const percent = convertToPercentCrop(next, frame);
|
|
135
|
+
props.onChange(next, percent);
|
|
136
|
+
if (event.state === "ended") {
|
|
137
|
+
gesture.current = null;
|
|
138
|
+
props.onComplete?.(next, percent);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const left = frame.x + crop.x, top = frame.y + crop.y;
|
|
142
|
+
if (__s.block.pending && !__s.block.crossRenderUpdate) return;
|
|
143
|
+
function __children$1(__props, __s, __extra) {
|
|
144
|
+
const __block = __s.block;
|
|
145
|
+
function __children$3(__props, __s, __extra) {
|
|
146
|
+
const __block = __s.block;
|
|
147
|
+
componentSlotVoid(__s, 0, __block.parentNode, Source, {
|
|
148
|
+
"src": props.src,
|
|
149
|
+
"alt": props.alt,
|
|
150
|
+
"width": viewport.width,
|
|
151
|
+
"height": viewport.height
|
|
152
|
+
}, __block.endMarker, void 0, 2, void 0, void 0, "c:43bdecbd");
|
|
153
|
+
componentSlotVoid(__s, 1, __block.parentNode, Gesture, {
|
|
154
|
+
"id": props.id ? props.id + "-move" : void 0,
|
|
155
|
+
"left": left,
|
|
156
|
+
"top": top,
|
|
157
|
+
"style": {
|
|
158
|
+
width: crop.width,
|
|
159
|
+
height: crop.height,
|
|
160
|
+
borderWidth: 1,
|
|
161
|
+
borderColor: "#ffffff",
|
|
162
|
+
backgroundColor: "rgba(255,255,255,0.13)"
|
|
163
|
+
},
|
|
164
|
+
"onPan": props.disabled ? void 0 : (e) => pan("move", e)
|
|
165
|
+
}, __block.endMarker, void 0, 2, void 0, void 0, "c:29511f8e");
|
|
166
|
+
{
|
|
167
|
+
const __ifCondition = props.ruleOfThirds;
|
|
168
|
+
ifBlock(__s, 2, __block.parentNode, __ifCondition, __then$6, null, __block.endMarker, __ifCondition ? [
|
|
169
|
+
crop,
|
|
170
|
+
left,
|
|
171
|
+
top
|
|
172
|
+
] : void 0);
|
|
173
|
+
}
|
|
174
|
+
forBlock(__s, 3, __block.parentNode, handles, _key$13, __item$14, 8 | (Gesture.$$singleRoot === true ? 2 : 0), [
|
|
175
|
+
crop,
|
|
176
|
+
frame,
|
|
177
|
+
left,
|
|
178
|
+
pan,
|
|
179
|
+
props,
|
|
180
|
+
top
|
|
181
|
+
], null, __block.endMarker);
|
|
182
|
+
}
|
|
183
|
+
componentSlot(__s, 0, __block.parentNode, Absolute, {
|
|
184
|
+
"style": viewport,
|
|
185
|
+
"children": markChildrenBlock(__children$3, __childrenBody)
|
|
186
|
+
}, __block.endMarker, void 0, void 0, void 0, void 0, "c:a85e82b5");
|
|
187
|
+
}
|
|
188
|
+
componentSlot(__s, 0, __block.parentNode, View, {
|
|
189
|
+
"id": props.id,
|
|
190
|
+
"ref": measure.ref,
|
|
191
|
+
"style": {
|
|
192
|
+
width: props.width,
|
|
193
|
+
height: props.height
|
|
194
|
+
},
|
|
195
|
+
"children": markChildrenBlock(__children$1, __childrenBody$)
|
|
196
|
+
}, __block.endMarker, void 0, void 0, true, void 0, "c:b2e40433");
|
|
197
|
+
};
|
|
198
|
+
//#endregion
|
|
199
|
+
export { ImageCrop };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { componentSlot, markWarm, registerWarmPlan, warmChild } from "octane";
|
|
2
|
+
import { Image } from "@octane-xplat/ui";
|
|
3
|
+
//#region src/Source.web.tsrx
|
|
4
|
+
var Source = /* @__PURE__ */ markWarm(function Source(props, __s, __extra) {
|
|
5
|
+
const __block = __s.block;
|
|
6
|
+
const __warmPlan = Source.__warm;
|
|
7
|
+
if (typeof __warmPlan === "function") registerWarmPlan(__warmPlan, props);
|
|
8
|
+
else registerWarmPlan(() => {
|
|
9
|
+
warmChild(Image, {
|
|
10
|
+
src: props.src,
|
|
11
|
+
alt: props.alt,
|
|
12
|
+
style: {
|
|
13
|
+
width: props.width,
|
|
14
|
+
height: props.height,
|
|
15
|
+
objectFit: "contain",
|
|
16
|
+
pointerEvents: "none",
|
|
17
|
+
userSelect: "none"
|
|
18
|
+
},
|
|
19
|
+
web: { draggable: false }
|
|
20
|
+
});
|
|
21
|
+
}, void 0);
|
|
22
|
+
componentSlot(__s, 0, __block.parentNode, Image, {
|
|
23
|
+
"src": props.src,
|
|
24
|
+
"alt": props.alt,
|
|
25
|
+
"style": {
|
|
26
|
+
width: props.width,
|
|
27
|
+
height: props.height,
|
|
28
|
+
objectFit: "contain",
|
|
29
|
+
pointerEvents: "none",
|
|
30
|
+
userSelect: "none"
|
|
31
|
+
},
|
|
32
|
+
"web": { draggable: false }
|
|
33
|
+
}, __block.endMarker, void 0, void 0, true, void 0, "c:5695e414");
|
|
34
|
+
}, (__wp) => {
|
|
35
|
+
const props = __wp;
|
|
36
|
+
warmChild(Image, {
|
|
37
|
+
src: props.src,
|
|
38
|
+
alt: props.alt,
|
|
39
|
+
style: {
|
|
40
|
+
width: props.width,
|
|
41
|
+
height: props.height,
|
|
42
|
+
objectFit: "contain",
|
|
43
|
+
pointerEvents: "none",
|
|
44
|
+
userSelect: "none"
|
|
45
|
+
},
|
|
46
|
+
web: { draggable: false }
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
//#endregion
|
|
50
|
+
export { Source };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
//#region src/geometry.ts
|
|
2
|
+
var positive = (n) => Number.isFinite(n) && n > 0 ? n : 0;
|
|
3
|
+
var finite = (n) => Number.isFinite(n) ? n : 0;
|
|
4
|
+
var clamp = (n, low, high) => Math.min(Math.max(n, Math.min(low, high)), high);
|
|
5
|
+
/** Displayed image rectangle inside a centered contain-fit viewport. */
|
|
6
|
+
function containFrame(view, natural) {
|
|
7
|
+
const scale = natural.width > 0 && natural.height > 0 ? Math.min(positive(view.width) / natural.width, positive(view.height) / natural.height) : 0;
|
|
8
|
+
const width = positive(natural.width * scale), height = positive(natural.height * scale);
|
|
9
|
+
return {
|
|
10
|
+
x: (positive(view.width) - width) / 2,
|
|
11
|
+
y: (positive(view.height) - height) / 2,
|
|
12
|
+
width,
|
|
13
|
+
height
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function convertToPixelCrop(crop, size) {
|
|
17
|
+
const sx = crop.unit === "%" ? positive(size.width) / 100 : 1;
|
|
18
|
+
const sy = crop.unit === "%" ? positive(size.height) / 100 : 1;
|
|
19
|
+
return {
|
|
20
|
+
unit: "px",
|
|
21
|
+
x: finite(crop.x) * sx,
|
|
22
|
+
y: finite(crop.y) * sy,
|
|
23
|
+
width: positive(crop.width) * sx,
|
|
24
|
+
height: positive(crop.height) * sy
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function convertToPercentCrop(crop, size) {
|
|
28
|
+
const p = convertToPixelCrop(crop, size);
|
|
29
|
+
return {
|
|
30
|
+
unit: "%",
|
|
31
|
+
x: size.width > 0 ? p.x / size.width * 100 : 0,
|
|
32
|
+
y: size.height > 0 ? p.y / size.height * 100 : 0,
|
|
33
|
+
width: size.width > 0 ? p.width / size.width * 100 : 0,
|
|
34
|
+
height: size.height > 0 ? p.height / size.height * 100 : 0
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Convert displayed-image coordinates to natural pixels for a separate pixel exporter. */
|
|
38
|
+
function toNaturalCrop(crop, displayed, natural) {
|
|
39
|
+
return convertToPixelCrop(convertToPercentCrop(crop, displayed), natural);
|
|
40
|
+
}
|
|
41
|
+
function fromNaturalCrop(crop, natural, displayed) {
|
|
42
|
+
return convertToPixelCrop(convertToPercentCrop(crop, natural), displayed);
|
|
43
|
+
}
|
|
44
|
+
/** Clamp external controlled input. Impossible minimums yield to bounds/maxima. */
|
|
45
|
+
function constrainCrop(crop, size, limits = {}) {
|
|
46
|
+
const p = convertToPixelCrop(crop, size);
|
|
47
|
+
const maxW = Math.min(positive(size.width), limits.maxWidth === void 0 ? Infinity : positive(limits.maxWidth));
|
|
48
|
+
const maxH = Math.min(positive(size.height), limits.maxHeight === void 0 ? Infinity : positive(limits.maxHeight));
|
|
49
|
+
let width = clamp(p.width, positive(limits.minWidth ?? 0), maxW);
|
|
50
|
+
let height = clamp(p.height, positive(limits.minHeight ?? 0), maxH);
|
|
51
|
+
const aspect = positive(limits.aspect ?? 0);
|
|
52
|
+
if (aspect) {
|
|
53
|
+
width = clamp(width, Math.max(positive(limits.minWidth ?? 0), positive(limits.minHeight ?? 0) * aspect), Math.min(maxW, maxH * aspect));
|
|
54
|
+
height = width / aspect;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
unit: "px",
|
|
58
|
+
x: clamp(p.x, 0, positive(size.width) - width),
|
|
59
|
+
y: clamp(p.y, 0, positive(size.height) - height),
|
|
60
|
+
width,
|
|
61
|
+
height
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Resize from a frozen gesture origin using cumulative view-space deltas. */
|
|
65
|
+
function dragCrop(start, handle, dx, dy, size, limits = {}) {
|
|
66
|
+
const p = constrainCrop(start, size, limits);
|
|
67
|
+
dx = finite(dx);
|
|
68
|
+
dy = finite(dy);
|
|
69
|
+
if (handle === "move") return {
|
|
70
|
+
...p,
|
|
71
|
+
x: clamp(p.x + dx, 0, size.width - p.width),
|
|
72
|
+
y: clamp(p.y + dy, 0, size.height - p.height)
|
|
73
|
+
};
|
|
74
|
+
const west = handle.includes("w"), east = handle.includes("e");
|
|
75
|
+
const north = handle.includes("n"), south = handle.includes("s");
|
|
76
|
+
const ax = west ? p.x + p.width : east ? p.x : p.x + p.width / 2;
|
|
77
|
+
const ay = north ? p.y + p.height : south ? p.y : p.y + p.height / 2;
|
|
78
|
+
const boundW = west ? ax : east ? size.width - ax : 2 * Math.min(ax, size.width - ax);
|
|
79
|
+
const boundH = north ? ay : south ? size.height - ay : 2 * Math.min(ay, size.height - ay);
|
|
80
|
+
const maxW = Math.min(boundW, limits.maxWidth ?? Infinity);
|
|
81
|
+
const maxH = Math.min(boundH, limits.maxHeight ?? Infinity);
|
|
82
|
+
let width = p.width + (west ? -dx : east ? dx : 0);
|
|
83
|
+
let height = p.height + (north ? -dy : south ? dy : 0);
|
|
84
|
+
const aspect = positive(limits.aspect ?? 0);
|
|
85
|
+
if (aspect) {
|
|
86
|
+
if (!west && !east || (north || south) && Math.abs(dy * aspect) > Math.abs(dx)) width = height * aspect;
|
|
87
|
+
width = clamp(width, Math.max(positive(limits.minWidth ?? 0), positive(limits.minHeight ?? 0) * aspect), Math.max(0, Math.min(maxW, maxH * aspect)));
|
|
88
|
+
height = width / aspect;
|
|
89
|
+
} else {
|
|
90
|
+
width = clamp(width, positive(limits.minWidth ?? 0), Math.max(0, maxW));
|
|
91
|
+
height = clamp(height, positive(limits.minHeight ?? 0), Math.max(0, maxH));
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
unit: "px",
|
|
95
|
+
x: west ? ax - width : east ? ax : ax - width / 2,
|
|
96
|
+
y: north ? ay - height : south ? ay : ay - height / 2,
|
|
97
|
+
width,
|
|
98
|
+
height
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { constrainCrop, containFrame, convertToPercentCrop, convertToPixelCrop, dragCrop, fromNaturalCrop, toNaturalCrop };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { constrainCrop, containFrame, convertToPercentCrop, convertToPixelCrop, dragCrop, fromNaturalCrop, toNaturalCrop } from "./geometry.js";
|
|
2
|
+
import { ImageCrop } from "./ImageCrop.js";
|
|
3
|
+
export { ImageCrop, constrainCrop, containFrame, convertToPercentCrop, convertToPixelCrop, dragCrop, fromNaturalCrop, toNaturalCrop };
|
package/package.json
CHANGED
|
@@ -1,11 +1,89 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/image-crop",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Inline controlled image crop selection for Octane Xplat web, iOS, Android and macOS",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"crop",
|
|
7
|
+
"cross-platform",
|
|
8
|
+
"image",
|
|
9
|
+
"nativescript",
|
|
10
|
+
"octane"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/image-crop",
|
|
5
13
|
"license": "MIT",
|
|
6
14
|
"repository": {
|
|
7
15
|
"type": "git",
|
|
8
16
|
"url": "https://github.com/octane-xplat/octane-xplat",
|
|
9
17
|
"directory": "packages/image-crop"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"types",
|
|
22
|
+
"src",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"macos": {
|
|
29
|
+
"types": "./types/generated/index.d.ts",
|
|
30
|
+
"default": "./src/index.macos.ts"
|
|
31
|
+
},
|
|
32
|
+
"types": "./types/generated/index.d.ts",
|
|
33
|
+
"web": "./dist/web/index.js",
|
|
34
|
+
"native": "./dist/native/index.js",
|
|
35
|
+
"default": "./dist/native/index.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@octane-xplat/ui": "0.10.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@nativescript-community/octane": "0.2.4",
|
|
46
|
+
"@nativescript/core": "9.1.2",
|
|
47
|
+
"@octane-xplat/macos-renderer": "0.10.0",
|
|
48
|
+
"@octanejs/vite-plugin": "0.1.61",
|
|
49
|
+
"@tsrx/typescript-plugin": "0.4.11",
|
|
50
|
+
"octane": "0.6.3",
|
|
51
|
+
"tsrx-typegen": "0.2.2",
|
|
52
|
+
"typescript": "5.9.3",
|
|
53
|
+
"vite": "8.3.0",
|
|
54
|
+
"vitest": "5.0.1"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
58
|
+
"@nativescript/core": ">=9.1.0 <10",
|
|
59
|
+
"@octane-xplat/macos-renderer": ">=0.8.0 <1",
|
|
60
|
+
"octane": ">=0.6.3 <1"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"@nativescript-community/octane": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"@nativescript/core": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@octane-xplat/macos-renderer": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"octane": {
|
|
73
|
+
"optional": true
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"nativescript": {
|
|
77
|
+
"platforms": {
|
|
78
|
+
"android": "9.1.0",
|
|
79
|
+
"ios": "9.1.0"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"scripts": {
|
|
83
|
+
"build": "vite build && vite build --mode native && pnpm typegen && node ../../scripts/check-native-dist.mjs",
|
|
84
|
+
"typegen": "tsrx-typegen --target crop",
|
|
85
|
+
"typecheck": "tsrx-tsc --noEmit -p tsconfig.json && tsrx-tsc --noEmit -p tsconfig.mobile.json && tsrx-tsc --noEmit -p tsconfig.macos.json",
|
|
86
|
+
"test": "vitest run tests/geometry.test.ts",
|
|
87
|
+
"pack:check": "tsrx-typegen --pack-check"
|
|
10
88
|
}
|
|
11
|
-
}
|
|
89
|
+
}
|
package/src/Gesture.tsrx
ADDED
package/src/Guide.tsrx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import { View } from '@octane-xplat/ui'
|
|
3
|
+
import type { ViewProps } from '@octane-xplat/ui'
|
|
4
|
+
export function Guide(props: ViewProps) @{
|
|
5
|
+
<View {...props} ios={{ isUserInteractionEnabled: false }} android={{ isUserInteractionEnabled: false }} />
|
|
6
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useRef } from 'octane'
|
|
2
|
+
import { Absolute, View, useMeasure } from '@octane-xplat/ui'
|
|
3
|
+
import type { PanEvent } from '@octane-xplat/ui'
|
|
4
|
+
import type { CropHandle, ImageCropProps, PixelCrop, Size } from './props'
|
|
5
|
+
import { constrainCrop, containFrame, convertToPercentCrop, dragCrop } from './geometry'
|
|
6
|
+
import { Gesture } from './Gesture'
|
|
7
|
+
import { Guide } from './Guide'
|
|
8
|
+
import { Source } from './Source'
|
|
9
|
+
|
|
10
|
+
const handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const
|
|
11
|
+
|
|
12
|
+
/** Inline controlled selection. View.onPan delegates to the UI usePan leaves. */
|
|
13
|
+
export function ImageCrop(props: ImageCropProps) @{
|
|
14
|
+
const measure = useMeasure()
|
|
15
|
+
// Root hosts may offer more space than the explicitly sized image viewport.
|
|
16
|
+
const viewport = { width: Math.min(props.width, measure.bounds?.width ?? props.width), height: Math.min(props.height, measure.bounds?.height ?? props.height) }
|
|
17
|
+
const frame = containFrame(viewport, { width: props.imageWidth, height: props.imageHeight })
|
|
18
|
+
const crop = constrainCrop(props.crop, frame, props)
|
|
19
|
+
const gesture = useRef<{ handle: CropHandle; start: PixelCrop; size: Size; dx: number; dy: number; src: string } | null>(null)
|
|
20
|
+
const pan = (handle: CropHandle, event: PanEvent) => {
|
|
21
|
+
if (props.disabled || frame.width <= 0 || frame.height <= 0) { gesture.current = null; return }
|
|
22
|
+
if (event.state === 'began') {
|
|
23
|
+
gesture.current = { handle, start: crop, size: frame, dx: event.dx, dy: event.dy, src: props.src }
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const active = gesture.current
|
|
28
|
+
if (!active || active.handle !== handle) return
|
|
29
|
+
if (event.state === 'cancelled') { gesture.current = null; return }
|
|
30
|
+
// Layout/source changes invalidate a gesture rather than mixing scales.
|
|
31
|
+
if (active.src !== props.src || active.size.width !== frame.width || active.size.height !== frame.height) { gesture.current = null; return }
|
|
32
|
+
const next = dragCrop(active.start, handle, event.dx - active.dx, event.dy - active.dy, frame, props)
|
|
33
|
+
const percent = convertToPercentCrop(next, frame)
|
|
34
|
+
props.onChange(next, percent)
|
|
35
|
+
if (event.state === 'ended') {
|
|
36
|
+
gesture.current = null
|
|
37
|
+
props.onComplete?.(next, percent)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const left = frame.x + crop.x, top = frame.y + crop.y
|
|
42
|
+
|
|
43
|
+
<View id={props.id} ref={measure.ref} style={{ width: props.width, height: props.height }}>
|
|
44
|
+
<Absolute style={viewport}>
|
|
45
|
+
<Source src={props.src} alt={props.alt} width={viewport.width} height={viewport.height} />
|
|
46
|
+
<Gesture id={props.id ? props.id + '-move' : undefined} left={left} top={top}
|
|
47
|
+
style={{ width: crop.width, height: crop.height, borderWidth: 1, borderColor: '#ffffff', backgroundColor: 'rgba(255,255,255,0.13)' }}
|
|
48
|
+
onPan={props.disabled ? undefined : (e: PanEvent) => pan('move', e)} />
|
|
49
|
+
@if (props.ruleOfThirds) {
|
|
50
|
+
@for (const fraction of [1 / 3, 2 / 3]; key fraction) {
|
|
51
|
+
<>
|
|
52
|
+
<Guide left={left + crop.width * fraction} top={top} style={{ width: 1, height: crop.height, backgroundColor: 'rgba(255,255,255,0.53)' }} />
|
|
53
|
+
<Guide left={left} top={top + crop.height * fraction} style={{ width: crop.width, height: 1, backgroundColor: 'rgba(255,255,255,0.53)' }} />
|
|
54
|
+
</>
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
@for (const handle of handles; key handle) {
|
|
58
|
+
const hx = handle.includes('w') ? 0 : handle.includes('e') ? crop.width : crop.width / 2
|
|
59
|
+
const hy = handle.includes('n') ? 0 : handle.includes('s') ? crop.height : crop.height / 2
|
|
60
|
+
// Keep hit regions inside the image, including at image edges.
|
|
61
|
+
const x = Math.max(frame.x, Math.min(left + hx - 12, frame.x + frame.width - 24))
|
|
62
|
+
const y = Math.max(frame.y, Math.min(top + hy - 12, frame.y + frame.height - 24))
|
|
63
|
+
<Gesture id={props.id ? props.id + '-' + handle : undefined} left={x} top={y}
|
|
64
|
+
accessibilityLabel={'Resize crop ' + handle} accessibilityState={{ disabled: props.disabled }}
|
|
65
|
+
style={{ width: Math.min(24, frame.width), height: Math.min(24, frame.height), backgroundColor: props.disabled ? 'rgba(255,255,255,0.27)' : 'rgba(255,255,255,0.53)', borderWidth: 1, borderColor: '#ffffff' }}
|
|
66
|
+
onPan={props.disabled ? undefined : (e: PanEvent) => pan(handle, e)} />
|
|
67
|
+
}
|
|
68
|
+
</Absolute>
|
|
69
|
+
</View>
|
|
70
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** @jsxImportSource @octane-xplat/macos-renderer */
|
|
2
|
+
import { Image } from '@octane-xplat/ui'
|
|
3
|
+
import type { ImageCropProps } from './props'
|
|
4
|
+
export function Source(props: Pick<ImageCropProps, 'src' | 'alt' | 'width' | 'height'>) @{
|
|
5
|
+
<Image src={props.src} alt={props.alt} style={{ width: props.width, height: props.height, objectFit: 'contain', pointerEvents: 'none' }} />
|
|
6
|
+
}
|
package/src/Source.tsrx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import { Image } from '@octane-xplat/ui'
|
|
3
|
+
import type { ImageCropProps } from './props'
|
|
4
|
+
export function Source(props: Pick<ImageCropProps, 'src' | 'alt' | 'width' | 'height'>) @{
|
|
5
|
+
<Image src={props.src} alt={props.alt} style={{ width: props.width, height: props.height }}
|
|
6
|
+
ios={{ stretch: 'aspectFit', isUserInteractionEnabled: false }}
|
|
7
|
+
android={{ stretch: 'aspectFit', isUserInteractionEnabled: false }} />
|
|
8
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Image } from '@octane-xplat/ui'
|
|
2
|
+
import type { ImageCropProps } from './props'
|
|
3
|
+
export function Source(props: Pick<ImageCropProps, 'src' | 'alt' | 'width' | 'height'>) @{
|
|
4
|
+
<Image src={props.src} alt={props.alt} style={{ width: props.width, height: props.height, objectFit: 'contain', pointerEvents: 'none', userSelect: 'none' }} web={{ draggable: false }} />
|
|
5
|
+
}
|