@meistrari/minuta-nuxt 0.1.0 → 1.1.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 +39 -11
- package/dist/runtime/components/MinutaDocumentViewer.vue +105 -47
- package/dist/runtime/components/MinutaDocxEditor.vue +211 -15
- package/dist/runtime/components/MinutaEmbed.vue +122 -22
- 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 +14 -2
- package/dist/runtime/composables/editorExtensions.d.ts +38 -1
- package/dist/runtime/composables/editorExtensions.js +84 -2
- package/dist/runtime/composables/paginationPlugin.d.ts +13 -0
- package/dist/runtime/composables/paginationPlugin.js +88 -4
- 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 +97 -96
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
|
|
|
@@ -15,6 +15,11 @@ const props = defineProps<{
|
|
|
15
15
|
analysis: MinutaAnalysis
|
|
16
16
|
/** container com scroll do documento (para o scroll suave ao clicar) */
|
|
17
17
|
scrollContainer: HTMLElement | null
|
|
18
|
+
/**
|
|
19
|
+
* Modo lista: em tela estreita não há margem onde ancorar os cards, então
|
|
20
|
+
* eles viram uma coluna em fluxo normal (dentro de um painel lateral).
|
|
21
|
+
*/
|
|
22
|
+
compact?: boolean
|
|
18
23
|
}>()
|
|
19
24
|
|
|
20
25
|
const emit = defineEmits<{
|
|
@@ -66,6 +71,9 @@ function metaFor(item: GutterItem) {
|
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
const items = ref<GutterItem[]>([])
|
|
74
|
+
/** itens sem `range`: a análise não trouxe âncora ou o trecho sumiu do texto */
|
|
75
|
+
const orphanCount = computed(() => items.value.filter(i => !i.range).length)
|
|
76
|
+
const noticeRef = ref<HTMLElement | null>(null)
|
|
69
77
|
const activeId = ref<string | null>(null)
|
|
70
78
|
const cardRefs = new Map<string, HTMLElement>()
|
|
71
79
|
|
|
@@ -84,17 +92,24 @@ function flatten(): GutterItem[] {
|
|
|
84
92
|
manual: anno.manual,
|
|
85
93
|
trechoOriginal: anno.trechoOriginal,
|
|
86
94
|
})
|
|
95
|
+
/**
|
|
96
|
+
* `onde` é a âncora de reserva (seção da peça) quando não há `trecho`. Uma
|
|
97
|
+
* geração em produção escreveu `secao` no lugar de `onde` e derrubou até
|
|
98
|
+
* esse fallback — aceitar o sinônimo custa uma linha e salva a tela de
|
|
99
|
+
* uma análise fora do contrato.
|
|
100
|
+
*/
|
|
101
|
+
const onde = (anno: { onde?: string, secao?: string }) => anno.onde ?? anno.secao
|
|
87
102
|
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
|
|
103
|
+
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
104
|
})
|
|
90
105
|
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
|
|
106
|
+
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
107
|
})
|
|
93
108
|
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
|
|
109
|
+
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
110
|
})
|
|
96
111
|
analysis.fatos?.forEach((fato, i) => {
|
|
97
|
-
out.push({ id: `fato-${i}`, kind: 'fato', source: { key: 'fatos', index: i }, title: fato.dado, onde: fato
|
|
112
|
+
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
113
|
})
|
|
99
114
|
return out
|
|
100
115
|
}
|
|
@@ -230,7 +245,9 @@ async function compute() {
|
|
|
230
245
|
|
|
231
246
|
// passo 2: resolve sobreposição com as alturas reais renderizadas
|
|
232
247
|
await nextTick()
|
|
233
|
-
|
|
248
|
+
// os cards são absolutos; o aviso é o único elemento no fluxo, então a
|
|
249
|
+
// primeira posição livre começa abaixo dele (senão ele cobre o 1º card)
|
|
250
|
+
let cursor = 8 + (noticeRef.value?.offsetHeight ? noticeRef.value.offsetHeight + 8 : 0)
|
|
234
251
|
for (const item of items.value) {
|
|
235
252
|
const desired = item.y ?? cursor
|
|
236
253
|
item.finalY = Math.max(desired, cursor)
|
|
@@ -259,9 +276,13 @@ function syncDecorations() {
|
|
|
259
276
|
}
|
|
260
277
|
|
|
261
278
|
function onCardClick(item: GutterItem) {
|
|
279
|
+
// card sem âncora não tem para onde levar: ativá-lo só apagaria o destaque
|
|
280
|
+
// do card que está ativo, e ele já se apresenta como inerte (cursor normal)
|
|
281
|
+
if (!item.range)
|
|
282
|
+
return
|
|
262
283
|
activeId.value = item.id
|
|
263
284
|
syncDecorations()
|
|
264
|
-
if (
|
|
285
|
+
if (props.editor)
|
|
265
286
|
scrollToAnnotation(props.editor, item.range.from, props.scrollContainer)
|
|
266
287
|
}
|
|
267
288
|
|
|
@@ -290,7 +311,7 @@ watch(() => props.editor, (editor, old) => {
|
|
|
290
311
|
}, { immediate: true })
|
|
291
312
|
|
|
292
313
|
onMounted(() => {
|
|
293
|
-
const sheet = rootRef.value?.parentElement
|
|
314
|
+
const sheet = props.compact ? null : rootRef.value?.parentElement
|
|
294
315
|
if (sheet) {
|
|
295
316
|
// como o Google Docs: com os comentários abertos, a folha desloca pra
|
|
296
317
|
// esquerda pra dar lugar à coluna (a classe vive enquanto o gutter vive)
|
|
@@ -315,7 +336,13 @@ onUnmounted(() => {
|
|
|
315
336
|
</script>
|
|
316
337
|
|
|
317
338
|
<template>
|
|
318
|
-
<div ref="rootRef" class="minuta-gutter">
|
|
339
|
+
<div ref="rootRef" class="minuta-gutter" :class="{ 'minuta-gutter--compact': props.compact }">
|
|
340
|
+
<!-- análise sem âncora: sem `trecho` não há onde grudar o card; diz isso
|
|
341
|
+
em vez de empilhar tudo no topo com clique morto -->
|
|
342
|
+
<div v-if="items.length && orphanCount === items.length" ref="noticeRef" class="minuta-gutter-notice">
|
|
343
|
+
Esta análise veio sem os trechos de referência, então os itens não
|
|
344
|
+
estão ligados a pontos do texto.
|
|
345
|
+
</div>
|
|
319
346
|
<div
|
|
320
347
|
v-for="item in items"
|
|
321
348
|
:key="item.id"
|
|
@@ -325,7 +352,8 @@ onUnmounted(() => {
|
|
|
325
352
|
'minuta-gutter-card--active': activeId === item.id,
|
|
326
353
|
'minuta-gutter-card--orphan': !item.range,
|
|
327
354
|
}"
|
|
328
|
-
:style="{ transform: `translateY(${item.finalY}px)` }"
|
|
355
|
+
:style="props.compact ? undefined : { transform: `translateY(${item.finalY}px)` }"
|
|
356
|
+
:title="item.range ? undefined : 'Sem trecho de referência: não dá para localizar no texto'"
|
|
329
357
|
@click="onCardClick(item)"
|
|
330
358
|
>
|
|
331
359
|
<div class="minuta-gutter-card__kind">
|
|
@@ -361,7 +389,7 @@ onUnmounted(() => {
|
|
|
361
389
|
</template>
|
|
362
390
|
|
|
363
391
|
<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}
|
|
392
|
+
.minuta-gutter{height:100%;left:calc(100% + 16px);pointer-events:none;position:absolute;top:0;width:264px;z-index:20}.minuta-gutter--compact{display:flex;flex-direction:column;gap:8px;position:static;width:100%}.minuta-gutter--compact .minuta-gutter-card{position:static;transform:none!important}.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
393
|
</style>
|
|
366
394
|
|
|
367
395
|
<style>
|
|
@@ -4,57 +4,98 @@ import type { MinutaFile } from '../composables/useMinutaApi'
|
|
|
4
4
|
import { useMinutaApi } from '../composables/useMinutaApi'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Visualização dos documentos do caso, aberta pelos cards de fato extraído da
|
|
8
|
+
* margem ou pelo botão Documentos.
|
|
9
|
+
*
|
|
10
|
+
* O corpo é o `TelaPreview` do Tela Build — o mesmo visualizador que o resto
|
|
11
|
+
* do produto usa. Ele resolve o que a implementação anterior (um `<iframe>`
|
|
12
|
+
* apontando para uma `blob:` URL) não resolvia:
|
|
13
|
+
*
|
|
14
|
+
* - **destaque do trecho.** O card de fato manda `highlight`, e antes esse
|
|
15
|
+
* campo era recebido e descartado: o visualizador nativo do Chrome não tem
|
|
16
|
+
* API de destaque. O TelaPreview aceita `highlightText`/`highlightPage`.
|
|
17
|
+
* - zoom, navegação por página, modo arrasto e tela cheia, que o `<iframe>`
|
|
18
|
+
* delegava à barra cinza do browser.
|
|
19
|
+
* - `vault://` resolvido por `resolveFileUrl`, e o cartão de download para
|
|
20
|
+
* formato que o navegador não abre.
|
|
9
21
|
*/
|
|
10
22
|
|
|
11
23
|
const props = defineProps<{
|
|
12
24
|
files: MinutaFile[]
|
|
13
25
|
initialUrl?: string | null
|
|
14
|
-
/** página
|
|
26
|
+
/** página para onde rolar e destacar */
|
|
15
27
|
initialPage?: number | null
|
|
28
|
+
/** trecho a destacar na página (vem do card de fato extraído) */
|
|
29
|
+
highlight?: string | null
|
|
16
30
|
}>()
|
|
17
31
|
|
|
18
32
|
const emit = defineEmits<{ close: [], error: [message: string] }>()
|
|
19
33
|
|
|
20
34
|
const { getDownloadUrl } = useMinutaApi()
|
|
35
|
+
const { pdfLoader } = usePdfLoader()
|
|
21
36
|
|
|
22
37
|
const selected = ref(props.initialUrl || props.files[0]?.url || '')
|
|
23
|
-
|
|
24
|
-
|
|
38
|
+
watch(() => props.initialUrl, (url) => {
|
|
39
|
+
if (url)
|
|
40
|
+
selected.value = url
|
|
41
|
+
})
|
|
25
42
|
|
|
26
|
-
const
|
|
43
|
+
const EXT_MIME: Record<string, string> = {
|
|
44
|
+
pdf: 'application/pdf',
|
|
45
|
+
png: 'image/png',
|
|
46
|
+
jpg: 'image/jpeg',
|
|
47
|
+
jpeg: 'image/jpeg',
|
|
48
|
+
gif: 'image/gif',
|
|
49
|
+
webp: 'image/webp',
|
|
50
|
+
svg: 'image/svg+xml',
|
|
51
|
+
txt: 'text/plain',
|
|
52
|
+
md: 'text/markdown',
|
|
53
|
+
csv: 'text/csv',
|
|
54
|
+
json: 'application/json',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mimeOf(name: string): string {
|
|
58
|
+
const ext = name.split('.').pop()?.toLowerCase() ?? ''
|
|
59
|
+
return EXT_MIME[ext] ?? 'application/octet-stream'
|
|
60
|
+
}
|
|
27
61
|
|
|
28
|
-
|
|
29
|
-
|
|
62
|
+
/** o seletor do TelaPreview agrupa por "variável"; aqui só há uma categoria */
|
|
63
|
+
const fileOptions = computed(() => props.files.map(file => ({
|
|
64
|
+
variable: 'Documentos do caso',
|
|
65
|
+
file: file.name,
|
|
66
|
+
value: file.url,
|
|
67
|
+
mimeType: mimeOf(file.name),
|
|
68
|
+
})))
|
|
69
|
+
|
|
70
|
+
const currentFile = computed(() => {
|
|
71
|
+
const file = props.files.find(f => f.url === selected.value) ?? props.files[0]
|
|
30
72
|
if (!file)
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 }
|
|
42
|
-
}
|
|
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' }
|
|
47
|
-
}
|
|
73
|
+
return null
|
|
74
|
+
return {
|
|
75
|
+
id: file.url,
|
|
76
|
+
fileName: file.name,
|
|
77
|
+
fileUrl: file.url,
|
|
78
|
+
fileType: mimeOf(file.name),
|
|
48
79
|
}
|
|
49
|
-
|
|
50
|
-
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/** o destaque só vale para o documento que o card apontou */
|
|
83
|
+
const highlightForCurrent = computed(() => (
|
|
84
|
+
selected.value === props.initialUrl ? props.highlight ?? null : null
|
|
85
|
+
))
|
|
86
|
+
const pageForCurrent = computed(() => (
|
|
87
|
+
selected.value === props.initialUrl ? props.initialPage ?? null : null
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
async function resolveFileUrl(url: string): Promise<string> {
|
|
91
|
+
try {
|
|
92
|
+
return await getDownloadUrl(url)
|
|
51
93
|
}
|
|
52
|
-
|
|
53
|
-
|
|
94
|
+
catch (error: any) {
|
|
95
|
+
emit('error', error?.message || 'Não foi possível abrir o documento')
|
|
96
|
+
return ''
|
|
54
97
|
}
|
|
55
98
|
}
|
|
56
|
-
|
|
57
|
-
watch(selected, load, { immediate: true })
|
|
58
99
|
</script>
|
|
59
100
|
|
|
60
101
|
<template>
|
|
@@ -62,23 +103,40 @@ watch(selected, load, { immediate: true })
|
|
|
62
103
|
<div class="minuta-viewer__overlay" @click.self="emit('close')">
|
|
63
104
|
<div class="minuta-viewer__modal">
|
|
64
105
|
<div class="minuta-viewer__head">
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
{{ file.name }}
|
|
68
|
-
</option>
|
|
69
|
-
</select>
|
|
70
|
-
<span v-else>{{ currentName }}</span>
|
|
71
|
-
<button type="button" @click="emit('close')">
|
|
72
|
-
×
|
|
73
|
-
</button>
|
|
106
|
+
<span class="minuta-viewer__title">Documentos do caso</span>
|
|
107
|
+
<TelaButton variant="ghost" size="sm" icon="i-ph-x" @click="emit('close')" />
|
|
74
108
|
</div>
|
|
75
109
|
<div class="minuta-viewer__body">
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
110
|
+
<TelaPreview
|
|
111
|
+
class="h-full"
|
|
112
|
+
:model-value="selected"
|
|
113
|
+
:file-options="fileOptions"
|
|
114
|
+
:current-file="currentFile"
|
|
115
|
+
:resolve-file-url="resolveFileUrl"
|
|
116
|
+
:pdf-loader="pdfLoader"
|
|
117
|
+
:highlight-text="highlightForCurrent"
|
|
118
|
+
:highlight-page="pageForCurrent"
|
|
119
|
+
:labels="{ selectVariable: 'Selecionar documento', file: 'Documento' }"
|
|
120
|
+
:select-variable-labels="{
|
|
121
|
+
placeholder: 'Selecionar documento',
|
|
122
|
+
searchPlaceholder: 'Buscar documento...',
|
|
123
|
+
noVariablesFound: 'Nenhum documento encontrado',
|
|
124
|
+
variablesLabel: 'Categoria',
|
|
125
|
+
filesLabel: 'documentos',
|
|
126
|
+
}"
|
|
127
|
+
:content-labels="{
|
|
128
|
+
failedToLoadFile: 'Não foi possível carregar o documento',
|
|
129
|
+
page: 'Página',
|
|
130
|
+
download: 'Baixar',
|
|
131
|
+
unableToPreview: 'Este formato não abre no navegador',
|
|
132
|
+
zoomOut: 'Diminuir',
|
|
133
|
+
zoomIn: 'Aumentar',
|
|
134
|
+
handMode: 'Modo arrasto',
|
|
135
|
+
fullscreen: 'Tela cheia',
|
|
136
|
+
pageOf: 'de',
|
|
137
|
+
}"
|
|
138
|
+
@update:model-value="selected = $event"
|
|
139
|
+
/>
|
|
82
140
|
</div>
|
|
83
141
|
</div>
|
|
84
142
|
</div>
|
|
@@ -86,5 +144,5 @@ watch(selected, load, { immediate: true })
|
|
|
86
144
|
</template>
|
|
87
145
|
|
|
88
146
|
<style scoped>
|
|
89
|
-
.minuta-viewer__overlay{align-items:center;background:rgba(
|
|
147
|
+
.minuta-viewer__overlay{align-items:center;background:rgba(15,23,42,.45);display:flex;inset:0;justify-content:center;padding:24px;position:fixed;z-index:60}.minuta-viewer__modal{background:#fff;border-radius:12px;box-shadow:0 24px 60px rgba(15,23,42,.28);display:flex;flex-direction:column;height:min(88vh,100%);overflow:hidden;width:min(1100px,100%)}.minuta-viewer__head{align-items:center;border-bottom:1px solid rgb(0 0 0/8%);display:flex;gap:12px;justify-content:space-between;padding:10px 12px 10px 16px}.minuta-viewer__title{color:#171717;font-size:13px;font-weight:540}.minuta-viewer__body{background:#fff;flex:1;min-height:0}@media (max-width:640px){.minuta-viewer__overlay{padding:0}.minuta-viewer__modal{border-radius:0;height:100%;width:100%}}
|
|
90
148
|
</style>
|