@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 +97 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/MinutaAnnotationsGutter.vue +31 -8
- package/dist/runtime/components/MinutaDocumentViewer.vue +187 -23
- package/dist/runtime/components/MinutaDocxEditor.vue +207 -3
- package/dist/runtime/components/MinutaIcon.vue +2 -0
- package/dist/runtime/components/MinutaTemplateEditor.vue +225 -0
- package/dist/runtime/components/MinutaVariablePicker.vue +149 -0
- package/dist/runtime/composables/annotationAnchors.d.ts +0 -1
- package/dist/runtime/composables/annotationAnchors.js +10 -3
- package/dist/runtime/composables/docxExport.js +9 -1
- package/dist/runtime/composables/editorExtensions.d.ts +38 -1
- package/dist/runtime/composables/editorExtensions.js +84 -2
- package/dist/runtime/composables/useMinutaApi.d.ts +11 -2
- package/dist/runtime/composables/useMinutaApi.js +6 -1
- package/dist/runtime/utils/templateVariables.d.ts +49 -0
- package/dist/runtime/utils/templateVariables.js +36 -0
- package/package.json +1 -1
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,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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
8
|
-
*
|
|
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
|
|
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<
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
<
|
|
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-
|
|
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>
|