@meistrari/minuta-nuxt 0.1.0 → 1.0.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
@@ -66,6 +66,93 @@ const minuta = await createMinuta({
66
66
  // navegue para a tela que renderiza <MinutaEmbed :minuta-id="minuta.id" />
67
67
  ```
68
68
 
69
+ Para peça de texto fixo com campos variáveis, sem agente, veja
70
+ [Templates e variáveis](#templates-e-variáveis).
71
+
72
+ ## Templates e variáveis
73
+
74
+ Nem toda peça precisa de agente. Quando o texto já está decidido e o que muda
75
+ são alguns campos — petições intermediárias, ofícios, requerimentos de rotina —
76
+ existe o caminho **template**: um documento com placeholders, escrito no mesmo
77
+ editor A4, que vira minuta pronta no mesmo request, sem passar por agente
78
+ nenhum.
79
+
80
+ **O CDB não guarda template.** Quem guarda é o app hospedeiro, que já tem
81
+ cadastro para isso. O que o embed entrega é o editor e o formato; o que o CDB
82
+ faz é trocar placeholder por valor.
83
+
84
+ ### Escrever o template
85
+
86
+ ```vue
87
+ <MinutaTemplateEditor
88
+ v-model="cadastro.templateHtml"
89
+ :variables="CATALOGO"
90
+ style="height: 100vh"
91
+ @update:used-variables="keys => cadastro.variaveis = keys"
92
+ />
93
+ ```
94
+
95
+ O catálogo é um array plano, definido pelo host — o CDB não sabe o que significa
96
+ nenhuma dessas chaves:
97
+
98
+ ```ts
99
+ const CATALOGO = [
100
+ { key: 'PROCESSO', label: 'Nº do Processo', description: 'De info_processos.processo', group: 'Processo' },
101
+ { key: 'NOME_REU', label: 'Nome do Réu', group: 'Réu' },
102
+ ]
103
+ ```
104
+
105
+ No documento, o advogado digita `/` (ou usa o botão `{ }` da barra), busca **pelo
106
+ rótulo** e insere. O placeholder é um nó atômico: seleciona inteiro, apaga
107
+ inteiro, e negrito/fonte aplicam nele por completo. Não dá para digitar uma
108
+ chave que não existe, e não dá para partir uma pela metade ao formatar —
109
+ que é o motivo de ser nó, e não `#NOME_VARIAVEL` no texto.
110
+
111
+ `update:used-variables` devolve as chaves usadas, na ordem do documento e sem
112
+ repetição. É com ela que o host sabe o que perguntar; ele nunca precisa ler o
113
+ HTML.
114
+
115
+ ### Gerar a peça
116
+
117
+ ```ts
118
+ const minuta = await createMinuta({
119
+ name: 'Requerimento — contrato 123',
120
+ templateHtml: cadastro.templateHtml,
121
+ variables: { PROCESSO: '1234567-89.2026.8.26.0100', NOME_REU: null },
122
+ })
123
+
124
+ minuta.status // 'READY' — sem agente, sem polling
125
+ minuta.missingVariables // ['NOME_REU']
126
+ ```
127
+
128
+ Só texto nos valores: número e data chegam já formatados pelo host, que é quem
129
+ sabe se `1234.5` é `R$ 1.234,50`. `null` significa "não resolveu".
130
+
131
+ **Variável sem valor não some.** Ela continua sendo um placeholder vivo dentro
132
+ do documento, no parágrafo em que está — quem abrir a minuta vê o buraco onde
133
+ ele fica, em vez de descobrir que falta algo por um botão desabilitado. O host
134
+ decide o que fazer com `missingVariables` (abrir pendência, bloquear o
135
+ protocolo, ou deixar preencher no editor mesmo).
136
+
137
+ `templateHtml` e `agentId` se excluem: mandar os dois é 400.
138
+
139
+ ### Props do `<MinutaTemplateEditor>`
140
+
141
+ | Prop | Default | Descrição |
142
+ |---|---|---|
143
+ | `modelValue` | `''` | HTML do template (`v-model`) |
144
+ | `variables` | `[]` | catálogo `{ key, label, description?, group? }` |
145
+ | `editable` | `true` | permite editar |
146
+ | `showToolbar` | `true` | barra com o resumo das variáveis usadas |
147
+ | `margins` | 2,5cm/3cm | margens da página em px @96dpi |
148
+ | `chrome` | `null` | cabeçalho/rodapé; `v-model:chrome` para persistir |
149
+
150
+ Eventos: `update:modelValue`, `update:usedVariables`, `update:chrome`,
151
+ `ready(editor)`. Slots: `toolbar-start`, `toolbar-end`.
152
+ `defineExpose`: `getHtml()`, `usedVariables`, `editor`.
153
+
154
+ Playground: `bun run dev` e abra `/?template`.
155
+
69
156
  ### Props do `<MinutaEmbed>`
70
157
 
71
158
  | Prop | Default | Descrição |
@@ -75,10 +162,20 @@ const minuta = await createMinuta({
75
162
  | `autoSave` | `true` | salva 2s após a última edição |
76
163
  | `showToolbar` | `true` | barra com nome, estado de save e export |
77
164
  | `margins` | 2,5cm/3cm | margens da página em px @96dpi |
165
+ | `renamable` | segue `editable` | nome editável na própria barra |
166
+ | `chatAgentId` | `null` | id de plataforma do agente assistente; sem ele, o chat não aparece |
78
167
 
79
168
  Eventos: `ready(editor)`, `saved(minuta)`, `error(message)`.
169
+ Slot: `toolbar-start` (o host injeta navegação própria, ex.: botão voltar).
80
170
  `defineExpose`: `saveNow()`, `exportDocx()`, `editor`.
81
171
 
172
+ **Dois agentes, dois lugares.** O que *escreve* a minuta vai no `agentId` da
173
+ chamada de criação acima — é decisão de quem cria, e cada app manda o seu (o
174
+ mesmo campo existe na Data API, para quem cria do servidor; a env
175
+ `MINUTA_AVULSA_AGENT` do CDB é só o default de quem não mandar nada). O que
176
+ *edita pelo chat* é prop do componente (`chatAgentId`), porque vive dentro da
177
+ tela de edição.
178
+
82
179
  A barra do embed inclui **Histórico**: versões são criadas automaticamente
83
180
  conforme o documento é editado (snapshot no auto-save, com espaçamento mínimo
84
181
  de 10 min) e podem ser restauradas — o estado atual vira um snapshot antes.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/minuta-nuxt",
3
3
  "configKey": "minutaNuxt",
4
- "version": "0.1.0",
4
+ "version": "1.0.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "0.8.4",
7
7
  "unbuild": "2.0.0"
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Editor } from '@tiptap/core'
3
- import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
3
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
4
4
  import type { MinutaAnalysis } from '../composables/useMinutaApi'
5
5
  import { annotationY, applyAnnotationDecorations, findTextRange, getAnnotationDecorationRanges, getUserEditRanges, locateAnnotationDetailed, scrollToAnnotation, setUserEditRanges, trackUserEdits } from '../composables/annotationAnchors'
6
6
 
@@ -66,6 +66,9 @@ function metaFor(item: GutterItem) {
66
66
  }
67
67
 
68
68
  const items = ref<GutterItem[]>([])
69
+ /** itens sem `range`: a análise não trouxe âncora ou o trecho sumiu do texto */
70
+ const orphanCount = computed(() => items.value.filter(i => !i.range).length)
71
+ const noticeRef = ref<HTMLElement | null>(null)
69
72
  const activeId = ref<string | null>(null)
70
73
  const cardRefs = new Map<string, HTMLElement>()
71
74
 
@@ -84,17 +87,24 @@ function flatten(): GutterItem[] {
84
87
  manual: anno.manual,
85
88
  trechoOriginal: anno.trechoOriginal,
86
89
  })
90
+ /**
91
+ * `onde` é a âncora de reserva (seção da peça) quando não há `trecho`. Uma
92
+ * geração em produção escreveu `secao` no lugar de `onde` e derrubou até
93
+ * esse fallback — aceitar o sinônimo custa uma linha e salva a tela de
94
+ * uma análise fora do contrato.
95
+ */
96
+ const onde = (anno: { onde?: string, secao?: string }) => anno.onde ?? anno.secao
87
97
  analysis.teses?.forEach((tese, i) => {
88
- out.push({ id: `tese-${i}`, kind: 'tese', source: { key: 'teses', index: i }, title: tese.titulo, body: tese.fundamento, onde: tese.onde, ...anchor(tese), range: null, y: null, finalY: 0 })
98
+ out.push({ id: `tese-${i}`, kind: 'tese', source: { key: 'teses', index: i }, title: tese.titulo, body: tese.fundamento, onde: onde(tese), ...anchor(tese), range: null, y: null, finalY: 0 })
89
99
  })
90
100
  analysis.jurisprudencias?.forEach((juris, i) => {
91
- out.push({ id: `juris-${i}`, kind: 'jurisprudencia', source: { key: 'jurisprudencias', index: i }, title: juris.referencia, body: juris.tese, onde: juris.onde, ...anchor(juris), range: null, y: null, finalY: 0 })
101
+ out.push({ id: `juris-${i}`, kind: 'jurisprudencia', source: { key: 'jurisprudencias', index: i }, title: juris.referencia, body: juris.tese, onde: onde(juris), ...anchor(juris), range: null, y: null, finalY: 0 })
92
102
  })
93
103
  analysis.citacoes?.forEach((citacao, i) => {
94
- out.push({ id: `cit-${i}`, kind: 'citacao', source: { key: 'citacoes', index: i }, title: citacao.referencia, body: citacao.como, onde: citacao.onde, ...anchor(citacao), range: null, y: null, finalY: 0 })
104
+ out.push({ id: `cit-${i}`, kind: 'citacao', source: { key: 'citacoes', index: i }, title: citacao.referencia, body: citacao.como, onde: onde(citacao), ...anchor(citacao), range: null, y: null, finalY: 0 })
95
105
  })
96
106
  analysis.fatos?.forEach((fato, i) => {
97
- out.push({ id: `fato-${i}`, kind: 'fato', source: { key: 'fatos', index: i }, title: fato.dado, onde: fato.onde, documento: fato.documento, pagina: fato.pagina, ...anchor(fato), range: null, y: null, finalY: 0 })
107
+ out.push({ id: `fato-${i}`, kind: 'fato', source: { key: 'fatos', index: i }, title: fato.dado, onde: onde(fato), documento: fato.documento, pagina: fato.pagina, ...anchor(fato), range: null, y: null, finalY: 0 })
98
108
  })
99
109
  return out
100
110
  }
@@ -230,7 +240,9 @@ async function compute() {
230
240
 
231
241
  // passo 2: resolve sobreposição com as alturas reais renderizadas
232
242
  await nextTick()
233
- let cursor = 8
243
+ // os cards são absolutos; o aviso é o único elemento no fluxo, então a
244
+ // primeira posição livre começa abaixo dele (senão ele cobre o 1º card)
245
+ let cursor = 8 + (noticeRef.value?.offsetHeight ? noticeRef.value.offsetHeight + 8 : 0)
234
246
  for (const item of items.value) {
235
247
  const desired = item.y ?? cursor
236
248
  item.finalY = Math.max(desired, cursor)
@@ -259,9 +271,13 @@ function syncDecorations() {
259
271
  }
260
272
 
261
273
  function onCardClick(item: GutterItem) {
274
+ // card sem âncora não tem para onde levar: ativá-lo só apagaria o destaque
275
+ // do card que está ativo, e ele já se apresenta como inerte (cursor normal)
276
+ if (!item.range)
277
+ return
262
278
  activeId.value = item.id
263
279
  syncDecorations()
264
- if (item.range && props.editor)
280
+ if (props.editor)
265
281
  scrollToAnnotation(props.editor, item.range.from, props.scrollContainer)
266
282
  }
267
283
 
@@ -316,6 +332,12 @@ onUnmounted(() => {
316
332
 
317
333
  <template>
318
334
  <div ref="rootRef" class="minuta-gutter">
335
+ <!-- análise sem âncora: sem `trecho` não há onde grudar o card; diz isso
336
+ em vez de empilhar tudo no topo com clique morto -->
337
+ <div v-if="items.length && orphanCount === items.length" ref="noticeRef" class="minuta-gutter-notice">
338
+ Esta análise veio sem os trechos de referência, então os itens não
339
+ estão ligados a pontos do texto.
340
+ </div>
319
341
  <div
320
342
  v-for="item in items"
321
343
  :key="item.id"
@@ -326,6 +348,7 @@ onUnmounted(() => {
326
348
  'minuta-gutter-card--orphan': !item.range,
327
349
  }"
328
350
  :style="{ transform: `translateY(${item.finalY}px)` }"
351
+ :title="item.range ? undefined : 'Sem trecho de referência: não dá para localizar no texto'"
329
352
  @click="onCardClick(item)"
330
353
  >
331
354
  <div class="minuta-gutter-card__kind">
@@ -361,7 +384,7 @@ onUnmounted(() => {
361
384
  </template>
362
385
 
363
386
  <style scoped>
364
- .minuta-gutter{height:100%;left:calc(100% + 16px);pointer-events:none;position:absolute;top:0;width:264px;z-index:20}.minuta-gutter-card{background:#fff;border:.5px solid #dee3e7;border-radius:10px;cursor:pointer;left:0;padding:8px 12px;pointer-events:auto;position:absolute;right:0;transition:border-color .15s ease,background .15s ease,transform .2s ease}.minuta-gutter-card:hover{border-color:#b6bcc6}.minuta-gutter-card--active{background:#fafbfc;border-color:#565d69}.minuta-gutter-card--orphan{border-style:dashed;opacity:.7}.minuta-gutter-card__source{align-items:baseline;border-top:.5px solid #eceef1;display:flex;gap:8px;justify-content:space-between;margin-top:5px;padding-top:5px}.minuta-gutter-card__source-text{color:#9ca3af;font-size:10px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.minuta-gutter-card__source-btn{background:none;border:none;color:#565d69;cursor:pointer;flex-shrink:0;font-size:10px;font-weight:500;padding:0}.minuta-gutter-card__source-btn:hover{color:#111827;text-decoration:underline}.minuta-gutter-card__badge{align-items:center;border-radius:999px;display:inline-flex;flex-shrink:0;font-size:9.5px;font-weight:650;letter-spacing:.05em;line-height:1.5;padding:1px 7px;text-transform:uppercase}.minuta-gutter-card__kind{align-items:center;display:flex;gap:6px;margin-bottom:4px;min-width:0}.minuta-gutter-card__onde{color:#b6bcc6;font-size:10px;font-weight:400;letter-spacing:normal;min-width:0;overflow:hidden;text-overflow:ellipsis;text-transform:none;white-space:nowrap}.minuta-gutter-card__title{color:#111827;font-size:12px;font-weight:590;line-height:1.35;margin:0}.minuta-gutter-card__body{color:#6b7280;display:-webkit-box;font-size:11px;-webkit-line-clamp:3;line-height:1.45;margin:3px 0 0;-webkit-box-orient:vertical;overflow:hidden}
387
+ .minuta-gutter{height:100%;left:calc(100% + 16px);pointer-events:none;position:absolute;top:0;width:264px;z-index:20}.minuta-gutter-card{background:#fff;border:.5px solid #dee3e7;border-radius:10px;cursor:pointer;left:0;padding:8px 12px;pointer-events:auto;position:absolute;right:0;transition:border-color .15s ease,background .15s ease,transform .2s ease}.minuta-gutter-card:hover{border-color:#b6bcc6}.minuta-gutter-card--active{background:#fafbfc;border-color:#565d69}.minuta-gutter-card--orphan{border-style:dashed;cursor:default;opacity:.7}.minuta-gutter-notice{background:#fafbfc;border:.5px solid #e5e7eb;border-radius:8px;color:#6b7280;font-size:11.5px;line-height:1.45;margin-bottom:8px;padding:8px 10px;position:relative}.minuta-gutter-card__source{align-items:baseline;border-top:.5px solid #eceef1;display:flex;gap:8px;justify-content:space-between;margin-top:5px;padding-top:5px}.minuta-gutter-card__source-text{color:#9ca3af;font-size:10px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.minuta-gutter-card__source-btn{background:none;border:none;color:#565d69;cursor:pointer;flex-shrink:0;font-size:10px;font-weight:500;padding:0}.minuta-gutter-card__source-btn:hover{color:#111827;text-decoration:underline}.minuta-gutter-card__badge{align-items:center;border-radius:999px;display:inline-flex;flex-shrink:0;font-size:9.5px;font-weight:650;letter-spacing:.05em;line-height:1.5;padding:1px 7px;text-transform:uppercase}.minuta-gutter-card__kind{align-items:center;display:flex;gap:6px;margin-bottom:4px;min-width:0}.minuta-gutter-card__onde{color:#b6bcc6;font-size:10px;font-weight:400;letter-spacing:normal;min-width:0;overflow:hidden;text-overflow:ellipsis;text-transform:none;white-space:nowrap}.minuta-gutter-card__title{color:#111827;font-size:12px;font-weight:590;line-height:1.35;margin:0}.minuta-gutter-card__body{color:#6b7280;display:-webkit-box;font-size:11px;-webkit-line-clamp:3;line-height:1.45;margin:3px 0 0;-webkit-box-orient:vertical;overflow:hidden}
365
388
  </style>
366
389
 
367
390
  <style>
@@ -1,17 +1,33 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from 'vue'
2
+ import { computed, onBeforeUnmount, ref, watch } from 'vue'
3
3
  import type { MinutaFile } from '../composables/useMinutaApi'
4
4
  import { useMinutaApi } from '../composables/useMinutaApi'
5
5
 
6
6
  /**
7
- * Overlay de visualização dos documentos do caso (imagem/texto/iframe),
8
- * aberto pelos cards de fato extraído da margem ou pelo botão Documentos.
7
+ * Overlay de visualização dos documentos do caso, aberto pelos cards de fato
8
+ * extraído da margem ou pelo botão Documentos.
9
+ *
10
+ * O documento é baixado com `fetch` e exibido a partir de uma `blob:` URL, e
11
+ * não apontando o `<iframe>` direto para a URL presignada do Vault.
12
+ *
13
+ * O sintoma que isso corrige é o "modal em branco que começa a baixar a peça",
14
+ * e ele tem duas causas possíveis que produzem exatamente a mesma tela:
15
+ *
16
+ * 1. a URL presignada responde `Content-Disposition: attachment` — o iframe
17
+ * navega, o browser trata a navegação como download, e vale até para PDF;
18
+ * 2. o arquivo é de um formato que o browser não renderiza (docx, xlsx), e aí
19
+ * o iframe baixa mesmo sem disposition nenhum.
20
+ *
21
+ * A `blob:` URL resolve (1): o disposition da origem não a acompanha, e o tipo
22
+ * do blob é o que dissermos que é. A classificação por tipo resolve (2): o que
23
+ * o browser não abre ganha um cartão com botão de baixar, em vez de cair no
24
+ * mesmo `else` do PDF e baixar sozinho sem nunca ter chance de aparecer.
9
25
  */
10
26
 
11
27
  const props = defineProps<{
12
28
  files: MinutaFile[]
13
29
  initialUrl?: string | null
14
- /** página inicial (PDF em iframe via #page=N) */
30
+ /** página inicial (PDF via #page=N) */
15
31
  initialPage?: number | null
16
32
  }>()
17
33
 
@@ -19,42 +35,169 @@ const emit = defineEmits<{ close: [], error: [message: string] }>()
19
35
 
20
36
  const { getDownloadUrl } = useMinutaApi()
21
37
 
38
+ type ViewerKind = 'image' | 'text' | 'pdf' | 'binary'
39
+ type ViewerContent = { kind: ViewerKind, src: string, text?: string, sizeLabel?: string }
40
+
22
41
  const selected = ref(props.initialUrl || props.files[0]?.url || '')
23
- const content = ref<{ src: string, kind: 'image' | 'text' | 'frame', text?: string } | null>(null)
42
+ const content = ref<ViewerContent | null>(null)
24
43
  const loading = ref(false)
44
+ const loadError = ref<string | null>(null)
25
45
 
26
46
  const currentName = computed(() => props.files.find(f => f.url === selected.value)?.name ?? '')
27
47
 
48
+ const IMAGE_RE = /\.(?:png|jpe?g|gif|webp|avif|bmp|svg)$/i
49
+ const TEXT_RE = /\.(?:txt|md|json|csv|tsv|log|xml|ya?ml)$/i
50
+ const PDF_RE = /\.pdf$/i
51
+
52
+ /**
53
+ * O nome do arquivo manda; o `Content-Type` do blob é a reserva.
54
+ *
55
+ * Referência do Vault costuma trazer o nome original (extensão junto), mas
56
+ * upload sem extensão existe — e aí adivinhar pelo tipo é melhor do que mandar
57
+ * tudo para o cartão de download.
58
+ */
59
+ function classify(name: string, mimeType: string): ViewerKind {
60
+ if (IMAGE_RE.test(name))
61
+ return 'image'
62
+ if (PDF_RE.test(name))
63
+ return 'pdf'
64
+ if (TEXT_RE.test(name))
65
+ return 'text'
66
+ if (mimeType.startsWith('image/'))
67
+ return 'image'
68
+ if (mimeType === 'application/pdf')
69
+ return 'pdf'
70
+ if (mimeType.startsWith('text/') || mimeType === 'application/json')
71
+ return 'text'
72
+ return 'binary'
73
+ }
74
+
75
+ function formatSize(bytes: number): string {
76
+ if (bytes < 1024)
77
+ return `${bytes} B`
78
+ if (bytes < 1024 * 1024)
79
+ return `${Math.round(bytes / 1024)} KB`
80
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
81
+ }
82
+
83
+ /** só uma `blob:` viva por vez — sem isto, abrir 10 documentos segura os 10 na memória */
84
+ let objectUrl: string | null = null
85
+ function releaseObjectUrl() {
86
+ if (objectUrl) {
87
+ URL.revokeObjectURL(objectUrl)
88
+ objectUrl = null
89
+ }
90
+ }
91
+
92
+ let loadToken = 0
93
+
94
+ /**
95
+ * Uma carga só pode escrever no estado se ainda for a carga corrente.
96
+ *
97
+ * Vale depois de CADA await, não só do primeiro: `blob.text()` de um arquivo
98
+ * grande termina depois de o usuário já ter trocado de documento, e escreveria
99
+ * o conteúdo antigo sob o nome novo.
100
+ *
101
+ * O desmonte também invalida (incrementa o token), e não só revoga a `blob:`
102
+ * atual: sem isso, uma carga em voo criaria uma `blob:` DEPOIS da limpeza —
103
+ * URL que ninguém mais revoga, segurando os bytes do arquivo pelo resto da vida
104
+ * da página.
105
+ */
106
+ function isStale(token: number): boolean {
107
+ return token !== loadToken
108
+ }
109
+
28
110
  async function load() {
29
111
  const file = props.files.find(f => f.url === selected.value)
30
112
  if (!file)
31
113
  return
114
+
115
+ const token = ++loadToken
32
116
  loading.value = true
117
+ loadError.value = null
33
118
  content.value = null
119
+
34
120
  try {
35
- const src = await getDownloadUrl(file.url)
36
- if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file.name)) {
37
- content.value = { src, kind: 'image' }
38
- }
39
- else if (/\.(txt|md|json|csv|log)$/i.test(file.name)) {
40
- const text = await (await fetch(src)).text()
41
- content.value = { src, kind: 'text', text }
121
+ const downloadUrl = await getDownloadUrl(file.url)
122
+ const response = await fetch(downloadUrl)
123
+ if (!response.ok)
124
+ throw new Error(`HTTP ${response.status}`)
125
+ const blob = await response.blob()
126
+ if (isStale(token))
127
+ return
128
+
129
+ const kind = classify(file.name, blob.type || '')
130
+
131
+ if (kind === 'text') {
132
+ const text = await blob.text()
133
+ if (isStale(token))
134
+ return
135
+ releaseObjectUrl()
136
+ content.value = { kind, src: '', text }
137
+ return
42
138
  }
43
- else {
44
- const isPdf = /\.pdf$/i.test(file.name)
45
- const page = props.initialPage && file.url === props.initialUrl ? `#page=${props.initialPage}` : ''
46
- content.value = { src: isPdf ? src + page : src, kind: 'frame' }
139
+
140
+ // daqui até o fim é síncrono: a checagem acima ainda vale, e nada é
141
+ // criado depois de a carga ter sido invalidada
142
+
143
+ // o tipo do blob manda no que o browser faz com ele: forçar
144
+ // application/pdf faz o visualizador nativo abrir mesmo quando o Vault
145
+ // devolveu octet-stream
146
+ const typed = kind === 'pdf' && blob.type !== 'application/pdf'
147
+ ? new Blob([blob], { type: 'application/pdf' })
148
+ : blob
149
+
150
+ releaseObjectUrl()
151
+ objectUrl = URL.createObjectURL(typed)
152
+ const page = kind === 'pdf' && props.initialPage && file.url === props.initialUrl
153
+ ? `#page=${props.initialPage}`
154
+ : ''
155
+ content.value = {
156
+ kind,
157
+ src: objectUrl + page,
158
+ sizeLabel: formatSize(blob.size),
47
159
  }
48
160
  }
49
161
  catch {
162
+ if (isStale(token))
163
+ return
164
+ loadError.value = 'Não consegui carregar o documento.'
50
165
  emit('error', 'Não consegui carregar o documento')
51
166
  }
52
167
  finally {
53
- loading.value = false
168
+ if (!isStale(token))
169
+ loading.value = false
54
170
  }
55
171
  }
56
172
 
173
+ /**
174
+ * Baixar de propósito. O `download` de uma `blob:` URL não depende do
175
+ * disposition da origem, então funciona igual para PDF e para docx.
176
+ */
177
+ function downloadCurrent() {
178
+ const current = content.value
179
+ if (!current)
180
+ return
181
+ const link = document.createElement('a')
182
+ link.download = currentName.value || 'documento'
183
+ if (current.kind === 'text') {
184
+ const href = URL.createObjectURL(new Blob([current.text ?? ''], { type: 'text/plain' }))
185
+ link.href = href
186
+ link.click()
187
+ URL.revokeObjectURL(href)
188
+ return
189
+ }
190
+ // sem o fragmento `#page=`: ele é do visualizador, não do arquivo
191
+ link.href = current.src.split('#')[0]!
192
+ link.click()
193
+ }
194
+
57
195
  watch(selected, load, { immediate: true })
196
+ onBeforeUnmount(() => {
197
+ // invalida antes de revogar: ver `isStale`
198
+ loadToken++
199
+ releaseObjectUrl()
200
+ })
58
201
  </script>
59
202
 
60
203
  <template>
@@ -68,17 +211,38 @@ watch(selected, load, { immediate: true })
68
211
  </option>
69
212
  </select>
70
213
  <span v-else>{{ currentName }}</span>
71
- <button type="button" @click="emit('close')">
72
- ×
73
- </button>
214
+ <div class="minuta-viewer__actions">
215
+ <button v-if="content" type="button" class="minuta-viewer__download" @click="downloadCurrent">
216
+ Baixar
217
+ </button>
218
+ <button type="button" class="minuta-viewer__close" @click="emit('close')">
219
+ ×
220
+ </button>
221
+ </div>
74
222
  </div>
75
223
  <div class="minuta-viewer__body">
76
224
  <p v-if="loading" class="minuta-viewer__loading">
77
225
  Carregando…
78
226
  </p>
79
- <img v-else-if="content?.kind === 'image'" :src="content.src">
227
+ <p v-else-if="loadError" class="minuta-viewer__loading">
228
+ {{ loadError }}
229
+ </p>
230
+ <img v-else-if="content?.kind === 'image'" :src="content.src" :alt="currentName">
80
231
  <pre v-else-if="content?.kind === 'text'">{{ content.text }}</pre>
81
- <iframe v-else-if="content" :src="content.src" />
232
+ <iframe v-else-if="content?.kind === 'pdf'" :src="content.src" :title="currentName" />
233
+ <!-- formato que o browser não abre: cartão explícito, e não
234
+ um iframe em branco que dispara download sozinho -->
235
+ <div v-else-if="content" class="minuta-viewer__binary">
236
+ <p class="minuta-viewer__binary-name">
237
+ {{ currentName }}
238
+ </p>
239
+ <p class="minuta-viewer__binary-hint">
240
+ Este formato não abre no navegador{{ content.sizeLabel ? ` — ${content.sizeLabel}` : '' }}.
241
+ </p>
242
+ <button type="button" class="minuta-viewer__download" @click="downloadCurrent">
243
+ Baixar arquivo
244
+ </button>
245
+ </div>
82
246
  </div>
83
247
  </div>
84
248
  </div>
@@ -86,5 +250,5 @@ watch(selected, load, { immediate: true })
86
250
  </template>
87
251
 
88
252
  <style scoped>
89
- .minuta-viewer__overlay{align-items:center;background:rgba(0,0,0,.5);display:flex;inset:0;justify-content:center;padding:24px;position:fixed;z-index:100}.minuta-viewer__modal{background:#fff;border-radius:12px;box-shadow:0 20px 25px -5px rgba(0,0,0,.2);display:flex;flex-direction:column;height:100%;max-height:90vh;max-width:960px;overflow:hidden;width:100%}.minuta-viewer__head{align-items:center;border-bottom:1px solid #e5e7eb;color:#111827;display:flex;font-size:14px;font-weight:600;gap:12px;justify-content:space-between;padding:10px 16px}.minuta-viewer__head select{background:#fff;border:.5px solid #d4d9df;border-radius:6px;font-size:13px;max-width:70%;padding:4px 8px}.minuta-viewer__head button{background:none;border:none;color:#6b7280;cursor:pointer;font-size:18px;line-height:1}.minuta-viewer__body{background:#f9fafb;flex:1;min-height:0;overflow-y:auto}.minuta-viewer__loading{color:#6b7280;font-size:13px;padding:24px}.minuta-viewer__body img{height:100%;-o-object-fit:contain;object-fit:contain;width:100%}.minuta-viewer__body pre{color:#1f2937;font-size:13px;line-height:1.6;margin:0;padding:20px;white-space:pre-wrap}.minuta-viewer__body iframe{border:0;height:100%;width:100%}
253
+ .minuta-viewer__overlay{align-items:center;background:rgba(0,0,0,.5);display:flex;inset:0;justify-content:center;padding:24px;position:fixed;z-index:100}.minuta-viewer__modal{background:#fff;border-radius:12px;box-shadow:0 20px 25px -5px rgba(0,0,0,.2);display:flex;flex-direction:column;height:100%;max-height:90vh;max-width:960px;overflow:hidden;width:100%}.minuta-viewer__head{align-items:center;border-bottom:1px solid #e5e7eb;color:#111827;display:flex;font-size:14px;font-weight:600;gap:12px;justify-content:space-between;padding:10px 16px}.minuta-viewer__head select{background:#fff;border:.5px solid #d4d9df;border-radius:6px;font-size:13px;max-width:70%;padding:4px 8px}.minuta-viewer__actions{align-items:center;display:flex;gap:8px}.minuta-viewer__close{background:none;border:none;color:#6b7280;cursor:pointer;font-size:18px;line-height:1}.minuta-viewer__download{background:#fff;border:.5px solid #d4d9df;border-radius:6px;color:#374151;cursor:pointer;font-family:inherit;font-size:12px;font-weight:500;padding:4px 10px}.minuta-viewer__download:hover{background:#f9fafb}.minuta-viewer__binary{align-items:center;display:flex;flex-direction:column;gap:8px;height:100%;justify-content:center;padding:24px;text-align:center}.minuta-viewer__binary-name{color:#111827;font-size:14px;font-weight:500;margin:0;word-break:break-all}.minuta-viewer__binary-hint{color:#6b7280;font-size:13px;margin:0}.minuta-viewer__body{background:#f9fafb;flex:1;min-height:0;overflow-y:auto}.minuta-viewer__loading{color:#6b7280;font-size:13px;padding:24px}.minuta-viewer__body img{height:100%;-o-object-fit:contain;object-fit:contain;width:100%}.minuta-viewer__body pre{color:#1f2937;font-size:13px;line-height:1.6;margin:0;padding:20px;white-space:pre-wrap}.minuta-viewer__body iframe{border:0;height:100%;width:100%}
90
254
  </style>