@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.
Files changed (45) hide show
  1. package/README.md +106 -0
  2. package/dist/module.cjs +5 -0
  3. package/dist/module.d.mts +18 -0
  4. package/dist/module.d.ts +18 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +45 -0
  7. package/dist/runtime/components/MinutaAnnotationsGutter.vue +425 -0
  8. package/dist/runtime/components/MinutaChatEditCard.vue +124 -0
  9. package/dist/runtime/components/MinutaChromeCanvas.vue +549 -0
  10. package/dist/runtime/components/MinutaChromeEditor.vue +100 -0
  11. package/dist/runtime/components/MinutaDocumentViewer.vue +90 -0
  12. package/dist/runtime/components/MinutaDocxEditor.vue +1158 -0
  13. package/dist/runtime/components/MinutaEmbed.vue +783 -0
  14. package/dist/runtime/components/MinutaIcon.vue +53 -0
  15. package/dist/runtime/composables/annotationAnchors.d.ts +73 -0
  16. package/dist/runtime/composables/annotationAnchors.js +243 -0
  17. package/dist/runtime/composables/chatEditOps.d.ts +37 -0
  18. package/dist/runtime/composables/chatEditOps.js +61 -0
  19. package/dist/runtime/composables/docxExport.d.ts +24 -0
  20. package/dist/runtime/composables/docxExport.js +1292 -0
  21. package/dist/runtime/composables/editorExtensions.d.ts +56 -0
  22. package/dist/runtime/composables/editorExtensions.js +466 -0
  23. package/dist/runtime/composables/paginationPlugin.d.ts +126 -0
  24. package/dist/runtime/composables/paginationPlugin.js +421 -0
  25. package/dist/runtime/composables/useMinutaApi.d.ts +112 -0
  26. package/dist/runtime/composables/useMinutaApi.js +68 -0
  27. package/dist/runtime/composables/vaultImages.d.ts +12 -0
  28. package/dist/runtime/composables/vaultImages.js +85 -0
  29. package/dist/runtime/composables/versionDiff.d.ts +34 -0
  30. package/dist/runtime/composables/versionDiff.js +62 -0
  31. package/dist/runtime/server/api/minuta/[...path].d.ts +2 -0
  32. package/dist/runtime/server/api/minuta/[...path].js +6 -0
  33. package/dist/runtime/server/api/minuta/index.d.ts +2 -0
  34. package/dist/runtime/server/api/minuta/index.js +3 -0
  35. package/dist/runtime/server/utils/minuta-proxy.d.ts +10 -0
  36. package/dist/runtime/server/utils/minuta-proxy.js +63 -0
  37. package/dist/runtime/utils/footnoteHtml.d.ts +26 -0
  38. package/dist/runtime/utils/footnoteHtml.js +117 -0
  39. package/dist/runtime/utils/pocKeepWithNext.d.ts +44 -0
  40. package/dist/runtime/utils/pocKeepWithNext.js +9 -0
  41. package/dist/runtime/utils/sanitizeRichHtml.d.ts +10 -0
  42. package/dist/runtime/utils/sanitizeRichHtml.js +73 -0
  43. package/dist/types.d.mts +7 -0
  44. package/dist/types.d.ts +7 -0
  45. 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.
@@ -0,0 +1,5 @@
1
+ module.exports = function(...args) {
2
+ return import('./module.mjs').then(m => m.default.call(this, ...args))
3
+ }
4
+ const _meta = module.exports.meta = require('./module.json')
5
+ module.exports.getMeta = () => Promise.resolve(_meta)
@@ -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 };
@@ -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 };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@meistrari/minuta-nuxt",
3
+ "configKey": "minutaNuxt",
4
+ "version": "0.1.0",
5
+ "builder": {
6
+ "@nuxt/module-builder": "0.8.4",
7
+ "unbuild": "2.0.0"
8
+ }
9
+ }
@@ -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>