@meistrari/minuta-nuxt 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/MinutaAnnotationsGutter.vue +31 -8
- package/dist/runtime/components/MinutaDocumentViewer.vue +187 -23
- package/dist/runtime/components/MinutaDocxEditor.vue +207 -3
- package/dist/runtime/components/MinutaIcon.vue +2 -0
- package/dist/runtime/components/MinutaTemplateEditor.vue +225 -0
- package/dist/runtime/components/MinutaVariablePicker.vue +149 -0
- package/dist/runtime/composables/annotationAnchors.d.ts +0 -1
- package/dist/runtime/composables/annotationAnchors.js +10 -3
- package/dist/runtime/composables/docxExport.js +9 -1
- package/dist/runtime/composables/editorExtensions.d.ts +38 -1
- package/dist/runtime/composables/editorExtensions.js +84 -2
- package/dist/runtime/composables/useMinutaApi.d.ts +11 -2
- package/dist/runtime/composables/useMinutaApi.js +6 -1
- package/dist/runtime/utils/templateVariables.d.ts +49 -0
- package/dist/runtime/utils/templateVariables.js +36 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
|
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",
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { extractTemplateVariables, resolveTemplateVariables } from '../utils/templateVariables.js';
|
|
2
|
+
export type { MinutaVariableDef } from '../utils/templateVariables.js';
|
|
3
|
+
export { extractTemplateVariables, resolveTemplateVariables };
|
|
1
4
|
export interface MinutaFile {
|
|
2
5
|
name: string;
|
|
3
6
|
url: string;
|
|
@@ -43,6 +46,8 @@ export interface AnnotationAnchorState {
|
|
|
43
46
|
trecho?: string;
|
|
44
47
|
manual?: boolean;
|
|
45
48
|
trechoOriginal?: string;
|
|
49
|
+
/** sinônimo de `onde` que já apareceu numa geração fora do contrato */
|
|
50
|
+
secao?: string;
|
|
46
51
|
}
|
|
47
52
|
export interface MinutaAnalysis {
|
|
48
53
|
resumo?: string;
|
|
@@ -88,10 +93,14 @@ export declare function useMinutaApi(): {
|
|
|
88
93
|
fetchMinutaById: (id: string) => Promise<Minuta>;
|
|
89
94
|
createMinuta: (input: {
|
|
90
95
|
name: string;
|
|
91
|
-
files
|
|
96
|
+
files?: MinutaFile[];
|
|
92
97
|
context?: string;
|
|
93
98
|
agentId?: string;
|
|
94
|
-
|
|
99
|
+
templateHtml?: string;
|
|
100
|
+
variables?: Record<string, string | null>;
|
|
101
|
+
}) => Promise<Minuta & {
|
|
102
|
+
missingVariables?: string[];
|
|
103
|
+
}>;
|
|
95
104
|
updateMinuta: (id: string, patch: {
|
|
96
105
|
name?: string;
|
|
97
106
|
contentHtml?: string;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { $fetch } from "ofetch";
|
|
2
|
+
import { extractTemplateVariables, resolveTemplateVariables } from "../utils/templateVariables.js";
|
|
3
|
+
export { extractTemplateVariables, resolveTemplateVariables };
|
|
2
4
|
export function useMinutaApi() {
|
|
3
5
|
const base = "/api/minuta";
|
|
4
6
|
function fetchMinutas() {
|
|
@@ -8,7 +10,10 @@ export function useMinutaApi() {
|
|
|
8
10
|
return $fetch(`${base}/${encodeURIComponent(id)}`);
|
|
9
11
|
}
|
|
10
12
|
function createMinuta(input) {
|
|
11
|
-
return $fetch(base, {
|
|
13
|
+
return $fetch(base, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
body: { files: [], ...input }
|
|
16
|
+
});
|
|
12
17
|
}
|
|
13
18
|
function updateMinuta(id, patch) {
|
|
14
19
|
return $fetch(`${base}/${encodeURIComponent(id)}`, { method: "PATCH", body: patch });
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variáveis de template da minuta — o formato do placeholder e a resolução dele.
|
|
3
|
+
*
|
|
4
|
+
* O template é um documento normal do editor; onde entra um valor há um NÓ
|
|
5
|
+
* atômico (`templateVariable`, ver editorExtensions), serializado como
|
|
6
|
+
* `<span data-minuta-var="CHAVE" data-minuta-var-label="Rótulo">Rótulo</span>`.
|
|
7
|
+
*
|
|
8
|
+
* Por que nó e não `#CHAVE` no texto: no editor rico o texto é fatiado em
|
|
9
|
+
* marks. Negritar metade de um `#PROCESSO` produz
|
|
10
|
+
* `<b>#PRO</b>CESSO`, e qualquer substituição textual passa reto — em silêncio,
|
|
11
|
+
* só naquele template, só naquela linha. Como nó atômico o placeholder não tem
|
|
12
|
+
* "metade": seleciona inteiro, apaga inteiro, formata inteiro.
|
|
13
|
+
*
|
|
14
|
+
* ATENÇÃO: este arquivo é DUPLICADO em
|
|
15
|
+
* `packages/server/src/standalone-minuta/template-variables.ts` — o servidor
|
|
16
|
+
* resolve na criação da minuta e não pode importar o pacote publicado no npm.
|
|
17
|
+
* Mudou aqui, muda lá (e vice-versa); há teste de paridade no server.
|
|
18
|
+
*/
|
|
19
|
+
/** Definição de uma variável disponível — vem do app hospedeiro, não do CDB. */
|
|
20
|
+
export type MinutaVariableDef = {
|
|
21
|
+
/** chave usada no documento e nos valores (ex.: `PROCESSO`) */
|
|
22
|
+
key: string;
|
|
23
|
+
/** rótulo exibido no chip e no seletor (ex.: `Nº do Processo`) */
|
|
24
|
+
label: string;
|
|
25
|
+
/** de onde o valor sai — vira o tooltip do chip */
|
|
26
|
+
description?: string;
|
|
27
|
+
/** agrupador opcional no seletor (ex.: `Processo`, `Réu`) */
|
|
28
|
+
group?: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function escapeHtml(value: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Chaves das variáveis usadas no documento, na ordem em que aparecem e sem
|
|
33
|
+
* repetição. É o que substitui o `extractPeticaoVariaveisFromTexto` de quem
|
|
34
|
+
* hospeda: o host guarda esta lista junto com o template e não precisa ler HTML.
|
|
35
|
+
*/
|
|
36
|
+
export declare function extractTemplateVariables(html: string | null | undefined): string[];
|
|
37
|
+
/**
|
|
38
|
+
* Troca os placeholders pelos valores. O que NÃO tem valor continua sendo um
|
|
39
|
+
* placeholder vivo no documento — não vira texto vazio nem some.
|
|
40
|
+
*
|
|
41
|
+
* É de propósito: uma variável sem valor é um buraco na peça, e o lugar de
|
|
42
|
+
* mostrar isso é o próprio parágrafo em que ela está, não um botão desabilitado
|
|
43
|
+
* no rodapé do modal. Como o nó sobrevive, `extractTemplateVariables` na minuta
|
|
44
|
+
* gerada responde "o que ainda falta", e mandar os valores de novo resolve.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveTemplateVariables(html: string | null | undefined, values: Record<string, string | null | undefined> | null | undefined): {
|
|
47
|
+
html: string;
|
|
48
|
+
missing: string[];
|
|
49
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const VARIABLE_TAG_RE = /<span\b[^>]*\bdata-minuta-var="([^"]*)"[^>]*>[^<]*<\/span>/g;
|
|
2
|
+
export function escapeHtml(value) {
|
|
3
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
4
|
+
}
|
|
5
|
+
function decodeAttr(value) {
|
|
6
|
+
return value.replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
|
|
7
|
+
}
|
|
8
|
+
export function extractTemplateVariables(html) {
|
|
9
|
+
if (!html)
|
|
10
|
+
return [];
|
|
11
|
+
const keys = [];
|
|
12
|
+
for (const match of html.matchAll(VARIABLE_TAG_RE)) {
|
|
13
|
+
const key = decodeAttr(match[1] ?? "").trim();
|
|
14
|
+
if (key && !keys.includes(key))
|
|
15
|
+
keys.push(key);
|
|
16
|
+
}
|
|
17
|
+
return keys;
|
|
18
|
+
}
|
|
19
|
+
export function resolveTemplateVariables(html, values) {
|
|
20
|
+
if (!html)
|
|
21
|
+
return { html: "", missing: [] };
|
|
22
|
+
const missing = [];
|
|
23
|
+
const resolved = html.replace(VARIABLE_TAG_RE, (tag, rawKey) => {
|
|
24
|
+
const key = decodeAttr(rawKey ?? "").trim();
|
|
25
|
+
if (!key)
|
|
26
|
+
return tag;
|
|
27
|
+
const value = values?.[key];
|
|
28
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
29
|
+
if (!missing.includes(key))
|
|
30
|
+
missing.push(key);
|
|
31
|
+
return tag;
|
|
32
|
+
}
|
|
33
|
+
return escapeHtml(value).replace(/\r?\n/g, "<br>");
|
|
34
|
+
});
|
|
35
|
+
return { html: resolved, missing };
|
|
36
|
+
}
|
package/package.json
CHANGED