@meistrari/minuta-react 1.1.0 → 1.3.0

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 CHANGED
@@ -35,6 +35,8 @@ um `Blob` de `.docx`. Buscar o documento, persistir a edição e baixar o arquiv
35
35
  | `chrome` | — | `{ header, footer, headerScope, footerScope, showPageNumber }` |
36
36
  | `variables` | — | catálogo `{ key, label, description?, group? }[]` → liga o modo template |
37
37
  | `uploadImage` | — | `(file) => Promise<{ src, vaultSrc } \| null>` |
38
+ | `imageFallback` | `'dataUri'` | `'none'` = upload falhou → não insere nada, só `onImageUploadError` |
39
+ | `onImageUploadError` | — | `(error, file) => void` |
38
40
  | `toolbar` | `true` | `false` = você monta a sua sobre `ref.current.tiptap` |
39
41
  | `blockClickWhileEditing` | `false` | dispara `onBlockClick` também em edição (padrão: só em leitura) |
40
42
  | `onChange` | — | `(html) => void`, a cada edição |
@@ -80,26 +82,40 @@ Tudo pelo seu backend, com `x-api-key` + `x-org-id` — seus usuários não prec
80
82
  de conta no defesa.ai:
81
83
 
82
84
  ```
83
- GET /data/case/:id/html → { html, chrome, margins, source, editionId }
84
- POST /data/case/:id/editions { name, html } → cria a edição (o documento lapidado)
85
- PATCH /data/case/:id/editions/:editionId { html } → salva (versão automática)
86
- POST /data/case/:id/docx → .docx do estado atual (a edição, se existir)
85
+ GET /data/case/:id/html → { html, chrome, margins, source, editionId, variablesMarked, markedVariables }
86
+ POST /data/case/:id/editions { name, html } → cria a edição → { id, updatedAt, contentHash, … }
87
+ PATCH /data/case/:id/editions/:id → salva → { updatedAt, updatedBy, contentHash, … }
88
+ { html, baseUpdatedAt } 400 sem o token; 409 se outro salvou antes
89
+ GET /data/case/:id/editions → [{ id, updatedAt, updatedBy, contentHash, … }] sem html
90
+ POST /data/case/:id/editions/:id/versions → marco no histórico (o PATCH NÃO cria versão)
91
+ { comment?, baseUpdatedAt? } com baseUpdatedAt: 409 se a revisão mudou; já versionada → 200 reused
92
+ POST /data/case/:id/docx → .docx do estado atual (a edição, se existir)
87
93
  ```
88
94
 
95
+ `contentHash` é o sha256 do `html` exatamente como foi gravado (o servidor não
96
+ normaliza): num save que deu timeout, compare com o hash do que você enviou
97
+ pela listagem, sem baixar o documento. `baseUpdatedAt` no `POST …/versions`
98
+ amarra o checkpoint à revisão que você viu: a versão nunca sai com o seu nome
99
+ e o texto que outra pessoa salvou depois, e repetir a chamada não duplica.
100
+
89
101
  ```tsx
90
102
  // abrir
91
103
  const doc = await api.get(`/case/${caseId}/html`)
92
104
  <MinutaEditor key={caseId} html={doc.html} chrome={doc.chrome ?? undefined} margins={doc.margins ?? undefined} onChange={setHtml} />
93
105
 
94
- // salvar (debounce do seu lado)
95
- await api.patch(`/case/${caseId}/editions/${editionId}`, { html })
106
+ // salvar (debounce do seu lado): baseUpdatedAt = o updatedAt da última resposta
107
+ const saved = await api.patch(`/case/${caseId}/editions/${editionId}`, { html, baseUpdatedAt })
108
+ baseUpdatedAt = saved.updatedAt
96
109
 
97
110
  // baixar — fiel ao preview, gerado no browser
98
111
  const blob = await editor.current!.exportDocx()
99
112
  ```
100
113
 
101
- Mande `x-user-email` / `x-user-name` nas chamadas de edição para a autoria
102
- ficar registrada.
114
+ Mande `x-user-email` / `x-user-name` em toda chamada feita em nome de alguém:
115
+ nas rotas de edição viram `createdBy` (criação), `updatedBy` (último save de
116
+ conteúdo), `createdBy` de cada versão e `deletedBy` (descarte); no
117
+ `PATCH /data/case/:id` viram `updatedByEmail`/`updatedByName` do case. Sem os
118
+ headers a autoria fica `null` — nunca "a integração".
103
119
 
104
120
  ## Formulário ↔ documento
105
121
 
@@ -109,8 +125,9 @@ e o editor trata a marca como nó atômico. Isso dá as duas direções sem casa
109
125
  texto (que erra quando o valor coincide com algo que já existia no template):
110
126
 
111
127
  ```tsx
112
- // documento → formulário: clicou no trecho, foca o campo
113
- <MinutaEditor ref={editor} html={html} onVariableClick={v => focusField(v.variableId)} />
128
+ // documento → formulário: clicou no trecho, foca o campo. `kind` diz o que foi
129
+ // clicado: 'text' (chip), 'image' (<img> marcada) ou 'missingImage' (aviso)
130
+ <MinutaEditor ref={editor} html={html} onVariableClick={v => focusField(v.variableId, v.kind)} />
114
131
 
115
132
  // formulário → documento: focou o campo, rola até o trecho
116
133
  editor.current!.scrollToVariable('nome_cliente')
@@ -135,17 +152,23 @@ edição criada a partir de HTML já resolvido (sem marcas) a ponte não existe,
135
152
  | `<for item="c" in="contratos"> <variable name="c.numero">` | um chip por linha, com `data-variable-index` | `contratos[0].numero`, `contratos[1].numero`… |
136
153
  | `<img variable="img_doc">` com valor | `<img data-variable-id data-parent-block-id>` | `img_doc` |
137
154
  | `<img variable="img_doc">` sem valor | aviso `missing-image-warning` (nó `missingImage` no editor) | `img_doc` |
155
+ | `<variable name="valor">` sem valor | aviso vermelho `<span data-variable-id data-parent-block-id data-invalid-variable>` | `valor` |
138
156
  | cada bloco do template | `<div data-minuta-block data-block-id data-block-title>` | `data-block-id` = id do bloco no `templateSpec` |
139
157
 
140
- Variável de texto **sem valor** sai como aviso (`data-invalid-variable`), não
141
- como chip, e não entra em `markedVariables`. Booleans não viram marca: eles
142
- decidem se um trecho ou bloco é emitido; a regra fica em `displayCondition` e
143
- nos `<if condition>` do template (podem envolver várias variáveis).
158
+ O aviso de texto sem valor é uma marca como as outras: entra em
159
+ `markedVariables`, dispara `onVariableClick` (`kind: 'text'`) e
160
+ `setVariableValue` o converte em chip comum ao receber valor. Booleans não
161
+ viram marca: eles decidem se um trecho ou bloco é emitido; a regra fica em
162
+ `displayCondition` e nos `<if condition>` do template (podem envolver várias
163
+ variáveis).
144
164
 
145
165
  ```tsx
146
166
  // array: uma linha de cada vez
147
167
  editor.current!.setVariableValue('contratos[1].numero', '375586874')
148
168
 
169
+ // texto sem valor: o aviso vermelho vira chip
170
+ editor.current!.setVariableValue('valor', 'R$ 1.000,00')
171
+
149
172
  // imagem: troca a existente, ou transforma o aviso de "faltando" em imagem
150
173
  editor.current!.setVariableImage('img_doc', urlAssinada) // → nº de ocorrências
151
174
  editor.current!.scrollToVariable('img_faltando') // acha o aviso também
@@ -155,11 +178,39 @@ editor.current!.scrollToVariable('img_faltando') // acha o aviso
155
178
  ```
156
179
 
157
180
  Sobre o fluxo de "enviar variáveis de volta": com uma edição viva o documento
158
- **é** a edição — o `PATCH` de variáveis no case não re-renderiza a peça (senão
159
- apagaria o texto do advogado). Ou o formulário trava quando a lapidação começa
160
- (o modelo do defesa.ai), ou você mantém os dois vivos com `setVariableValue` +
181
+ **é** a edição — o `PATCH /data/case/:id` de variáveis não re-renderiza a peça
182
+ (senão apagaria o texto do advogado). A API aceita a mudança e devolve
183
+ `activeEdition` (`{ id, name, updatedAt }` da edição que ficou como estava, ou
184
+ `null`) para o app decidir: ou o formulário trava quando a lapidação começa (o
185
+ modelo do defesa.ai), ou você mantém os dois vivos com `setVariableValue` +
161
186
  salva a edição. Decisão de UX do seu app; as duas funcionam.
162
187
 
188
+ ### Imagens inseridas pelo advogado
189
+
190
+ `uploadImage` devolve `{ src, vaultSrc }`: o editor grava a tag com o `src`
191
+ (presignado, expira) e `data-vault-src` (a `vault://`, durável). Persista o
192
+ `getHTML()` como está: o `POST /data/case/:id/docx` usa o `data-vault-src` e
193
+ resolve `vault://` sozinho. Ao reabrir uma edição salva, renove os links antes
194
+ de mostrar:
195
+
196
+ ```tsx
197
+ // resolver: POST /data/vault/download-url { vaultUrl } → { downloadUrl } (pelo seu backend)
198
+ await editor.current!.refreshVaultImages(vaultUrl => api.post('/vault/download-url', { vaultUrl }).then(r => r.downloadUrl))
199
+ ```
200
+
201
+ Renova tanto `data-vault-src` quanto imagens salvas só com `src="vault://…"`
202
+ (que ganham o `data-vault-src`), mantendo a referência durável. Não entra no
203
+ undo nem dispara `onChange`: chame `getHTML()` depois se guarda uma cópia. O
204
+ `exportDocx()` do browser baixa pelo `src`, então renove antes de exportar.
205
+
206
+ Se o upload falhar (ou `uploadImage` devolver `null`), o padrão é embutir a
207
+ imagem em base64. Um app cujo documento canônico depende de `vault://` não
208
+ quer isso: `imageFallback="none"` não insere nada e `onImageUploadError` recebe
209
+ o erro e o arquivo para você mostrar e deixar tentar de novo.
210
+
211
+ `setVariableImage(id, src, vaultSrc)` grava o `data-vault-src` junto — use a
212
+ forma de três argumentos para a imagem sobreviver à expiração do `src`.
213
+
163
214
  ## Next.js (App Router)
164
215
 
165
216
  O entry publicado carrega `'use client'`: dá para importar o componente direto
package/dist/index.d.mts CHANGED
@@ -32,6 +32,9 @@ interface MinutaEditorProps {
32
32
  chrome?: Partial<MinutaChromeText>;
33
33
  variables?: MinutaVariableCatalog;
34
34
  uploadImage?: MinutaEditorOptions['uploadImage'];
35
+ /** `'dataUri'` (default) embute em base64 quando o upload falha; `'none'` não insere e avisa por `onImageUploadError` */
36
+ imageFallback?: MinutaEditorOptions['imageFallback'];
37
+ onImageUploadError?: MinutaEditorOptions['onImageUploadError'];
35
38
  /** barra de formatação do pacote (default: true). `false` = monte a sua sobre `ref.tiptap` */
36
39
  toolbar?: boolean;
37
40
  showBlockOrigin?: boolean;
package/dist/index.d.ts CHANGED
@@ -32,6 +32,9 @@ interface MinutaEditorProps {
32
32
  chrome?: Partial<MinutaChromeText>;
33
33
  variables?: MinutaVariableCatalog;
34
34
  uploadImage?: MinutaEditorOptions['uploadImage'];
35
+ /** `'dataUri'` (default) embute em base64 quando o upload falha; `'none'` não insere e avisa por `onImageUploadError` */
36
+ imageFallback?: MinutaEditorOptions['imageFallback'];
37
+ onImageUploadError?: MinutaEditorOptions['onImageUploadError'];
35
38
  /** barra de formatação do pacote (default: true). `false` = monte a sua sobre `ref.tiptap` */
36
39
  toolbar?: boolean;
37
40
  showBlockOrigin?: boolean;
package/dist/index.mjs CHANGED
@@ -21,6 +21,8 @@ const MinutaEditor = forwardRef((props, ref) => {
21
21
  variables: props.variables,
22
22
  // lido na hora do upload, não no mount: o host pode trocar a função
23
23
  uploadImage: (file) => callbacks.current.uploadImage ? callbacks.current.uploadImage(file) : Promise.resolve(null),
24
+ imageFallback: props.imageFallback,
25
+ onImageUploadError: (error, file) => callbacks.current.onImageUploadError?.(error, file),
24
26
  toolbar: props.toolbar,
25
27
  showBlockOrigin: props.showBlockOrigin,
26
28
  blockClickWhileEditing: props.blockClickWhileEditing,
@@ -64,6 +66,9 @@ const MinutaEditor = forwardRef((props, ref) => {
64
66
  useEffect(() => {
65
67
  instance.current?.setBlockClickWhileEditing(props.blockClickWhileEditing === true);
66
68
  }, [props.blockClickWhileEditing]);
69
+ useEffect(() => {
70
+ instance.current?.setImageFallback(props.imageFallback ?? "dataUri");
71
+ }, [props.imageFallback]);
67
72
  useImperativeHandle(ref, () => instance.current);
68
73
  return createElement("div", {
69
74
  ref: host,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/minuta-react",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Componente React do editor de minuta do defesa.ai — casca fina sobre @meistrari/minuta-editor",
5
5
  "repository": {
6
6
  "type": "git",