@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.
@@ -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;list-style-type:disc;margin-bottom:16px;margin-left:48px}: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-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}
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>