@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
|
@@ -5,6 +5,8 @@ import { sanitizeRichHtml } from '../utils/sanitizeRichHtml'
|
|
|
5
5
|
import { vaultImageHtml } from '../composables/vaultImages'
|
|
6
6
|
import { sanitizeFootnoteHtml } from '../utils/footnoteHtml'
|
|
7
7
|
import MinutaIcon from './MinutaIcon.vue'
|
|
8
|
+
import MinutaVariablePicker from './MinutaVariablePicker.vue'
|
|
9
|
+
import type { MinutaVariableDef } from '../utils/templateVariables'
|
|
8
10
|
import type { Editor } from '@tiptap/core'
|
|
9
11
|
import type { PocPageConfig, PocPageNote } from '../composables/paginationPlugin'
|
|
10
12
|
import { POC_ASSET_SWAP, buildPocExtensions, renumberPocFootnotes } from '../composables/editorExtensions'
|
|
@@ -29,6 +31,13 @@ const props = defineProps<{
|
|
|
29
31
|
* falha), a imagem vira data-URI embutida no documento.
|
|
30
32
|
*/
|
|
31
33
|
uploadImage?: ((file: File) => Promise<{ src: string, vaultSrc: string } | null>) | null
|
|
34
|
+
/**
|
|
35
|
+
* Catálogo de variáveis do app hospedeiro. Com ele o editor vira editor de
|
|
36
|
+
* TEMPLATE: `/` e o botão da toolbar inserem placeholders. Vazio (o padrão),
|
|
37
|
+
* nada muda — placeholders já existentes no documento continuam sendo
|
|
38
|
+
* renderizados, só não há como inserir novos.
|
|
39
|
+
*/
|
|
40
|
+
variables?: MinutaVariableDef[]
|
|
32
41
|
}>()
|
|
33
42
|
|
|
34
43
|
const emit = defineEmits<{
|
|
@@ -141,7 +150,10 @@ onMounted(() => {
|
|
|
141
150
|
element: editorHost.value!,
|
|
142
151
|
content: props.initialHtml,
|
|
143
152
|
editable: props.editable,
|
|
144
|
-
extensions: buildPocExtensions(pageConfig
|
|
153
|
+
extensions: buildPocExtensions(pageConfig, {
|
|
154
|
+
variables: () => props.variables ?? [],
|
|
155
|
+
handleKeyDown: onEditorKeyDown,
|
|
156
|
+
}),
|
|
145
157
|
onCreate: ({ editor }) => {
|
|
146
158
|
emit('ready', editor as Editor)
|
|
147
159
|
nextTick(() => {
|
|
@@ -153,6 +165,7 @@ onMounted(() => {
|
|
|
153
165
|
},
|
|
154
166
|
onTransaction: ({ transaction }) => {
|
|
155
167
|
uiTick.value++
|
|
168
|
+
syncVariableTrigger(transaction.docChanged)
|
|
156
169
|
if (transaction.docChanged) {
|
|
157
170
|
// troca de imagem fria (load progressivo) não é edição do
|
|
158
171
|
// advogado: repagina, mas não marca o doc como sujo — senão
|
|
@@ -658,6 +671,177 @@ function clearColor() {
|
|
|
658
671
|
editor.value?.chain().focus().unsetMark('inlineStyle').run()
|
|
659
672
|
}
|
|
660
673
|
|
|
674
|
+
// --- variáveis de template: gatilho `/`, botão da toolbar e seletor ---
|
|
675
|
+
|
|
676
|
+
const hasVariables = computed(() => (props.variables?.length ?? 0) > 0)
|
|
677
|
+
const variablePickerRef = ref<any>(null)
|
|
678
|
+
/**
|
|
679
|
+
* `from` = posição do `/` que abriu o seletor (apagado na inserção); `null`
|
|
680
|
+
* quando veio do botão da toolbar, que não digitou nada no documento.
|
|
681
|
+
* `query === undefined` faz o popover usar o próprio campo de busca.
|
|
682
|
+
*/
|
|
683
|
+
const variablePicker = ref<{ top: number, left: number, from: number | null, query: string | undefined } | null>(null)
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Coordenada do documento → coordenada dentro da folha.
|
|
687
|
+
*
|
|
688
|
+
* A folha tem `zoom` CSS: `coordsAtPos` devolve px de TELA e o `top`/`left` do
|
|
689
|
+
* popover é interpretado em px de LAYOUT lá dentro. Sem dividir pelo zoom o
|
|
690
|
+
* seletor desgruda do cursor em qualquer zoom diferente de 100%.
|
|
691
|
+
*/
|
|
692
|
+
function sheetCoords(pos: number): { top: number, left: number } | null {
|
|
693
|
+
const ed = editor.value
|
|
694
|
+
const sheet = sheetRef.value
|
|
695
|
+
if (!ed || ed.isDestroyed || !sheet)
|
|
696
|
+
return null
|
|
697
|
+
let coords
|
|
698
|
+
try {
|
|
699
|
+
coords = ed.view.coordsAtPos(pos)
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
return null
|
|
703
|
+
}
|
|
704
|
+
const rect = sheet.getBoundingClientRect()
|
|
705
|
+
const z = zoom.value || 1
|
|
706
|
+
return {
|
|
707
|
+
top: (coords.bottom - rect.top) / z + 6,
|
|
708
|
+
left: Math.max(8, Math.min((coords.left - rect.left) / z, A4_WIDTH_PX - 330)),
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* `/` no início de palavra abre o seletor; o que vem depois filtra.
|
|
714
|
+
*
|
|
715
|
+
* Exige delimitador à esquerda para não disparar dentro de uma data ("12/03")
|
|
716
|
+
* nem de uma referência de artigo ("art. 5º, II/CF") — onde a barra é conteúdo,
|
|
717
|
+
* não comando.
|
|
718
|
+
*/
|
|
719
|
+
const VARIABLE_TRIGGER_RE = /(?:^|[\s(\[«"'])\/([\p{L}\d_]*)$/u
|
|
720
|
+
/** acima disto não é mais busca, é texto — o gatilho desiste */
|
|
721
|
+
const VARIABLE_TRIGGER_MAX_QUERY = 24
|
|
722
|
+
|
|
723
|
+
function closeVariablePicker() {
|
|
724
|
+
variablePicker.value = null
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Reavalia o gatilho a cada transação. Só ABRE quando o documento mudou (foi
|
|
729
|
+
* alguém digitando); mover o cursor para perto de uma barra que já estava no
|
|
730
|
+
* texto não abre nada, mas fecha o que estiver aberto.
|
|
731
|
+
*/
|
|
732
|
+
function syncVariableTrigger(docChanged: boolean) {
|
|
733
|
+
const ed = editor.value
|
|
734
|
+
if (!ed || ed.isDestroyed || !hasVariables.value || !props.editable) {
|
|
735
|
+
if (variablePicker.value)
|
|
736
|
+
closeVariablePicker()
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
// seletor aberto pela toolbar não tem gatilho no texto para reavaliar
|
|
740
|
+
if (variablePicker.value && variablePicker.value.from === null)
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
const { from, empty } = ed.state.selection
|
|
744
|
+
if (!empty) {
|
|
745
|
+
closeVariablePicker()
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const blockStart = ed.state.doc.resolve(from).start()
|
|
750
|
+
// '\ufffc' (object replacement) para nós atômicos: um chip antes do cursor
|
|
751
|
+
// conta como 1 caractere e não some do texto, senão o `^` casaria errado
|
|
752
|
+
const textBefore = ed.state.doc.textBetween(blockStart, from, '\n', '\uFFFC')
|
|
753
|
+
const match = textBefore.match(VARIABLE_TRIGGER_RE)
|
|
754
|
+
|
|
755
|
+
if (!match || (match[1]?.length ?? 0) > VARIABLE_TRIGGER_MAX_QUERY) {
|
|
756
|
+
closeVariablePicker()
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
if (!variablePicker.value && !docChanged)
|
|
760
|
+
return
|
|
761
|
+
|
|
762
|
+
const slashPos = from - match[0].length + match[0].indexOf('/')
|
|
763
|
+
const coords = sheetCoords(slashPos)
|
|
764
|
+
if (!coords) {
|
|
765
|
+
closeVariablePicker()
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
variablePicker.value = { ...coords, from: slashPos, query: match[1] ?? '' }
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Botão da toolbar: mesmo seletor, ancorado no cursor, com campo de busca
|
|
773
|
+
* próprio.
|
|
774
|
+
*
|
|
775
|
+
* NÃO devolve o foco ao editor aqui. O `focus()` do Tiptap é adiado por um
|
|
776
|
+
* `requestAnimationFrame`, então ele chegava DEPOIS do popover montar e roubava
|
|
777
|
+
* o foco do campo de busca — o que a pessoa digitava virava texto do documento
|
|
778
|
+
* e o Enter inseria o primeiro item de uma lista que nunca chegou a ser
|
|
779
|
+
* filtrada. A posição do cursor não depende do foco: `state.selection`
|
|
780
|
+
* continua onde estava (ou no começo, se ninguém nunca clicou no documento).
|
|
781
|
+
*/
|
|
782
|
+
function openVariablePicker() {
|
|
783
|
+
const ed = editor.value
|
|
784
|
+
if (!ed || ed.isDestroyed || !hasVariables.value)
|
|
785
|
+
return
|
|
786
|
+
const coords = sheetCoords(ed.state.selection.from)
|
|
787
|
+
if (!coords)
|
|
788
|
+
return
|
|
789
|
+
variablePicker.value = { ...coords, from: null, query: undefined }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function insertVariable(variable: MinutaVariableDef) {
|
|
793
|
+
const ed = editor.value
|
|
794
|
+
if (!ed || ed.isDestroyed)
|
|
795
|
+
return
|
|
796
|
+
const trigger = variablePicker.value
|
|
797
|
+
const chain = ed.chain().focus()
|
|
798
|
+
// some com o `/consulta` que abriu o seletor — o placeholder toma o lugar dele
|
|
799
|
+
if (trigger?.from != null)
|
|
800
|
+
chain.deleteRange({ from: trigger.from, to: ed.state.selection.from })
|
|
801
|
+
chain.insertContent({
|
|
802
|
+
type: 'templateVariable',
|
|
803
|
+
attrs: { varKey: variable.key, label: variable.label },
|
|
804
|
+
}).run()
|
|
805
|
+
closeVariablePicker()
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Com o seletor aberto o foco continua NO EDITOR (o usuário está digitando o
|
|
810
|
+
* texto do filtro), então as setas e o Enter têm que ser interceptados aqui —
|
|
811
|
+
* senão andam o cursor e quebram o parágrafo.
|
|
812
|
+
*/
|
|
813
|
+
function onEditorKeyDown(_view: unknown, event: KeyboardEvent): boolean {
|
|
814
|
+
const picker = variablePickerRef.value
|
|
815
|
+
if (!variablePicker.value || !picker)
|
|
816
|
+
return false
|
|
817
|
+
if (event.key === 'ArrowDown') {
|
|
818
|
+
picker.move(1)
|
|
819
|
+
return true
|
|
820
|
+
}
|
|
821
|
+
if (event.key === 'ArrowUp') {
|
|
822
|
+
picker.move(-1)
|
|
823
|
+
return true
|
|
824
|
+
}
|
|
825
|
+
if (event.key === 'Escape') {
|
|
826
|
+
closeVariablePicker()
|
|
827
|
+
return true
|
|
828
|
+
}
|
|
829
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
830
|
+
// sem resultado o Enter é Enter: quebrar o parágrafo é o que a pessoa quis
|
|
831
|
+
if (!picker.hasResults())
|
|
832
|
+
return false
|
|
833
|
+
picker.choose()
|
|
834
|
+
return true
|
|
835
|
+
}
|
|
836
|
+
return false
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// catálogo esvaziado (ou editor virou read-only) com o seletor aberto
|
|
840
|
+
watch(() => [hasVariables.value, props.editable], () => {
|
|
841
|
+
if (variablePicker.value && (!hasVariables.value || !props.editable))
|
|
842
|
+
closeVariablePicker()
|
|
843
|
+
})
|
|
844
|
+
|
|
661
845
|
defineExpose({ editor, schedulePaginate, refreshChrome, containerRef })
|
|
662
846
|
</script>
|
|
663
847
|
|
|
@@ -759,6 +943,15 @@ defineExpose({ editor, schedulePaginate, refreshChrome, containerRef })
|
|
|
759
943
|
<MinutaIcon name="i-ph-image" />
|
|
760
944
|
</button>
|
|
761
945
|
<input ref="imageInput" type="file" accept="image/*" class="hidden" @change="onImageFile">
|
|
946
|
+
<button
|
|
947
|
+
v-if="hasVariables"
|
|
948
|
+
class="poc-tool"
|
|
949
|
+
:class="{ 'poc-tool--on': !!variablePicker }"
|
|
950
|
+
title="Inserir variável (ou digite / no texto)"
|
|
951
|
+
@click="openVariablePicker"
|
|
952
|
+
>
|
|
953
|
+
<MinutaIcon name="i-ph-brackets-curly" />
|
|
954
|
+
</button>
|
|
762
955
|
<button class="poc-tool" title="Inserir nota de rodapé" @click="insertFootnote">
|
|
763
956
|
<MinutaIcon name="i-ph-note" />
|
|
764
957
|
</button>
|
|
@@ -770,7 +963,7 @@ defineExpose({ editor, schedulePaginate, refreshChrome, containerRef })
|
|
|
770
963
|
</button>
|
|
771
964
|
</div>
|
|
772
965
|
|
|
773
|
-
<div ref="containerRef" class="relative min-h-0 w-full flex-1 overflow-y-auto bg-gray-120 py-24px" @click="openPalette = null">
|
|
966
|
+
<div ref="containerRef" class="relative min-h-0 w-full flex-1 overflow-y-auto bg-gray-120 py-24px" @click="openPalette = null; closeVariablePicker()">
|
|
774
967
|
<div ref="sheetRef" class="poc-sheet relative mx-auto bg-white shadow-md" :style="sheetStyle" @click="onNotesClick">
|
|
775
968
|
<!-- cabeçalho da 1ª página (na margem superior da folha) -->
|
|
776
969
|
<div class="poc-first-header" :style="firstHeaderStyle" title="Clique para editar o cabeçalho" @click="editChrome('header')">
|
|
@@ -817,6 +1010,17 @@ defineExpose({ editor, schedulePaginate, refreshChrome, containerRef })
|
|
|
817
1010
|
<div v-else class="poc-chrome-text" />
|
|
818
1011
|
<span v-if="chromeText.showPageNumber !== false" class="ml-auto">Página {{ totalPages }} de {{ totalPages }}</span>
|
|
819
1012
|
</div>
|
|
1013
|
+
<MinutaVariablePicker
|
|
1014
|
+
v-if="variablePicker"
|
|
1015
|
+
ref="variablePickerRef"
|
|
1016
|
+
:variables="props.variables ?? []"
|
|
1017
|
+
:top="variablePicker.top"
|
|
1018
|
+
:left="variablePicker.left"
|
|
1019
|
+
:query="variablePicker.query"
|
|
1020
|
+
@select="insertVariable"
|
|
1021
|
+
@close="closeVariablePicker"
|
|
1022
|
+
/>
|
|
1023
|
+
|
|
820
1024
|
<slot name="gutter" />
|
|
821
1025
|
|
|
822
1026
|
<!-- popover de edição da nota de rodapé (ancorado na chamada) -->
|
|
@@ -933,7 +1137,7 @@ defineExpose({ editor, schedulePaginate, refreshChrome, containerRef })
|
|
|
933
1137
|
</template>
|
|
934
1138
|
|
|
935
1139
|
<style scoped>
|
|
936
|
-
:deep(.poc-ins){background:rgba(34,197,94,.16);text-decoration:underline;text-decoration-color:#16a34a}:deep(.poc-del){background:rgba(239,68,68,.12);color:#b91c1c;text-decoration:line-through}.poc-bubble-btn{border-radius:6px;font-size:13px;padding:4px 8px;transition:all .15s;transition-timing-function:ease}.poc-toolbar{background:#fafbfc;border-bottom:.5px solid #dee3e7;flex-wrap:wrap;gap:2px;padding:4px 12px}.poc-tool,.poc-toolbar{align-items:center;display:flex}.poc-tool{border-radius:6px;font-size:13px;height:28px;justify-content:center;min-width:28px;padding-left:6px;padding-right:6px;position:relative;transition:all .15s;transition-timing-function:ease}.poc-tool-sep{background:#dee3e7;height:20px;margin:0 6px;width:1px}.poc-tool-select{background:#fff;border-radius:6px;font-size:12px;height:28px;padding-left:6px;padding-right:6px}.poc-color-underline{background:#dc2626;border-radius:1px;bottom:4px;height:3px;left:7px;position:absolute;right:7px}.poc-palette{background:#fff;border:.5px solid #dee3e7;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-wrap:wrap;gap:2px;left:0;padding:8px;position:absolute;top:32px;width:230px;z-index:50}.poc-palette-swatch{border:1px solid rgba(0,0,0,.1);border-radius:3px;cursor:pointer;height:19px;width:19px}.poc-palette-swatch:hover{transform:scale(1.2)}.poc-palette-custom{align-items:center;color:#565d69;cursor:pointer;display:flex;font-size:12px;margin-top:6px;width:100%}.poc-color-swatch{border:1px solid rgba(0,0,0,.12);border-radius:4px;cursor:pointer;height:16px;margin:0 2px;width:16px}.poc-color-swatch:hover{transform:scale(1.15)}.poc-first-header{align-items:center;cursor:pointer;display:flex;gap:12px;position:absolute;top:0}.poc-first-header[style*=center]{justify-content:center}.poc-first-header[style*=right]{justify-content:flex-end}.poc-first-header:hover,.poc-last-footer:hover,:deep(.poc-page-footerarea:hover),:deep(.poc-page-headerarea:hover){background:rgba(99,102,241,.06);outline:1px dashed #c7d2fe}.poc-last-footer{align-items:center;bottom:0;color:#94a3b8;cursor:pointer;display:flex;font-size:11px;justify-content:space-between;position:absolute}.poc-chrome-text,:deep(.poc-chrome-text){color:#64748b;font-size:11px}.poc-chrome-text :deep(img),:deep(.poc-chrome-text img){display:inline-block;max-height:48px;max-width:200px;vertical-align:middle}:deep(.poc-pagebreak-marker){border-top:1px dashed #a5b4fc;font-size:0!important;height:16px;line-height:0!important;margin:6px 0!important;position:relative}:deep(.poc-pagebreak-marker):after{background:#fff;color:#818cf8;content:"quebra de página do template";font-size:10px;left:50%;line-height:16px;padding:0 8px;position:absolute;top:-8px;transform:translateX(-50%)}:deep(.poc-page-chrome){pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}:deep(.poc-page-footerarea),:deep(.poc-page-headerarea){cursor:pointer;pointer-events:auto}:deep(.poc-page-footerarea){align-items:center;color:#94a3b8;display:flex;font-size:11px;justify-content:space-between}:deep(.poc-page-gapband){background:#eceef1;box-shadow:inset 0 6px 6px -6px rgba(0,0,0,.25),inset 0 -6px 6px -6px rgba(0,0,0,.25)}:deep(.poc-page-headerarea){align-items:center;display:flex}:deep(.poc-page-headerarea[style*=center]){justify-content:center}:deep(.poc-page-headerarea[style*=right]){justify-content:flex-end}:deep(.ProseMirror){outline:none;white-space:pre-wrap;word-wrap:break-word}:deep(h1){color:#0f172a;font-size:16px;font-weight:540;letter-spacing:-.2px;line-height:20px;margin-bottom:20px}:deep(h2){color:#0f172a;font-size:14px;font-size:24px;font-weight:540;letter-spacing:-.15px;line-height:16px;margin-bottom:20px}:deep(h3){color:#0f172a;font-size:12px;font-size:20px;font-weight:540;letter-spacing:-.1px;line-height:14px;margin-bottom:20px}:deep(strong){font-weight:540}:deep(p){color:#000;font-size:18px;font-weight:400;letter-spacing:-.4px;line-height:28px;line-height:1.85em;margin-bottom:2em}:deep(blockquote){font-size:18px;font-weight:400;letter-spacing:-.4px;line-height:28px;margin-bottom:2em;margin-left:35%}:deep(ol),:deep(ul){margin-bottom:2em}:deep(li){list-style-position:inside;
|
|
1140
|
+
:deep(.poc-ins){background:rgba(34,197,94,.16);text-decoration:underline;text-decoration-color:#16a34a}:deep(.poc-del){background:rgba(239,68,68,.12);color:#b91c1c;text-decoration:line-through}.poc-bubble-btn{border-radius:6px;font-size:13px;padding:4px 8px;transition:all .15s;transition-timing-function:ease}.poc-toolbar{background:#fafbfc;border-bottom:.5px solid #dee3e7;flex-wrap:wrap;gap:2px;padding:4px 12px}.poc-tool,.poc-toolbar{align-items:center;display:flex}.poc-tool{border-radius:6px;font-size:13px;height:28px;justify-content:center;min-width:28px;padding-left:6px;padding-right:6px;position:relative;transition:all .15s;transition-timing-function:ease}.poc-tool-sep{background:#dee3e7;height:20px;margin:0 6px;width:1px}.poc-tool-select{background:#fff;border-radius:6px;font-size:12px;height:28px;padding-left:6px;padding-right:6px}.poc-color-underline{background:#dc2626;border-radius:1px;bottom:4px;height:3px;left:7px;position:absolute;right:7px}.poc-palette{background:#fff;border:.5px solid #dee3e7;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-wrap:wrap;gap:2px;left:0;padding:8px;position:absolute;top:32px;width:230px;z-index:50}.poc-palette-swatch{border:1px solid rgba(0,0,0,.1);border-radius:3px;cursor:pointer;height:19px;width:19px}.poc-palette-swatch:hover{transform:scale(1.2)}.poc-palette-custom{align-items:center;color:#565d69;cursor:pointer;display:flex;font-size:12px;margin-top:6px;width:100%}.poc-color-swatch{border:1px solid rgba(0,0,0,.12);border-radius:4px;cursor:pointer;height:16px;margin:0 2px;width:16px}.poc-color-swatch:hover{transform:scale(1.15)}.poc-first-header{align-items:center;cursor:pointer;display:flex;gap:12px;position:absolute;top:0}.poc-first-header[style*=center]{justify-content:center}.poc-first-header[style*=right]{justify-content:flex-end}.poc-first-header:hover,.poc-last-footer:hover,:deep(.poc-page-footerarea:hover),:deep(.poc-page-headerarea:hover){background:rgba(99,102,241,.06);outline:1px dashed #c7d2fe}.poc-last-footer{align-items:center;bottom:0;color:#94a3b8;cursor:pointer;display:flex;font-size:11px;justify-content:space-between;position:absolute}.poc-chrome-text,:deep(.poc-chrome-text){color:#64748b;font-size:11px}.poc-chrome-text :deep(img),:deep(.poc-chrome-text img){display:inline-block;max-height:48px;max-width:200px;vertical-align:middle}:deep(.poc-pagebreak-marker){border-top:1px dashed #a5b4fc;font-size:0!important;height:16px;line-height:0!important;margin:6px 0!important;position:relative}:deep(.poc-pagebreak-marker):after{background:#fff;color:#818cf8;content:"quebra de página do template";font-size:10px;left:50%;line-height:16px;padding:0 8px;position:absolute;top:-8px;transform:translateX(-50%)}:deep(.poc-page-chrome){pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}:deep(.poc-page-footerarea),:deep(.poc-page-headerarea){cursor:pointer;pointer-events:auto}:deep(.poc-page-footerarea){align-items:center;color:#94a3b8;display:flex;font-size:11px;justify-content:space-between}:deep(.poc-page-gapband){background:#eceef1;box-shadow:inset 0 6px 6px -6px rgba(0,0,0,.25),inset 0 -6px 6px -6px rgba(0,0,0,.25)}:deep(.poc-page-headerarea){align-items:center;display:flex}:deep(.poc-page-headerarea[style*=center]){justify-content:center}:deep(.poc-page-headerarea[style*=right]){justify-content:flex-end}:deep(.ProseMirror){outline:none;white-space:pre-wrap;word-wrap:break-word}:deep(h1){color:#0f172a;font-size:16px;font-weight:540;letter-spacing:-.2px;line-height:20px;margin-bottom:20px}:deep(h2){color:#0f172a;font-size:14px;font-size:24px;font-weight:540;letter-spacing:-.15px;line-height:16px;margin-bottom:20px}:deep(h3){color:#0f172a;font-size:12px;font-size:20px;font-weight:540;letter-spacing:-.1px;line-height:14px;margin-bottom:20px}:deep(strong){font-weight:540}:deep(p){color:#000;font-size:18px;font-weight:400;letter-spacing:-.4px;line-height:28px;line-height:1.85em;margin-bottom:2em;text-align:justify}:deep(blockquote){font-size:18px;font-weight:400;letter-spacing:-.4px;line-height:28px;margin-bottom:2em;margin-left:35%}:deep(ol),:deep(ul){margin-bottom:2em}:deep(li){list-style-position:inside;margin-bottom:16px;margin-left:48px}:deep(ul)>li{list-style-type:disc}:deep(ol)>li{list-style-type:decimal}:deep(table){border:.5px solid #d4d9df;max-width:100%;table-layout:fixed;width:100%}:deep(th){color:inherit;font-size:inherit;font-weight:700;overflow-wrap:anywhere;padding:12px;text-align:left}:deep(td){color:inherit;font-size:inherit;line-height:inherit;overflow-wrap:anywhere;padding:12px;text-align:left}:deep(td p),:deep(th p){color:inherit;font-size:inherit;line-height:inherit;margin-bottom:0}:deep(img){display:block;max-width:100%}:deep(.poc-island--img>img),:deep(.poc-island>img:only-child){margin-left:auto;margin-right:auto}:deep(.poc-chrome-canvas){overflow:hidden}:deep(.poc-chrome-canvas [data-el]){position:absolute}:deep(.poc-chrome-canvas [data-el=text]){color:#64748b;font-size:11px;line-height:1.4;text-align:left;white-space:pre-wrap}:deep(img[data-vault-src]){animation:poc-img-loading 1.4s ease-in-out infinite}@keyframes poc-img-loading{0%,to{opacity:.5}50%{opacity:.9}}:deep(.poc-lone-row){justify-content:center!important}:deep(.poc-lone-row>:not(.poc-lone-cell)){flex:0 0 0!important;min-width:0!important;padding:0!important}:deep(.poc-lone-row>.poc-lone-cell){align-items:center!important;flex:0 0 auto!important;max-width:100%;min-width:0}:deep([data-horizontal-align=left] .poc-island--img>img),:deep([data-horizontal-align=left] .poc-island>img:only-child){margin-left:0}:deep([data-horizontal-align=right] .poc-island--img>img),:deep([data-horizontal-align=right] .poc-island>img:only-child){margin-right:0}.poc-review-cursor :deep(.poc-minuta-block){cursor:pointer}:deep(.poc-minuta-block){border-radius:4px;position:relative}:deep(.poc-minuta-block:hover){outline:1px dashed #c7d2fe;outline-offset:6px}.poc-no-block-origin :deep(.poc-minuta-block:hover){outline:none}.poc-no-block-origin :deep(.poc-minuta-block:hover):before{content:none}:deep(.poc-minuta-block:hover):before{background:#eef2ff;border:1px solid #c7d2fe;border-radius:4px;color:#6366f1;content:attr(data-block-title);font-size:10px;letter-spacing:.04em;padding:1px 8px;pointer-events:none;position:absolute;right:0;text-transform:uppercase;top:-22px;z-index:3}:deep(.poc-varchip){background:rgba(59,130,246,.06);border-radius:3px;box-shadow:inset 0 0 0 1px rgba(59,130,246,.25);cursor:pointer;transition:background .15s ease}:deep(.poc-varchip:hover){background:rgba(59,130,246,.16)}:deep(.poc-tplvar){background:rgba(251,191,36,.16);border-radius:3px;box-shadow:inset 0 0 0 1px rgba(180,83,9,.35);color:#78350f;display:inline;padding:0 2px;white-space:pre-wrap}:deep(.poc-tplvar--unknown){background:rgba(244,63,94,.12);box-shadow:inset 0 0 0 1px rgba(190,18,60,.45);color:#881337;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}:deep(.ProseMirror-selectednode.poc-tplvar){box-shadow:inset 0 0 0 2px rgba(180,83,9,.75)}:deep(.poc-missing-image .missing-image-warning:hover){background-color:rgba(254,174,0,.09);border-color:rgba(223,139,13,.85)}:deep(.poc-missing-image__remove){background:hsla(0,0%,100%,.75);border:none;border-radius:999px;color:#773613;cursor:pointer;font-size:16px;height:22px;line-height:1;opacity:0;position:absolute;right:4px;top:4px;transition:opacity .15s ease,background .15s ease;width:22px}:deep(.poc-missing-image:hover .poc-missing-image__remove){opacity:1}:deep(.poc-missing-image__remove:hover){background:rgba(254,174,0,.35)}:deep(.poc-missing-image.ProseMirror-selectednode .missing-image-warning){outline:2px solid rgba(223,139,13,.9);outline-offset:2px}.poc-page-notes,:deep(.poc-page-notes){color:#1c242d;font-size:10.67px;line-height:1.25;padding-top:6px}.poc-page-notes:before,:deep(.poc-page-notes):before{border-top:.5px solid #9aa4ae;content:"";display:block;margin-bottom:6px;width:30%}.poc-page-note,:deep(.poc-page-note){color:#1c242d;font-size:10.67px;font-weight:400;line-height:1.25;margin:0;text-align:justify}.poc-page-note sup,:deep(.poc-page-note sup){font-size:.8em;line-height:0;vertical-align:super}.poc-page-note a,:deep(.poc-page-note a){color:#0563c1;text-decoration:underline}:deep(.poc-footnote){color:#1d4ed8;cursor:help;font-size:.7em;line-height:0;vertical-align:super}:deep(.poc-island){position:relative}:deep(.poc-island--img){cursor:grab}:deep(.poc-img-handle){background:#3b82f6;border:2px solid #fff;border-radius:3px;bottom:-6px;box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:nwse-resize;height:12px;opacity:0;position:absolute;right:-6px;transition:opacity .12s ease;width:12px}:deep(.poc-island--img.ProseMirror-selectednode .poc-img-handle),:deep(.poc-island--img:hover .poc-img-handle){opacity:1}:deep(.poc-island.ProseMirror-selectednode){outline:2px solid #93c5fd;outline-offset:2px}
|
|
937
1141
|
</style>
|
|
938
1142
|
|
|
939
1143
|
<style scoped>
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
PhArrowUUpRight,
|
|
6
6
|
PhArrowsOutLineHorizontal,
|
|
7
7
|
PhArrowsOutLineVertical,
|
|
8
|
+
PhBracketsCurly,
|
|
8
9
|
PhEraser,
|
|
9
10
|
PhHighlighter,
|
|
10
11
|
PhImage,
|
|
@@ -29,6 +30,7 @@ const ICONS: Record<string, unknown> = {
|
|
|
29
30
|
'i-ph-arrow-u-up-right': PhArrowUUpRight,
|
|
30
31
|
'i-ph-arrows-out-line-horizontal': PhArrowsOutLineHorizontal,
|
|
31
32
|
'i-ph-arrows-out-line-vertical': PhArrowsOutLineVertical,
|
|
33
|
+
'i-ph-brackets-curly': PhBracketsCurly,
|
|
32
34
|
'i-ph-eraser': PhEraser,
|
|
33
35
|
'i-ph-highlighter': PhHighlighter,
|
|
34
36
|
'i-ph-image': PhImage,
|
|
@@ -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>
|