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,54 @@
1
+ import { useImageEditorContext } from "../provider/useImageEditorContext";
2
+ import { presetsData } from "../constants";
3
+ import type { ActionCrop } from "../types";
4
+ import { Select } from "../ui";
5
+
6
+ const presetOptions = presetsData.map((p) => p.options).flat();
7
+
8
+ export const SidebarPresets = () => {
9
+ const { setCurrentAction, currentAction, setSidebar } =
10
+ useImageEditorContext();
11
+ const currentValue =
12
+ currentAction?.name === "crop" &&
13
+ presetOptions.some((preset) => preset.value === currentAction.args.id)
14
+ ? currentAction.args.id
15
+ : "";
16
+
17
+ const handleChange = (value: string | null) => {
18
+ if (!value) {
19
+ return;
20
+ }
21
+
22
+ const flattenedData = presetsData.map((p) => p.options).flat();
23
+ const option = flattenedData.find((po) => po.value === value);
24
+ if (!option) {
25
+ return;
26
+ }
27
+
28
+ const ratio = option.w / option.h;
29
+
30
+ const args: ActionCrop = {
31
+ id: value,
32
+ ratio,
33
+ isFree: false,
34
+ preset: { w: option.w, h: option.h },
35
+ };
36
+
37
+ setCurrentAction({ name: "crop", args });
38
+ setSidebar(false);
39
+ };
40
+
41
+ return (
42
+ <div className="sidebar-preset">
43
+ <h4>Presets</h4>
44
+ <div className="relative">
45
+ <Select
46
+ value={currentValue}
47
+ placeholder="Select a preset"
48
+ onChange={handleChange}
49
+ items={presetsData}
50
+ />
51
+ </div>
52
+ </div>
53
+ );
54
+ };
@@ -0,0 +1,26 @@
1
+ import { Fullscreen } from "../assets/icons";
2
+ import { useImageEditorContext } from "../provider/useImageEditorContext";
3
+ import { Button } from "../ui";
4
+
5
+ export const SidebarResize = () => {
6
+ const { getLastHistoryItem, setCurrentAction, setSidebar } =
7
+ useImageEditorContext();
8
+ const { width, height } = getLastHistoryItem() ?? { width: 0, height: 0 };
9
+
10
+ const handleClick = () => {
11
+ setCurrentAction({ name: "resize", args: { width, height } });
12
+ setSidebar(false);
13
+ };
14
+
15
+ return (
16
+ <Button variant="outline" className="sidebar-resize" onClick={handleClick}>
17
+ <div className="sidebar-resize-icon">
18
+ <Fullscreen />
19
+ Resize
20
+ </div>
21
+ <div className="sidebar-resize-size">
22
+ {width} x {height}
23
+ </div>
24
+ </Button>
25
+ );
26
+ };
@@ -0,0 +1 @@
1
+ export { Sidebar } from "./Sidebar";
@@ -0,0 +1,101 @@
1
+ .sidebar {
2
+ position: relative;
3
+ width: 0;
4
+ z-index: 10;
5
+ transition: transform 0.15s ease-out;
6
+
7
+ @media (min-width: 768px) {
8
+ width: 320px;
9
+ transition: none;
10
+ }
11
+ }
12
+
13
+ .sidebar-settings {
14
+ position: absolute;
15
+ top: 8px;
16
+ right: 8px;
17
+ width: 40px;
18
+ height: 40px;
19
+ border-radius: 50%;
20
+ box-shadow:
21
+ 0 20px 25px -5px rgba(0, 0, 0, 0.1),
22
+ 0 8px 10px -6px rgba(0, 0, 0, 0.1);
23
+
24
+ @media (min-width: 768px) {
25
+ display: none;
26
+ }
27
+ }
28
+
29
+ .sidebar-container {
30
+ position: absolute;
31
+ top: 0;
32
+ right: 0;
33
+ bottom: 0;
34
+ left: 0;
35
+ box-sizing: border-box;
36
+ border-left: 1px solid var(--border);
37
+ }
38
+
39
+ .sidebar-content {
40
+ position: absolute;
41
+ top: 0;
42
+ left: 0;
43
+ height: 100%;
44
+ width: 320px;
45
+ display: flex;
46
+ flex-direction: column;
47
+ overflow-y: auto;
48
+ background-color: var(--background);
49
+ padding: 16px;
50
+ box-sizing: border-box;
51
+
52
+ @media (max-width: 767px) {
53
+ padding-top: 48px;
54
+ }
55
+ }
56
+
57
+ .sidebar-close-btn {
58
+ position: absolute;
59
+ top: 8px;
60
+ right: 8px;
61
+ }
62
+
63
+ .sidebar-separator {
64
+ margin-top: 20px;
65
+ margin-bottom: 12px;
66
+ }
67
+
68
+ .sidebar-resize {
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: space-between;
72
+ }
73
+
74
+ .sidebar-resize-icon {
75
+ display: flex;
76
+ align-items: center;
77
+ gap: 8px;
78
+ }
79
+
80
+ .sidebar-resize-size {
81
+ font-size: 14px;
82
+ color: var(--muted-foreground);
83
+ }
84
+
85
+ .sidebar-crop {
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: 8px;
89
+ }
90
+
91
+ .sidebar-crop-ratios {
92
+ display: grid;
93
+ grid-template-columns: repeat(3, 1fr);
94
+ gap: 8px;
95
+ }
96
+
97
+ .sidebar-preset {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: 8px;
101
+ }
@@ -0,0 +1,59 @@
1
+ export const breakpoints = {
2
+ sm: 640,
3
+ md: 768,
4
+ lg: 1024,
5
+ xl: 1280,
6
+ } as const;
7
+
8
+ export type Breakpoint = keyof typeof breakpoints;
9
+
10
+ export type Sizes = {
11
+ width: number;
12
+ height: number;
13
+ };
14
+
15
+ export type Direction = "r" | "l" | "t" | "b" | "tl" | "tr" | "bl" | "br";
16
+
17
+ export type CropSizes = {
18
+ w: number;
19
+ h: number;
20
+ };
21
+
22
+ export type CropRect = CropSizes & {
23
+ x: number;
24
+ y: number;
25
+ xPx?: number;
26
+ yPx?: number;
27
+ wPx?: number;
28
+ hPx?: number;
29
+ };
30
+
31
+ export type ActionCrop = {
32
+ id: string;
33
+ ratio: number;
34
+ isFree: boolean;
35
+ preset: CropSizes | null;
36
+ };
37
+
38
+ export type ActionWithVal = {
39
+ val: number;
40
+ };
41
+
42
+ export type Action =
43
+ | { name: "resize"; args: Sizes }
44
+ | { name: "crop"; args: ActionCrop }
45
+ | { name: "flipH"; args: ActionWithVal }
46
+ | { name: "flipV"; args: ActionWithVal }
47
+ | { name: "filters"; args: Record<string, number> };
48
+
49
+ export type HistoryItem = Sizes & {
50
+ name: string;
51
+ base64: string;
52
+ };
53
+
54
+ export type History = {
55
+ pointer: number;
56
+ items: Array<HistoryItem>;
57
+ };
58
+
59
+ export type FuncSaveArgs = (base64: string) => Promise<void> | void;
@@ -0,0 +1,42 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { fn } from "@storybook/test";
3
+ import { Button } from "./Button";
4
+ import { Check } from "../../assets/icons";
5
+
6
+ const meta = {
7
+ title: "ImageEditor/UI/Button",
8
+ component: Button,
9
+ tags: ["autodocs"],
10
+ args: {
11
+ children: "Button",
12
+ onClick: fn(),
13
+ },
14
+ } satisfies Meta<typeof Button>;
15
+
16
+ export default meta;
17
+ type Story = StoryObj<typeof meta>;
18
+
19
+ export const Default: Story = {};
20
+
21
+ export const Outline: Story = {
22
+ args: {
23
+ variant: "outline",
24
+ },
25
+ };
26
+
27
+ export const Ghost: Story = {
28
+ args: {
29
+ variant: "ghost",
30
+ },
31
+ };
32
+
33
+ export const WithIcon: Story = {
34
+ args: {
35
+ children: (
36
+ <>
37
+ <Check />
38
+ Button
39
+ </>
40
+ ),
41
+ },
42
+ };
@@ -0,0 +1,21 @@
1
+ import { forwardRef } from "react";
2
+ import "./button.css";
3
+
4
+ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
5
+ variant?: "default" | "outline" | "ghost";
6
+ className?: string;
7
+ children?: React.ReactNode;
8
+ }
9
+
10
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
11
+ ({ className = "", variant = "default", children, ...props }, ref) => {
12
+ const variantClass = `btn-${variant}`;
13
+ const combinedClasses = `btn ${variantClass} ${className}`.trim();
14
+
15
+ return (
16
+ <button ref={ref} className={combinedClasses} {...props}>
17
+ {children}
18
+ </button>
19
+ );
20
+ },
21
+ );
@@ -0,0 +1,69 @@
1
+ .btn {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ gap: 8px;
6
+ font-family: inherit;
7
+ font-size: 14px;
8
+ font-weight: 500;
9
+ height: 32px;
10
+ padding-left: 8px;
11
+ padding-right: 8px;
12
+ border-radius: 4px;
13
+ border: 1px solid transparent;
14
+ background-color: transparent;
15
+ cursor: pointer;
16
+ white-space: nowrap;
17
+ user-select: none;
18
+ box-sizing: border-box;
19
+ transition:
20
+ background-color 0.2s,
21
+ border-color 0.2s,
22
+ opacity 0.2s;
23
+ }
24
+
25
+ .btn:disabled {
26
+ pointer-events: none;
27
+ opacity: 0.5;
28
+ }
29
+
30
+ .btn:active {
31
+ transform: translateY(1px);
32
+ }
33
+
34
+ .btn svg {
35
+ width: 16px;
36
+ height: 16px;
37
+ flex-shrink: 0;
38
+ }
39
+
40
+ .btn-default {
41
+ background-color: var(--primary);
42
+ color: var(--primary-foreground);
43
+ }
44
+ .btn-default:hover {
45
+ opacity: 0.9;
46
+ }
47
+
48
+ .btn-outline {
49
+ border-color: var(--border);
50
+ background-color: var(--background);
51
+ color: var(--foreground);
52
+ }
53
+ .btn-outline:hover {
54
+ background-color: var(--secondary);
55
+ }
56
+
57
+ .btn-ghost {
58
+ color: var(--foreground);
59
+ padding-left: 8px;
60
+ padding-right: 8px;
61
+ }
62
+ .btn-ghost:hover {
63
+ background-color: var(--secondary);
64
+ }
65
+
66
+ .btn-rect {
67
+ width: 32px;
68
+ height: 32px;
69
+ }
@@ -0,0 +1,26 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { fn } from "@storybook/test";
3
+ import { ButtonCrop } from "./ButtonCrop";
4
+ import { Crop } from "../../assets/icons";
5
+
6
+ const meta = {
7
+ title: "ImageEditor/UI/ButtonCrop",
8
+ component: ButtonCrop,
9
+ tags: ["autodocs"],
10
+ args: {
11
+ icon: <Crop />,
12
+ label: "Crop",
13
+ onClick: fn(),
14
+ },
15
+ } satisfies Meta<typeof ButtonCrop>;
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ export const Inactive: Story = {};
21
+
22
+ export const Active: Story = {
23
+ args: {
24
+ active: true,
25
+ },
26
+ };
@@ -0,0 +1,29 @@
1
+ import { forwardRef } from "react";
2
+ import "./button-crop.css";
3
+
4
+ interface ButtonCropProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
5
+ active?: boolean;
6
+ icon: React.ReactNode;
7
+ label: string;
8
+ }
9
+
10
+ export const ButtonCrop = forwardRef<HTMLButtonElement, ButtonCropProps>(
11
+ ({ className = "", active = false, icon, label, ...props }, ref) => {
12
+ const combinedClasses = `btn-crop ${className}`.trim();
13
+
14
+ return (
15
+ <button
16
+ ref={ref}
17
+ type="button"
18
+ aria-pressed={active}
19
+ className={combinedClasses}
20
+ {...props}
21
+ >
22
+ {icon}
23
+ <span className="semibold">{label}</span>
24
+ </button>
25
+ );
26
+ },
27
+ );
28
+
29
+ ButtonCrop.displayName = "ButtonCrop";
@@ -0,0 +1,38 @@
1
+ .btn-crop {
2
+ display: inline-flex;
3
+ flex-direction: column;
4
+ align-items: center;
5
+ justify-content: center;
6
+ gap: 8px;
7
+ padding: 12px;
8
+ border-radius: 4px;
9
+ border: 1px solid var(--border);
10
+ background-color: var(--background);
11
+ color: var(--foreground);
12
+ font-family: inherit;
13
+ font-size: 12px;
14
+ cursor: pointer;
15
+ user-select: none;
16
+ box-sizing: border-box;
17
+ transition: all 0.2s ease;
18
+ }
19
+
20
+ .btn-crop:hover:not([aria-pressed="true"]) {
21
+ background-color: var(--secondary);
22
+ }
23
+
24
+ .btn-crop[aria-pressed="true"] {
25
+ border-color: var(--accent-blue);
26
+ background-color: var(--accent-blue-light);
27
+ color: var(--foreground);
28
+ }
29
+
30
+ .btn-crop svg {
31
+ width: 20px;
32
+ height: 20px;
33
+ flex-shrink: 0;
34
+ }
35
+
36
+ .btn-crop span {
37
+ white-space: nowrap;
38
+ }
@@ -0,0 +1,6 @@
1
+ export { Button } from "./button/Button";
2
+ export { ButtonCrop } from "./button-crop/ButtonCrop";
3
+ export { Separator } from "./separator/Separator";
4
+ export { Select } from "./select/Select";
5
+ export { Input } from "./input/Input";
6
+ export { InputPixel } from "./input-pixel/InputPixel";
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { fn } from "@storybook/test";
3
+ import { Input } from "./Input";
4
+
5
+ const meta = {
6
+ title: "ImageEditor/UI/Input",
7
+ component: Input,
8
+ tags: ["autodocs"],
9
+ args: {
10
+ placeholder: "Type here...",
11
+ onChange: fn(),
12
+ },
13
+ decorators: [
14
+ (Story) => (
15
+ <div style={{ maxWidth: 320 }}>
16
+ <Story />
17
+ </div>
18
+ ),
19
+ ],
20
+ } satisfies Meta<typeof Input>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ export const Text: Story = {
26
+ args: {
27
+ type: "text",
28
+ value: "Sample text",
29
+ },
30
+ };
31
+
32
+ export const Number: Story = {
33
+ args: {
34
+ type: "number",
35
+ value: 42,
36
+ },
37
+ };
38
+
39
+ export const NumberWithArrows: Story = {
40
+ args: {
41
+ type: "number",
42
+ hideArrows: false,
43
+ value: 42,
44
+ },
45
+ };
@@ -0,0 +1,20 @@
1
+ import { forwardRef } from "react";
2
+ import "./input.css";
3
+
4
+ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
5
+ hideArrows?: boolean;
6
+ }
7
+
8
+ export const Input = forwardRef<HTMLInputElement, InputProps>(
9
+ ({ className = "", type = "text", hideArrows = true, ...props }, ref) => {
10
+ const numberClass =
11
+ type === "number" && hideArrows ? "input-number-clean" : "";
12
+ const combinedClasses = `input ${numberClass} ${className}`.trim();
13
+
14
+ return (
15
+ <input ref={ref} type={type} className={combinedClasses} {...props} />
16
+ );
17
+ },
18
+ );
19
+
20
+ Input.displayName = "Input";
@@ -0,0 +1,48 @@
1
+ .input {
2
+ display: block;
3
+ width: 100%;
4
+ height: 32px;
5
+ padding-left: 8px;
6
+ padding-right: 8px;
7
+ font-family: inherit;
8
+ font-size: 14px;
9
+ line-height: normal;
10
+ color: var(--foreground);
11
+ background-color: var(--background);
12
+ border: 1px solid var(--input);
13
+ border-radius: 4px;
14
+ box-sizing: border-box;
15
+ outline: none;
16
+ transition:
17
+ border-color 0.2s,
18
+ box-shadow 0.2s;
19
+ }
20
+
21
+ .input:hover:not(:disabled):not(:focus) {
22
+ border-color: var(--muted-foreground);
23
+ }
24
+
25
+ .input:focus {
26
+ box-shadow: 0 0 0 1px var(--ring);
27
+ }
28
+
29
+ .input:disabled {
30
+ cursor: not-allowed;
31
+ opacity: 0.5;
32
+ background-color: var(--secondary);
33
+ }
34
+
35
+ .input::placeholder {
36
+ color: var(--muted-foreground);
37
+ opacity: 1;
38
+ }
39
+
40
+ .input-number-clean::-webkit-outer-spin-button,
41
+ .input-number-clean::-webkit-inner-spin-button {
42
+ -webkit-appearance: none;
43
+ margin: 0;
44
+ }
45
+
46
+ .input-number-clean[type="number"] {
47
+ -moz-appearance: textfield;
48
+ }
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { fn } from "@storybook/test";
3
+ import { InputPixel } from "./InputPixel";
4
+
5
+ const meta = {
6
+ title: "ImageEditor/UI/InputPixel",
7
+ component: InputPixel,
8
+ tags: ["autodocs"],
9
+ args: {
10
+ label: "Width",
11
+ value: 120,
12
+ onChange: fn(),
13
+ },
14
+ decorators: [
15
+ (Story) => (
16
+ <div style={{ maxWidth: 320 }}>
17
+ <Story />
18
+ </div>
19
+ ),
20
+ ],
21
+ } satisfies Meta<typeof InputPixel>;
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ export const Default: Story = {};
27
+
28
+ export const WithValue: Story = {
29
+ args: {
30
+ label: "Height",
31
+ value: 240,
32
+ },
33
+ };
@@ -0,0 +1,23 @@
1
+ import { forwardRef } from "react";
2
+ import "./input-pixel.css";
3
+ import { Input } from "../../ui";
4
+
5
+ interface InputPixelProps extends React.InputHTMLAttributes<HTMLInputElement> {
6
+ label: string;
7
+ className?: string;
8
+ }
9
+
10
+ export const InputPixel = forwardRef<HTMLInputElement, InputPixelProps>(
11
+ ({ label, className = "", style, ...props }, ref) => {
12
+ return (
13
+ <div
14
+ className={`input-pixel-container ${className}`.trim()}
15
+ style={style}
16
+ >
17
+ <span className="input-pixel-legend">{label}</span>
18
+ <Input ref={ref} type="number" {...props} />
19
+ <span className="input-pixel-suffix">px</span>
20
+ </div>
21
+ );
22
+ },
23
+ );
@@ -0,0 +1,24 @@
1
+ .input-pixel-container {
2
+ position: relative;
3
+ }
4
+
5
+ .input-pixel-legend {
6
+ position: absolute;
7
+ top: -9px;
8
+ font-size: 12px;
9
+ color: var(--muted-foreground);
10
+ padding-left: 4px;
11
+ padding-right: 4px;
12
+ margin-left: 8px;
13
+ background: var(--background);
14
+ }
15
+
16
+ .input-pixel-suffix {
17
+ position: absolute;
18
+ right: 8px;
19
+ bottom: 2px;
20
+ font-size: 14px;
21
+ color: var(--muted-foreground);
22
+ pointer-events: none;
23
+ user-select: none;
24
+ }