@rojaostudio/ds 2.0.0-next.2 → 2.0.0-next.21
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 +66 -0
- package/dist/components/avatar.d.ts +20 -12
- package/dist/components/avatar.js +8 -6
- package/dist/components/badge.d.ts +7 -6
- package/dist/components/badge.js +2 -1
- package/dist/components/breadcrumb.d.ts +4 -4
- package/dist/components/breadcrumb.js +1 -1
- package/dist/components/bubble.d.ts +26 -11
- package/dist/components/bubble.js +3 -3
- package/dist/components/button.d.ts +1 -1
- package/dist/components/card.d.ts +28 -12
- package/dist/components/card.js +4 -2
- package/dist/components/chart.d.ts +17 -3
- package/dist/components/chart.js +70 -24
- package/dist/components/choice-carousel.d.ts +1 -1
- package/dist/components/choice-carousel.js +2 -4
- package/dist/components/choice-list.d.ts +40 -0
- package/dist/components/choice-list.js +122 -0
- package/dist/components/choice-preview-card.d.ts +1 -7
- package/dist/components/color-input.d.ts +1 -1
- package/dist/components/context-menu.d.ts +5 -2
- package/dist/components/currency-input.d.ts +0 -2
- package/dist/components/data-table-header.d.ts +34 -16
- package/dist/components/data-table-header.js +132 -67
- package/dist/components/date-picker.d.ts +1 -1
- package/dist/components/dialog.d.ts +6 -3
- package/dist/components/dialog.js +2 -2
- package/dist/components/dropdown-menu.d.ts +5 -2
- package/dist/components/dropzone.d.ts +2 -2
- package/dist/components/dropzone.js +1 -1
- package/dist/components/file-input.d.ts +13 -8
- package/dist/components/file-input.js +24 -21
- package/dist/components/filter-chip.d.ts +11 -5
- package/dist/components/filter-chip.js +15 -6
- package/dist/components/floating-stepper.d.ts +11 -28
- package/dist/components/floating-stepper.js +2 -30
- package/dist/components/heading.d.ts +42 -0
- package/dist/components/heading.js +25 -0
- package/dist/components/icon-button.d.ts +1 -1
- 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 -55
- package/dist/components/image-crop-modal.js +2 -249
- package/dist/components/image-upload.d.ts +8 -17
- package/dist/components/image-upload.js +3 -3
- package/dist/components/index.d.ts +11 -8
- package/dist/components/index.js +8 -0
- package/dist/components/internal/button.d.ts +1 -1
- package/dist/components/internal/icons.d.ts +11 -1
- package/dist/components/internal/icons.js +20 -0
- package/dist/components/internal/menu.d.ts +3 -2
- package/dist/components/internal/menu.js +1 -1
- package/dist/components/internal/modal.js +9 -0
- package/dist/components/internal/toggle.d.ts +1 -1
- package/dist/components/item.d.ts +14 -7
- package/dist/components/item.js +5 -2
- package/dist/components/marker.d.ts +11 -3
- package/dist/components/marker.js +4 -3
- package/dist/components/number-input.d.ts +1 -1
- package/dist/components/progress.d.ts +3 -3
- package/dist/components/progress.js +2 -2
- package/dist/components/row-actions.d.ts +3 -0
- package/dist/components/row-actions.js +8 -4
- package/dist/components/saving-bar.d.ts +16 -4
- package/dist/components/saving-bar.js +37 -9
- package/dist/components/section-header.d.ts +4 -3
- package/dist/components/selectable-card.d.ts +2 -10
- package/dist/components/sidebar.d.ts +8 -11
- package/dist/components/sidebar.js +4 -5
- package/dist/components/spinner.d.ts +9 -6
- package/dist/components/spinner.js +3 -3
- package/dist/components/star-rating.d.ts +3 -3
- package/dist/components/star-rating.js +2 -2
- package/dist/components/stat.d.ts +18 -6
- package/dist/components/stat.js +9 -3
- package/dist/components/status.d.ts +4 -4
- package/dist/components/status.js +2 -2
- package/dist/components/stepper.d.ts +33 -0
- package/dist/components/stepper.js +36 -0
- package/dist/components/summary-bar.d.ts +3 -2
- package/dist/components/summary-bar.js +10 -1
- package/dist/components/tile.d.ts +5 -5
- package/dist/components/tile.js +2 -2
- package/dist/components/time-picker.d.ts +1 -1
- package/dist/components/toast.d.ts +17 -6
- package/dist/components/toast.js +5 -3
- package/dist/components/toggle-card-compact.d.ts +6 -25
- package/dist/components/toggle-card-compact.js +8 -35
- package/dist/components/toggle-card.d.ts +23 -9
- package/dist/components/toggle-card.js +38 -2
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/typing-indicator.d.ts +2 -2
- package/dist/components/typing-indicator.js +1 -1
- package/package.json +4 -4
- package/styles/base.css +13 -1
- package/styles/rds/components-styles.css +611 -173
- package/styles/rds/components.css +40 -25
- package/styles/rds/foundation.css +1 -1
- package/styles/rds/theme.css +40 -29
- package/styles/rds.css +1 -1
|
@@ -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 line (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,35 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import "../chunk-ORMEWXMH.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import { Card } from "./card.js";
|
|
6
|
-
function FloatingStepper({ steps, current, onNavigate, canNavigate, action, className, "aria-label": ariaLabel = "Etapas" }) {
|
|
7
|
-
const currentIdx = steps.findIndex((s) => s.key === current);
|
|
8
|
-
return /* @__PURE__ */ jsxs(Card, { as: "div", size: "sm", role: "navigation", "aria-label": ariaLabel, className: ["rds-floating-stepper", className].filter(Boolean).join(" "), children: [
|
|
9
|
-
steps.map((s, idx) => {
|
|
10
|
-
const isCurrent = idx === currentIdx;
|
|
11
|
-
const canGo = canNavigate ? canNavigate(s.key, idx, currentIdx) : idx < currentIdx;
|
|
12
|
-
return /* @__PURE__ */ jsx(
|
|
13
|
-
Button,
|
|
14
|
-
{
|
|
15
|
-
tone: "neutral",
|
|
16
|
-
variant: isCurrent ? "fill" : "ghost",
|
|
17
|
-
disabled: !canGo && !isCurrent,
|
|
18
|
-
"aria-current": isCurrent ? "step" : void 0,
|
|
19
|
-
onClick: () => {
|
|
20
|
-
if (canGo && !isCurrent) onNavigate == null ? void 0 : onNavigate(s.key);
|
|
21
|
-
},
|
|
22
|
-
children: s.label
|
|
23
|
-
},
|
|
24
|
-
s.key
|
|
25
|
-
);
|
|
26
|
-
}),
|
|
27
|
-
action && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
28
|
-
/* @__PURE__ */ jsx("span", { className: "rds-floating-stepper__divider", "aria-hidden": "true" }),
|
|
29
|
-
/* @__PURE__ */ jsx(Button, { tone: "neutral", variant: "ghost", icon: action.icon, onClick: action.onClick, children: action.label })
|
|
30
|
-
] })
|
|
31
|
-
] });
|
|
32
|
-
}
|
|
3
|
+
import { Stepper } from "./stepper.js";
|
|
4
|
+
const FloatingStepper = Stepper;
|
|
33
5
|
export {
|
|
34
6
|
FloatingStepper
|
|
35
7
|
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { HTMLAttributes, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/** The [RDS] type scale for titles: band 36/48, heading 24/30, value 20/30. */
|
|
5
|
+
type HeadingLevel = 'band' | 'heading' | 'value';
|
|
6
|
+
/** neutral: text/heading (navy on light in Rojão, white on dark and on the brand plate). accent: the logo orange. */
|
|
7
|
+
type HeadingTone = 'neutral' | 'accent';
|
|
8
|
+
/** The element in the page outline. The visual level never decides it: the order of the page does. */
|
|
9
|
+
type HeadingElement = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
10
|
+
interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
|
|
11
|
+
/** The size, from the type scale (Figma `level`). */
|
|
12
|
+
level?: HeadingLevel;
|
|
13
|
+
/**
|
|
14
|
+
* The colour of the brand pair (Figma `tone`): neutral (default) or accent. The Mark takes the other one.
|
|
15
|
+
* `'default'` is deprecated (2.0.0-next): it is `'neutral'`.
|
|
16
|
+
*/
|
|
17
|
+
tone?: HeadingTone | 'default';
|
|
18
|
+
/** The heading element. Default h2. */
|
|
19
|
+
as?: HeadingElement;
|
|
20
|
+
/** The text (Figma `text`), with an optional <Heading.Mark> stretch (Figma `showMark` + `mark`). */
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
interface HeadingMarkProps extends HTMLAttributes<HTMLSpanElement> {
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Heading.Mark — Figma [RDS] .heading/mark. A stretch of the title in the other colour of the pair: accent inside a
|
|
28
|
+
* neutral Heading, neutral inside an accent one. A plain span: screen readers read the whole title, unannounced.
|
|
29
|
+
*/
|
|
30
|
+
declare function HeadingMark({ className, children, ...rest }: HeadingMarkProps): react.JSX.Element;
|
|
31
|
+
/**
|
|
32
|
+
* Heading — Figma [RDS] Content/Heading. A title in the brand pair: one colour, or both in the same title with a
|
|
33
|
+
* Heading.Mark. The colour is chosen per screen; tone=neutral is the rule. Orange on white fails contrast even for
|
|
34
|
+
* a large title (2.9:1 in Rojão): tone=accent belongs on dark or on the brand plate. Styles: heading.css.
|
|
35
|
+
*/
|
|
36
|
+
declare function HeadingRoot({ level, tone, as: Tag, className, children, ...rest }: HeadingProps): react.JSX.Element;
|
|
37
|
+
/** `<Heading>` with `<Heading.Mark>` for the stretch in the other colour. */
|
|
38
|
+
declare const Heading: typeof HeadingRoot & {
|
|
39
|
+
Mark: typeof HeadingMark;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export { Heading, type HeadingElement, type HeadingLevel, HeadingMark, type HeadingMarkProps, type HeadingProps, type HeadingTone };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__objRest,
|
|
3
|
+
__spreadProps,
|
|
4
|
+
__spreadValues
|
|
5
|
+
} from "../chunk-ORMEWXMH.js";
|
|
6
|
+
import { jsx } from "react/jsx-runtime";
|
|
7
|
+
function HeadingMark(_a) {
|
|
8
|
+
var _b = _a, { className, children } = _b, rest = __objRest(_b, ["className", "children"]);
|
|
9
|
+
return /* @__PURE__ */ jsx("span", __spreadProps(__spreadValues({}, rest), { className: ["rds-heading__mark", className].filter(Boolean).join(" "), children }));
|
|
10
|
+
}
|
|
11
|
+
function HeadingRoot(_c) {
|
|
12
|
+
var _d = _c, { level = "heading", tone = "neutral", as: Tag = "h2", className, children } = _d, rest = __objRest(_d, ["level", "tone", "as", "className", "children"]);
|
|
13
|
+
return /* @__PURE__ */ jsx(
|
|
14
|
+
Tag,
|
|
15
|
+
__spreadProps(__spreadValues({}, rest), {
|
|
16
|
+
className: ["rds-heading", `rds-heading--${level}`, tone === "accent" && "rds-heading--accent", className].filter(Boolean).join(" "),
|
|
17
|
+
children
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
const Heading = Object.assign(HeadingRoot, { Mark: HeadingMark });
|
|
22
|
+
export {
|
|
23
|
+
Heading,
|
|
24
|
+
HeadingMark
|
|
25
|
+
};
|
|
@@ -9,7 +9,7 @@ interface IconButtonProps extends Omit<ComponentPropsWithRef<'button'>, 'childre
|
|
|
9
9
|
label: string;
|
|
10
10
|
/** Same tones as the Button. */
|
|
11
11
|
tone?: ButtonTone;
|
|
12
|
-
/**
|
|
12
|
+
/** The emphasis (Figma: `variant`). */
|
|
13
13
|
variant?: ButtonVariant;
|
|
14
14
|
/**
|
|
15
15
|
* The size of the square (Figma: `size`): sm 36 with a 16 icon, md 44 with a 20 icon (default), lg 52 with a 24
|
|
@@ -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,59 +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
|
-
*
|
|
8
|
-
* Uso:
|
|
9
|
-
* <ImageCropModal
|
|
10
|
-
* open={open}
|
|
11
|
-
* file={selectedFile}
|
|
12
|
-
* onCancel={() => setOpen(false)}
|
|
13
|
-
* onConfirm={(blob) => uploadCroppedImage(blob)}
|
|
14
|
-
* />
|
|
15
|
-
*
|
|
16
|
-
* Presets default: Livre (1:2 a 2:1), 1:1, 4:5, 16:9.
|
|
17
|
-
* 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`.
|
|
18
9
|
*/
|
|
19
|
-
interface CropPreset {
|
|
20
|
-
id: string;
|
|
21
|
-
label: string;
|
|
22
|
-
/** Aspect ratio fixo. undefined = livre (com bounds MIN/MAX_ASPECT). */
|
|
23
|
-
aspect?: number;
|
|
24
|
-
/** Ícone opcional exibido antes do rótulo (ajuda quem não decodifica "16:9"). */
|
|
25
|
-
icon?: React.ReactNode;
|
|
26
|
-
}
|
|
27
|
-
interface ImageCropModalProps {
|
|
28
|
-
open: boolean;
|
|
29
|
-
file: File | null;
|
|
30
|
-
/** Label de contexto pro título (ex: "Foto 2 de 4"). */
|
|
31
|
-
position?: string;
|
|
32
|
-
onCancel: () => void;
|
|
33
|
-
/**
|
|
34
|
-
* Recebe o blob do recorte. Pode ser assíncrono (ex.: upload) — o modal AGUARDA a promise
|
|
35
|
-
* e mantém o botão travado/carregando até resolver, evitando duplo-clique que duplicaria a
|
|
36
|
-
* imagem (o upload roda fora do `busy` se o modal não esperar).
|
|
37
|
-
*/
|
|
38
|
-
onConfirm: (blob: Blob) => void | Promise<void>;
|
|
39
|
-
/** Presets disponíveis. default = Livre + 1:1 + 4:5 + 16:9. */
|
|
40
|
-
presets?: CropPreset[];
|
|
41
|
-
/** Limite mínimo de aspect ratio (alto-baixo). default = 0.5 (1:2). */
|
|
42
|
-
minAspect?: number;
|
|
43
|
-
/** Limite máximo de aspect ratio (largo-baixo). default = 2.0 (2:1). */
|
|
44
|
-
maxAspect?: number;
|
|
45
|
-
/** Título do modal. default = "Ajustar foto". */
|
|
46
|
-
title?: string;
|
|
47
|
-
/** Label do botão confirmar. default = "Aplicar e enviar". */
|
|
48
|
-
confirmLabel?: string;
|
|
49
|
-
/**
|
|
50
|
-
* Teto de tamanho do blob de saída (bytes). Quando definido, a saída do crop
|
|
51
|
-
* é forçada a JPEG e comprimida (qualidade + downscale) até caber no limite —
|
|
52
|
-
* evita que foto de celular grande estoure o limite de upload. Sem ele, a
|
|
53
|
-
* saída mantém o tipo original e qualidade fixa (comportamento inalterado).
|
|
54
|
-
*/
|
|
55
|
-
maxSizeBytes?: number;
|
|
56
|
-
}
|
|
57
|
-
declare function ImageCropModal({ open, file, position, onCancel, onConfirm, presets, minAspect, maxAspect, title, confirmLabel, maxSizeBytes, }: ImageCropModalProps): react.JSX.Element;
|
|
58
10
|
|
|
59
|
-
|
|
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 };
|