@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.
@@ -0,0 +1,225 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
3
+ import type { Editor } from '@tiptap/core'
4
+ import MinutaDocxEditor from './MinutaDocxEditor.vue'
5
+ import MinutaChromeEditor from './MinutaChromeEditor.vue'
6
+ import type { MinutaVariableDef } from '../utils/templateVariables'
7
+ import { extractTemplateVariables } from '../utils/templateVariables'
8
+ import type { MinutaChrome } from '../composables/useMinutaApi'
9
+
10
+ /**
11
+ * Editor de TEMPLATE de minuta — o mesmo documento paginado A4 do
12
+ * `<MinutaEmbed>`, só que sem minuta: o conteúdo é `v-model` do app hospedeiro,
13
+ * e onde entra um valor há um placeholder de variável.
14
+ *
15
+ * É componente separado do `MinutaEmbed` de propósito. O embed é uma minuta
16
+ * concreta: tem id, salva sozinho no CDB, tem histórico, agente, análise de
17
+ * margem e documentos-fonte — nada disso existe num template. Um `mode` no
18
+ * embed significaria desligar 80% dele e manter os dois caminhos vivos em cada
19
+ * mudança futura.
20
+ *
21
+ * O CDB não guarda template nenhum: quem persiste é o host, que já tem cadastro
22
+ * (na Omni, a definição de ocorrência). O que sai daqui é o HTML do documento e
23
+ * a lista de variáveis usadas — o host nunca precisa ler HTML.
24
+ *
25
+ * ```vue
26
+ * <MinutaTemplateEditor
27
+ * v-model="definicao.templateHtml"
28
+ * :variables="PETICAO_VARIAVEIS"
29
+ * @update:used-variables="keys => definicao.variaveis = keys"
30
+ * />
31
+ * ```
32
+ */
33
+
34
+ const props = withDefaults(defineProps<{
35
+ /** HTML do template (documento com os placeholders) */
36
+ modelValue?: string | null
37
+ /** catálogo de variáveis disponíveis — definido pelo host */
38
+ variables?: MinutaVariableDef[]
39
+ editable?: boolean
40
+ /** barra superior com o resumo das variáveis usadas (default: true) */
41
+ showToolbar?: boolean
42
+ /** margens da página em px @96dpi (default: 2,5cm topo/rodapé, 3cm laterais) */
43
+ margins?: { top: number, right: number, bottom: number, left: number }
44
+ /** cabeçalho/rodapé da folha; `v-model:chrome` para persistir junto */
45
+ chrome?: MinutaChrome | null
46
+ }>(), {
47
+ modelValue: '',
48
+ variables: () => [],
49
+ editable: true,
50
+ showToolbar: true,
51
+ margins: () => ({ top: 95, right: 113, bottom: 95, left: 113 }),
52
+ chrome: null,
53
+ })
54
+
55
+ const emit = defineEmits<{
56
+ 'update:modelValue': [html: string]
57
+ /** chaves usadas no documento, na ordem de aparição e sem repetição */
58
+ 'update:usedVariables': [keys: string[]]
59
+ 'update:chrome': [chrome: MinutaChrome]
60
+ 'ready': [editor: Editor]
61
+ }>()
62
+
63
+ const HEADER = { logoUrl: null as string | null, position: 'left' as const }
64
+
65
+ const editor = ref<Editor | null>(null)
66
+ const editorCompRef = ref<any>(null)
67
+ /**
68
+ * Conteúdo inicial CONGELADO no mount. O editor é a fonte da verdade enquanto
69
+ * está vivo; realimentá-lo com o `modelValue` que ele mesmo acabou de emitir
70
+ * remontaria o documento a cada tecla e o cursor iria para o começo.
71
+ */
72
+ const initialHtml = ref(props.modelValue || '<p></p>')
73
+
74
+ const chromeText = reactive({
75
+ header: props.chrome?.header ?? '',
76
+ footer: props.chrome?.footer ?? '',
77
+ headerScope: props.chrome?.headerScope ?? 'all' as 'all' | 'first',
78
+ footerScope: props.chrome?.footerScope ?? 'all' as 'all' | 'first',
79
+ showPageNumber: props.chrome?.showPageNumber !== false,
80
+ })
81
+
82
+ const usedVariables = ref<string[]>(extractTemplateVariables(props.modelValue))
83
+
84
+ const catalogByKey = computed(() => new Map(props.variables.map(v => [v.key, v])))
85
+
86
+ /**
87
+ * Variáveis no documento que saíram do catálogo — o host renomeou ou removeu.
88
+ * Nunca vão resolver, então merecem aviso na hora de salvar, não na geração.
89
+ */
90
+ const unknownVariables = computed(() =>
91
+ usedVariables.value.filter(key => !catalogByKey.value.has(key)))
92
+
93
+ function labelOf(key: string) {
94
+ return catalogByKey.value.get(key)?.label ?? key
95
+ }
96
+
97
+ function currentHtml(): string {
98
+ return editor.value?.getHTML() ?? ''
99
+ }
100
+
101
+ function syncOut() {
102
+ const html = currentHtml()
103
+ const keys = extractTemplateVariables(html)
104
+ const changed = keys.length !== usedVariables.value.length
105
+ || keys.some((key, i) => key !== usedVariables.value[i])
106
+ if (changed) {
107
+ usedVariables.value = keys
108
+ emit('update:usedVariables', keys)
109
+ }
110
+ emit('update:modelValue', html)
111
+ }
112
+
113
+ function onReady(instance: Editor) {
114
+ editor.value = instance
115
+ // o parse do HTML inicial pode normalizar o documento (nós desconhecidos,
116
+ // wrapper de bloco): emite uma vez para o host guardar o que o editor de
117
+ // fato tem, e não o que ele mandou
118
+ syncOut()
119
+ emit('ready', instance)
120
+ }
121
+
122
+ // --- cabeçalho/rodapé (mesmo canvas do embed) ---
123
+ const chromeEditor = ref<{ part: 'header' | 'footer' } | null>(null)
124
+
125
+ function onEditChrome(event: Event) {
126
+ if (!props.editable)
127
+ return
128
+ const part = (event as CustomEvent).detail?.part
129
+ if (part === 'header' || part === 'footer')
130
+ chromeEditor.value = { part }
131
+ }
132
+
133
+ function applyChrome({ html, scope, showPageNumber }: { html: string, scope: 'all' | 'first', showPageNumber?: boolean }) {
134
+ if (!chromeEditor.value)
135
+ return
136
+ const part = chromeEditor.value.part
137
+ chromeText[part] = html
138
+ chromeText[part === 'header' ? 'headerScope' : 'footerScope'] = scope
139
+ if (showPageNumber !== undefined)
140
+ chromeText.showPageNumber = showPageNumber
141
+ chromeEditor.value = null
142
+ emit('update:chrome', { ...chromeText })
143
+ }
144
+
145
+ onMounted(() => window.addEventListener('poc:edit-chrome', onEditChrome))
146
+ onUnmounted(() => window.removeEventListener('poc:edit-chrome', onEditChrome))
147
+
148
+ // host trocando o template inteiro (abrir outro cadastro): remonta o editor
149
+ watch(() => props.modelValue, (value) => {
150
+ if (!editor.value || value == null)
151
+ return
152
+ if (value === currentHtml())
153
+ return
154
+ initialHtml.value = value || '<p></p>'
155
+ editor.value.commands.setContent(initialHtml.value, false)
156
+ syncOut()
157
+ })
158
+
159
+ defineExpose({ editor, getHtml: currentHtml, usedVariables })
160
+ </script>
161
+
162
+ <template>
163
+ <div class="minuta-tpl">
164
+ <div v-if="showToolbar" class="minuta-tpl__bar">
165
+ <slot name="toolbar-start" />
166
+ <span class="minuta-tpl__count">
167
+ {{ usedVariables.length }}
168
+ {{ usedVariables.length === 1 ? 'variável no template' : 'variáveis no template' }}
169
+ </span>
170
+ <div class="minuta-tpl__chips">
171
+ <span
172
+ v-for="key in usedVariables"
173
+ :key="key"
174
+ class="minuta-tpl__chip"
175
+ :class="{ 'minuta-tpl__chip--unknown': !catalogByKey.has(key) }"
176
+ :title="key"
177
+ >
178
+ {{ labelOf(key) }}
179
+ </span>
180
+ </div>
181
+ <slot name="toolbar-end" />
182
+ </div>
183
+
184
+ <p v-if="unknownVariables.length" class="minuta-tpl__warn">
185
+ {{ unknownVariables.length === 1 ? 'A variável' : 'As variáveis' }}
186
+ <b>{{ unknownVariables.join(', ') }}</b>
187
+ {{ unknownVariables.length === 1 ? 'não está' : 'não estão' }}
188
+ no catálogo — {{ unknownVariables.length === 1 ? 'ela nunca vai' : 'elas nunca vão' }}
189
+ receber valor na geração.
190
+ </p>
191
+
192
+ <div class="minuta-tpl__body">
193
+ <MinutaDocxEditor
194
+ ref="editorCompRef"
195
+ :initial-html="initialHtml"
196
+ :margins="props.margins"
197
+ :header="HEADER"
198
+ :editable="props.editable"
199
+ :show-block-origin="false"
200
+ :variables="props.variables"
201
+ :chrome-text="chromeText"
202
+ @ready="onReady"
203
+ @changed="syncOut"
204
+ />
205
+
206
+ <div v-if="chromeEditor" class="minuta-tpl__chrome-overlay">
207
+ <MinutaChromeEditor
208
+ :key="chromeEditor.part"
209
+ :part="chromeEditor.part"
210
+ :html="chromeText[chromeEditor.part]"
211
+ :scope="chromeText[chromeEditor.part === 'header' ? 'headerScope' : 'footerScope']"
212
+ :band-height-px="chromeEditor.part === 'header' ? props.margins.top : props.margins.bottom"
213
+ :show-page-number="chromeText.showPageNumber"
214
+ :allow-page-number-toggle="true"
215
+ @apply="applyChrome"
216
+ @close="chromeEditor = null"
217
+ />
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </template>
222
+
223
+ <style scoped>
224
+ .minuta-tpl{background:#f7f7f8;display:flex;flex-direction:column;height:100%}.minuta-tpl__bar{align-items:center;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;font-size:13px;gap:8px;padding:8px 16px}.minuta-tpl__count{color:#6b7280;white-space:nowrap}.minuta-tpl__chips{display:flex;flex:1;flex-wrap:wrap;gap:4px;min-width:0}.minuta-tpl__chip{background:rgba(251,191,36,.16);border-radius:999px;box-shadow:inset 0 0 0 1px rgba(180,83,9,.25);color:#78350f;font-size:12px;padding:1px 8px}.minuta-tpl__chip--unknown{background:rgba(244,63,94,.12);box-shadow:inset 0 0 0 1px rgba(190,18,60,.35);color:#881337}.minuta-tpl__warn{background:#fff1f2;border-bottom:1px solid #fecdd3;color:#881337;font-size:12px;margin:0;padding:8px 16px}.minuta-tpl__body{flex:1;min-height:0;position:relative}.minuta-tpl__chrome-overlay{inset:0;position:absolute;z-index:40}
225
+ </style>
@@ -0,0 +1,149 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import type { MinutaVariableDef } from '../utils/templateVariables'
4
+
5
+ /**
6
+ * Seletor de variável do template — o popover que abre pelo `/` no texto ou
7
+ * pelo botão da toolbar.
8
+ *
9
+ * A busca é por RÓTULO, não por chave: quem monta o template pensa em "número
10
+ * do processo", não em `PROCESSO`. A chave aparece ao lado porque é ela que o
11
+ * host recebe de volta, e ver as duas juntas é o que ensina o vocabulário.
12
+ */
13
+
14
+ const props = defineProps<{
15
+ variables: MinutaVariableDef[]
16
+ /** posição absoluta dentro do container do editor */
17
+ top: number
18
+ left: number
19
+ /** texto digitado depois do `/` (busca ao vivo do gatilho no documento) */
20
+ query?: string
21
+ }>()
22
+
23
+ const emit = defineEmits<{ select: [variable: MinutaVariableDef], close: [] }>()
24
+
25
+ const search = ref('')
26
+ const activeIndex = ref(0)
27
+ const searchInput = ref<HTMLInputElement | null>(null)
28
+
29
+ /** o gatilho `/` filtra pelo que está sendo digitado NO DOCUMENTO; o botão, pelo campo */
30
+ const term = computed(() => (props.query ?? search.value).trim().toLowerCase())
31
+
32
+ const filtered = computed(() => {
33
+ if (!term.value)
34
+ return props.variables
35
+ return props.variables.filter(v =>
36
+ v.label.toLowerCase().includes(term.value)
37
+ || v.key.toLowerCase().includes(term.value)
38
+ || (v.description?.toLowerCase().includes(term.value) ?? false),
39
+ )
40
+ })
41
+
42
+ /** lista achatada com cabeçalhos de grupo — o índice ativo anda só nos itens */
43
+ const rows = computed(() => {
44
+ const out: Array<{ kind: 'group', label: string } | { kind: 'item', variable: MinutaVariableDef, index: number }> = []
45
+ let index = 0
46
+ let lastGroup: string | null = null
47
+ for (const variable of filtered.value) {
48
+ const group = variable.group ?? null
49
+ if (group && group !== lastGroup)
50
+ out.push({ kind: 'group', label: group })
51
+ lastGroup = group
52
+ out.push({ kind: 'item', variable, index })
53
+ index++
54
+ }
55
+ return out
56
+ })
57
+
58
+ watch(filtered, () => {
59
+ activeIndex.value = 0
60
+ })
61
+
62
+ /**
63
+ * Aberto pelo BOTÃO (sem `query`), o filtro é o campo daqui e ele precisa do
64
+ * foco — quem abriu não estava digitando no documento. Aberto pelo GATILHO `/`,
65
+ * o foco fica no editor de propósito: a pessoa continua digitando o texto do
66
+ * filtro no próprio parágrafo, e é o pai que encaminha as teclas para cá.
67
+ *
68
+ * `onMounted`, e não um watcher `immediate`: no watcher o ref do input ainda
69
+ * não existe, e o foco caía no editor — daí o que se digitava virar texto do
70
+ * documento em vez de busca.
71
+ */
72
+ onMounted(() => {
73
+ if (props.query === undefined)
74
+ searchInput.value?.focus()
75
+ })
76
+
77
+ function move(delta: number) {
78
+ const total = filtered.value.length
79
+ if (!total)
80
+ return
81
+ activeIndex.value = (activeIndex.value + delta + total) % total
82
+ }
83
+
84
+ function choose(index = activeIndex.value) {
85
+ const variable = filtered.value[index]
86
+ if (variable)
87
+ emit('select', variable)
88
+ }
89
+
90
+ /** o gatilho `/` mantém o foco no editor — o pai delega as teclas para cá */
91
+ defineExpose({ move, choose, hasResults: () => filtered.value.length > 0 })
92
+ </script>
93
+
94
+ <template>
95
+ <div
96
+ class="poc-varpicker"
97
+ :style="{ top: `${top}px`, left: `${left}px` }"
98
+ @click.stop
99
+ >
100
+ <input
101
+ v-if="query === undefined"
102
+ ref="searchInput"
103
+ v-model="search"
104
+ class="poc-varpicker__search"
105
+ placeholder="Buscar variável…"
106
+ spellcheck="false"
107
+ @keydown.down.prevent="move(1)"
108
+ @keydown.up.prevent="move(-1)"
109
+ @keydown.enter.prevent="choose()"
110
+ @keydown.esc.prevent="emit('close')"
111
+ >
112
+ <div v-else class="poc-varpicker__hint">
113
+ Variável<span v-if="query">: <b>{{ query }}</b></span>
114
+ </div>
115
+
116
+ <!-- `mousedown.prevent` só na lista: na raiz ele impediria o clique de
117
+ focar o próprio campo de busca. Aqui ele serve para o clique num
118
+ item não tirar o foco de onde está (editor ou campo) antes do
119
+ `click` — o que fecharia o popover pelo caminho de "clicou fora". -->
120
+ <div class="poc-varpicker__list" @mousedown.prevent>
121
+ <p v-if="!filtered.length" class="poc-varpicker__empty">
122
+ Nenhuma variável encontrada.
123
+ </p>
124
+ <template v-for="(row, i) in rows" :key="i">
125
+ <div v-if="row.kind === 'group'" class="poc-varpicker__group">
126
+ {{ row.label }}
127
+ </div>
128
+ <button
129
+ v-else
130
+ type="button"
131
+ class="poc-varpicker__item"
132
+ :class="{ 'poc-varpicker__item--on': row.index === activeIndex }"
133
+ @mouseenter="activeIndex = row.index"
134
+ @click="choose(row.index)"
135
+ >
136
+ <span class="poc-varpicker__label">{{ row.variable.label }}</span>
137
+ <span class="poc-varpicker__key">{{ row.variable.key }}</span>
138
+ <span v-if="row.variable.description" class="poc-varpicker__desc">
139
+ {{ row.variable.description }}
140
+ </span>
141
+ </button>
142
+ </template>
143
+ </div>
144
+ </div>
145
+ </template>
146
+
147
+ <style scoped>
148
+ .poc-varpicker{background:#fff;border:1px solid #e5e7eb;border-radius:10px;box-shadow:0 12px 24px -8px rgba(0,0,0,.25);display:flex;flex-direction:column;font-size:13px;max-height:300px;overflow:hidden;position:absolute;width:320px;z-index:60}.poc-varpicker__search{border:0;border-bottom:1px solid #f3f4f6;font-family:inherit;font-size:13px;outline:none;padding:10px 12px}.poc-varpicker__hint{border-bottom:1px solid #f3f4f6;color:#6b7280;font-size:12px;padding:8px 12px}.poc-varpicker__list{flex:1;min-height:0;overflow-y:auto;padding:4px}.poc-varpicker__empty{color:#6b7280;font-size:12px;margin:0;padding:12px}.poc-varpicker__group{color:#9ca3af;font-size:11px;font-weight:600;letter-spacing:.04em;padding:8px 8px 4px;text-transform:uppercase}.poc-varpicker__item{background:none;border:0;border-radius:6px;cursor:pointer;display:grid;font-family:inherit;gap:2px 8px;grid-template-columns:1fr auto;padding:6px 8px;text-align:left;width:100%}.poc-varpicker__item--on{background:#f3f4f6}.poc-varpicker__label{color:#111827;font-weight:500}.poc-varpicker__key{color:#9ca3af;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}.poc-varpicker__desc{color:#6b7280;display:-webkit-box;font-size:11px;grid-column:1/-1;-webkit-line-clamp:2;line-height:1.4;overflow:hidden;-webkit-box-orient:vertical}
149
+ </style>
@@ -11,7 +11,6 @@ export interface AnnotationTarget {
11
11
  /** título da seção onde o item aparece (fallback) */
12
12
  onde?: string | null;
13
13
  }
14
- /** range PM de um recorte verbatim do documento (busca whitespace-tolerante) */
15
14
  export declare function findTextRange(editor: Editor, needle: string): {
16
15
  from: number;
17
16
  to: number;
@@ -50,18 +50,24 @@ function findRange(text, posAt, needle) {
50
50
  }
51
51
  return null;
52
52
  }
53
+ function plainNeedle(needle) {
54
+ if (!needle.includes("<") && !needle.includes("&"))
55
+ return needle;
56
+ const doc = new DOMParser().parseFromString(needle, "text/html");
57
+ return doc.body.textContent ?? needle;
58
+ }
53
59
  export function findTextRange(editor, needle) {
54
60
  const { text, posAt } = buildTextIndex(editor);
55
61
  if (!text.length)
56
62
  return null;
57
- return findRange(text, posAt, needle);
63
+ return findRange(text, posAt, plainNeedle(needle));
58
64
  }
59
65
  export function locateAnnotationDetailed(editor, target) {
60
66
  const { text, posAt } = buildTextIndex(editor);
61
67
  if (!text.length)
62
68
  return null;
63
69
  if (target.trecho) {
64
- const range = findRange(text, posAt, target.trecho);
70
+ const range = findRange(text, posAt, plainNeedle(target.trecho));
65
71
  if (range)
66
72
  return { range, matchedBy: "trecho" };
67
73
  }
@@ -227,7 +233,8 @@ export function setUserEditRanges(editor, ranges) {
227
233
  export function scrollToAnnotation(editor, from, scrollContainer) {
228
234
  try {
229
235
  const coords = editor.view.coordsAtPos(from);
230
- if (scrollContainer) {
236
+ const rolavel = !!scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight + 4;
237
+ if (scrollContainer && rolavel) {
231
238
  const rect = scrollContainer.getBoundingClientRect();
232
239
  scrollContainer.scrollTo({
233
240
  top: scrollContainer.scrollTop + (coords.top - rect.top) - rect.height * 0.3,
@@ -202,6 +202,12 @@ function inlineToRuns(node, base, ctx) {
202
202
  }
203
203
  return;
204
204
  }
205
+ if (child.type.name === "templateVariable") {
206
+ const label = child.attrs.label || child.attrs.varKey || "";
207
+ if (label)
208
+ runs.push(new TextRun({ text: `[${label}]`, ...runOptions(base) }));
209
+ return;
210
+ }
205
211
  if (child.type.name === "variableChip") {
206
212
  if (child.attrs.text)
207
213
  runs.push(new TextRun({ text: child.attrs.text, ...runOptions(base) }));
@@ -327,7 +333,9 @@ function textBlockToParagraph(node, pos, ctx, opts = {}) {
327
333
  const runs = inlineToRuns(node, base, ctx);
328
334
  const paragraph = new Paragraph({
329
335
  children: runs,
330
- alignment: m?.align ?? parseAlignment(style),
336
+ // sem alinhamento explícito, corpo de peça vai justificado (o Word
337
+ // usaria esquerda e o .docx sairia diferente da praxe e do preview)
338
+ alignment: m?.align ?? parseAlignment(style) ?? (opts.heading ? void 0 : AlignmentType.JUSTIFIED),
331
339
  spacing,
332
340
  indent: total ? { left: total } : void 0,
333
341
  heading: opts.heading ? headingLevelFor(opts.heading) : void 0,
@@ -1211,7 +1219,11 @@ export async function pocExportDocx(editor, margins, chrome, options = {}) {
1211
1219
  const doc = editor.state.doc;
1212
1220
  const ctx = {
1213
1221
  view: editor.view,
1214
- breakSet: new Set((options.breaks ?? []).map((b) => b.pos)),
1222
+ // quebras `soft` (dentro de um parágrafo) não viram pageBreakBefore:
1223
+ // partir o parágrafo em dois no Word entregaria ao advogado um arquivo
1224
+ // que não reflui ao editar. Ali o Word quebra sozinho, no mesmo lugar
1225
+ // ou a uma linha de distância.
1226
+ breakSet: new Set((options.breaks ?? []).filter((b) => !b.soft).map((b) => b.pos)),
1215
1227
  pending: { breakBefore: false },
1216
1228
  lastAfterTw: 0,
1217
1229
  meta: /* @__PURE__ */ new WeakMap(),
@@ -1,6 +1,8 @@
1
1
  import type { Editor } from '@tiptap/core';
2
2
  import { Extension as TiptapExtension, Mark as TiptapMark, Node as TiptapNode } from '@tiptap/core';
3
+ import type { EditorView as PMEditorView } from '@tiptap/pm/view';
3
4
  import type { PocPageConfig } from './paginationPlugin.js';
5
+ import type { MinutaVariableDef } from '../utils/templateVariables.js';
4
6
  /**
5
7
  * Extensões Tiptap da PoC do editor de minuta.
6
8
  *
@@ -8,6 +10,7 @@ import type { PocPageConfig } from './paginationPlugin.js';
8
10
  * - minutaBlock: wrapper com proveniência (blockId/título do bloco do template)
9
11
  * - htmlIsland: tabelas/listas/imagens/svg preservados como ilhas não-editáveis (HTML cru)
10
12
  * - variableChip: <span data-variable-id> vira nó atômico — a variável continua "viva"
13
+ * - templateVariable: <span data-minuta-var> — placeholder NÃO resolvido do template
11
14
  * - inlineStyle/styledDiv/passthrough: preservam o CSS inline do pipeline de render
12
15
  */
13
16
  /**
@@ -39,6 +42,26 @@ export declare const PocHtmlIsland: TiptapNode<any, any>;
39
42
  */
40
43
  export declare const PocMissingImage: TiptapNode<any, any>;
41
44
  export declare const PocVariableChip: TiptapNode<any, any>;
45
+ /**
46
+ * Placeholder de variável do TEMPLATE — o `#NOME_VARIAVEL` de antigamente, só
47
+ * que como nó atômico em vez de texto (ver `utils/templateVariables.ts` para o
48
+ * porquê e para o formato).
49
+ *
50
+ * O catálogo (`options.catalog`) vem do app hospedeiro: o CDB não sabe o que é
51
+ * `SIGLA_FORO`, só sabe que existe um lugar no documento com esse nome. O nó
52
+ * guarda o rótulo junto (para um template abrir legível sem catálogo, ex.: no
53
+ * histórico), mas quem manda na exibição é o catálogo — renomear uma variável
54
+ * no host reflete em todo template já salvo, sem migrar documento nenhum.
55
+ *
56
+ * Sobrevive à geração: `resolveTemplateVariables` só troca o que tem valor, e o
57
+ * que sobra continua sendo este nó — um buraco visível no parágrafo em que ele
58
+ * está, e não um erro genérico no rodapé de um modal.
59
+ */
60
+ /** o catálogo pode chegar depois do mount (host que carrega async) — daí o getter */
61
+ export type MinutaVariableCatalog = MinutaVariableDef[] | (() => MinutaVariableDef[]);
62
+ export declare const PocTemplateVariable: TiptapNode<{
63
+ catalog: MinutaVariableCatalog;
64
+ }, any>;
42
65
  /**
43
66
  * Nota de rodapé: o render devolve `<sup data-footnote="texto">n</sup>` e o
44
67
  * export transforma em nota de rodapé DE VERDADE no Word (o pipeline legado faz
@@ -53,4 +76,18 @@ export declare const PocInlineStyle: TiptapMark<any, any>;
53
76
  export declare const PocUnderline: TiptapMark<any, any>;
54
77
  /** Preserva style/class inline nos blocos de texto (o pipeline de render inlina todo o CSS). */
55
78
  export declare const PocStylePassthrough: TiptapExtension<any, any>;
56
- export declare function buildPocExtensions(pageConfig?: PocPageConfig): (TiptapNode<any, any> | TiptapMark<any, any> | TiptapExtension<any, any>)[];
79
+ export declare function buildPocExtensions(pageConfig?: PocPageConfig, options?: {
80
+ /** catálogo de variáveis do host — habilita os chips do modo template */
81
+ variables?: MinutaVariableCatalog;
82
+ /**
83
+ * Primeiro a ver o teclado, ANTES do keymap do editor. Devolver `true`
84
+ * consome a tecla.
85
+ *
86
+ * Existe porque `editorProps.handleKeyDown` não serve: o `someProp` do
87
+ * ProseMirror percorre os plugins antes das props diretas, então o keymap
88
+ * do StarterKit trata o Enter (quebra o parágrafo) e o handler da view
89
+ * nunca roda. Quem precisa de um popover dirigido pelo teclado com o foco
90
+ * ainda no editor — o seletor de variável — depende disso.
91
+ */
92
+ handleKeyDown?: (view: PMEditorView, event: KeyboardEvent) => boolean;
93
+ }): (TiptapNode<any, any> | TiptapMark<any, any> | TiptapExtension<any, any>)[];
@@ -4,6 +4,7 @@ import Table from "@tiptap/extension-table";
4
4
  import TableCell from "@tiptap/extension-table-cell";
5
5
  import TableHeader from "@tiptap/extension-table-header";
6
6
  import TableRow from "@tiptap/extension-table-row";
7
+ import { Plugin as PMPlugin } from "@tiptap/pm/state";
7
8
  import { sanitizeRichHtml } from "../utils/sanitizeRichHtml.js";
8
9
  import { PocPaginationPlugin } from "./paginationPlugin.js";
9
10
  export const POC_ASSET_SWAP = "pocAssetSwap";
@@ -272,6 +273,74 @@ export const PocVariableChip = TiptapNode.create({
272
273
  };
273
274
  }
274
275
  });
276
+ function readCatalog(catalog) {
277
+ return typeof catalog === "function" ? catalog() : catalog;
278
+ }
279
+ export const PocTemplateVariable = TiptapNode.create({
280
+ name: "templateVariable",
281
+ inline: true,
282
+ group: "inline",
283
+ atom: true,
284
+ selectable: true,
285
+ addOptions() {
286
+ return { catalog: [] };
287
+ },
288
+ addAttributes() {
289
+ return {
290
+ varKey: { default: null },
291
+ label: { default: null }
292
+ };
293
+ },
294
+ parseHTML() {
295
+ return [{
296
+ // acima do PocInlineStyle (40): é nó, não span estilizado
297
+ tag: "span[data-minuta-var]",
298
+ priority: 115,
299
+ getAttrs: (el) => {
300
+ const key = el.getAttribute("data-minuta-var")?.trim();
301
+ if (!key)
302
+ return false;
303
+ return {
304
+ varKey: key,
305
+ label: el.getAttribute("data-minuta-var-label")?.trim() || el.textContent?.trim() || key
306
+ };
307
+ }
308
+ }];
309
+ },
310
+ renderHTML({ node }) {
311
+ const key = node.attrs.varKey ?? "";
312
+ const label = node.attrs.label || key;
313
+ return ["span", {
314
+ "data-minuta-var": key,
315
+ "data-minuta-var-label": label,
316
+ "class": "poc-tplvar"
317
+ }, label];
318
+ },
319
+ addNodeView() {
320
+ return ({ node }) => {
321
+ const key = node.attrs.varKey ?? "";
322
+ const catalog = readCatalog(this.options.catalog);
323
+ const def = catalog.find((item) => item.key === key);
324
+ const dom = document.createElement("span");
325
+ dom.className = "poc-tplvar";
326
+ dom.contentEditable = "false";
327
+ dom.setAttribute("data-minuta-var", key);
328
+ dom.textContent = def?.label || node.attrs.label || key;
329
+ dom.title = def?.description ? `${key} \u2014 ${def.description}` : key;
330
+ if (catalog.length && !def)
331
+ dom.classList.add("poc-tplvar--unknown");
332
+ return { dom };
333
+ };
334
+ },
335
+ addCommands() {
336
+ return {
337
+ insertTemplateVariable: (variable) => ({ commands }) => commands.insertContent({
338
+ type: "templateVariable",
339
+ attrs: { varKey: variable.key, label: variable.label }
340
+ })
341
+ };
342
+ }
343
+ });
275
344
  export const PocFootnoteRef = TiptapNode.create({
276
345
  name: "footnoteRef",
277
346
  inline: true,
@@ -391,7 +460,7 @@ export const PocInlineStyle = TiptapMark.create({
391
460
  tag: "span",
392
461
  priority: 40,
393
462
  getAttrs: (el) => {
394
- if (el.hasAttribute("data-variable-id"))
463
+ if (el.hasAttribute("data-variable-id") || el.hasAttribute("data-minuta-var"))
395
464
  return false;
396
465
  const style = el.getAttribute("style");
397
466
  const cls = el.getAttribute("class");
@@ -436,7 +505,7 @@ export const PocStylePassthrough = TiptapExtension.create({
436
505
  }];
437
506
  }
438
507
  });
439
- export function buildPocExtensions(pageConfig) {
508
+ export function buildPocExtensions(pageConfig, options) {
440
509
  const extensions = [
441
510
  PocDocument,
442
511
  TiptapStarterKit.configure({ document: false }),
@@ -448,12 +517,25 @@ export function buildPocExtensions(pageConfig) {
448
517
  PocHtmlIsland,
449
518
  PocMissingImage,
450
519
  PocVariableChip,
520
+ PocTemplateVariable.configure({ catalog: options?.variables ?? [] }),
451
521
  PocFootnoteRef,
452
522
  PocStyledDiv,
453
523
  PocInlineStyle,
454
524
  PocUnderline,
455
525
  PocStylePassthrough
456
526
  ];
527
+ const handleKeyDown = options?.handleKeyDown;
528
+ if (handleKeyDown) {
529
+ extensions.push(TiptapExtension.create({
530
+ name: "pocKeyDownFirst",
531
+ // acima do StarterKit (100): o ExtensionManager ordena os plugins
532
+ // por prioridade, e é essa ordem que o someProp percorre
533
+ priority: 1e3,
534
+ addProseMirrorPlugins() {
535
+ return [new PMPlugin({ props: { handleKeyDown } })];
536
+ }
537
+ }));
538
+ }
457
539
  if (pageConfig) {
458
540
  extensions.push(TiptapExtension.create({
459
541
  name: "pocPagination",
@@ -54,11 +54,24 @@ export interface PocPageNote {
54
54
  text: string;
55
55
  /** corpo declarado no template, em pontos (default: 8pt, como o Word) */
56
56
  size?: number | null;
57
+ /**
58
+ * posição do MARCADOR no documento. Só é usada para repartir as notas
59
+ * quando a quebra cai no meio do parágrafo: a nota mora na página da
60
+ * chamada, então as que ficam depois do corte viajam para a página nova.
61
+ */
62
+ at?: number;
57
63
  }
58
64
  export interface PocPageBreak {
59
65
  pos: number;
60
66
  filler: number;
61
67
  pageNo: number;
68
+ /**
69
+ * quebra no MEIO de um parágrafo (entre linhas). O DOCX não recebe
70
+ * pageBreakBefore nessas: partir o parágrafo em dois no Word entregaria
71
+ * ao advogado um arquivo que não reflui quando ele edita o texto. Aqui o
72
+ * Word quebra naturalmente, e o preview só antecipa onde.
73
+ */
74
+ soft?: boolean;
62
75
  /** notas cujos marcadores estão nesta página — desenhadas acima do rodapé */
63
76
  notes?: PocPageNote[];
64
77
  /** altura reservada pelas notas (já descontada do filler) */