@meistrari/minuta-nuxt 0.1.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/MinutaAnnotationsGutter.vue +39 -11
- package/dist/runtime/components/MinutaDocumentViewer.vue +105 -47
- package/dist/runtime/components/MinutaDocxEditor.vue +211 -15
- package/dist/runtime/components/MinutaEmbed.vue +122 -22
- package/dist/runtime/components/MinutaIcon.vue +2 -0
- package/dist/runtime/components/MinutaTemplateEditor.vue +225 -0
- package/dist/runtime/components/MinutaVariablePicker.vue +149 -0
- package/dist/runtime/composables/annotationAnchors.d.ts +0 -1
- package/dist/runtime/composables/annotationAnchors.js +10 -3
- package/dist/runtime/composables/docxExport.js +14 -2
- package/dist/runtime/composables/editorExtensions.d.ts +38 -1
- package/dist/runtime/composables/editorExtensions.js +84 -2
- package/dist/runtime/composables/paginationPlugin.d.ts +13 -0
- package/dist/runtime/composables/paginationPlugin.js +88 -4
- package/dist/runtime/composables/useMinutaApi.d.ts +11 -2
- package/dist/runtime/composables/useMinutaApi.js +6 -1
- package/dist/runtime/utils/templateVariables.d.ts +49 -0
- package/dist/runtime/utils/templateVariables.js +36 -0
- package/package.json +97 -96
|
@@ -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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
|
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) */
|