@chrono-os/image-editor-react
v0.8.0
Published
Editor de imagens com crop visual: <ImagePicker> controlado + <ImageCropPicker> standalone + <BadgeOverlay> + editores de badge (<BadgeEditorCard>, <BadgePartControls>, <SliderRow>)
Readme
@chrono-os/image-editor-react
Editor de imagens com crop visual + preview de badge. Inclui <ImagePicker> controlado (preview + modal de edição com crop draggable, snap nas bordas, zoom, filtros saturação/sépia, cor de fundo, espelhar), <ImageCropPicker> standalone (sem o wrapper), <BadgeOverlay> reutilizável e os editores de badge <BadgeEditorCard> / <BadgePartControls> / <SliderRow> (admin).
Install
yarn add @chrono-os/image-editor-reactPublicado em registry.npmjs.org público — sem auth pra consumir.
Peer dependencies: react@>=18 react-dom@>=18.
Setup tema — obrigatório desde 0.7.0
Importar o CSS de tokens no layout root (Next.js App Router):
// app/layout.tsx
import '@chrono-os/image-editor-react/theme.css'Mudou em 0.7.0. Antes o import era recomendado e o tema escuro vinha das classes
dark:do Tailwind do consumer. Agora as superfícies também são tokens, então sem este import o escuro não acontece — os componentes caem nos fallbacks inline, que são os valores do tema claro.
Três temas: claro · escuro · black OLED
O escuro é detectado pelos três marcadores usados no parque — classe .dark, [data-mode="dark"] e [data-theme="dark"]. O OLED soma ao escuro (o marcador de dark continua ligado) e é marcado pela classe .oled, que só rebaixa fundo, borda e overlay. Nada a configurar: basta o marcador estar no <html>.
| Token | Claro | Escuro | OLED | Onde aparece |
|---|---|---|---|---|
| --ie-surface | #ffffff | #171717 | #0a0a0a | fundo do modal e dos cards |
| --ie-bg | #fafafa | #262626 | #141414 | superfície elevada (subcards, thumbs) |
| --ie-surface-hover | #f5f5f5 | #262626 | #141414 | hover de botão-ícone |
| --ie-field-bg | #ffffff | #171717 | #0a0a0a | fundo de <input> |
| --ie-border | #d4d4d4 | #404040 | #2e2e2e | borda de controle |
| --ie-border-subtle | #e5e5e5 | #404040 | #242424 | divisória, borda de card |
| --ie-border-hover | #737373 | #737373 | #4a4a4a | hover de borda |
| --ie-border-hover-subtle | #a3a3a3 | #737373 | #4a4a4a | hover de borda de card |
| --ie-text-strong | #171717 | #f5f5f5 | #ededed | texto em hover |
| --ie-text-emphasis | #404040 | #e5e5e5 | #dedede | nome do arquivo no grid de uploads |
| --ie-text | #404040 | #d4d4d4 | #d0d0d0 | texto de corpo |
| --ie-text-soft | #525252 | #d4d4d4 | #d0d0d0 | rótulo de slider |
| --ie-text-muted | #616161 | #a3a3a3 | #a8a8a8 | rótulo de seção, hint |
| --ie-text-faint | #6e6e6e | #929292 | #8f8f8f | placeholder, min/max de slider |
| --ie-field-text | currentColor | #f5f5f5 | #ededed | texto digitado no <input> |
| --ie-info-bg / -border / -text | #eff6ff / #bfdbfe / #1e40af | #172554 / #1e3a8a / #bfdbfe | #0e1930 / #1c3163 / #b9d3fb | banner de aviso |
| --ie-danger-bg / -border / -border-subtle / -text | #fef2f2 / #fca5a5 / #fecaca / #991b1b | #450a0a / #7f1d1d / #7f1d1d / #fecaca | #240b0b / #5e1a1a / #5e1a1a / #f3c9c9 | banner e botão de erro |
| --ie-danger | #b91c1c | #fca5a5 | #f0a3a3 | texto do botão remover |
| --ie-danger-hover | #ef4444 | #ef4444 | #ef4444 | borda do botão remover em hover |
No claro, --ie-field-text é currentColor de propósito: o preflight do Tailwind deixa <input> com color: inherit, e currentColor aplicado ao próprio color equivale a herdar — o campo continua pegando a cor do contexto do consumer, como sempre pegou.
Contraste: WCAG AA nos três temas
Todo token de texto bate 4,5:1 contra a pior superfície em que pode aparecer, nos três temas. src/theme.test.ts mede e falha se algum cair abaixo, então a régua não depende de ninguém lembrar dela.
Em 0.8.0 três tokens mudaram de valor no tema claro e um no escuro porque reprovavam:
| Token | Tema | Antes | Depois | Contraste (pior superfície) |
|---|---|---|---|---|
| --ie-text-faint | claro | #a3a3a3 | #6e6e6e | 2,31:1 → 4,68:1 |
| --ie-text-faint | escuro | #737373 | #929292 | 3,19:1 → 4,86:1 |
| --ie-text-muted | claro | #737373 | #616161 | 4,35:1 → 5,68:1 |
| --ie-danger | claro | #dc2626 | #b91c1c | 4,41:1 → 5,91:1 |
O OLED já passava (faint em 5,70:1) e ficou intacto. --ie-text-muted desceu junto com o faint porque no claro o teto de um texto que ainda bate 4,5:1 sobre #f5f5f5 é ~#707070 — subir só o faint o faria encostar no muted e a escada soft → muted → faint colapsaria em dois degraus indistinguíveis. Eles aparecem lado a lado na mesma linha de slider (rótulo · valor · reset), então a distância importa.
--ie-accent fica fora desse gate: é cor de marca definida pelo consumer, e o pacote não tem autoridade para mudá-la. O default #556fff rende 3,68–4,81:1 como texto (tab ativa, hover de botão) — quem usa accent em texto pequeno deve escolher um tom mais escuro no claro e mais claro no escuro.
O accent é theme-independent de propósito
:root {
--ie-accent: #C9A961; /* default electric blue #556FFF */
--ie-accent-hover: #A88842;
}--ie-accent e --ie-accent-hover são cor de marca: ficam definidos uma vez, fora dos blocos de tema, e valem iguais no claro, no escuro e no OLED. Sobrescrevê-los em :root é o caminho certo e continua funcionando.
Override de token neutro precisa ser por tema
Os blocos de tema do pacote usam :where() (especificidade 0,0,0) justamente para que o consumer consiga sobrescrever qualquer token. O efeito colateral é que um override em :root (0,1,0) vence os três temas e congela a cor:
/* ❌ congela a borda no claro, no escuro e no OLED */
:root { --ie-border: #C5CBD6; }
/* ✅ um valor por tema */
:root:not(.dark) { --ie-border: #C5CBD6; }
:root.dark { --ie-border: #3A4152; }
:root.oled { --ie-border: #2A2E38; }Isso vale só para token neutro e de estado — o accent é a exceção descrita acima.
Os componentes usam text-[color:var(--ie-text,#404040)] / bg-[color:var(--ie-surface,#ffffff)] (Tailwind arbitrary values, sempre com fallback do tema claro), então qualquer ancestor .image-editor-root ou :root aplicando essas vars é suficiente.
Setup Tailwind (opcional)
Funciona com Tailwind 3+ sem config extra — as classes usadas são todas core utilities + arbitrary values. Se o consumer tiver purge/content configurado, garantir que o node_modules/@chrono-os/image-editor-react/dist/**/*.{js,cjs} está incluído:
// tailwind.config.ts
export default {
content: [
'./app/**/*.{ts,tsx}',
'./node_modules/@chrono-os/image-editor-react/dist/**/*.{js,cjs}',
],
// ...
}Exemplo: <ImagePicker> completo
'use client'
import { useState } from 'react'
import {
ImagePicker,
type UploadAdapter,
type CropValue,
type BadgeData,
type ImagePreset,
} from '@chrono-os/image-editor-react'
import '@chrono-os/image-editor-react/theme.css'
const uploadAdapter: UploadAdapter = {
list: async () => {
const r = await fetch('/api/admin/uploads', { credentials: 'include' })
if (!r.ok) throw new Error('Falha ao listar')
const data = await r.json()
return data.items
},
upload: async (file) => {
const form = new FormData()
form.append('file', file, file.name)
const r = await fetch('/api/admin/uploads', {
method: 'POST',
body: form,
credentials: 'include',
})
if (!r.ok) throw new Error('Upload falhou')
return r.json()
},
delete: async (filename) => {
const r = await fetch(
`/api/admin/uploads/${encodeURIComponent(filename)}`,
{ method: 'DELETE', credentials: 'include' },
)
return r.ok
},
}
const presets: ImagePreset[] = [
{ label: 'Hero Naírio', url: '/branding/hero-nairio.webp' },
{ label: 'Background ABS', url: '/branding/abs-pattern.webp' },
]
export function HeroImageField() {
const [url, setUrl] = useState('')
const [crop, setCrop] = useState<CropValue>({})
const badge: BadgeData = {
numero: '201',
label: 'advogado',
numeroEnabled: true,
labelEnabled: true,
}
return (
<ImagePicker
label="Foto do hero"
hint="Recomendado: 1200×1600px (3:4)"
value={url}
onChange={(next) => setUrl(next)}
aspect="3/4"
crop={crop}
onCropChange={setCrop}
badge={badge}
uploadAdapter={uploadAdapter}
presets={presets}
/>
)
}Exemplo: <BadgeOverlay> standalone
Pra renderizar só o badge sobreposto (sem o picker — útil em previews de listagem, cards, etc):
import { BadgeOverlay } from '@chrono-os/image-editor-react/badge'
export function HeroCard({ imageUrl }: { imageUrl: string }) {
return (
<div
className="relative aspect-[3/4] overflow-hidden rounded-lg"
style={{ containerType: 'inline-size' }}
>
<img src={imageUrl} alt="" className="h-full w-full object-cover" />
<BadgeOverlay
badge={{
numero: '201',
label: 'advogado',
numeroEnabled: true,
labelEnabled: true,
}}
/>
</div>
)
}Importante: o pai do
<BadgeOverlay>precisa decontainerType: 'inline-size'no CSS pra que oscqw(container query width) funcionem proporcionalmente em qualquer tamanho.
Exemplo: <BadgeEditorCard> no extraCards
Renderiza um subcard completo de edição de parte do badge (título + checkbox "Exibir" + texto + cor + sliders). Encaixa direto no extraCards do <ImagePicker>:
'use client'
import {
ImagePicker,
BadgeEditorCard,
type BadgePartState,
type RecentColorsAdapter,
} from '@chrono-os/image-editor-react'
// Adapter opcional pra integrar com sistema de cores recentes do consumer.
const recentColors: RecentColorsAdapter = {
colors: { text: ['#13203B', '#C9A961', '#FFFFFF'] },
addColor: (hex, kind) => {
// persistir no DB do consumer
},
}
export function HeroBadgeFields({
badge,
onBadgeChange,
}: {
badge: { numero?: BadgePartState; label?: BadgePartState }
onBadgeChange: (next: typeof badge) => void
}) {
return (
<ImagePicker
// ...props do ImagePicker
value="/img/hero.webp"
onChange={() => {}}
uploadAdapter={uploadAdapter}
extraCards={[
<BadgeEditorCard
key="numero"
title="Badge — Número"
enabledLabel="Exibir número"
value={badge.numero ?? {}}
onChange={(next) => onBadgeChange({ ...badge, numero: next })}
defaultColor="#C9A961"
defaultPosX={6}
defaultPosY={84}
recentColors={recentColors}
/>,
<BadgeEditorCard
key="label"
title="Badge — Label"
enabledLabel="Exibir label"
value={badge.label ?? {}}
onChange={(next) => onBadgeChange({ ...badge, label: next })}
defaultColor="#FFFFFF"
defaultPosX={36}
defaultPosY={88}
recentColors={recentColors}
/>,
]}
/>
)
}A interface BadgePartState é:
export interface BadgePartState {
enabled?: boolean // undefined === true (exibe), false === oculto
text?: string
color?: string // #RRGGBB
size?: number // % do default (50-500)
offsetX?: number // % horizontal (0-100)
offsetY?: number // % vertical (0-100)
}Exemplo: <BadgePartControls> standalone
Se você já tem o subcard externo (título + checkbox + texto) e quer só os controles compactos de cor + sliders:
'use client'
import { BadgePartControls } from '@chrono-os/image-editor-react'
export function MyBadgeControls({ partState, onPatch }) {
return (
<BadgePartControls
color={partState.color}
defaultColor="#C9A961"
size={partState.size}
offsetX={partState.offsetX}
offsetY={partState.offsetY}
defaultPosX={6}
defaultPosY={84}
onColorChange={(v) => onPatch({ color: v })}
onSizeChange={(v) => onPatch({ size: v })}
onOffsetXChange={(v) => onPatch({ offsetX: v })}
onOffsetYChange={(v) => onPatch({ offsetY: v })}
/>
)
}Exemplo: <ImageCropPicker> standalone
Pra quem só quer o editor de crop sem o wrapper de upload/preview:
'use client'
import { useState } from 'react'
import { ImageCropPicker, type CropValue } from '@chrono-os/image-editor-react'
export function CropOnly({ imageUrl }: { imageUrl: string }) {
const [crop, setCrop] = useState<CropValue>({})
return (
<ImageCropPicker
imageUrl={imageUrl}
targetAspect={3 / 4}
value={crop}
onChange={setCrop}
maxHeight={520}
/>
)
}Props do <ImagePicker>
| Prop | Tipo | Default | Descrição |
|---|---|---|---|
| value | string | — | URL atual da imagem (controlado). |
| onChange | (url: string, aspectRatio?: number) => void | — | Chamado quando o consumer escolhe nova imagem. |
| uploadAdapter | UploadAdapter | — | Adapter contra o backend (list/upload/delete/resolveUrl). |
| label | string | — | Rótulo do field acima do preview. |
| hint | string | — | Texto de ajuda abaixo do label. |
| aspect | '1/1'\|'3/4'\|'16/9'\|'4/3' | '3/4' | Aspect ratio do frame de destino. |
| crop | CropValue | {} | Valor de crop (position/scale/mirror/filters/backdrop). |
| onCropChange | (crop: CropValue) => void | — | Chamado quando o usuário ajusta o crop no modal. |
| badge | BadgeData | — | Badge sobreposto na preview (número + label). |
| extraCards | ReactNode[] | — | Cards extras ao lado direito do preview (slots de UI custom). |
| presets | ImagePreset[] | — | Atalhos de imagem curados (mostrados na aba "Imagens"). |
Interface UploadAdapter
export interface UploadAdapter {
/** Lista uploads existentes (ordem decrescente por createdAt no consumer). */
list: () => Promise<UploadItem[]>
/** Sobe um arquivo e devolve o item criado. */
upload: (file: File) => Promise<UploadItem>
/** Remove um upload por filename. Retorna true em sucesso. */
delete: (filename: string) => Promise<boolean>
/**
* Resolve uma URL armazenada (relativa ou absoluta) pra URL absoluta usada
* no <img src>. Default = identity. Útil quando backend serve /uploads/*
* em domínio diferente sem rewrite.
*/
resolveUrl?: (url: string) => string
}
export interface UploadItem {
filename: string
url: string
sizeBytes: number
createdAt?: string
width?: number
height?: number
}Interface CropValue
export interface CropValue {
/** CSS object-position style: "50% 50%" ou "center top". */
position?: string
/** Zoom. 1 = cover normal, >1 = zoom in, <1 = zoom out (mostra backdrop). */
scale?: number
mirror?: boolean
grayscale?: number
saturation?: number
sepia?: number
sepiaColor?: string
/** Cor de fundo do container (#RRGGBB ou 'transparent'). */
backdropColor?: string
/** Opacidade da cor de backdrop 0-100. */
backdropOpacity?: number
/** Translate offset adicional (frame fora da imagem). */
overflowX?: number
overflowY?: number
}Interface BadgeData
export interface BadgeData {
numero?: string
label?: string
numeroEnabled?: boolean
labelEnabled?: boolean
numeroColor?: string
numeroSize?: number // 50-500% (default 100)
numeroOffsetX?: number // %, default 6
numeroOffsetY?: number // %, default 84
labelColor?: string
labelSize?: number // 50-500% (default 100)
labelOffsetX?: number // %, default 36
labelOffsetY?: number // %, default 88
}Props do <BadgeEditorCard>
| Prop | Tipo | Default | Descrição |
|---|---|---|---|
| title | string | — | Título do subcard (ex "Badge — Número"). |
| value | BadgePartState | — | Estado controlado (enabled/text/color/size/offsetX/offsetY). |
| onChange | (next: BadgePartState) => void | — | Recebe o estado completo a cada edição. |
| defaultColor | string | — | Hex mostrado quando value.color é undefined. |
| defaultPosX | number | — | % horizontal exibida no slider quando undefined. |
| defaultPosY | number | — | % vertical exibida no slider quando undefined. |
| enabledLabel | string | 'Exibir' | Label da checkbox. |
| textPlaceholder | string | — | Placeholder do field texto. |
| recentColors | RecentColorsAdapter | — | Adapter pra cores recentes (sumir swatches se ausente). |
| recentKind | string | 'text' | Chave de cor pra agrupar recentes (do adapter). |
| seedColors | string[] | ['#FFFFFF', '#000000'] | Sementes quando recentes acabam. |
| sizeMin | number | 50 | Mínimo do slider de Tamanho (%). |
| sizeMax | number | 500 | Máximo do slider de Tamanho (%). |
Props do <BadgePartControls>
| Prop | Tipo | Default | Descrição |
|---|---|---|---|
| color | string \| undefined | — | Hex atual (undefined cai pra defaultColor). |
| defaultColor | string | — | Hex placeholder quando color é undefined. |
| size | number \| undefined | — | Tamanho atual (%). |
| offsetX | number \| undefined | — | Posição X atual (%). |
| offsetY | number \| undefined | — | Posição Y atual (%). |
| defaultPosX | number | — | Posição X exibida quando offsetX é undefined. |
| defaultPosY | number | — | Posição Y exibida quando offsetY é undefined. |
| onColorChange | (v?: string) => void | — | Emit. Recebe undefined em reset. |
| onSizeChange | (v?: number) => void | — | Emit. undefined em reset. |
| onOffsetXChange | (v?: number) => void | — | idem. |
| onOffsetYChange | (v?: number) => void | — | idem. |
| recentColors | RecentColorsAdapter | — | Adapter opcional de cores recentes. |
| recentKind | string | 'text' | Chave do adapter. |
| seedColors | string[] | ['#FFFFFF', '#000000'] | Sementes neutras. |
| sizeMin | number | 50 | Mínimo do slider Tamanho. |
| sizeMax | number | 500 | Máximo do slider Tamanho. |
Roadmap
- Drag-and-drop de arquivos no card de uploads
- Mais aspect ratios (
2/3,9/16) - Animações suaves na troca de modal/preview
- Adapter de recentes (cores usadas anteriormente nos badges)
License
MIT
