@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -3
  3. package/dist/native/Gesture.js +12 -0
  4. package/dist/native/Guide.js +24 -0
  5. package/dist/native/ImageCrop.js +295 -0
  6. package/dist/native/Source.js +83 -0
  7. package/dist/native/geometry.js +102 -0
  8. package/dist/native/index.js +3 -0
  9. package/dist/native/octane-client-build.json +8 -0
  10. package/dist/web/Gesture.web.js +17 -0
  11. package/dist/web/Guide.web.js +15 -0
  12. package/dist/web/ImageCrop.js +199 -0
  13. package/dist/web/Source.web.js +50 -0
  14. package/dist/web/geometry.js +102 -0
  15. package/dist/web/index.js +3 -0
  16. package/dist/web/octane-client-build.json +8 -0
  17. package/package.json +81 -3
  18. package/src/Gesture.macos.tsrx +6 -0
  19. package/src/Gesture.tsrx +6 -0
  20. package/src/Gesture.web.tsrx +5 -0
  21. package/src/Guide.macos.tsrx +6 -0
  22. package/src/Guide.tsrx +6 -0
  23. package/src/Guide.web.tsrx +5 -0
  24. package/src/ImageCrop.tsrx +70 -0
  25. package/src/Source.macos.tsrx +6 -0
  26. package/src/Source.tsrx +8 -0
  27. package/src/Source.web.tsrx +5 -0
  28. package/src/geometry.ts +163 -0
  29. package/src/index.macos.ts +12 -0
  30. package/src/index.ts +12 -0
  31. package/src/index.web.ts +12 -0
  32. package/src/props.ts +57 -0
  33. package/types/generated/.tsrx-typegen-manifest.json +20 -0
  34. package/types/generated/Gesture.d.ts +2 -0
  35. package/types/generated/Gesture.macos.d.ts +2 -0
  36. package/types/generated/Gesture.web.d.ts +2 -0
  37. package/types/generated/Guide.d.ts +2 -0
  38. package/types/generated/Guide.macos.d.ts +2 -0
  39. package/types/generated/Guide.web.d.ts +2 -0
  40. package/types/generated/ImageCrop.d.ts +3 -0
  41. package/types/generated/Source.d.ts +2 -0
  42. package/types/generated/Source.macos.d.ts +2 -0
  43. package/types/generated/Source.web.d.ts +2 -0
  44. package/types/generated/geometry.d.ts +12 -0
  45. package/types/generated/index.d.ts +3 -0
  46. package/types/generated/index.macos.d.ts +3 -0
  47. package/types/generated/index.web.d.ts +3 -0
  48. 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 };
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": 1,
3
+ "buildId": "4d71b428-eff6-4252-a6e5-04b9651fb264",
4
+ "mode": "production",
5
+ "capabilities": {
6
+ "independentHydration": false
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,11 +1,89 @@
1
1
  {
2
2
  "name": "@octane-xplat/image-crop",
3
- "version": "0.0.1",
4
- "description": "Placeholder stub for @octane-xplat/image-crop — no supported API yet.",
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
+ }
@@ -0,0 +1,6 @@
1
+ /** @jsxImportSource @octane-xplat/macos-renderer */
2
+ import { View } from '@octane-xplat/ui'
3
+ import type { ViewProps } from '@octane-xplat/ui'
4
+ export function Gesture(props: ViewProps) @{
5
+ <View {...props} />
6
+ }
@@ -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 Gesture(props: ViewProps) @{
5
+ <View {...props} />
6
+ }
@@ -0,0 +1,5 @@
1
+ import { View } from '@octane-xplat/ui'
2
+ import type { ViewProps } from '@octane-xplat/ui'
3
+ export function Gesture(props: ViewProps) @{
4
+ <View {...props} style={{ ...props.style, touchAction: 'none', userSelect: 'none', boxSizing: 'border-box' }} />
5
+ }
@@ -0,0 +1,6 @@
1
+ /** @jsxImportSource @octane-xplat/macos-renderer */
2
+ import { View } from '@octane-xplat/ui'
3
+ import type { ViewProps } from '@octane-xplat/ui'
4
+ export function Guide(props: ViewProps) @{
5
+ <View {...props} style={{ ...props.style, pointerEvents: 'none' }} />
6
+ }
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,5 @@
1
+ import { View } from '@octane-xplat/ui'
2
+ import type { ViewProps } from '@octane-xplat/ui'
3
+ export function Guide(props: ViewProps) @{
4
+ <View {...props} style={{ ...props.style, pointerEvents: 'none' }} />
5
+ }
@@ -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
+ }
@@ -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
+ }