npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@meistrari/minuta-react

v1.3.0

Published

Componente React do editor de minuta do defesa.ai — casca fina sobre @meistrari/minuta-editor

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