@rojaostudio/ds 2.0.0-next.12 → 2.0.0-next.14
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/README.md +42 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/components/floating-stepper.d.ts +11 -28
- package/dist/components/floating-stepper.js +2 -31
- package/dist/components/image-crop-dialog.d.ts +60 -0
- package/dist/components/image-crop-dialog.js +240 -0
- package/dist/components/image-crop-modal.d.ts +12 -56
- package/dist/components/image-crop-modal.js +2 -235
- package/dist/components/image-upload.d.ts +2 -2
- package/dist/components/image-upload.js +2 -2
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +2 -0
- package/dist/components/number-input.d.ts +1 -1
- package/dist/components/stepper.d.ts +33 -0
- package/dist/components/stepper.js +36 -0
- package/dist/components/toast.d.ts +16 -5
- package/dist/components/toast.js +5 -3
- package/package.json +3 -4
- package/styles/base.css +13 -1
- package/styles/rds/components-styles.css +39 -39
- package/styles/rds/components.css +5 -5
- package/styles/rds/foundation.css +1 -1
- package/styles/rds/theme.css +36 -28
- package/styles/rds.css +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Add `dark` to `<html>` (or any element) for dark mode:
|
|
|
26
26
|
<html class="dark">
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
The stylesheet declares its layers first (`rds.theme`, `rds.tokens`, `rds.components`), so your own unlayered CSS
|
|
29
|
+
The stylesheet declares its layers first (`rds.legacy`, `rds.theme`, `rds.tokens`, `rds.components`), so your own unlayered CSS
|
|
30
30
|
always wins over the design system without `!important`. The components need no Tailwind and no other stylesheet.
|
|
31
31
|
|
|
32
32
|
### Legacy: `base.css` (deprecated)
|
|
@@ -42,6 +42,41 @@ utilities (`bg-surface-*`, `text-fg-*`, `h-control-*`…) and its CSS variables,
|
|
|
42
42
|
@import "@rojaostudio/ds/styles/rds.css";
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
Both stylesheets declare the same layer order, `rds.legacy, rds.theme, rds.tokens, rds.components`, and everything
|
|
46
|
+
in `base.css` (its variables, the primitives, the `h1`–`h6` rule, the `rojao-*` animations) lives in `rds.legacy`.
|
|
47
|
+
So, whatever the import order, the 2.0 wins wherever the two declare the same variable: 31 names, among them
|
|
48
|
+
`--surface-page`, `--text-muted`, `--text-disabled`, `--border-default`, `--border-strong`, `--radius-card`,
|
|
49
|
+
`--radius-control`, `--z-modal`, `--z-dropdown`, `--z-tooltip`, `--space-4` … `--space-64`, `--toast-text`,
|
|
50
|
+
`--toast-border`. A 1.x utility that reads one of them (`bg-surface-page`, `text-fg-muted` through
|
|
51
|
+
`--text-muted`) shows the 2.0 value while both are imported; the 1.x-only names (`--brand-primary`,
|
|
52
|
+
`--surface-default`…) keep theirs. Tailwind's `@utility` and `@theme` blocks stay at the top level, as Tailwind
|
|
53
|
+
requires, and the focus ring stays in Tailwind's `base` layer, so `outline-none` still wins over it. Your own
|
|
54
|
+
unlayered CSS wins over all of it.
|
|
55
|
+
|
|
56
|
+
## Theme scopes
|
|
57
|
+
|
|
58
|
+
The shipped theme switches on `.dark`, `.ds-plate` and `.ds-scope`, and on three generic attributes:
|
|
59
|
+
`data-rds-mode="dark"`, `data-rds-plate` and `data-rds-scope`. The component tokens are declared again on each of
|
|
60
|
+
them (`RDS_SCOPE_SELECTORS` in `@rojaostudio/ds-core/generate`): a component token holds `var(--theme-role)`, which
|
|
61
|
+
resolves where it is declared, so a theme switched on an element none of them match would repaint the roles but
|
|
62
|
+
not the components inside.
|
|
63
|
+
|
|
64
|
+
```html
|
|
65
|
+
<section data-rds-mode="dark">…</section> <!-- a dark section on a light page -->
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
For your own theme, `emitRdsCss(theme, { scope, dark, plate })` checks each selector against that list and throws
|
|
69
|
+
on one the components would not follow:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
emitRdsCss(theme, { scope: ".my-scope[data-rds-scope]" }); // <div class="my-scope" data-rds-scope>
|
|
73
|
+
emitRdsCss(theme, { scope: ":root", dark: ':root[data-theme="dark"]' }); // next-themes, attribute="data-theme"
|
|
74
|
+
emitRdsCss(theme, { scope: ".my-scope" }); // throws: add data-rds-scope
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A dark selector anchored at the root (`:root[…]`, `html…`) is used as is, which is where next-themes puts its
|
|
78
|
+
attribute. `allowUncovered: true` skips the check, for a theme read only by your own CSS.
|
|
79
|
+
|
|
45
80
|
## Import the component, not the barrel
|
|
46
81
|
|
|
47
82
|
```tsx
|
|
@@ -99,7 +134,7 @@ renders itself (the × of Alert, Dialog and Toast, the Calendar and Carousel arr
|
|
|
99
134
|
## Requirements
|
|
100
135
|
|
|
101
136
|
React 19. Tailwind CSS is an **optional** peer: only the legacy `base.css` needs it. A few components bring an
|
|
102
|
-
optional peer of their own, installed only if you use them: `react-image-crop` (
|
|
137
|
+
optional peer of their own, installed only if you use them: `react-image-crop` (ImageCropDialog, ImageUpload) and
|
|
103
138
|
`react-international-phone` (PhoneInput).
|
|
104
139
|
|
|
105
140
|
## Icons
|
|
@@ -115,6 +150,11 @@ import { PixIcon } from '@rojaostudio/ds/icons';
|
|
|
115
150
|
|
|
116
151
|
## React Native
|
|
117
152
|
|
|
153
|
+
> **Experimental, outside semver in 2.0.** The native target (`@rojaostudio/ds/native/*`) still follows the 1.x
|
|
154
|
+
> API (`Button variant="primary"`, `Modal`, `Menu`…) and the 1.x theme (`generateTheme`). It will be aligned with
|
|
155
|
+
> the 2.0 components in 2.1; until then it may change in any release, minor and patch included. Pin an exact
|
|
156
|
+
> version if you depend on it. The web components never import from it.
|
|
157
|
+
|
|
118
158
|
```tsx
|
|
119
159
|
import { DSThemeProvider } from '@rojaostudio/ds/native/components';
|
|
120
160
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -55,7 +55,7 @@ O símbolo do Pix (`packages/ds/icons/pix.tsx`) é marca do Banco Central do Bra
|
|
|
55
55
|
|---|---|---|
|
|
56
56
|
| `@radix-ui/react-*` | MIT | comportamento acessível de overlays, menus, abas e afins |
|
|
57
57
|
| `lucide-react` (peer opcional) | ISC | re-exportação de ícones em `@rojaostudio/ds/icons` |
|
|
58
|
-
| `react-image-crop` (peer opcional) | ISC | recorte do `
|
|
58
|
+
| `react-image-crop` (peer opcional) | ISC | recorte do `ImageCropDialog` |
|
|
59
59
|
| `react-international-phone` (peer opcional) | MIT | campo e bandeiras do `PhoneInput` |
|
|
60
60
|
|
|
61
61
|
## Fontes (referenciadas pelo nome, não distribuídas)
|
|
@@ -1,33 +1,16 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { Stepper, StepperProps, StepperStep } from './stepper.js';
|
|
2
|
+
import 'react';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* earlier steps go back, later ones are disabled; `canNavigate` changes the rule. `action` is a last Button after
|
|
8
|
-
* a vertical Separator (Reset). Where it floats is the caller's (className or a wrapper). Styles: floating-stepper.css.
|
|
5
|
+
* floating-stepper — the 1.x and 2.0-next name of the Stepper, kept so `@rojaostudio/ds/components/floating-stepper`
|
|
6
|
+
* and `FloatingStepper` still work in 2.0. The codemod (`npx @rojaostudio/ds-codemod`) renames both.
|
|
9
7
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
current: string;
|
|
18
|
-
onNavigate?: (key: string) => void;
|
|
19
|
-
/** Regra de navegação por etapa. Default: só etapas anteriores à atual. */
|
|
20
|
-
canNavigate?: (key: string, index: number, currentIndex: number) => boolean;
|
|
21
|
-
/** Ação à direita, separada por divisor (ex.: Reset). */
|
|
22
|
-
action?: {
|
|
23
|
-
label: string;
|
|
24
|
-
icon?: ReactNode;
|
|
25
|
-
onClick: () => void;
|
|
26
|
-
};
|
|
27
|
-
className?: string;
|
|
28
|
-
/** The navigation's name. Default "Etapas". */
|
|
29
|
-
'aria-label'?: string;
|
|
30
|
-
}
|
|
31
|
-
declare function FloatingStepper({ steps, current, onNavigate, canNavigate, action, className, 'aria-label': ariaLabel }: FloatingStepperProps): react.JSX.Element;
|
|
8
|
+
|
|
9
|
+
/** @deprecated Renamed to `Stepper` (`@rojaostudio/ds/components/stepper`) in 2.0. Same props. */
|
|
10
|
+
declare const FloatingStepper: typeof Stepper;
|
|
11
|
+
/** @deprecated Renamed to `StepperProps` in 2.0. */
|
|
12
|
+
type FloatingStepperProps = StepperProps;
|
|
13
|
+
/** @deprecated Renamed to `StepperStep` in 2.0. */
|
|
14
|
+
type FloatingStepperStep = StepperStep;
|
|
32
15
|
|
|
33
16
|
export { FloatingStepper, type FloatingStepperProps, type FloatingStepperStep };
|
|
@@ -1,36 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import "../chunk-ORMEWXMH.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import { Card } from "./card.js";
|
|
6
|
-
import { Separator } from "./separator.js";
|
|
7
|
-
function FloatingStepper({ steps, current, onNavigate, canNavigate, action, className, "aria-label": ariaLabel = "Etapas" }) {
|
|
8
|
-
const currentIdx = steps.findIndex((s) => s.key === current);
|
|
9
|
-
return /* @__PURE__ */ jsxs(Card, { as: "div", size: "sm", role: "navigation", "aria-label": ariaLabel, className: ["rds-floating-stepper", className].filter(Boolean).join(" "), children: [
|
|
10
|
-
steps.map((s, idx) => {
|
|
11
|
-
const isCurrent = idx === currentIdx;
|
|
12
|
-
const canGo = canNavigate ? canNavigate(s.key, idx, currentIdx) : idx < currentIdx;
|
|
13
|
-
return /* @__PURE__ */ jsx(
|
|
14
|
-
Button,
|
|
15
|
-
{
|
|
16
|
-
tone: "neutral",
|
|
17
|
-
variant: isCurrent ? "fill" : "ghost",
|
|
18
|
-
disabled: !canGo && !isCurrent,
|
|
19
|
-
"aria-current": isCurrent ? "step" : void 0,
|
|
20
|
-
onClick: () => {
|
|
21
|
-
if (canGo && !isCurrent) onNavigate == null ? void 0 : onNavigate(s.key);
|
|
22
|
-
},
|
|
23
|
-
children: s.label
|
|
24
|
-
},
|
|
25
|
-
s.key
|
|
26
|
-
);
|
|
27
|
-
}),
|
|
28
|
-
action && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
29
|
-
/* @__PURE__ */ jsx(Separator, { orientation: "vertical", className: "rds-floating-stepper__divider" }),
|
|
30
|
-
/* @__PURE__ */ jsx(Button, { tone: "neutral", variant: "ghost", icon: action.icon, onClick: action.onClick, children: action.label })
|
|
31
|
-
] })
|
|
32
|
-
] });
|
|
33
|
-
}
|
|
3
|
+
import { Stepper } from "./stepper.js";
|
|
4
|
+
const FloatingStepper = Stepper;
|
|
34
5
|
export {
|
|
35
6
|
FloatingStepper
|
|
36
7
|
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ImageCropDialog — Figma [RDS] Overlays/ImageCropDialog: a composition of the Dialog, a ToggleGroup (the aspect
|
|
5
|
+
* presets), the Alert (an aspect out of bounds) and the Spinner: crops an image before upload. Needs
|
|
6
|
+
* `react-image-crop` (an optional peer, needed only here). The crop's shade, line and handles and the stage's
|
|
7
|
+
* background are its own tokens (Overlays, image-crop-dialog/*). Styles: image-crop-dialog.css.
|
|
8
|
+
*
|
|
9
|
+
* Uso:
|
|
10
|
+
* <ImageCropDialog
|
|
11
|
+
* open={open}
|
|
12
|
+
* file={selectedFile}
|
|
13
|
+
* onCancel={() => setOpen(false)}
|
|
14
|
+
* onConfirm={(blob) => uploadCroppedImage(blob)}
|
|
15
|
+
* />
|
|
16
|
+
*
|
|
17
|
+
* Presets default: Livre (1:2 a 2:1), 1:1, 4:5, 16:9.
|
|
18
|
+
* Customize via prop `presets`.
|
|
19
|
+
*/
|
|
20
|
+
interface CropPreset {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
/** Aspect ratio fixo. undefined = livre (com bounds MIN/MAX_ASPECT). */
|
|
24
|
+
aspect?: number;
|
|
25
|
+
/** Ícone opcional exibido antes do rótulo (ajuda quem não decodifica "16:9"). */
|
|
26
|
+
icon?: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
interface ImageCropDialogProps {
|
|
29
|
+
open: boolean;
|
|
30
|
+
file: File | null;
|
|
31
|
+
/** Label de contexto pro título (ex: "Foto 2 de 4"). */
|
|
32
|
+
position?: string;
|
|
33
|
+
onCancel: () => void;
|
|
34
|
+
/**
|
|
35
|
+
* Recebe o blob do recorte. Pode ser assíncrono (ex.: upload) — o modal AGUARDA a promise
|
|
36
|
+
* e mantém o botão travado/carregando até resolver, evitando duplo-clique que duplicaria a
|
|
37
|
+
* imagem (o upload roda fora do `busy` se o modal não esperar).
|
|
38
|
+
*/
|
|
39
|
+
onConfirm: (blob: Blob) => void | Promise<void>;
|
|
40
|
+
/** Presets disponíveis. default = Livre + 1:1 + 4:5 + 16:9. */
|
|
41
|
+
presets?: CropPreset[];
|
|
42
|
+
/** Limite mínimo de aspect ratio (alto-baixo). default = 0.5 (1:2). */
|
|
43
|
+
minAspect?: number;
|
|
44
|
+
/** Limite máximo de aspect ratio (largo-baixo). default = 2.0 (2:1). */
|
|
45
|
+
maxAspect?: number;
|
|
46
|
+
/** Título do modal. default = "Ajustar foto". */
|
|
47
|
+
title?: string;
|
|
48
|
+
/** Label do botão confirmar. default = "Aplicar e enviar". */
|
|
49
|
+
confirmLabel?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Teto de tamanho do blob de saída (bytes). Quando definido, a saída do crop
|
|
52
|
+
* é forçada a JPEG e comprimida (qualidade + downscale) até caber no limite —
|
|
53
|
+
* evita que foto de celular grande estoure o limite de upload. Sem ele, a
|
|
54
|
+
* saída mantém o tipo original e qualidade fixa (comportamento inalterado).
|
|
55
|
+
*/
|
|
56
|
+
maxSizeBytes?: number;
|
|
57
|
+
}
|
|
58
|
+
declare function ImageCropDialog({ open, file, position, onCancel, onConfirm, presets, minAspect, maxAspect, title, confirmLabel, maxSizeBytes, }: ImageCropDialogProps): react.JSX.Element;
|
|
59
|
+
|
|
60
|
+
export { type CropPreset, ImageCropDialog, type ImageCropDialogProps };
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import "../chunk-ORMEWXMH.js";
|
|
3
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import ReactCrop, { centerCrop } from "react-image-crop";
|
|
6
|
+
import "react-image-crop/dist/ReactCrop.css";
|
|
7
|
+
import { Alert } from "./alert.js";
|
|
8
|
+
import { Button } from "./button.js";
|
|
9
|
+
import { Dialog } from "./dialog.js";
|
|
10
|
+
import { RectangleHorizontalIcon, RectangleVerticalIcon, SquareDashedIcon, SquareIcon } from "./internal/icons.js";
|
|
11
|
+
import { Spinner } from "./spinner.js";
|
|
12
|
+
import { ToggleGroup, ToggleGroupItem } from "./toggle-group.js";
|
|
13
|
+
const DEFAULT_PRESETS = [
|
|
14
|
+
{ id: "square", label: "1:1", aspect: 1, icon: /* @__PURE__ */ jsx(SquareIcon, {}) },
|
|
15
|
+
{ id: "portrait", label: "4:5", aspect: 4 / 5, icon: /* @__PURE__ */ jsx(RectangleVerticalIcon, {}) },
|
|
16
|
+
{ id: "landscape", label: "16:9", aspect: 16 / 9, icon: /* @__PURE__ */ jsx(RectangleHorizontalIcon, {}) },
|
|
17
|
+
{ id: "free", label: "Livre", icon: /* @__PURE__ */ jsx(SquareDashedIcon, {}) }
|
|
18
|
+
];
|
|
19
|
+
const DEFAULT_MIN_ASPECT = 0.5;
|
|
20
|
+
const DEFAULT_MAX_ASPECT = 2;
|
|
21
|
+
function buildInitialCrop(width, height, aspect) {
|
|
22
|
+
if (!aspect) {
|
|
23
|
+
return { unit: "%", x: 0, y: 0, width: 100, height: 100 };
|
|
24
|
+
}
|
|
25
|
+
const imageAspect = width / height;
|
|
26
|
+
let cropW;
|
|
27
|
+
let cropH;
|
|
28
|
+
if (aspect >= imageAspect) {
|
|
29
|
+
cropW = 100;
|
|
30
|
+
cropH = imageAspect / aspect * 100;
|
|
31
|
+
} else {
|
|
32
|
+
cropH = 100;
|
|
33
|
+
cropW = aspect / imageAspect * 100;
|
|
34
|
+
}
|
|
35
|
+
return centerCrop({ unit: "%", x: 0, y: 0, width: cropW, height: cropH }, width, height);
|
|
36
|
+
}
|
|
37
|
+
const QUALITY_STEPS = [0.85, 0.75, 0.65, 0.55];
|
|
38
|
+
const MAX_SIDE_STEPS = [1600, 1200];
|
|
39
|
+
function canvasToBlob(canvas, mimeType, quality) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("toBlob failed")), mimeType, quality);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function drawCrop(image, crop, maxSide) {
|
|
45
|
+
const canvas = document.createElement("canvas");
|
|
46
|
+
const scaleX = image.naturalWidth / image.width;
|
|
47
|
+
const scaleY = image.naturalHeight / image.height;
|
|
48
|
+
let outW = Math.round(crop.width * scaleX);
|
|
49
|
+
let outH = Math.round(crop.height * scaleY);
|
|
50
|
+
if (maxSide && Math.max(outW, outH) > maxSide) {
|
|
51
|
+
const ratio = maxSide / Math.max(outW, outH);
|
|
52
|
+
outW = Math.round(outW * ratio);
|
|
53
|
+
outH = Math.round(outH * ratio);
|
|
54
|
+
}
|
|
55
|
+
canvas.width = outW;
|
|
56
|
+
canvas.height = outH;
|
|
57
|
+
const ctx = canvas.getContext("2d");
|
|
58
|
+
if (!ctx) throw new Error("Canvas not supported");
|
|
59
|
+
ctx.drawImage(
|
|
60
|
+
image,
|
|
61
|
+
crop.x * scaleX,
|
|
62
|
+
crop.y * scaleY,
|
|
63
|
+
crop.width * scaleX,
|
|
64
|
+
crop.height * scaleY,
|
|
65
|
+
0,
|
|
66
|
+
0,
|
|
67
|
+
outW,
|
|
68
|
+
outH
|
|
69
|
+
);
|
|
70
|
+
return canvas;
|
|
71
|
+
}
|
|
72
|
+
async function cropToBlob(image, crop, mimeType, maxSizeBytes) {
|
|
73
|
+
const baseCanvas = drawCrop(image, crop);
|
|
74
|
+
if (!maxSizeBytes) {
|
|
75
|
+
return canvasToBlob(baseCanvas, mimeType, 0.92);
|
|
76
|
+
}
|
|
77
|
+
const out = await canvasToBlob(baseCanvas, "image/jpeg", 0.92);
|
|
78
|
+
if (out.size <= maxSizeBytes) return out;
|
|
79
|
+
let best = out;
|
|
80
|
+
for (const q of QUALITY_STEPS) {
|
|
81
|
+
const candidate = await canvasToBlob(baseCanvas, "image/jpeg", q);
|
|
82
|
+
best = candidate;
|
|
83
|
+
if (candidate.size <= maxSizeBytes) return candidate;
|
|
84
|
+
}
|
|
85
|
+
for (const side of MAX_SIDE_STEPS) {
|
|
86
|
+
const scaledCanvas = drawCrop(image, crop, side);
|
|
87
|
+
for (const q of QUALITY_STEPS) {
|
|
88
|
+
const candidate = await canvasToBlob(scaledCanvas, "image/jpeg", q);
|
|
89
|
+
best = candidate;
|
|
90
|
+
if (candidate.size <= maxSizeBytes) return candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return best;
|
|
94
|
+
}
|
|
95
|
+
function ImageCropDialog({
|
|
96
|
+
open,
|
|
97
|
+
file,
|
|
98
|
+
position,
|
|
99
|
+
onCancel,
|
|
100
|
+
onConfirm,
|
|
101
|
+
presets = DEFAULT_PRESETS,
|
|
102
|
+
minAspect = DEFAULT_MIN_ASPECT,
|
|
103
|
+
maxAspect = DEFAULT_MAX_ASPECT,
|
|
104
|
+
title = "Ajustar foto",
|
|
105
|
+
confirmLabel = "Aplicar e enviar",
|
|
106
|
+
maxSizeBytes
|
|
107
|
+
}) {
|
|
108
|
+
const [src, setSrc] = useState(null);
|
|
109
|
+
const [preset, setPreset] = useState(presets[0]);
|
|
110
|
+
const [crop, setCrop] = useState();
|
|
111
|
+
const [completedCrop, setCompletedCrop] = useState(null);
|
|
112
|
+
const [aspectError, setAspectError] = useState(null);
|
|
113
|
+
const [busy, setBusy] = useState(false);
|
|
114
|
+
const [imgLoaded, setImgLoaded] = useState(false);
|
|
115
|
+
const imgRef = useRef(null);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!file) {
|
|
118
|
+
setSrc(null);
|
|
119
|
+
setImgLoaded(false);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const url = URL.createObjectURL(file);
|
|
123
|
+
setSrc(url);
|
|
124
|
+
setPreset(presets[0]);
|
|
125
|
+
setCrop(void 0);
|
|
126
|
+
setCompletedCrop(null);
|
|
127
|
+
setAspectError(null);
|
|
128
|
+
setImgLoaded(false);
|
|
129
|
+
return () => URL.revokeObjectURL(url);
|
|
130
|
+
}, [file, presets]);
|
|
131
|
+
function handleImageLoad(e) {
|
|
132
|
+
var _a, _b, _c, _d;
|
|
133
|
+
const { width, height } = e.currentTarget;
|
|
134
|
+
const initial = buildInitialCrop(width, height, preset.aspect);
|
|
135
|
+
setCrop(initial);
|
|
136
|
+
const pixelInitial = {
|
|
137
|
+
unit: "px",
|
|
138
|
+
x: ((_a = initial.x) != null ? _a : 0) / 100 * width,
|
|
139
|
+
y: ((_b = initial.y) != null ? _b : 0) / 100 * height,
|
|
140
|
+
width: ((_c = initial.width) != null ? _c : 0) / 100 * width,
|
|
141
|
+
height: ((_d = initial.height) != null ? _d : 0) / 100 * height
|
|
142
|
+
};
|
|
143
|
+
setCompletedCrop(pixelInitial);
|
|
144
|
+
setImgLoaded(true);
|
|
145
|
+
}
|
|
146
|
+
function applyPreset(p) {
|
|
147
|
+
setPreset(p);
|
|
148
|
+
setAspectError(null);
|
|
149
|
+
if (imgRef.current) {
|
|
150
|
+
const { width, height } = imgRef.current;
|
|
151
|
+
setCrop(buildInitialCrop(width, height, p.aspect));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function validateFreeAspect(c) {
|
|
155
|
+
if (preset.aspect) return true;
|
|
156
|
+
if (!c.width || !c.height) return false;
|
|
157
|
+
const ratio = c.width / c.height;
|
|
158
|
+
if (ratio < minAspect) {
|
|
159
|
+
setAspectError(`Recorte muito alto. M\xEDnimo permitido: ${minAspect.toFixed(1)}.`);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
if (ratio > maxAspect) {
|
|
163
|
+
setAspectError(`Recorte muito largo. M\xE1ximo permitido: ${maxAspect.toFixed(1)}.`);
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
setAspectError(null);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
async function handleConfirm() {
|
|
170
|
+
if (busy) return;
|
|
171
|
+
if (!completedCrop || !imgRef.current || !file) return;
|
|
172
|
+
if (!validateFreeAspect(completedCrop)) return;
|
|
173
|
+
setBusy(true);
|
|
174
|
+
try {
|
|
175
|
+
const blob = await cropToBlob(imgRef.current, completedCrop, file.type || "image/jpeg", maxSizeBytes);
|
|
176
|
+
await onConfirm(blob);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error("cropToBlob error", err);
|
|
179
|
+
} finally {
|
|
180
|
+
setBusy(false);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return /* @__PURE__ */ jsx(
|
|
184
|
+
Dialog,
|
|
185
|
+
{
|
|
186
|
+
open,
|
|
187
|
+
onOpenChange: (next) => {
|
|
188
|
+
if (!next) onCancel();
|
|
189
|
+
},
|
|
190
|
+
size: "lg",
|
|
191
|
+
title: position ? `${title} \xB7 ${position}` : title,
|
|
192
|
+
footer: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
193
|
+
/* @__PURE__ */ jsx(Button, { tone: "neutral", variant: "ghost", onClick: onCancel, children: "Cancelar" }),
|
|
194
|
+
/* @__PURE__ */ jsx(Button, { onClick: handleConfirm, disabled: busy || !completedCrop || !!aspectError, loading: busy, children: busy ? "Aplicando\u2026" : confirmLabel })
|
|
195
|
+
] }),
|
|
196
|
+
children: /* @__PURE__ */ jsxs("div", { className: "rds-image-crop", children: [
|
|
197
|
+
presets.length > 1 && /* @__PURE__ */ jsx(
|
|
198
|
+
ToggleGroup,
|
|
199
|
+
{
|
|
200
|
+
type: "single",
|
|
201
|
+
"aria-label": "Propor\xE7\xE3o",
|
|
202
|
+
value: preset.id,
|
|
203
|
+
onValueChange: (id) => {
|
|
204
|
+
const next = presets.find((p) => p.id === id);
|
|
205
|
+
if (next) applyPreset(next);
|
|
206
|
+
},
|
|
207
|
+
className: "rds-image-crop__presets",
|
|
208
|
+
children: presets.map((p) => /* @__PURE__ */ jsx(ToggleGroupItem, { value: p.id, icon: p.icon, children: p.label }, p.id))
|
|
209
|
+
}
|
|
210
|
+
),
|
|
211
|
+
/* @__PURE__ */ jsxs("div", { className: "rds-image-crop__stage", children: [
|
|
212
|
+
src && /* @__PURE__ */ jsx(
|
|
213
|
+
ReactCrop,
|
|
214
|
+
{
|
|
215
|
+
crop,
|
|
216
|
+
onChange: (_, percentCrop) => setCrop(percentCrop),
|
|
217
|
+
onComplete: (c) => {
|
|
218
|
+
setCompletedCrop(c);
|
|
219
|
+
validateFreeAspect(c);
|
|
220
|
+
},
|
|
221
|
+
aspect: preset.aspect,
|
|
222
|
+
keepSelection: true,
|
|
223
|
+
minWidth: 50,
|
|
224
|
+
minHeight: 50,
|
|
225
|
+
className: "rds-image-crop__crop",
|
|
226
|
+
children: /* @__PURE__ */ jsx("img", { ref: imgRef, src, alt: "", onLoad: handleImageLoad, className: "rds-image-crop__image" })
|
|
227
|
+
}
|
|
228
|
+
),
|
|
229
|
+
!imgLoaded && /* @__PURE__ */ jsx("div", { className: "rds-image-crop__layer", children: /* @__PURE__ */ jsx(Spinner, { size: "lg", label: "Carregando imagem\u2026" }) }),
|
|
230
|
+
busy && /* @__PURE__ */ jsx("div", { className: "rds-image-crop__layer rds-image-crop__layer--busy", children: /* @__PURE__ */ jsx(Spinner, { size: "lg", tone: "inverse", label: "Enviando\u2026" }) })
|
|
231
|
+
] }),
|
|
232
|
+
aspectError && /* @__PURE__ */ jsx(Alert, { tone: "danger", title: aspectError, announce: "alert" }),
|
|
233
|
+
!aspectError && imgLoaded && !busy && /* @__PURE__ */ jsx("p", { className: "rds-image-crop__hint", children: preset.aspect ? "Arraste as al\xE7as para ajustar o recorte." : `Livre: propor\xE7\xE3o entre ${minAspect.toFixed(1)} (vertical) e ${maxAspect.toFixed(1)} (horizontal).` })
|
|
234
|
+
] })
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
export {
|
|
239
|
+
ImageCropDialog
|
|
240
|
+
};
|
|
@@ -1,60 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ImageCropDialog, ImageCropDialogProps } from './image-crop-dialog.js';
|
|
2
|
+
export { CropPreset } from './image-crop-dialog.js';
|
|
3
|
+
import 'react';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
* background are its own tokens (Overlays, image-crop-modal/*). Styles: image-crop-modal.css.
|
|
8
|
-
*
|
|
9
|
-
* Uso:
|
|
10
|
-
* <ImageCropModal
|
|
11
|
-
* open={open}
|
|
12
|
-
* file={selectedFile}
|
|
13
|
-
* onCancel={() => setOpen(false)}
|
|
14
|
-
* onConfirm={(blob) => uploadCroppedImage(blob)}
|
|
15
|
-
* />
|
|
16
|
-
*
|
|
17
|
-
* Presets default: Livre (1:2 a 2:1), 1:1, 4:5, 16:9.
|
|
18
|
-
* Customize via prop `presets`.
|
|
6
|
+
* image-crop-modal — the 1.x and 2.0-next name of the ImageCropDialog, kept so
|
|
7
|
+
* `@rojaostudio/ds/components/image-crop-modal` and `ImageCropModal` still work in 2.0. The codemod
|
|
8
|
+
* (`npx @rojaostudio/ds-codemod`) renames both. Like the dialog, it needs the optional peer `react-image-crop`.
|
|
19
9
|
*/
|
|
20
|
-
interface CropPreset {
|
|
21
|
-
id: string;
|
|
22
|
-
label: string;
|
|
23
|
-
/** Aspect ratio fixo. undefined = livre (com bounds MIN/MAX_ASPECT). */
|
|
24
|
-
aspect?: number;
|
|
25
|
-
/** Ícone opcional exibido antes do rótulo (ajuda quem não decodifica "16:9"). */
|
|
26
|
-
icon?: React.ReactNode;
|
|
27
|
-
}
|
|
28
|
-
interface ImageCropModalProps {
|
|
29
|
-
open: boolean;
|
|
30
|
-
file: File | null;
|
|
31
|
-
/** Label de contexto pro título (ex: "Foto 2 de 4"). */
|
|
32
|
-
position?: string;
|
|
33
|
-
onCancel: () => void;
|
|
34
|
-
/**
|
|
35
|
-
* Recebe o blob do recorte. Pode ser assíncrono (ex.: upload) — o modal AGUARDA a promise
|
|
36
|
-
* e mantém o botão travado/carregando até resolver, evitando duplo-clique que duplicaria a
|
|
37
|
-
* imagem (o upload roda fora do `busy` se o modal não esperar).
|
|
38
|
-
*/
|
|
39
|
-
onConfirm: (blob: Blob) => void | Promise<void>;
|
|
40
|
-
/** Presets disponíveis. default = Livre + 1:1 + 4:5 + 16:9. */
|
|
41
|
-
presets?: CropPreset[];
|
|
42
|
-
/** Limite mínimo de aspect ratio (alto-baixo). default = 0.5 (1:2). */
|
|
43
|
-
minAspect?: number;
|
|
44
|
-
/** Limite máximo de aspect ratio (largo-baixo). default = 2.0 (2:1). */
|
|
45
|
-
maxAspect?: number;
|
|
46
|
-
/** Título do modal. default = "Ajustar foto". */
|
|
47
|
-
title?: string;
|
|
48
|
-
/** Label do botão confirmar. default = "Aplicar e enviar". */
|
|
49
|
-
confirmLabel?: string;
|
|
50
|
-
/**
|
|
51
|
-
* Teto de tamanho do blob de saída (bytes). Quando definido, a saída do crop
|
|
52
|
-
* é forçada a JPEG e comprimida (qualidade + downscale) até caber no limite —
|
|
53
|
-
* evita que foto de celular grande estoure o limite de upload. Sem ele, a
|
|
54
|
-
* saída mantém o tipo original e qualidade fixa (comportamento inalterado).
|
|
55
|
-
*/
|
|
56
|
-
maxSizeBytes?: number;
|
|
57
|
-
}
|
|
58
|
-
declare function ImageCropModal({ open, file, position, onCancel, onConfirm, presets, minAspect, maxAspect, title, confirmLabel, maxSizeBytes, }: ImageCropModalProps): react.JSX.Element;
|
|
59
10
|
|
|
60
|
-
|
|
11
|
+
/** @deprecated Renamed to `ImageCropDialog` (`@rojaostudio/ds/components/image-crop-dialog`) in 2.0. Same props. */
|
|
12
|
+
declare const ImageCropModal: typeof ImageCropDialog;
|
|
13
|
+
/** @deprecated Renamed to `ImageCropDialogProps` in 2.0. */
|
|
14
|
+
type ImageCropModalProps = ImageCropDialogProps;
|
|
15
|
+
|
|
16
|
+
export { ImageCropModal, type ImageCropModalProps };
|