@meistrari/minuta-nuxt 0.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 +106 -0
- package/dist/module.cjs +5 -0
- package/dist/module.d.mts +18 -0
- package/dist/module.d.ts +18 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +45 -0
- package/dist/runtime/components/MinutaAnnotationsGutter.vue +425 -0
- package/dist/runtime/components/MinutaChatEditCard.vue +124 -0
- package/dist/runtime/components/MinutaChromeCanvas.vue +549 -0
- package/dist/runtime/components/MinutaChromeEditor.vue +100 -0
- package/dist/runtime/components/MinutaDocumentViewer.vue +90 -0
- package/dist/runtime/components/MinutaDocxEditor.vue +1158 -0
- package/dist/runtime/components/MinutaEmbed.vue +783 -0
- package/dist/runtime/components/MinutaIcon.vue +53 -0
- package/dist/runtime/composables/annotationAnchors.d.ts +73 -0
- package/dist/runtime/composables/annotationAnchors.js +243 -0
- package/dist/runtime/composables/chatEditOps.d.ts +37 -0
- package/dist/runtime/composables/chatEditOps.js +61 -0
- package/dist/runtime/composables/docxExport.d.ts +24 -0
- package/dist/runtime/composables/docxExport.js +1292 -0
- package/dist/runtime/composables/editorExtensions.d.ts +56 -0
- package/dist/runtime/composables/editorExtensions.js +466 -0
- package/dist/runtime/composables/paginationPlugin.d.ts +126 -0
- package/dist/runtime/composables/paginationPlugin.js +421 -0
- package/dist/runtime/composables/useMinutaApi.d.ts +112 -0
- package/dist/runtime/composables/useMinutaApi.js +68 -0
- package/dist/runtime/composables/vaultImages.d.ts +12 -0
- package/dist/runtime/composables/vaultImages.js +85 -0
- package/dist/runtime/composables/versionDiff.d.ts +34 -0
- package/dist/runtime/composables/versionDiff.js +62 -0
- package/dist/runtime/server/api/minuta/[...path].d.ts +2 -0
- package/dist/runtime/server/api/minuta/[...path].js +6 -0
- package/dist/runtime/server/api/minuta/index.d.ts +2 -0
- package/dist/runtime/server/api/minuta/index.js +3 -0
- package/dist/runtime/server/utils/minuta-proxy.d.ts +10 -0
- package/dist/runtime/server/utils/minuta-proxy.js +63 -0
- package/dist/runtime/utils/footnoteHtml.d.ts +26 -0
- package/dist/runtime/utils/footnoteHtml.js +117 -0
- package/dist/runtime/utils/pocKeepWithNext.d.ts +44 -0
- package/dist/runtime/utils/pocKeepWithNext.js +9 -0
- package/dist/runtime/utils/sanitizeRichHtml.d.ts +10 -0
- package/dist/runtime/utils/sanitizeRichHtml.js +73 -0
- package/dist/types.d.mts +7 -0
- package/dist/types.d.ts +7 -0
- package/package.json +100 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# @meistrari/minuta-nuxt
|
|
2
|
+
|
|
3
|
+
Embed do editor de minuta do **defesa.ai** para qualquer app Nuxt: o documento
|
|
4
|
+
paginado em A4 com edição rica e export DOCX fiel ao preview, renderizado
|
|
5
|
+
como componente — sem iframe e **sem segredos no browser**.
|
|
6
|
+
|
|
7
|
+
Segue a mesma arquitetura do `@meistrari/chat-nuxt`: o componente fala com
|
|
8
|
+
rotas Nitro registradas no próprio app hospedeiro (`/api/minuta/*`), e o proxy
|
|
9
|
+
encaminha para a API do Case Defense Builder com o **JWT Tela do usuário
|
|
10
|
+
logado** — a API valida o token contra a auth central e resolve o workspace.
|
|
11
|
+
|
|
12
|
+
O pacote mora em `packages/minuta-nuxt` do repositório
|
|
13
|
+
[case-defense-builder](https://github.com/meistrari/case-defense-builder) e é
|
|
14
|
+
publicado no npm pelo workflow `Publish @meistrari/minuta-nuxt`. O próprio
|
|
15
|
+
cliente do defesa.ai consome esta mesma versão publicada — a tela `/minutas/:id`
|
|
16
|
+
é só um invólucro do `<MinutaEmbed>`, então bug corrigido aqui chega nos dois.
|
|
17
|
+
|
|
18
|
+
## Instalação
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @meistrari/minuta-nuxt
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// nuxt.config.ts
|
|
26
|
+
export default defineNuxtConfig({
|
|
27
|
+
modules: ['@meistrari/minuta-nuxt'],
|
|
28
|
+
minutaNuxt: {
|
|
29
|
+
// URL da API do Case Defense Builder
|
|
30
|
+
cdbApiUrl: process.env.CDB_API_URL, // backend do defesa.ai
|
|
31
|
+
// opcional: cookie do host que carrega o JWT Tela do usuário
|
|
32
|
+
// (default: event.context.auth.token → cookie `tela-jwt`)
|
|
33
|
+
// authTokenCookie: 'meu-cookie-jwt',
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Uso
|
|
39
|
+
|
|
40
|
+
```vue
|
|
41
|
+
<template>
|
|
42
|
+
<MinutaEmbed
|
|
43
|
+
minuta-id="94a53f33-…"
|
|
44
|
+
style="height: 100vh"
|
|
45
|
+
@saved="onSaved"
|
|
46
|
+
/>
|
|
47
|
+
</template>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Criando uma minuta a partir do host (composable auto-importado):
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const { uploadMinutaFile, createMinuta } = useMinutaApi()
|
|
54
|
+
|
|
55
|
+
// arquivos de QUALQUER tipo — sobem pro Vault via proxy
|
|
56
|
+
const files = await Promise.all([...pickedFiles].map(f => uploadMinutaFile(f)))
|
|
57
|
+
|
|
58
|
+
const minuta = await createMinuta({
|
|
59
|
+
name: 'Contestação — João da Silva',
|
|
60
|
+
files,
|
|
61
|
+
// contexto livre sobre a peça (tipo, juízo, orientações do escritório…)
|
|
62
|
+
context: 'Contestação para o réu; linha de defesa: culpa exclusiva do consumidor.',
|
|
63
|
+
// agente Tela que escreve a minuta ("organizationName/repository")
|
|
64
|
+
agentId: 'tela/defesa-minuta-avulsa',
|
|
65
|
+
})
|
|
66
|
+
// navegue para a tela que renderiza <MinutaEmbed :minuta-id="minuta.id" />
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Props do `<MinutaEmbed>`
|
|
70
|
+
|
|
71
|
+
| Prop | Default | Descrição |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `minutaId` | — | id da minuta avulsa no CDB |
|
|
74
|
+
| `editable` | `true` | permite editar o documento |
|
|
75
|
+
| `autoSave` | `true` | salva 2s após a última edição |
|
|
76
|
+
| `showToolbar` | `true` | barra com nome, estado de save e export |
|
|
77
|
+
| `margins` | 2,5cm/3cm | margens da página em px @96dpi |
|
|
78
|
+
|
|
79
|
+
Eventos: `ready(editor)`, `saved(minuta)`, `error(message)`.
|
|
80
|
+
`defineExpose`: `saveNow()`, `exportDocx()`, `editor`.
|
|
81
|
+
|
|
82
|
+
A barra do embed inclui **Histórico**: versões são criadas automaticamente
|
|
83
|
+
conforme o documento é editado (snapshot no auto-save, com espaçamento mínimo
|
|
84
|
+
de 10 min) e podem ser restauradas — o estado atual vira um snapshot antes.
|
|
85
|
+
|
|
86
|
+
## Como funciona
|
|
87
|
+
|
|
88
|
+
1. `MinutaEmbed` busca a minuta em `/api/minuta/:id` (rota Nitro do módulo).
|
|
89
|
+
2. O proxy resolve o JWT do usuário (contexto de auth do host ou cookie) e
|
|
90
|
+
encaminha para `{cdbApiUrl}/standalone-minuta/:id` com `Authorization: Bearer`.
|
|
91
|
+
3. Enquanto `status === 'PROCESSING'`, o embed faz polling (3s) — o próprio GET
|
|
92
|
+
no CDB consulta o agente e materializa o resultado.
|
|
93
|
+
4. Com `status === 'READY'`, o HTML entra no editor paginado (Tiptap + motor de
|
|
94
|
+
paginação A4 próprio). O export DOCX é 100% client-side, com as quebras de
|
|
95
|
+
página idênticas ao preview.
|
|
96
|
+
|
|
97
|
+
## Desenvolvimento
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm install
|
|
101
|
+
npm run build # nuxt-module-build
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
O núcleo do editor (`MinutaDocxEditor.vue`, `paginationPlugin`, `docxExport`,
|
|
105
|
+
`editorExtensions`) é portado do Case Defense Builder — a intenção é que o CDB
|
|
106
|
+
passe a consumir este package para existir **um único editor** mantido.
|
package/dist/module.cjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
|
|
3
|
+
interface ModuleOptions {
|
|
4
|
+
/**
|
|
5
|
+
* URL base da API do Case Defense Builder (o backend do defesa.ai).
|
|
6
|
+
* O proxy Nitro encaminha as requisições do embed para cá.
|
|
7
|
+
*/
|
|
8
|
+
cdbApiUrl?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Nome do cookie do app hospedeiro que carrega o JWT Tela do usuário.
|
|
11
|
+
* Sem valor, usa `event.context.auth.token` e cai para o cookie `tela-jwt`.
|
|
12
|
+
*/
|
|
13
|
+
authTokenCookie?: string;
|
|
14
|
+
}
|
|
15
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
16
|
+
|
|
17
|
+
export { _default as default };
|
|
18
|
+
export type { ModuleOptions };
|
package/dist/module.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
|
|
3
|
+
interface ModuleOptions {
|
|
4
|
+
/**
|
|
5
|
+
* URL base da API do Case Defense Builder (o backend do defesa.ai).
|
|
6
|
+
* O proxy Nitro encaminha as requisições do embed para cá.
|
|
7
|
+
*/
|
|
8
|
+
cdbApiUrl?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Nome do cookie do app hospedeiro que carrega o JWT Tela do usuário.
|
|
11
|
+
* Sem valor, usa `event.context.auth.token` e cai para o cookie `tela-jwt`.
|
|
12
|
+
*/
|
|
13
|
+
authTokenCookie?: string;
|
|
14
|
+
}
|
|
15
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
16
|
+
|
|
17
|
+
export { _default as default };
|
|
18
|
+
export type { ModuleOptions };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addComponentsDir, addImports, addServerHandler } from '@nuxt/kit';
|
|
2
|
+
import { defu } from 'defu';
|
|
3
|
+
|
|
4
|
+
const module = defineNuxtModule({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "@meistrari/minuta-nuxt",
|
|
7
|
+
configKey: "minutaNuxt"
|
|
8
|
+
},
|
|
9
|
+
defaults: {
|
|
10
|
+
cdbApiUrl: "",
|
|
11
|
+
authTokenCookie: void 0
|
|
12
|
+
},
|
|
13
|
+
setup(options, nuxt) {
|
|
14
|
+
const resolver = createResolver(import.meta.url);
|
|
15
|
+
nuxt.options.css = nuxt.options.css || [];
|
|
16
|
+
if (!nuxt.options.css.includes("@fontsource-variable/inter"))
|
|
17
|
+
nuxt.options.css.push("@fontsource-variable/inter");
|
|
18
|
+
nuxt.options.runtimeConfig.minutaNuxt = defu(
|
|
19
|
+
nuxt.options.runtimeConfig.minutaNuxt,
|
|
20
|
+
{
|
|
21
|
+
cdbApiUrl: options.cdbApiUrl || process.env.CDB_API_URL || "",
|
|
22
|
+
authTokenCookie: options.authTokenCookie
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
addComponentsDir({
|
|
26
|
+
path: resolver.resolve("./runtime/components"),
|
|
27
|
+
pathPrefix: false
|
|
28
|
+
});
|
|
29
|
+
addImports({
|
|
30
|
+
name: "useMinutaApi",
|
|
31
|
+
as: "useMinutaApi",
|
|
32
|
+
from: resolver.resolve("./runtime/composables/useMinutaApi")
|
|
33
|
+
});
|
|
34
|
+
addServerHandler({
|
|
35
|
+
route: "/api/minuta",
|
|
36
|
+
handler: resolver.resolve("./runtime/server/api/minuta/index")
|
|
37
|
+
});
|
|
38
|
+
addServerHandler({
|
|
39
|
+
route: "/api/minuta/**",
|
|
40
|
+
handler: resolver.resolve("./runtime/server/api/minuta/[...path]")
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export { module as default };
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Editor } from '@tiptap/core'
|
|
3
|
+
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
4
|
+
import type { MinutaAnalysis } from '../composables/useMinutaApi'
|
|
5
|
+
import { annotationY, applyAnnotationDecorations, findTextRange, getAnnotationDecorationRanges, getUserEditRanges, locateAnnotationDetailed, scrollToAnnotation, setUserEditRanges, trackUserEdits } from '../composables/annotationAnchors'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Comentários de margem (estilo Google Docs): cada item da análise vira um
|
|
9
|
+
* card ancorado na altura do trecho da minuta onde ele se aplica. Clicar num
|
|
10
|
+
* card rola o documento até o trecho e o destaca.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
editor: Editor | null
|
|
15
|
+
analysis: MinutaAnalysis
|
|
16
|
+
/** container com scroll do documento (para o scroll suave ao clicar) */
|
|
17
|
+
scrollContainer: HTMLElement | null
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
/** card de fato extraído pediu o documento-fonte no preview */
|
|
22
|
+
openDocument: [payload: { documento: string, pagina?: number, highlight?: string }]
|
|
23
|
+
/**
|
|
24
|
+
* o estado de âncora mudou (anotação virou/deixou de ser manual, texto
|
|
25
|
+
* adicionado pelo usuário) — o dono persiste a analysis via PATCH
|
|
26
|
+
*/
|
|
27
|
+
updateAnalysis: [analysis: MinutaAnalysis]
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
/** o próprio gutter rola junto com o conteúdo — é a referência de coordenadas */
|
|
31
|
+
const rootRef = ref<HTMLElement | null>(null)
|
|
32
|
+
|
|
33
|
+
interface GutterItem {
|
|
34
|
+
id: string
|
|
35
|
+
kind: 'tese' | 'jurisprudencia' | 'citacao' | 'fato'
|
|
36
|
+
/** origem na analysis (para persistir mudanças de estado da âncora) */
|
|
37
|
+
source: { key: 'teses' | 'jurisprudencias' | 'citacoes' | 'fatos', index: number }
|
|
38
|
+
title: string
|
|
39
|
+
body?: string
|
|
40
|
+
onde?: string
|
|
41
|
+
trecho?: string
|
|
42
|
+
trechoOriginal?: string
|
|
43
|
+
/** fato extraído: documento-fonte e página */
|
|
44
|
+
documento?: string
|
|
45
|
+
pagina?: number
|
|
46
|
+
range: { from: number, to: number } | null
|
|
47
|
+
/** o usuário editou o trecho — a âncora verbatim original não vale mais */
|
|
48
|
+
manual?: boolean
|
|
49
|
+
y: number | null
|
|
50
|
+
finalY: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** cores por categoria — as MESMAS do highlight no texto (associação card↔trecho) */
|
|
54
|
+
const KIND_META: Record<GutterItem['kind'], { label: string, color: string, bg: string }> = {
|
|
55
|
+
tese: { label: 'Tese', color: '#6d28d9', bg: 'rgba(139, 92, 246, 0.12)' },
|
|
56
|
+
jurisprudencia: { label: 'Jurisprudência', color: '#b45309', bg: 'rgba(217, 119, 6, 0.12)' },
|
|
57
|
+
citacao: { label: 'Citação', color: '#1d4ed8', bg: 'rgba(37, 99, 235, 0.10)' },
|
|
58
|
+
fato: { label: 'Extraído', color: '#047857', bg: 'rgba(5, 150, 105, 0.12)' },
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** trecho editado pelo usuário: a anotação vira "manual" (cinza) */
|
|
62
|
+
const MANUAL_META = { label: 'Editado', color: '#565d69', bg: 'rgba(107, 114, 128, 0.12)' }
|
|
63
|
+
|
|
64
|
+
function metaFor(item: GutterItem) {
|
|
65
|
+
return item.manual ? MANUAL_META : KIND_META[item.kind]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const items = ref<GutterItem[]>([])
|
|
69
|
+
const activeId = ref<string | null>(null)
|
|
70
|
+
const cardRefs = new Map<string, HTMLElement>()
|
|
71
|
+
|
|
72
|
+
function setCardRef(id: string, el: unknown) {
|
|
73
|
+
if (el instanceof HTMLElement)
|
|
74
|
+
cardRefs.set(id, el)
|
|
75
|
+
else
|
|
76
|
+
cardRefs.delete(id)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function flatten(): GutterItem[] {
|
|
80
|
+
const analysis = props.analysis
|
|
81
|
+
const out: GutterItem[] = []
|
|
82
|
+
const anchor = (anno: { trecho?: string, manual?: boolean, trechoOriginal?: string }) => ({
|
|
83
|
+
trecho: anno.trecho,
|
|
84
|
+
manual: anno.manual,
|
|
85
|
+
trechoOriginal: anno.trechoOriginal,
|
|
86
|
+
})
|
|
87
|
+
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 })
|
|
89
|
+
})
|
|
90
|
+
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 })
|
|
92
|
+
})
|
|
93
|
+
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 })
|
|
95
|
+
})
|
|
96
|
+
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 })
|
|
98
|
+
})
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** ids que já ancoraram por trecho verbatim nesta sessão */
|
|
103
|
+
const trechoAnchored = new Set<string>()
|
|
104
|
+
|
|
105
|
+
/** menor tamanho de texto adicionado que vale persistir (âncora verbatim confiável) */
|
|
106
|
+
const MIN_PERSISTED_EDIT_LENGTH = 15
|
|
107
|
+
|
|
108
|
+
interface AnchorPatch {
|
|
109
|
+
source: GutterItem['source']
|
|
110
|
+
changes: { manual?: boolean, trecho?: string, trechoOriginal?: string }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** persiste mudanças de âncora (manual/trecho) + rastro de texto adicionado */
|
|
114
|
+
function emitAnalysisChanges(patches: AnchorPatch[], edicoesManuais: string[] | null) {
|
|
115
|
+
if (!patches.length && edicoesManuais === null)
|
|
116
|
+
return
|
|
117
|
+
const next: MinutaAnalysis = {
|
|
118
|
+
...props.analysis,
|
|
119
|
+
teses: props.analysis.teses?.map(a => ({ ...a })),
|
|
120
|
+
jurisprudencias: props.analysis.jurisprudencias?.map(a => ({ ...a })),
|
|
121
|
+
citacoes: props.analysis.citacoes?.map(a => ({ ...a })),
|
|
122
|
+
fatos: props.analysis.fatos?.map(a => ({ ...a })),
|
|
123
|
+
}
|
|
124
|
+
for (const patch of patches) {
|
|
125
|
+
const target = (next[patch.source.key] as Array<Record<string, unknown>> | undefined)?.[patch.source.index]
|
|
126
|
+
if (!target)
|
|
127
|
+
continue
|
|
128
|
+
for (const [key, value] of Object.entries(patch.changes)) {
|
|
129
|
+
if (value === undefined)
|
|
130
|
+
delete target[key]
|
|
131
|
+
else
|
|
132
|
+
target[key] = value
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (edicoesManuais !== null)
|
|
136
|
+
next.edicoesManuais = edicoesManuais
|
|
137
|
+
emit('updateAnalysis', next)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function compute() {
|
|
141
|
+
const editor = props.editor
|
|
142
|
+
const reference = rootRef.value
|
|
143
|
+
if (!editor || editor.isDestroyed || !reference)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
// ranges das decorações mapeados pelas edições — rastreiam o texto editado
|
|
147
|
+
const mapped = getAnnotationDecorationRanges(editor)
|
|
148
|
+
const capturedText = (range: { from: number, to: number }) =>
|
|
149
|
+
editor.state.doc.textBetween(range.from, Math.min(range.to, editor.state.doc.content.size), ' ').trim()
|
|
150
|
+
|
|
151
|
+
const patches: AnchorPatch[] = []
|
|
152
|
+
const flat = flatten()
|
|
153
|
+
for (const item of flat) {
|
|
154
|
+
if (item.manual) {
|
|
155
|
+
// anotação já convertida (persistida): o trecho aponta pro texto editado
|
|
156
|
+
const original = item.trechoOriginal ? findTextRange(editor, item.trechoOriginal) : null
|
|
157
|
+
if (original) {
|
|
158
|
+
// o texto original voltou (undo) → restaura a categoria
|
|
159
|
+
item.manual = false
|
|
160
|
+
item.range = original
|
|
161
|
+
trechoAnchored.add(item.id)
|
|
162
|
+
patches.push({ source: item.source, changes: { manual: undefined, trecho: item.trechoOriginal, trechoOriginal: undefined } })
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const current = item.trecho ? findTextRange(editor, item.trecho) : null
|
|
166
|
+
if (current) {
|
|
167
|
+
item.range = current
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// editou de novo: segue pelo range mapeado e atualiza o trecho
|
|
171
|
+
item.range = mapped.get(item.id) ?? null
|
|
172
|
+
if (item.range) {
|
|
173
|
+
const text = capturedText(item.range)
|
|
174
|
+
if (text && text !== item.trecho)
|
|
175
|
+
patches.push({ source: item.source, changes: { trecho: text } })
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
const located = locateAnnotationDetailed(editor, { trecho: item.trecho, onde: item.onde })
|
|
182
|
+
if (located?.matchedBy === 'trecho') {
|
|
183
|
+
// o trecho verbatim está no documento (inclusive de volta, após undo)
|
|
184
|
+
trechoAnchored.add(item.id)
|
|
185
|
+
item.range = located.range
|
|
186
|
+
}
|
|
187
|
+
else if (trechoAnchored.has(item.id)) {
|
|
188
|
+
// ancorava por trecho e não acha mais: o usuário editou ali.
|
|
189
|
+
// A anotação vira "manual", segue o texto pelo range mapeado e a
|
|
190
|
+
// conversão é PERSISTIDA (sobrevive a reload); trecho apagado → órfã.
|
|
191
|
+
item.manual = true
|
|
192
|
+
item.range = mapped.get(item.id) ?? null
|
|
193
|
+
const text = item.range ? capturedText(item.range) : ''
|
|
194
|
+
patches.push({
|
|
195
|
+
source: item.source,
|
|
196
|
+
changes: { manual: true, trechoOriginal: item.trecho, ...(text ? { trecho: text } : {}) },
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
item.range = located?.range ?? null
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
item.y = item.range ? annotationY(editor, item.range.from, reference) : null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- rastro de texto ADICIONADO pelo usuário ---
|
|
207
|
+
let userRanges = getUserEditRanges(editor)
|
|
208
|
+
const stored = props.analysis.edicoesManuais ?? []
|
|
209
|
+
if (!userRanges.length && stored.length) {
|
|
210
|
+
// pós-reload/reset: re-ancora os trechos persistidos por busca verbatim
|
|
211
|
+
const seeds = stored
|
|
212
|
+
.map(trecho => findTextRange(editor, trecho))
|
|
213
|
+
.filter((range): range is { from: number, to: number } => !!range)
|
|
214
|
+
if (seeds.length) {
|
|
215
|
+
setUserEditRanges(editor, seeds)
|
|
216
|
+
userRanges = getUserEditRanges(editor)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const persistable = userRanges
|
|
220
|
+
.map(capturedText)
|
|
221
|
+
.filter(text => text.length >= MIN_PERSISTED_EDIT_LENGTH)
|
|
222
|
+
const edicoesChanged = JSON.stringify(persistable) !== JSON.stringify(stored)
|
|
223
|
+
|
|
224
|
+
emitAnalysisChanges(patches, edicoesChanged ? persistable : null)
|
|
225
|
+
|
|
226
|
+
// ancorados por ordem de posição; resumo primeiro; não-localizados no fim
|
|
227
|
+
const anchored = flat.filter(i => i.y != null).sort((a, b) => a.y! - b.y!)
|
|
228
|
+
const orphans = flat.filter(i => i.y == null)
|
|
229
|
+
items.value = [...anchored, ...orphans]
|
|
230
|
+
|
|
231
|
+
// passo 2: resolve sobreposição com as alturas reais renderizadas
|
|
232
|
+
await nextTick()
|
|
233
|
+
let cursor = 8
|
|
234
|
+
for (const item of items.value) {
|
|
235
|
+
const desired = item.y ?? cursor
|
|
236
|
+
item.finalY = Math.max(desired, cursor)
|
|
237
|
+
const height = cardRefs.get(item.id)?.offsetHeight ?? 80
|
|
238
|
+
cursor = item.finalY + height + 10
|
|
239
|
+
}
|
|
240
|
+
// força re-render das posições
|
|
241
|
+
items.value = [...items.value]
|
|
242
|
+
syncDecorations()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** pinta no texto o highlight discreto de cada item ancorado, na cor da categoria */
|
|
246
|
+
function syncDecorations() {
|
|
247
|
+
const editor = props.editor
|
|
248
|
+
if (!editor || editor.isDestroyed)
|
|
249
|
+
return
|
|
250
|
+
applyAnnotationDecorations(editor, items.value
|
|
251
|
+
.filter(item => item.range)
|
|
252
|
+
.map(item => ({
|
|
253
|
+
id: item.id,
|
|
254
|
+
from: item.range!.from,
|
|
255
|
+
to: item.range!.to,
|
|
256
|
+
kind: item.manual ? 'manual' as const : item.kind,
|
|
257
|
+
active: item.id === activeId.value,
|
|
258
|
+
})))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function onCardClick(item: GutterItem) {
|
|
262
|
+
activeId.value = item.id
|
|
263
|
+
syncDecorations()
|
|
264
|
+
if (item.range && props.editor)
|
|
265
|
+
scrollToAnnotation(props.editor, item.range.from, props.scrollContainer)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --- recomputa quando o documento muda/assenta ---
|
|
269
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
270
|
+
function scheduleCompute(delay = 300) {
|
|
271
|
+
if (debounceTimer)
|
|
272
|
+
clearTimeout(debounceTimer)
|
|
273
|
+
debounceTimer = setTimeout(() => void compute(), delay)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const onEditorUpdate = () => scheduleCompute(400)
|
|
277
|
+
let resizeObserver: ResizeObserver | null = null
|
|
278
|
+
|
|
279
|
+
watch(() => props.editor, (editor, old) => {
|
|
280
|
+
old?.off('update', onEditorUpdate)
|
|
281
|
+
if (editor) {
|
|
282
|
+
// rastro de texto adicionado pelo usuário (highlight cinza)
|
|
283
|
+
trackUserEdits(editor)
|
|
284
|
+
editor.on('update', onEditorUpdate)
|
|
285
|
+
// a paginação assenta async — recomputa em ondas
|
|
286
|
+
scheduleCompute(50)
|
|
287
|
+
setTimeout(() => scheduleCompute(0), 800)
|
|
288
|
+
setTimeout(() => scheduleCompute(0), 2000)
|
|
289
|
+
}
|
|
290
|
+
}, { immediate: true })
|
|
291
|
+
|
|
292
|
+
onMounted(() => {
|
|
293
|
+
const sheet = rootRef.value?.parentElement
|
|
294
|
+
if (sheet) {
|
|
295
|
+
// como o Google Docs: com os comentários abertos, a folha desloca pra
|
|
296
|
+
// esquerda pra dar lugar à coluna (a classe vive enquanto o gutter vive)
|
|
297
|
+
sheet.classList.add('poc-sheet--with-gutter')
|
|
298
|
+
resizeObserver = new ResizeObserver(() => scheduleCompute(200))
|
|
299
|
+
resizeObserver.observe(sheet)
|
|
300
|
+
}
|
|
301
|
+
scheduleCompute(100)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
watch(() => props.analysis, () => scheduleCompute(50), { deep: true })
|
|
305
|
+
|
|
306
|
+
onUnmounted(() => {
|
|
307
|
+
rootRef.value?.parentElement?.classList.remove('poc-sheet--with-gutter')
|
|
308
|
+
if (props.editor && !props.editor.isDestroyed)
|
|
309
|
+
applyAnnotationDecorations(props.editor, null)
|
|
310
|
+
props.editor?.off('update', onEditorUpdate)
|
|
311
|
+
resizeObserver?.disconnect()
|
|
312
|
+
if (debounceTimer)
|
|
313
|
+
clearTimeout(debounceTimer)
|
|
314
|
+
})
|
|
315
|
+
</script>
|
|
316
|
+
|
|
317
|
+
<template>
|
|
318
|
+
<div ref="rootRef" class="minuta-gutter">
|
|
319
|
+
<div
|
|
320
|
+
v-for="item in items"
|
|
321
|
+
:key="item.id"
|
|
322
|
+
:ref="el => setCardRef(item.id, el)"
|
|
323
|
+
class="minuta-gutter-card"
|
|
324
|
+
:class="{
|
|
325
|
+
'minuta-gutter-card--active': activeId === item.id,
|
|
326
|
+
'minuta-gutter-card--orphan': !item.range,
|
|
327
|
+
}"
|
|
328
|
+
:style="{ transform: `translateY(${item.finalY}px)` }"
|
|
329
|
+
@click="onCardClick(item)"
|
|
330
|
+
>
|
|
331
|
+
<div class="minuta-gutter-card__kind">
|
|
332
|
+
<span
|
|
333
|
+
class="minuta-gutter-card__badge"
|
|
334
|
+
:style="{ color: metaFor(item).color, background: metaFor(item).bg }"
|
|
335
|
+
>
|
|
336
|
+
{{ metaFor(item).label }}
|
|
337
|
+
</span>
|
|
338
|
+
<span v-if="item.manual" class="minuta-gutter-card__onde">era: {{ KIND_META[item.kind].label.toLowerCase() }}</span>
|
|
339
|
+
<span v-else-if="item.onde" class="minuta-gutter-card__onde">{{ item.onde }}</span>
|
|
340
|
+
</div>
|
|
341
|
+
<p class="minuta-gutter-card__title">
|
|
342
|
+
{{ item.title }}
|
|
343
|
+
</p>
|
|
344
|
+
<p v-if="item.body" class="minuta-gutter-card__body">
|
|
345
|
+
{{ item.body }}
|
|
346
|
+
</p>
|
|
347
|
+
<div v-if="item.documento" class="minuta-gutter-card__source">
|
|
348
|
+
<span class="minuta-gutter-card__source-text">
|
|
349
|
+
{{ item.documento }}<template v-if="item.pagina">, pág. {{ item.pagina }}</template>
|
|
350
|
+
</span>
|
|
351
|
+
<button
|
|
352
|
+
class="minuta-gutter-card__source-btn"
|
|
353
|
+
type="button"
|
|
354
|
+
@click.stop="emit('openDocument', { documento: item.documento!, pagina: item.pagina, highlight: item.title })"
|
|
355
|
+
>
|
|
356
|
+
Ver documento
|
|
357
|
+
</button>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</template>
|
|
362
|
+
|
|
363
|
+
<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}
|
|
365
|
+
</style>
|
|
366
|
+
|
|
367
|
+
<style>
|
|
368
|
+
/* com os comentários abertos a folha desloca pra esquerda (estilo Google Docs):
|
|
369
|
+
centraliza o conjunto folha+coluna; em tela estreita encosta na esquerda */
|
|
370
|
+
.poc-sheet--with-gutter {
|
|
371
|
+
margin-left: max(8px, calc(50% - 545px)) !important;
|
|
372
|
+
margin-right: auto !important;
|
|
373
|
+
transition: margin-left 0.2s ease;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/* highlight persistente dos trechos da análise (decoração PM — mesma cor do
|
|
377
|
+
selo do card na margem, pra associação imediata cor↔categoria) */
|
|
378
|
+
.minuta-anno {
|
|
379
|
+
border-radius: 2px;
|
|
380
|
+
box-decoration-break: clone;
|
|
381
|
+
-webkit-box-decoration-break: clone;
|
|
382
|
+
transition: background 0.2s ease;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.minuta-anno--tese {
|
|
386
|
+
background: rgba(139, 92, 246, 0.13);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.minuta-anno--jurisprudencia {
|
|
390
|
+
background: rgba(217, 119, 6, 0.13);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.minuta-anno--citacao {
|
|
394
|
+
background: rgba(37, 99, 235, 0.11);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.minuta-anno--fato {
|
|
398
|
+
background: rgba(5, 150, 105, 0.13);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.minuta-anno--manual {
|
|
402
|
+
background: rgba(107, 114, 128, 0.13);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/* card ativo na margem → trecho correspondente acende mais forte */
|
|
406
|
+
.minuta-anno--active.minuta-anno--manual {
|
|
407
|
+
background: rgba(107, 114, 128, 0.28);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.minuta-anno--active.minuta-anno--tese {
|
|
411
|
+
background: rgba(139, 92, 246, 0.3);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.minuta-anno--active.minuta-anno--jurisprudencia {
|
|
415
|
+
background: rgba(217, 119, 6, 0.3);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.minuta-anno--active.minuta-anno--citacao {
|
|
419
|
+
background: rgba(37, 99, 235, 0.26);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.minuta-anno--active.minuta-anno--fato {
|
|
423
|
+
background: rgba(5, 150, 105, 0.28);
|
|
424
|
+
}
|
|
425
|
+
</style>
|