pixedi 1.0.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 (110) hide show
  1. package/.github/workflows/deploy.yml +54 -0
  2. package/.prettierr +9 -0
  3. package/.storybook/main.ts +16 -0
  4. package/.storybook/preview.ts +53 -0
  5. package/.vscode/extensions.json +6 -0
  6. package/README.md +75 -0
  7. package/eslint.config.js +22 -0
  8. package/index.html +13 -0
  9. package/package.json +57 -0
  10. package/pnpm-workspace.yaml +2 -0
  11. package/public/favicon.svg +1 -0
  12. package/src/app/App.tsx +10 -0
  13. package/src/app/entries/index.ts +145 -0
  14. package/src/app/providers/RouterProvider.tsx +6 -0
  15. package/src/app/providers/index.ts +1 -0
  16. package/src/app/router/AppRouter.tsx +12 -0
  17. package/src/app/router/index.ts +0 -0
  18. package/src/app/styles/index.css +4 -0
  19. package/src/features/gallery/GalleryPage.tsx +33 -0
  20. package/src/features/gallery/ImageEditPage.tsx +37 -0
  21. package/src/features/gallery/index.ts +2 -0
  22. package/src/main.tsx +10 -0
  23. package/src/shared/components/ImageCard/ImageCard.tsx +54 -0
  24. package/src/shared/components/ImageCard/index.ts +1 -0
  25. package/src/shared/components/ImageEditor/ImageEditor.tsx +48 -0
  26. package/src/shared/components/ImageEditor/assets/icons.tsx +114 -0
  27. package/src/shared/components/ImageEditor/constants.ts +118 -0
  28. package/src/shared/components/ImageEditor/crop/Crop.tsx +60 -0
  29. package/src/shared/components/ImageEditor/crop/CropBorders.tsx +28 -0
  30. package/src/shared/components/ImageEditor/crop/CropLines.tsx +8 -0
  31. package/src/shared/components/ImageEditor/crop/CropPointer.tsx +21 -0
  32. package/src/shared/components/ImageEditor/crop/CropPointers.tsx +44 -0
  33. package/src/shared/components/ImageEditor/crop/CropTools.tsx +138 -0
  34. package/src/shared/components/ImageEditor/crop/crop.css +180 -0
  35. package/src/shared/components/ImageEditor/crop/index.ts +8 -0
  36. package/src/shared/components/ImageEditor/crop/useCropInteraction.ts +230 -0
  37. package/src/shared/components/ImageEditor/eventBus.ts +8 -0
  38. package/src/shared/components/ImageEditor/frame/Frame.tsx +48 -0
  39. package/src/shared/components/ImageEditor/frame/frame.css +40 -0
  40. package/src/shared/components/ImageEditor/frame/index.ts +1 -0
  41. package/src/shared/components/ImageEditor/header/Header.tsx +81 -0
  42. package/src/shared/components/ImageEditor/header/header.css +27 -0
  43. package/src/shared/components/ImageEditor/header/index.ts +1 -0
  44. package/src/shared/components/ImageEditor/hooks/index.ts +5 -0
  45. package/src/shared/components/ImageEditor/hooks/useAbove.tsx +18 -0
  46. package/src/shared/components/ImageEditor/hooks/useBellow.tsx +18 -0
  47. package/src/shared/components/ImageEditor/hooks/useImageLoader.tsx +110 -0
  48. package/src/shared/components/ImageEditor/hooks/useImageProcessor.test.tsx +147 -0
  49. package/src/shared/components/ImageEditor/hooks/useImageProcessor.ts +225 -0
  50. package/src/shared/components/ImageEditor/hooks/useMobile.tsx +33 -0
  51. package/src/shared/components/ImageEditor/index.css +145 -0
  52. package/src/shared/components/ImageEditor/index.ts +1 -0
  53. package/src/shared/components/ImageEditor/provider/ImageEditorProvider.test.tsx +126 -0
  54. package/src/shared/components/ImageEditor/provider/ImageEditorProvider.tsx +107 -0
  55. package/src/shared/components/ImageEditor/provider/StoreContext.tsx +20 -0
  56. package/src/shared/components/ImageEditor/provider/initialState.ts +30 -0
  57. package/src/shared/components/ImageEditor/provider/useImageEditorContext.tsx +12 -0
  58. package/src/shared/components/ImageEditor/resize/ResizeTools.tsx +183 -0
  59. package/src/shared/components/ImageEditor/resize/index.ts +1 -0
  60. package/src/shared/components/ImageEditor/resize/resize.css +56 -0
  61. package/src/shared/components/ImageEditor/sidebar/Sidebar.tsx +54 -0
  62. package/src/shared/components/ImageEditor/sidebar/SidebarCrop.tsx +57 -0
  63. package/src/shared/components/ImageEditor/sidebar/SidebarPresets.tsx +54 -0
  64. package/src/shared/components/ImageEditor/sidebar/SidebarResize.tsx +26 -0
  65. package/src/shared/components/ImageEditor/sidebar/index.ts +1 -0
  66. package/src/shared/components/ImageEditor/sidebar/sidebar.css +101 -0
  67. package/src/shared/components/ImageEditor/types.ts +59 -0
  68. package/src/shared/components/ImageEditor/ui/button/Button.stories.tsx +42 -0
  69. package/src/shared/components/ImageEditor/ui/button/Button.tsx +21 -0
  70. package/src/shared/components/ImageEditor/ui/button/button.css +69 -0
  71. package/src/shared/components/ImageEditor/ui/button-crop/ButtonCrop.stories.tsx +26 -0
  72. package/src/shared/components/ImageEditor/ui/button-crop/ButtonCrop.tsx +29 -0
  73. package/src/shared/components/ImageEditor/ui/button-crop/button-crop.css +38 -0
  74. package/src/shared/components/ImageEditor/ui/index.ts +6 -0
  75. package/src/shared/components/ImageEditor/ui/input/Input.stories.tsx +45 -0
  76. package/src/shared/components/ImageEditor/ui/input/Input.tsx +20 -0
  77. package/src/shared/components/ImageEditor/ui/input/input.css +48 -0
  78. package/src/shared/components/ImageEditor/ui/input-pixel/InputPixel.stories.tsx +33 -0
  79. package/src/shared/components/ImageEditor/ui/input-pixel/InputPixel.tsx +23 -0
  80. package/src/shared/components/ImageEditor/ui/input-pixel/input-pixel.css +24 -0
  81. package/src/shared/components/ImageEditor/ui/select/Select.stories.tsx +67 -0
  82. package/src/shared/components/ImageEditor/ui/select/Select.tsx +161 -0
  83. package/src/shared/components/ImageEditor/ui/select/select.css +147 -0
  84. package/src/shared/components/ImageEditor/ui/separator/Separator.stories.tsx +37 -0
  85. package/src/shared/components/ImageEditor/ui/separator/Separator.tsx +27 -0
  86. package/src/shared/components/ImageEditor/ui/separator/separator.css +19 -0
  87. package/src/shared/components/ImageEditor/utils.test.ts +136 -0
  88. package/src/shared/components/ImageEditor/utils.ts +497 -0
  89. package/src/shared/components/ui/index.ts +1 -0
  90. package/src/shared/components/ui/preloader.tsx +78 -0
  91. package/src/shared/config/index.ts +3 -0
  92. package/src/shared/config/routes.ts +4 -0
  93. package/src/shared/features/gallery/GalleryPage.tsx +33 -0
  94. package/src/shared/features/gallery/ImageEditPage.tsx +37 -0
  95. package/src/shared/features/gallery/index.ts +2 -0
  96. package/src/shared/hooks/index.ts +1 -0
  97. package/src/shared/hooks/useImagePreload.tsx +49 -0
  98. package/src/shared/types/index.ts +11 -0
  99. package/src/test/setup.ts +14 -0
  100. package/src/virtual-css-injected-by-js.d.ts +8 -0
  101. package/src/widget.tsx +119 -0
  102. package/tsconfig.app.json +29 -0
  103. package/tsconfig.json +12 -0
  104. package/tsconfig.lib.json +18 -0
  105. package/tsconfig.node.json +23 -0
  106. package/vite-env.d.ts +1 -0
  107. package/vite.config.ts +24 -0
  108. package/vite.lib.config.ts +33 -0
  109. package/vite.widget.config.ts +39 -0
  110. package/wrangler.json +5 -0
@@ -0,0 +1,110 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ const mimeToExt: Record<string, string> = {
4
+ "image/jpeg": "jpg",
5
+ "image/jpg": "jpg",
6
+ "image/png": "png",
7
+ "image/gif": "gif",
8
+ "image/webp": "webp",
9
+ };
10
+
11
+ type UseImageResult = {
12
+ loading: boolean;
13
+ error: string | null;
14
+ width: number;
15
+ height: number;
16
+ extension: string | null;
17
+ base64: string | null;
18
+ };
19
+
20
+ export const useImageLoader = (src: string): UseImageResult => {
21
+ const [state, setState] = useState<UseImageResult>({
22
+ loading: true,
23
+ error: null,
24
+ width: 0,
25
+ height: 0,
26
+ extension: null,
27
+ base64: null,
28
+ });
29
+
30
+ useEffect(() => {
31
+ let isMounted = true;
32
+
33
+ const processImage = async () => {
34
+ try {
35
+ const response = await fetch(src);
36
+ if (!response.ok) {
37
+ throw new Error(`HTTP error! Status: ${response.status}`);
38
+ }
39
+
40
+ const mimeType = response.headers.get("content-type") || "";
41
+ const cleanMime = mimeType.split(";")[0].toLowerCase().trim();
42
+ const isImg = cleanMime.startsWith("image/");
43
+
44
+ const extension = mimeToExt[cleanMime] || "unknown";
45
+
46
+ if (!isImg) {
47
+ if (isMounted) {
48
+ setState((prev) => ({
49
+ ...prev,
50
+ loading: false,
51
+ error: "The requested resource is not a valid image.",
52
+ extension,
53
+ }));
54
+ }
55
+ return;
56
+ }
57
+
58
+ const blob = await response.blob();
59
+ const base64String = await new Promise<string>((resolve, reject) => {
60
+ const reader = new FileReader();
61
+ reader.onloadend = () => resolve(reader.result as string);
62
+ reader.onerror = () =>
63
+ reject(new Error("Failed to read image data stream"));
64
+ reader.readAsDataURL(blob);
65
+ });
66
+
67
+ const dimensions = await new Promise<{ width: number; height: number }>(
68
+ (resolve, reject) => {
69
+ const img = new Image();
70
+ img.src = base64String;
71
+ img.onload = () =>
72
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
73
+ img.onerror = () =>
74
+ reject(new Error("Failed to parse image dimensions"));
75
+ },
76
+ );
77
+
78
+ if (isMounted) {
79
+ setState({
80
+ loading: false,
81
+ error: null,
82
+ width: dimensions.width,
83
+ height: dimensions.height,
84
+ extension,
85
+ base64: base64String,
86
+ });
87
+ }
88
+ } catch (err: unknown) {
89
+ if (isMounted) {
90
+ setState((prev) => ({
91
+ ...prev,
92
+ loading: false,
93
+ error:
94
+ err instanceof Error
95
+ ? err.message
96
+ : "An error occurred during extraction.",
97
+ }));
98
+ }
99
+ }
100
+ };
101
+
102
+ processImage();
103
+
104
+ return () => {
105
+ isMounted = false;
106
+ };
107
+ }, [src]);
108
+
109
+ return state;
110
+ };
@@ -0,0 +1,147 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useImageProcessor } from "./useImageProcessor";
4
+
5
+ const bitmap = {
6
+ width: 120,
7
+ height: 80,
8
+ close: vi.fn(),
9
+ };
10
+
11
+ const context = {
12
+ clearRect: vi.fn(),
13
+ drawImage: vi.fn(),
14
+ filter: "",
15
+ restore: vi.fn(),
16
+ rotate: vi.fn(),
17
+ save: vi.fn(),
18
+ scale: vi.fn(),
19
+ translate: vi.fn(),
20
+ };
21
+
22
+ const validPng = "data:image/png;base64,aGVsbG8=";
23
+ const validJpeg = "data:image/jpeg;base64,aGVsbG8=";
24
+
25
+ describe("useImageProcessor", () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ vi.stubGlobal("createImageBitmap", vi.fn().mockResolvedValue(bitmap));
29
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
30
+ context as unknown as CanvasRenderingContext2D,
31
+ );
32
+ vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(
33
+ "data:image/png;base64,processed",
34
+ );
35
+ });
36
+
37
+ afterEach(() => {
38
+ vi.restoreAllMocks();
39
+ vi.unstubAllGlobals();
40
+ });
41
+
42
+ it("rejects malformed Base64 before attempting to decode an image", async () => {
43
+ const { result } = renderHook(() => useImageProcessor());
44
+
45
+ await expect(result.current.resize("invalid", 10, 10)).rejects.toThrow(
46
+ "Invalid Base64 image format",
47
+ );
48
+ expect(createImageBitmap).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it("resizes an image and serializes PNG output without a quality argument", async () => {
52
+ const { result } = renderHook(() => useImageProcessor());
53
+
54
+ await expect(result.current.resize(validPng, 60, 40)).resolves.toBe(
55
+ "data:image/png;base64,processed",
56
+ );
57
+
58
+ expect(createImageBitmap).toHaveBeenCalledTimes(1);
59
+ expect(context.clearRect).toHaveBeenCalledWith(0, 0, 60, 40);
60
+ expect(context.drawImage).toHaveBeenCalledWith(bitmap, 0, 0, 60, 40);
61
+ expect(bitmap.close).toHaveBeenCalledTimes(1);
62
+ expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith(
63
+ "image/png",
64
+ );
65
+ });
66
+
67
+ it("crops an image with source and destination rectangles", async () => {
68
+ const { result } = renderHook(() => useImageProcessor());
69
+
70
+ await result.current.crop(validPng, 5, 6, 50, 40);
71
+
72
+ expect(context.drawImage).toHaveBeenCalledWith(
73
+ bitmap,
74
+ 5,
75
+ 6,
76
+ 50,
77
+ 40,
78
+ 0,
79
+ 0,
80
+ 50,
81
+ 40,
82
+ );
83
+ expect(bitmap.close).toHaveBeenCalledTimes(1);
84
+ });
85
+
86
+ it("flips an image using the expected canvas transforms", async () => {
87
+ const { result } = renderHook(() => useImageProcessor());
88
+
89
+ await result.current.flip(validPng, true, false);
90
+
91
+ expect(context.translate).toHaveBeenCalledWith(120, 0);
92
+ expect(context.scale).toHaveBeenCalledWith(-1, 1);
93
+ expect(context.drawImage).toHaveBeenCalledWith(bitmap, 0, 0);
94
+ expect(context.restore).toHaveBeenCalledTimes(1);
95
+ expect(bitmap.close).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it("rotates an image around its center", async () => {
99
+ const { result } = renderHook(() => useImageProcessor());
100
+
101
+ await result.current.rotate(validPng, 90);
102
+
103
+ expect(context.translate).toHaveBeenCalledWith(60, 40);
104
+ expect(context.rotate).toHaveBeenCalledWith(Math.PI / 2);
105
+ expect(context.drawImage).toHaveBeenCalledWith(bitmap, -60, -40);
106
+ expect(context.restore).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it("applies filters and uses quality for JPEG output", async () => {
110
+ const { result } = renderHook(() => useImageProcessor());
111
+
112
+ await result.current.filter(validJpeg, { brightness: 120, contrast: 90 });
113
+
114
+ expect(context.filter).toBe("brightness(120%) contrast(90%)");
115
+ expect(context.drawImage).toHaveBeenCalledWith(bitmap, 0, 0);
116
+ expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith(
117
+ "image/jpeg",
118
+ 0.85,
119
+ );
120
+ });
121
+
122
+ it("uses quality for WebP output", async () => {
123
+ const { result } = renderHook(() => useImageProcessor());
124
+ const webp = "data:image/webp;base64,aGVsbG8=";
125
+
126
+ await result.current.resize(webp, 60, 40);
127
+
128
+ expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith(
129
+ "image/webp",
130
+ 0.85,
131
+ );
132
+ });
133
+
134
+ it("processes GIF input as PNG", async () => {
135
+ const { result } = renderHook(() => useImageProcessor());
136
+ const gif = "data:image/gif;base64,aGVsbG8=";
137
+
138
+ await result.current.resize(gif, 60, 40);
139
+
140
+ expect(createImageBitmap).toHaveBeenCalledWith(
141
+ expect.objectContaining({ type: "image/png" }),
142
+ );
143
+ expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith(
144
+ "image/png",
145
+ );
146
+ });
147
+ });
@@ -0,0 +1,225 @@
1
+ import { useCallback, useRef } from "react";
2
+
3
+ export interface ImageProcessor {
4
+ resize: (base64Str: string, width: number, height: number) => Promise<string>;
5
+ crop: (
6
+ base64Str: string,
7
+ x: number,
8
+ y: number,
9
+ width: number,
10
+ height: number,
11
+ ) => Promise<string>;
12
+ flip: (
13
+ base64Str: string,
14
+ horizontal: boolean,
15
+ vertical: boolean,
16
+ ) => Promise<string>;
17
+ rotate: (base64Str: string, angle: number) => Promise<string>;
18
+ filter: (
19
+ base64Str: string,
20
+ filters: Record<string, number>,
21
+ ) => Promise<string>;
22
+ }
23
+
24
+ export const useImageProcessor = (): ImageProcessor => {
25
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
26
+
27
+ const parseBase64 = useCallback((base64Str: string) => {
28
+ const match = base64Str.match(/^data:(image\/[a-zA-Z+.-]+);base64,(.+)$/);
29
+ if (!match) {
30
+ throw new Error("Invalid Base64 image format");
31
+ }
32
+
33
+ let mimeType = match[1];
34
+ const rawData = match[2];
35
+
36
+ if (mimeType === "image/gif") {
37
+ mimeType = "image/png";
38
+ }
39
+
40
+ return { mimeType, rawData };
41
+ }, []);
42
+
43
+ const getBitmap = useCallback(
44
+ async (
45
+ base64Str: string,
46
+ ): Promise<{ bitmap: ImageBitmap; mimeType: string }> => {
47
+ const { mimeType, rawData } = parseBase64(base64Str);
48
+
49
+ const binaryStr = atob(rawData);
50
+ const bytes = new Uint8Array(binaryStr.length);
51
+ for (let i = 0; i < binaryStr.length; i++) {
52
+ bytes[i] = binaryStr.charCodeAt(i);
53
+ }
54
+
55
+ const blob = new Blob([bytes], { type: mimeType });
56
+ const bitmap = await createImageBitmap(blob);
57
+
58
+ return { bitmap, mimeType };
59
+ },
60
+ [parseBase64],
61
+ );
62
+
63
+ const getContext = useCallback((mimeType: string) => {
64
+ if (!canvasRef.current) {
65
+ canvasRef.current = document.createElement("canvas");
66
+ }
67
+ const canvas = canvasRef.current;
68
+
69
+ // Enable alpha channel for formats that support transparency
70
+ const requiresAlpha = mimeType === "image/png" || mimeType === "image/webp";
71
+ const ctx = canvas.getContext("2d", { alpha: requiresAlpha });
72
+
73
+ return { canvas, ctx };
74
+ }, []);
75
+
76
+ const finalizeCanvas = useCallback(
77
+ (canvas: HTMLCanvasElement, mimeType: string): string => {
78
+ // PNG format does not support quality parameter
79
+ if (mimeType === "image/jpeg" || mimeType === "image/webp") {
80
+ return canvas.toDataURL(mimeType, 0.85);
81
+ }
82
+ return canvas.toDataURL(mimeType); // for PNG
83
+ },
84
+ [],
85
+ );
86
+
87
+ // 1. RESIZE
88
+ const resize = useCallback(
89
+ async (
90
+ base64Str: string,
91
+ width: number,
92
+ height: number,
93
+ ): Promise<string> => {
94
+ const { bitmap, mimeType } = await getBitmap(base64Str);
95
+ const { canvas, ctx } = getContext(mimeType);
96
+
97
+ canvas.width = width;
98
+ canvas.height = height;
99
+
100
+ if (ctx) {
101
+ ctx.clearRect(0, 0, width, height);
102
+ ctx.drawImage(bitmap, 0, 0, width, height);
103
+ }
104
+
105
+ bitmap.close();
106
+ return finalizeCanvas(canvas, mimeType);
107
+ },
108
+ [getBitmap, getContext, finalizeCanvas],
109
+ );
110
+
111
+ // 2. CROP
112
+ const crop = useCallback(
113
+ async (
114
+ base64Str: string,
115
+ x: number,
116
+ y: number,
117
+ width: number,
118
+ height: number,
119
+ ): Promise<string> => {
120
+ const { bitmap, mimeType } = await getBitmap(base64Str);
121
+ const { canvas, ctx } = getContext(mimeType);
122
+
123
+ canvas.width = width;
124
+ canvas.height = height;
125
+
126
+ if (ctx) {
127
+ ctx.clearRect(0, 0, width, height);
128
+ ctx.drawImage(bitmap, x, y, width, height, 0, 0, width, height);
129
+ }
130
+
131
+ bitmap.close();
132
+ return finalizeCanvas(canvas, mimeType);
133
+ },
134
+ [getBitmap, getContext, finalizeCanvas],
135
+ );
136
+
137
+ // 3. FLIP
138
+ const flip = useCallback(
139
+ async (
140
+ base64Str: string,
141
+ horizontal: boolean,
142
+ vertical: boolean,
143
+ ): Promise<string> => {
144
+ const { bitmap, mimeType } = await getBitmap(base64Str);
145
+ const { canvas, ctx } = getContext(mimeType);
146
+
147
+ canvas.width = bitmap.width;
148
+ canvas.height = bitmap.height;
149
+
150
+ if (ctx) {
151
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
152
+ ctx.save();
153
+ ctx.translate(
154
+ horizontal ? bitmap.width : 0,
155
+ vertical ? bitmap.height : 0,
156
+ );
157
+ ctx.scale(horizontal ? -1 : 1, vertical ? -1 : 1);
158
+ ctx.drawImage(bitmap, 0, 0);
159
+ ctx.restore();
160
+ }
161
+
162
+ bitmap.close();
163
+ return finalizeCanvas(canvas, mimeType);
164
+ },
165
+ [getBitmap, getContext, finalizeCanvas],
166
+ );
167
+
168
+ // 4. Rotate
169
+ const rotate = useCallback(
170
+ async (base64Str: string, angle: number): Promise<string> => {
171
+ const { bitmap, mimeType } = await getBitmap(base64Str);
172
+ const { canvas, ctx } = getContext(mimeType);
173
+
174
+ canvas.width = bitmap.width;
175
+ canvas.height = bitmap.height;
176
+
177
+ if (ctx) {
178
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
179
+ ctx.save();
180
+ ctx.translate(bitmap.width / 2, bitmap.height / 2);
181
+ ctx.rotate((angle * Math.PI) / 180);
182
+ ctx.drawImage(bitmap, -bitmap.width / 2, -bitmap.height / 2);
183
+ ctx.restore();
184
+ }
185
+
186
+ bitmap.close();
187
+ return finalizeCanvas(canvas, mimeType);
188
+ },
189
+ [getBitmap, getContext, finalizeCanvas],
190
+ );
191
+
192
+ // 5. Filter
193
+ const filter = useCallback(
194
+ async (
195
+ base64Str: string,
196
+ filters: Record<string, number>,
197
+ ): Promise<string> => {
198
+ const { bitmap, mimeType } = await getBitmap(base64Str);
199
+ const { canvas, ctx } = getContext(mimeType);
200
+
201
+ canvas.width = bitmap.width;
202
+ canvas.height = bitmap.height;
203
+
204
+ if (ctx) {
205
+ const filterString = Object.entries(filters)
206
+ .map(([name, value]) => `${name}(${value}%)`)
207
+ .join(" ");
208
+ ctx.filter = filterString;
209
+ ctx.drawImage(bitmap, 0, 0);
210
+ }
211
+
212
+ bitmap.close();
213
+ return finalizeCanvas(canvas, mimeType);
214
+ },
215
+ [getBitmap, getContext, finalizeCanvas],
216
+ );
217
+
218
+ return {
219
+ resize,
220
+ crop,
221
+ flip,
222
+ rotate,
223
+ filter,
224
+ };
225
+ };
@@ -0,0 +1,33 @@
1
+ import { useState } from "react";
2
+
3
+ type MobileDevice =
4
+ | "android"
5
+ | "ios"
6
+ | "windows-phone"
7
+ | "blackberry"
8
+ | "opera-mini"
9
+ | "mobile"
10
+ | null;
11
+
12
+ const detectMobileDevice = (): MobileDevice => {
13
+ if (typeof window === "undefined" || !window.navigator) {
14
+ return null;
15
+ }
16
+
17
+ const ua = window.navigator.userAgent.toLowerCase();
18
+
19
+ if (/android/.test(ua)) return "android";
20
+ if (/iphone|ipad|ipod/.test(ua)) return "ios";
21
+ if (/windows phone/.test(ua)) return "windows-phone";
22
+ if (/blackberry|bb10/.test(ua)) return "blackberry";
23
+ if (/opera mini/.test(ua)) return "opera-mini";
24
+ if (/mobile/.test(ua)) return "mobile";
25
+
26
+ return null;
27
+ };
28
+
29
+ export const useMobile = (): MobileDevice => {
30
+ const [mobile] = useState<MobileDevice>(() => detectMobileDevice());
31
+
32
+ return mobile;
33
+ };
@@ -0,0 +1,145 @@
1
+ .picedi {
2
+ font-size: 16px;
3
+ font-family: var(--font-sans);
4
+
5
+ --font-sans:
6
+ system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Tahoma,
7
+ Helvetica, Arial, sans-serif;
8
+
9
+ --color-white: #fff;
10
+ --color-black: #000;
11
+ --color-transparent: transparent;
12
+ --color-red: oklch(0.637 0.237 25.331);
13
+ --color-green: oklch(0.768 0.188 151.716);
14
+
15
+ --background: oklch(1 0 0);
16
+ --foreground: oklch(0.145 0 0);
17
+ --card: oklch(1 0 0);
18
+ --card-foreground: oklch(0.145 0 0);
19
+ --popover: oklch(1 0 0);
20
+ --popover-foreground: oklch(0.145 0 0);
21
+ --primary: oklch(0.205 0 0);
22
+ --primary-foreground: oklch(0.985 0 0);
23
+ --secondary: oklch(0.97 0 0);
24
+ --secondary-foreground: oklch(0.205 0 0);
25
+ --muted: oklch(0.97 0 0);
26
+ --muted-foreground: oklch(0.556 0 0);
27
+ --accent: oklch(0.97 0 0);
28
+ --accent-foreground: oklch(0.205 0 0);
29
+ --accent-blue: oklch(0.609 0.221 251.687);
30
+ --accent-blue-light: oklch(0.96 0.02 250);
31
+ --destructive: oklch(0.577 0.245 27.325);
32
+ --success: oklch(0.768 0.188 151.716);
33
+ --border: oklch(0.922 0 0);
34
+ --input: oklch(0.922 0 0);
35
+ --ring: oklch(0.708 0 0);
36
+ --radius: 4px;
37
+ --sidebar: oklch(0.985 0 0);
38
+ --sidebar-foreground: oklch(0.145 0 0);
39
+ --sidebar-primary: oklch(0.205 0 0);
40
+ --sidebar-primary-foreground: oklch(0.985 0 0);
41
+ --sidebar-accent: oklch(0.97 0 0);
42
+ --sidebar-accent-foreground: oklch(0.205 0 0);
43
+ --sidebar-border: oklch(0.922 0 0);
44
+ --sidebar-ring: oklch(0.708 0 0);
45
+ --overlay-bg: color-mix(in srgb, var(--color-black) 50%, transparent);
46
+ --overlay-bg-white: color-mix(in srgb, var(--color-white) 80%, transparent);
47
+ }
48
+
49
+ :host([data-theme="dark"]) .picedi,
50
+ [data-theme="dark"] .picedi,
51
+ [data-theme="dark"].picedi {
52
+ --background: oklch(0.145 0 0);
53
+ --foreground: oklch(0.985 0 0);
54
+ --card: oklch(0.205 0 0);
55
+ --card-foreground: oklch(0.985 0 0);
56
+ --popover: oklch(0.205 0 0);
57
+ --popover-foreground: oklch(0.985 0 0);
58
+ --primary: oklch(0.922 0 0);
59
+ --primary-foreground: oklch(0.205 0 0);
60
+ --secondary: oklch(0.269 0 0);
61
+ --secondary-foreground: oklch(0.985 0 0);
62
+ --muted: oklch(0.269 0 0);
63
+ --muted-foreground: oklch(0.708 0 0);
64
+ --accent: oklch(0.269 0 0);
65
+ --accent-foreground: oklch(0.985 0 0);
66
+ --accent-blue-light: oklch(0.25 0.06 250);
67
+ --destructive: oklch(0.704 0.191 22.216);
68
+ --border: oklch(1 0 0 / 10%);
69
+ --input: oklch(1 0 0 / 15%);
70
+ --ring: oklch(0.556 0 0);
71
+ --sidebar-foreground: oklch(0.985 0 0);
72
+ --sidebar-primary: oklch(0.488 0.243 264.376);
73
+ --sidebar-primary-foreground: oklch(0.985 0 0);
74
+ --sidebar-accent: oklch(0.269 0 0);
75
+ --sidebar-accent-foreground: oklch(0.985 0 0);
76
+ --sidebar-border: oklch(1 0 0 / 10%);
77
+ --sidebar-ring: oklch(0.556 0 0);
78
+ --overlay-bg: color-mix(in srgb, var(--color-black) 75%, transparent);
79
+ }
80
+
81
+ .picedi h1,
82
+ .picedi h2,
83
+ .picedi h3,
84
+ .picedi h4,
85
+ .picedi h5,
86
+ .picedi h6 {
87
+ margin: 0;
88
+ font-weight: 600;
89
+ }
90
+
91
+ .text-red {
92
+ color: var(--color-red);
93
+ }
94
+
95
+ .bg-red {
96
+ background-color: var(--color-red);
97
+ }
98
+
99
+ .bg-green {
100
+ background-color: var(--color-green);
101
+ }
102
+
103
+ .text-green {
104
+ color: var(--color-green);
105
+ }
106
+
107
+ .mask {
108
+ background-color: var(--overlay-bg);
109
+ }
110
+
111
+ .semibold {
112
+ font-weight: 600;
113
+ letter-spacing: 0.4px;
114
+ }
115
+
116
+ .bold {
117
+ font-weight: bold;
118
+ letter-spacing: 0.4px;
119
+ }
120
+
121
+ .sys {
122
+ position: relative;
123
+ width: 100%;
124
+ height: 100%;
125
+ display: flex;
126
+ justify-content: center;
127
+ align-items: center;
128
+ color: var(--muted-foreground);
129
+ }
130
+
131
+ .main {
132
+ display: grid;
133
+ grid-template-rows: auto 1fr;
134
+ height: 100%;
135
+ overflow: hidden;
136
+ background-color: var(--background);
137
+ }
138
+
139
+ .grid {
140
+ position: relative;
141
+ display: grid;
142
+ grid-template-columns: 1fr auto;
143
+ min-height: 0;
144
+ overflow: hidden;
145
+ }
@@ -0,0 +1 @@
1
+ export { ImageEditor } from "./ImageEditor";