@meistrari/minuta-react
v1.3.0
Published
Componente React do editor de minuta do defesa.ai — casca fina sobre @meistrari/minuta-editor
Maintainers
Keywords
Readme
@meistrari/minuta-react
O editor de minuta do defesa.ai como componente React — a folha A4 paginada,
edição rica, placeholders de variável e export DOCX fiel ao preview. Casca fina
sobre @meistrari/minuta-editor, o núcleo sem framework.
import { useRef } from 'react'
import { MinutaEditor, type MinutaEditorHandle } from '@meistrari/minuta-react'
import '@meistrari/minuta-editor/style.css'
function Lapidacao({ html, onSave }: { html: string, onSave: (html: string) => void }) {
const editor = useRef<MinutaEditorHandle>(null)
return (
<>
<button onClick={() => onSave(editor.current!.getHTML())}>Salvar</button>
<MinutaEditor ref={editor} html={html} style={{ height: '80vh' }} />
</>
)
}Zero I/O. O componente não busca nem salva nada — recebe HTML, devolve HTML e
um Blob de .docx. Buscar o documento, persistir a edição e baixar o arquivo
é do seu app (abaixo, o fluxo com a Data API do defesa.ai).
Props
| prop | default | |
|---|---|---|
| html | — | documento inicial. Lido uma vez — para carregar outro, troque a key |
| editable | true | |
| margins | 2,5cm / 3cm | { top, right, bottom, left } em px @96dpi |
| header | — | { logoUrl, position } |
| chrome | — | { header, footer, headerScope, footerScope, showPageNumber } |
| variables | — | catálogo { key, label, description?, group? }[] → liga o modo template |
| uploadImage | — | (file) => Promise<{ src, vaultSrc } \| null> |
| imageFallback | 'dataUri' | 'none' = upload falhou → não insere nada, só onImageUploadError |
| onImageUploadError | — | (error, file) => void |
| toolbar | true | false = você monta a sua sobre ref.current.tiptap |
| blockClickWhileEditing | false | dispara onBlockClick também em edição (padrão: só em leitura) |
| onChange | — | (html) => void, a cada edição |
| onTransaction | — | qualquer transação, inclusive só de seleção — para toolbar própria |
| onReady, onEditChrome, onBlockClick, onError | — | |
O ref entrega a instância do editor: getHTML(), exportDocx() (→ Blob),
usedVariables(), setEditable(), setChrome(), tiptap (a instância do
Tiptap — escotilha de fuga), root/container/sheet (os elementos) — e a
ponte formulário ↔ documento: scrollToVariable(), setVariableValue(),
setVariableImage(), variablesInDocument() (abaixo).
Por que o html é lido uma vez
O editor é a fonte da verdade enquanto vive. Realimentá-lo com o próprio
getHTML() a cada tecla remontaria o documento e mandaria o cursor para o
começo. Guarde o que onChange entrega; para abrir outro documento, mude a
key:
<MinutaEditor key={edicao.id} html={edicao.html} onChange={setHtml} />Toolbar própria (antd, Material…)
toolbar={false} e fale com o Tiptap direto. onTransaction é o que faz
isActive acompanhar o cursor, não só o texto:
const [, rerender] = useReducer(x => x + 1, 0)
const t = editor.current?.tiptap
<Button type={t?.isActive('bold') ? 'primary' : 'default'} onClick={() => t?.chain().focus().toggleBold().run()}>B</Button>
<MinutaEditor ref={editor} html={html} toolbar={false} onTransaction={rerender} />Os tipos de chain().toggleBold() viajam com o pacote — não precisa importar
@tiptap/* no seu código.
O fluxo com a Data API do defesa.ai
Tudo pelo seu backend, com x-api-key + x-org-id — seus usuários não precisam
de conta no defesa.ai:
GET /data/case/:id/html → { html, chrome, margins, source, editionId, variablesMarked, markedVariables }
POST /data/case/:id/editions { name, html } → cria a edição → { id, updatedAt, contentHash, … }
PATCH /data/case/:id/editions/:id → salva → { updatedAt, updatedBy, contentHash, … }
{ html, baseUpdatedAt } 400 sem o token; 409 se outro salvou antes
GET /data/case/:id/editions → [{ id, updatedAt, updatedBy, contentHash, … }] sem html
POST /data/case/:id/editions/:id/versions → marco no histórico (o PATCH NÃO cria versão)
{ comment?, baseUpdatedAt? } com baseUpdatedAt: 409 se a revisão mudou; já versionada → 200 reused
POST /data/case/:id/docx → .docx do estado atual (a edição, se existir)contentHash é o sha256 do html exatamente como foi gravado (o servidor não
normaliza): num save que deu timeout, compare com o hash do que você enviou
pela listagem, sem baixar o documento. baseUpdatedAt no POST …/versions
amarra o checkpoint à revisão que você viu: a versão nunca sai com o seu nome
e o texto que outra pessoa salvou depois, e repetir a chamada não duplica.
// abrir
const doc = await api.get(`/case/${caseId}/html`)
<MinutaEditor key={caseId} html={doc.html} chrome={doc.chrome ?? undefined} margins={doc.margins ?? undefined} onChange={setHtml} />
// salvar (debounce do seu lado): baseUpdatedAt = o updatedAt da última resposta
const saved = await api.patch(`/case/${caseId}/editions/${editionId}`, { html, baseUpdatedAt })
baseUpdatedAt = saved.updatedAt
// baixar — fiel ao preview, gerado no browser
const blob = await editor.current!.exportDocx()Mande x-user-email / x-user-name em toda chamada feita em nome de alguém:
nas rotas de edição viram createdBy (criação), updatedBy (último save de
conteúdo), createdBy de cada versão e deletedBy (descarte); no
PATCH /data/case/:id viram updatedByEmail/updatedByName do case. Sem os
headers a autoria fica null — nunca "a integração".
Formulário ↔ documento
O HTML do GET /data/case/:id/html vem com cada variável marcada —
<span data-variable-id="nome_cliente" data-parent-block-id="…">valor</span> —
e o editor trata a marca como nó atômico. Isso dá as duas direções sem casar
texto (que erra quando o valor coincide com algo que já existia no template):
// documento → formulário: clicou no trecho, foca o campo. `kind` diz o que foi
// clicado: 'text' (chip), 'image' (<img> marcada) ou 'missingImage' (aviso)
<MinutaEditor ref={editor} html={html} onVariableClick={v => focusField(v.variableId, v.kind)} />
// formulário → documento: focou o campo, rola até o trecho
editor.current!.scrollToVariable('nome_cliente')
// e o formulário continua valendo DEPOIS da edição livre: a marca sobrevive
// à lapidação, então mudar o valor troca o trecho sem tocar no texto em volta
editor.current!.setVariableValue('nome_cliente', 'Maria da Silva') // → nº de ocorrências
editor.current!.variablesInDocument() // → ['nome_cliente', …]A resposta do endpoint traz variablesMarked e markedVariables: numa
edição criada a partir de HTML já resolvido (sem marcas) a ponte não existe, e
é melhor saber antes de montar a tela do que descobrir com
variablesInDocument() vazio.
O que é marcado, e com que id
| no template | no HTML | id da marca |
|---|---|---|
| <variable name="comarca"> | <span data-variable-id data-parent-block-id> (chip) | comarca |
| variável compute | idem — o valor é calculado antes do render | o id da compute |
| <for item="c" in="contratos"> <variable name="c.numero"> | um chip por linha, com data-variable-index | contratos[0].numero, contratos[1].numero… |
| <img variable="img_doc"> com valor | <img data-variable-id data-parent-block-id> | img_doc |
| <img variable="img_doc"> sem valor | aviso missing-image-warning (nó missingImage no editor) | img_doc |
| <variable name="valor"> sem valor | aviso vermelho <span data-variable-id data-parent-block-id data-invalid-variable> | valor |
| cada bloco do template | <div data-minuta-block data-block-id data-block-title> | data-block-id = id do bloco no templateSpec |
O aviso de texto sem valor é uma marca como as outras: entra em
markedVariables, dispara onVariableClick (kind: 'text') e
setVariableValue o converte em chip comum ao receber valor. Booleans não
viram marca: eles decidem se um trecho ou bloco é emitido; a regra fica em
displayCondition e nos <if condition> do template (podem envolver várias
variáveis).
// array: uma linha de cada vez
editor.current!.setVariableValue('contratos[1].numero', '375586874')
// texto sem valor: o aviso vermelho vira chip
editor.current!.setVariableValue('valor', 'R$ 1.000,00')
// imagem: troca a existente, ou transforma o aviso de "faltando" em imagem
editor.current!.setVariableImage('img_doc', urlAssinada) // → nº de ocorrências
editor.current!.scrollToVariable('img_faltando') // acha o aviso também
// bloco: com blockClickWhileEditing o clique em edição também avisa
<MinutaEditor blockClickWhileEditing onBlockClick={id => openChecklistBlock(id)} … />Sobre o fluxo de "enviar variáveis de volta": com uma edição viva o documento
é a edição — o PATCH /data/case/:id de variáveis não re-renderiza a peça
(senão apagaria o texto do advogado). A API aceita a mudança e devolve
activeEdition ({ id, name, updatedAt } da edição que ficou como estava, ou
null) para o app decidir: ou o formulário trava quando a lapidação começa (o
modelo do defesa.ai), ou você mantém os dois vivos com setVariableValue +
salva a edição. Decisão de UX do seu app; as duas funcionam.
Imagens inseridas pelo advogado
uploadImage devolve { src, vaultSrc }: o editor grava a tag com o src
(presignado, expira) e data-vault-src (a vault://, durável). Persista o
getHTML() como está: o POST /data/case/:id/docx usa o data-vault-src e
resolve vault:// sozinho. Ao reabrir uma edição salva, renove os links antes
de mostrar:
// resolver: POST /data/vault/download-url { vaultUrl } → { downloadUrl } (pelo seu backend)
await editor.current!.refreshVaultImages(vaultUrl => api.post('/vault/download-url', { vaultUrl }).then(r => r.downloadUrl))Renova tanto data-vault-src quanto imagens salvas só com src="vault://…"
(que ganham o data-vault-src), mantendo a referência durável. Não entra no
undo nem dispara onChange: chame getHTML() depois se guarda uma cópia. O
exportDocx() do browser baixa pelo src, então renove antes de exportar.
Se o upload falhar (ou uploadImage devolver null), o padrão é embutir a
imagem em base64. Um app cujo documento canônico depende de vault:// não
quer isso: imageFallback="none" não insere nada e onImageUploadError recebe
o erro e o arquivo para você mostrar e deixar tentar de novo.
setVariableImage(id, src, vaultSrc) grava o data-vault-src junto — use a
forma de três argumentos para a imagem sobreviver à expiração do src.
Next.js (App Router)
O entry publicado carrega 'use client': dá para importar o componente direto
de um Server Component, que o Next faz a fronteira de cliente sozinho. O editor
é DOM, então dynamic(() => import(...), { ssr: false }) continua sendo uma
boa ideia em página renderizada no servidor.
Props depois do mount
Todas as props são sincronizadas com o editor enquanto ele vive — toolbar,
margins, chrome, header, variables, editable, showBlockOrigin. A
única só de mount é html; voltar uma prop a undefined volta ao default.
StrictMode
Funciona com o <React.StrictMode> do React 18: o duplo-mount do efeito destrói
e recria a instância sobre o mesmo nó, sem instância órfã nem listener duplicado.
O playground roda assim de propósito.
Desenvolvimento
bun install && bun link @meistrari/minuta-editor # o core, via link em dev
bun run dev # playground em :3299 (caso real como fixture)
bun run build