@meistrari/minuta-nuxt 1.1.1 → 1.2.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.
@@ -1,21 +1,19 @@
1
1
  <script setup lang="ts">
2
- import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
3
- import { Editor as TiptapEditor, BubbleMenu as TiptapBubbleMenu } from '@tiptap/vue-3'
4
- import { sanitizeRichHtml } from '../utils/sanitizeRichHtml'
5
- import { vaultImageHtml } from '../composables/vaultImages'
6
- import { sanitizeFootnoteHtml } from '../utils/footnoteHtml'
7
- import MinutaIcon from './MinutaIcon.vue'
8
- import MinutaVariablePicker from './MinutaVariablePicker.vue'
9
- import type { MinutaVariableDef } from '../utils/templateVariables'
2
+ import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
10
3
  import type { Editor } from '@tiptap/core'
11
- import type { PocPageConfig, PocPageNote } from '../composables/paginationPlugin'
12
- import { POC_ASSET_SWAP, buildPocExtensions, renumberPocFootnotes } from '../composables/editorExtensions'
13
- import {
14
- PAGINATE_META,
15
- breaksEqual,
16
- computePocBreaks,
17
- pocPageKey,
18
- } from '../composables/paginationPlugin'
4
+ import { createMinutaEditor } from '@meistrari/minuta-editor'
5
+ import type { MinutaEditor, MinutaVariableDef } from '@meistrari/minuta-editor'
6
+ import '@meistrari/minuta-editor/style.css'
7
+
8
+ /**
9
+ * Casca Vue do editor. O editor em si — folha A4, paginação, toolbar,
10
+ * popovers, placeholders — mora em `@meistrari/minuta-editor`, sem framework;
11
+ * este componente só traduz props/emits/expose para a API dele.
12
+ *
13
+ * A API pública (props, eventos, `defineExpose`, slot `gutter`) é a mesma de
14
+ * antes da extração: quem consome `<MinutaDocxEditor>` — o embed, o editor de
15
+ * template, o client do defesa.ai — não muda uma linha.
16
+ */
19
17
 
20
18
  const props = defineProps<{
21
19
  initialHtml: string
@@ -33,9 +31,7 @@ const props = defineProps<{
33
31
  uploadImage?: ((file: File) => Promise<{ src: string, vaultSrc: string } | null>) | null
34
32
  /**
35
33
  * 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.
34
+ * TEMPLATE: `/` e o botão da toolbar inserem placeholders.
39
35
  */
40
36
  variables?: MinutaVariableDef[]
41
37
  }>()
@@ -46,1309 +42,75 @@ const emit = defineEmits<{
46
42
  blockClick: [blockId: string]
47
43
  }>()
48
44
 
49
- /** Na fase de revisão (read-only), clicar num bloco abre o bloco no checklist. */
50
- function onSheetClick(event: MouseEvent) {
51
- if (props.editable)
52
- return
53
- const target = event.target as HTMLElement
54
- if (target.closest('.poc-varchip') || target.closest('.poc-page-headerarea')
55
- || target.closest('.poc-page-footerarea') || target.closest('.poc-first-header')
56
- || target.closest('.poc-last-footer')) {
57
- return
58
- }
59
- const block = target.closest('[data-block-id]')
60
- if (block)
61
- emit('blockClick', block.getAttribute('data-block-id')!)
62
- }
63
-
64
- const A4_WIDTH_PX = 794
65
- const A4_HEIGHT_PX = 1123
66
- const PAGE_GAP_PX = 32
67
-
68
- const pageConfig: PocPageConfig = {
69
- pageHeight: A4_HEIGHT_PX,
70
- margins: props.margins,
71
- gap: PAGE_GAP_PX,
72
- headerLogoUrl: props.header.logoUrl,
73
- headerPosition: props.header.position,
74
- headerText: props.chromeText.header,
75
- footerText: props.chromeText.footer,
76
- headerScope: props.chromeText.headerScope ?? 'all',
77
- footerScope: props.chromeText.footerScope ?? 'all',
78
- showPageNumber: props.chromeText.showPageNumber !== false,
79
- version: 0,
80
- }
81
-
45
+ const host = ref<HTMLElement | null>(null)
46
+ /** a instância do Tiptap — o que os consumidores sempre receberam em `editor` */
82
47
  const editor = shallowRef<Editor | null>(null)
83
- const editorHost = ref<HTMLElement | null>(null)
84
- const sheetRef = ref<HTMLElement | null>(null)
85
- const createError = ref<string | null>(null)
86
- const totalPages = ref(1)
87
- const uiTick = ref(0)
88
- /** notas de rodapé cujas chamadas caem na ÚLTIMA página (sem chrome próprio) */
89
- const lastPageNotes = ref<PocPageNote[]>([])
90
-
91
- /** Reconstrói o chrome (cabeçalhos/rodapés) com os textos atuais, sem repaginar. */
92
- function refreshChrome() {
93
- const ed = editor.value
94
- if (!ed)
95
- return
96
- pageConfig.headerLogoUrl = props.header.logoUrl
97
- pageConfig.headerPosition = props.header.position
98
- pageConfig.headerText = props.chromeText.header
99
- pageConfig.footerText = props.chromeText.footer
100
- pageConfig.headerScope = props.chromeText.headerScope ?? 'all'
101
- pageConfig.footerScope = props.chromeText.footerScope ?? 'all'
102
- pageConfig.showPageNumber = props.chromeText.showPageNumber !== false
103
- pageConfig.version = (pageConfig.version ?? 0) + 1
104
- const current = pocPageKey.getState(ed.state)
105
- if (current)
106
- ed.view.dispatch(ed.state.tr.setMeta(PAGINATE_META, { ...current, breaks: [...current.breaks] }))
107
- }
108
-
109
- watch(
110
- () => [props.chromeText.header, props.chromeText.footer, props.chromeText.headerScope, props.chromeText.footerScope, props.chromeText.showPageNumber, props.header.logoUrl, props.header.position],
111
- () => refreshChrome(),
112
- )
113
-
114
- // --- paginação: agenda a medição fora do ciclo do PM (rAF) e só despacha se os breaks mudaram ---
115
- // (lone rows — linha com uma única célula preenchida — agora são node
116
- // decorations do plugin de paginação: sobrevivem a qualquer redraw do PM)
117
- let paginateScheduled = false
118
- function schedulePaginate() {
119
- if (paginateScheduled || !editor.value || !sheetRef.value)
120
- return
121
- paginateScheduled = true
122
- requestAnimationFrame(() => {
123
- paginateScheduled = false
124
- const ed = editor.value
125
- const sheet = sheetRef.value
126
- if (!ed || ed.isDestroyed || !sheet)
127
- return
128
- const { breaks, total, markers, lastNotes } = computePocBreaks(ed.view, pageConfig, sheet)
129
- const notesChanged = lastNotes.length !== lastPageNotes.value.length
130
- if (notesChanged)
131
- lastPageNotes.value = lastNotes
132
- const current = pocPageKey.getState(ed.state)
133
- if (current && breaksEqual(current.breaks, breaks) && current.total === total
134
- && current.markers.length === markers.length && !notesChanged) {
135
- return
136
- }
137
- totalPages.value = total
138
- ed.view.dispatch(ed.state.tr.setMeta(PAGINATE_META, { breaks, total, markers }))
139
- })
140
- }
48
+ /** container com scroll: o embed ancora o gutter de anotações nele */
49
+ const containerRef = ref<HTMLElement | null>(null)
50
+ /** a folha: alvo do Teleport do slot `gutter` */
51
+ const sheetEl = ref<HTMLElement | null>(null)
141
52
 
142
- let contentObserver: ResizeObserver | null = null
53
+ let instance: MinutaEditor | null = null
143
54
 
144
55
  onMounted(() => {
145
- // chamada de nota clicada (nodeview das extensões) → abre o popover
146
- window.addEventListener('poc:edit-footnote', onEditFootnoteEvent)
147
- try {
148
- // TiptapEditor = classe Editor de @tiptap/vue-3 (auto-import do nuxt-tiptap-editor)
149
- editor.value = new TiptapEditor({
150
- element: editorHost.value!,
151
- content: props.initialHtml,
152
- editable: props.editable,
153
- extensions: buildPocExtensions(pageConfig, {
154
- variables: () => props.variables ?? [],
155
- handleKeyDown: onEditorKeyDown,
156
- }),
157
- onCreate: ({ editor }) => {
158
- emit('ready', editor as Editor)
159
- nextTick(() => {
160
- schedulePaginate()
161
- const pmEl = (editor as Editor).view.dom as HTMLElement
162
- contentObserver = new ResizeObserver(() => schedulePaginate())
163
- contentObserver.observe(pmEl)
164
- })
165
- },
166
- onTransaction: ({ transaction }) => {
167
- uiTick.value++
168
- syncVariableTrigger(transaction.docChanged)
169
- if (transaction.docChanged) {
170
- // troca de imagem fria (load progressivo) não é edição do
171
- // advogado: repagina, mas não marca o doc como sujo — senão
172
- // o autosave dispararia sozinho durante o carregamento
173
- if (!transaction.getMeta(POC_ASSET_SWAP))
174
- emit('changed')
175
- schedulePaginate()
176
- }
177
- },
178
- }) as unknown as Editor
179
- }
180
- catch (error: any) {
181
- createError.value = error?.message ?? String(error)
182
- console.error('[poc] falha ao criar editor:', error)
183
- }
184
- })
185
-
186
- onBeforeUnmount(() => {
187
- contentObserver?.disconnect()
188
- editor.value?.destroy()
189
- })
190
-
191
- watch(() => props.editable, (value) => {
192
- editor.value?.setEditable(value)
193
- })
194
-
195
- // --- zoom (mesma abordagem do preview: CSS zoom quando o painel é mais estreito que A4) ---
196
- const containerRef = ref<HTMLElement | null>(null)
197
- const zoom = ref(1)
198
- let resizeObserver: ResizeObserver | null = null
199
- watch(containerRef, (el) => {
200
- resizeObserver?.disconnect()
201
- resizeObserver = null
202
- if (!el)
203
- return
204
- resizeObserver = new ResizeObserver(() => {
205
- // respiro lateral da folha: 48px dá moldura no desktop, mas em tela de
206
- // celular custa ~13% do zoom e o texto fica ilegível
207
- const breathing = el.clientWidth < 640 ? 12 : 48
208
- zoom.value = Math.min(1, (el.clientWidth - breathing) / A4_WIDTH_PX)
56
+ if (!host.value)
57
+ return
58
+ instance = createMinutaEditor(host.value, {
59
+ html: props.initialHtml,
60
+ editable: props.editable,
61
+ margins: props.margins,
62
+ header: props.header,
63
+ chrome: props.chromeText,
64
+ // getter: o catálogo pode chegar depois do mount (host que carrega async)
65
+ variables: () => props.variables ?? [],
66
+ uploadImage: props.uploadImage ?? undefined,
67
+ showBlockOrigin: props.showBlockOrigin,
68
+ onReady: (tiptap) => {
69
+ editor.value = tiptap
70
+ emit('ready', tiptap)
71
+ },
72
+ onChange: () => emit('changed'),
73
+ onBlockClick: blockId => emit('blockClick', blockId),
209
74
  })
210
- resizeObserver.observe(el)
211
- }, { immediate: true })
212
- onUnmounted(() => {
213
- window.removeEventListener('poc:edit-footnote', onEditFootnoteEvent)
214
- resizeObserver?.disconnect()
215
- })
216
-
217
- const sheetStyle = computed(() => ({
218
- width: `${A4_WIDTH_PX}px`,
219
- minHeight: `${A4_HEIGHT_PX}px`,
220
- padding: `${props.margins.top}px ${props.margins.right}px ${props.margins.bottom}px ${props.margins.left}px`,
221
- boxSizing: 'border-box' as const,
222
- zoom: zoom.value,
223
- }))
224
-
225
- /** chrome em formato canvas (editor estilo Word): coordenadas no frame da página */
226
- function isChromeCanvas(html: string | undefined) {
227
- return !!html && html.includes('data-el=')
228
- }
229
-
230
- const chromeCanvasStyle = computed(() => ({
231
- position: 'absolute' as const,
232
- top: '0',
233
- left: `${-props.margins.left}px`,
234
- width: '794px',
235
- height: '100%',
236
- pointerEvents: 'none' as const,
237
- }))
238
-
239
- const firstHeaderStyle = computed(() => ({
240
- height: `${props.margins.top}px`,
241
- left: `${props.margins.left}px`,
242
- right: `${props.margins.right}px`,
243
- textAlign: props.header.position,
244
- lineHeight: `${props.margins.top}px`,
245
- }))
246
-
247
- const lastFooterStyle = computed(() => ({
248
- height: `${props.margins.bottom}px`,
249
- left: `${props.margins.left}px`,
250
- right: `${props.margins.right}px`,
251
- }))
252
-
253
- /** as notas da última página encostam no rodapé, dentro da área útil */
254
- const lastNotesStyle = computed(() => ({
255
- position: 'absolute' as const,
256
- bottom: `${props.margins.bottom}px`,
257
- left: `${props.margins.left}px`,
258
- right: `${props.margins.right}px`,
259
- }))
260
-
261
- function editChrome(part: 'header' | 'footer') {
262
- window.dispatchEvent(new CustomEvent('poc:edit-chrome', { detail: { part } }))
263
- }
264
-
265
- // --- toolbar estilo Google Docs (etapa de edição) ---
266
- const PALETTE = [
267
- '#000000',
268
- '#434343',
269
- '#666666',
270
- '#999999',
271
- '#B7B7B7',
272
- '#CCCCCC',
273
- '#D9D9D9',
274
- '#EFEFEF',
275
- '#F3F3F3',
276
- '#FFFFFF',
277
- '#980000',
278
- '#FF0000',
279
- '#FF9900',
280
- '#FFFF00',
281
- '#00FF00',
282
- '#00FFFF',
283
- '#4A86E8',
284
- '#0000FF',
285
- '#9900FF',
286
- '#FF00FF',
287
- '#E6B8AF',
288
- '#F4CCCC',
289
- '#FCE5CD',
290
- '#FFF2CC',
291
- '#D9EAD3',
292
- '#D0E0E3',
293
- '#C9DAF8',
294
- '#CFE2F3',
295
- '#D9D2E9',
296
- '#EAD1DC',
297
- '#CC4125',
298
- '#E06666',
299
- '#F6B26B',
300
- '#FFD966',
301
- '#93C47D',
302
- '#76A5AF',
303
- '#6D9EEB',
304
- '#6FA8DC',
305
- '#8E7CC3',
306
- '#C27BA0',
307
- '#A61C00',
308
- '#CC0000',
309
- '#E69138',
310
- '#F1C232',
311
- '#6AA84F',
312
- '#45818E',
313
- '#3C78D8',
314
- '#3D85C6',
315
- '#674EA7',
316
- '#A64D79',
317
- ]
318
-
319
- const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 24, 30, 36]
320
-
321
- /**
322
- * tamanho (pt) efetivo da seleção: mark inlineStyle manda; senão o computed
323
- * do bloco onde o cursor está (o pipeline inlina font-size nos parágrafos)
324
- */
325
- const currentFontSizePt = computed<number | null>(() => {
326
- void uiTick.value
327
- const ed = editor.value
328
- if (!ed)
329
- return null
330
- const markStyle: string | undefined = ed.getAttributes('inlineStyle')?.style
331
- const fromMark = markStyle?.match(/font-size:\s*([\d.]+)\s*(pt|px)/i)
332
- if (fromMark) {
333
- const value = Number.parseFloat(fromMark[1])
334
- return Math.round(fromMark[2].toLowerCase() === 'pt' ? value : value / (96 / 72))
335
- }
336
- try {
337
- const { node, offset } = ed.view.domAtPos(ed.state.selection.from)
338
- const el = node.nodeType === Node.TEXT_NODE
339
- ? node.parentElement
340
- : (node.childNodes[offset] instanceof HTMLElement ? node.childNodes[offset] as HTMLElement : node as HTMLElement)
341
- if (el instanceof HTMLElement)
342
- return Math.round(Number.parseFloat(getComputedStyle(el).fontSize) / (96 / 72))
343
- }
344
- catch {
345
- // posição sem DOM (ex.: seleção de nó atômico)
346
- }
347
- return null
348
- })
349
-
350
- const openPalette = ref<'color' | 'highlight' | null>(null)
351
-
352
- const active = computed(() => {
353
- void uiTick.value
354
- const ed = editor.value
355
- if (!ed)
356
- return { bold: false, italic: false, underline: false, strike: false, bullet: false, ordered: false, heading: 0 }
357
- return {
358
- bold: ed.isActive('bold'),
359
- italic: ed.isActive('italic'),
360
- underline: ed.isActive('underline'),
361
- strike: ed.isActive('strike'),
362
- bullet: ed.isActive('bulletList'),
363
- ordered: ed.isActive('orderedList'),
364
- heading: ed.isActive('heading', { level: 1 }) ? 1 : ed.isActive('heading', { level: 2 }) ? 2 : ed.isActive('heading', { level: 3 }) ? 3 : 0,
365
- }
75
+ containerRef.value = instance.container
76
+ sheetEl.value = instance.sheet
366
77
  })
367
78
 
368
- /** aplica uma propriedade de estilo inline preservando as demais (cor + tamanho convivem) */
369
- function mergeInlineStyle(prop: string, value: string) {
370
- const ed = editor.value
371
- if (!ed)
372
- return
373
- const current: string = ed.getAttributes('inlineStyle')?.style || ''
374
- const cleaned = current.replace(new RegExp(`${prop}:\\s*[^;]+;?`, 'gi'), '').trim()
375
- const style = `${cleaned}${cleaned && !cleaned.endsWith(';') ? ';' : ''} ${prop}: ${value};`.trim()
376
- ed.chain().focus().setMark('inlineStyle', { style }).run()
377
- }
378
-
379
- function setTextColor(color: string) {
380
- mergeInlineStyle('color', color)
381
- openPalette.value = null
382
- }
383
-
384
- function setHighlight(color: string) {
385
- mergeInlineStyle('background-color', color)
386
- openPalette.value = null
387
- }
388
-
389
- function setFontSize(event: Event) {
390
- const value = (event.target as HTMLSelectElement).value
391
- if (value)
392
- mergeInlineStyle('font-size', `${value}pt`)
393
- }
394
-
395
- /** mesmas fontes do canvas de cabeçalho/rodapé — o export lê a fonte por run */
396
- const DOC_FONTS = ['Arial', 'Times New Roman', 'Calibri', 'Cambria', 'Georgia', 'Garamond', 'Verdana', 'Courier New']
397
-
398
- const currentFontFamily = computed<string | null>(() => {
399
- void uiTick.value
400
- const ed = editor.value
401
- if (!ed)
402
- return null
403
- const markStyle: string | undefined = ed.getAttributes('inlineStyle')?.style
404
- const fromMark = markStyle?.match(/font-family:\s*([^;]+)/i)
405
- if (fromMark)
406
- return fromMark[1].split(',')[0].trim().replace(/["']/g, '')
407
- try {
408
- const { node } = ed.view.domAtPos(ed.state.selection.from)
409
- const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node as HTMLElement
410
- const family = el ? getComputedStyle(el).fontFamily : ''
411
- return family.split(',')[0]?.trim().replace(/["']/g, '') || null
412
- }
413
- catch {
414
- return null
415
- }
79
+ onBeforeUnmount(() => {
80
+ instance?.destroy()
81
+ instance = null
82
+ editor.value = null
416
83
  })
417
84
 
418
- function setFontFamily(event: Event) {
419
- const value = (event.target as HTMLSelectElement).value
420
- if (value)
421
- mergeInlineStyle('font-family', value)
422
- }
423
-
424
- // --- notas de rodapé: popover ancorado no documento (estilo Google Docs) ---
425
- const footnoteEditor = ref<{ pos: number, text: string, top: number, left: number, isNew: boolean } | null>(null)
426
- const footnoteInputRef = ref<HTMLDivElement | null>(null)
427
-
428
- /** personalização da nota: mesmos tamanhos/cores acessíveis num clique */
429
- const FOOTNOTE_SIZES = [7, 8, 9, 10, 11, 12]
430
- const FOOTNOTE_COLORS = ['#000000', '#6b7280', '#b91c1c', '#1d4ed8', '#047857']
431
-
432
- function openFootnoteEditor(pos: number, isNew: boolean) {
433
- const ed = editor.value
434
- const sheet = sheetRef.value
435
- if (!ed || ed.isDestroyed || !sheet)
436
- return
437
- const node = ed.state.doc.nodeAt(pos)
438
- if (!node || node.type.name !== 'footnoteRef')
439
- return
440
- const coords = ed.view.coordsAtPos(pos)
441
- const sheetRect = sheet.getBoundingClientRect()
442
- footnoteEditor.value = {
443
- pos,
444
- text: node.attrs.text,
445
- top: coords.bottom - sheetRect.top + 6,
446
- left: Math.max(8, Math.min(coords.left - sheetRect.left, sheetRect.width - 372)),
447
- isNew,
448
- }
449
- nextTick(() => {
450
- const el = footnoteInputRef.value
451
- if (!el)
452
- return
453
- el.innerHTML = sanitizeFootnoteHtml(footnoteEditor.value?.text ?? '')
454
- el.focus()
455
- // caret no fim do conteúdo
456
- const sel = window.getSelection()
457
- if (sel) {
458
- const range = document.createRange()
459
- range.selectNodeContents(el)
460
- range.collapse(false)
461
- sel.removeAllRanges()
462
- sel.addRange(range)
463
- }
464
- })
465
- }
466
-
467
- /** B/I/U no trecho selecionado dentro do popover */
468
- function footnoteExec(command: 'bold' | 'italic' | 'underline') {
469
- footnoteInputRef.value?.focus()
470
- document.execCommand(command)
471
- }
472
-
473
- /** fonte/tamanho/cor no trecho selecionado (span com estilo permitido) */
474
- function applyFootnoteStyle(prop: 'font-family' | 'font-size' | 'color', value: string) {
475
- const el = footnoteInputRef.value
476
- const sel = window.getSelection()
477
- if (!el || !sel || sel.rangeCount === 0)
478
- return
479
- const range = sel.getRangeAt(0)
480
- if (!el.contains(range.commonAncestorContainer) || range.collapsed)
481
- return
482
- const span = document.createElement('span')
483
- span.style.setProperty(prop, value)
484
- span.appendChild(range.extractContents())
485
- range.insertNode(span)
486
- sel.removeAllRanges()
487
- const next = document.createRange()
488
- next.selectNodeContents(span)
489
- sel.addRange(next)
490
- el.focus()
491
- }
492
-
493
- function onFootnoteFontChange(event: Event) {
494
- const value = (event.target as HTMLSelectElement).value
495
- if (value)
496
- applyFootnoteStyle('font-family', value)
497
- ;(event.target as HTMLSelectElement).value = ''
498
- }
499
-
500
- function onFootnoteSizeChange(event: Event) {
501
- const value = (event.target as HTMLSelectElement).value
502
- if (value)
503
- applyFootnoteStyle('font-size', `${value}pt`)
504
- ;(event.target as HTMLSelectElement).value = ''
505
- }
506
-
507
- /** insere a chamada no cursor e abre o popover pra digitar a nota */
508
- function insertFootnote() {
509
- const ed = editor.value
510
- if (!ed)
511
- return
512
- // colapsa a seleção (inserir com texto selecionado o substituiria)
513
- const to = ed.state.selection.to
514
- ed.chain().focus().setTextSelection(to).insertContent({ type: 'footnoteRef', attrs: { number: '?', text: '' } }).run()
515
- renumberPocFootnotes(ed)
516
- const pos = ed.state.selection.from - 1
517
- openFootnoteEditor(pos, true)
518
- }
519
-
520
- function saveFootnote() {
521
- const state = footnoteEditor.value
522
- const ed = editor.value
523
- // lê o conteúdo formatado ANTES de fechar o popover
524
- const html = footnoteInputRef.value ? sanitizeFootnoteHtml(footnoteInputRef.value.innerHTML) : state?.text ?? ''
525
- footnoteEditor.value = null
526
- if (!state || !ed || ed.isDestroyed)
527
- return
528
- const node = ed.state.doc.nodeAt(state.pos)
529
- if (!node || node.type.name !== 'footnoteRef')
530
- return
531
- const text = html.trim()
532
- if (!text)
533
- ed.chain().focus().deleteRange({ from: state.pos, to: state.pos + node.nodeSize }).run()
534
- else
535
- ed.view.dispatch(ed.state.tr.setNodeMarkup(state.pos, undefined, { ...node.attrs, text }))
536
- renumberPocFootnotes(ed)
537
- }
538
-
539
- function cancelFootnote() {
540
- const state = footnoteEditor.value
541
- const ed = editor.value
542
- footnoteEditor.value = null
543
- // cancelar uma nota recém-criada (ainda vazia) remove a chamada
544
- if (state?.isNew && ed && !ed.isDestroyed) {
545
- const node = ed.state.doc.nodeAt(state.pos)
546
- if (node?.type.name === 'footnoteRef' && !node.attrs.text)
547
- ed.chain().focus().deleteRange({ from: state.pos, to: state.pos + node.nodeSize }).run()
548
- renumberPocFootnotes(ed)
549
- }
550
- }
551
-
552
- function removeFootnote() {
553
- if (footnoteInputRef.value)
554
- footnoteInputRef.value.innerHTML = ''
555
- saveFootnote()
556
- }
557
-
558
- function onEditFootnoteEvent(event: Event) {
559
- if (!props.editable)
560
- return
561
- const { pos } = (event as CustomEvent).detail || {}
562
- if (typeof pos === 'number')
563
- openFootnoteEditor(pos, false)
564
- }
565
-
566
- /** clicar na PRÓPRIA nota no pé da página também abre a edição */
567
- function onNotesClick(event: MouseEvent) {
568
- if (!props.editable)
569
- return
570
- const line = (event.target as HTMLElement).closest?.('.poc-page-note')
571
- if (!line)
572
- return
573
- const number = line.querySelector('sup')?.textContent?.trim()
574
- const ed = editor.value
575
- if (!number || !ed)
576
- return
577
- let target: number | null = null
578
- ed.state.doc.descendants((node, pos) => {
579
- if (target != null)
580
- return false
581
- if (node.type.name === 'footnoteRef' && node.attrs.number === number) {
582
- target = pos
583
- return false
584
- }
585
- return true
586
- })
587
- if (target != null)
588
- openFootnoteEditor(target, false)
589
- }
590
-
591
- /** alinhamento por bloco: mescla text-align no style dos parágrafos/títulos selecionados */
592
- function setAlignment(align: 'left' | 'center' | 'right' | 'justify') {
593
- const ed = editor.value
594
- if (!ed)
595
- return
596
- const { state, view } = ed
597
- const { from, to } = state.selection
598
- const tr = state.tr
599
- state.doc.nodesBetween(from, to, (node, pos) => {
600
- if (node.type.name === 'paragraph' || node.type.name === 'heading') {
601
- const style = (node.attrs.style || '').replace(/text-align:\s*[^;]+;?/gi, '').trim()
602
- const merged = `${style}${style && !style.endsWith(';') ? ';' : ''} text-align: ${align};`.trim()
603
- tr.setNodeMarkup(pos, undefined, { ...node.attrs, style: merged })
604
- }
605
- })
606
- view.dispatch(tr)
607
- ed.commands.focus()
608
- }
609
-
610
- // inserir imagem do computador (vira ilha: arrastável e redimensionável)
611
- const imageInput = ref<HTMLInputElement | null>(null)
612
-
613
- function pickImage() {
614
- imageInput.value?.click()
615
- }
616
-
617
- function insertDataUriImage(file: File) {
618
- const reader = new FileReader()
619
- reader.onload = () => {
620
- editor.value?.chain().focus().insertContent(
621
- `<img src="${reader.result}" style="width: 420px; height: auto; max-width: 100%;">`,
622
- ).run()
623
- }
624
- reader.readAsDataURL(file)
625
- }
626
-
627
- function onImageFile(event: Event) {
628
- const file = (event.target as HTMLInputElement).files?.[0]
629
- if (!file || !file.type.startsWith('image/'))
630
- return
631
- if (props.uploadImage) {
632
- void (async () => {
633
- try {
634
- const uploaded = await props.uploadImage!(file)
635
- if (uploaded) {
636
- editor.value?.chain().focus().insertContent(vaultImageHtml(uploaded.src, uploaded.vaultSrc)).run()
637
- return
638
- }
639
- }
640
- catch (error) {
641
- console.error('[minuta-nuxt] upload de imagem falhou, embutindo como data URI', error)
642
- }
643
- insertDataUriImage(file)
644
- })()
645
- }
646
- else {
647
- insertDataUriImage(file)
648
- }
649
- ;(event.target as HTMLInputElement).value = ''
650
- }
651
-
652
- /** insere a quebra de página manual do template (o engine e o DOCX respeitam) */
653
- function insertPageBreak() {
654
- editor.value?.chain().focus().insertContent('<p>PAGEBREAK</p>').run()
655
- }
656
-
657
- function clearFormatting() {
658
- editor.value?.chain().focus().unsetAllMarks().run()
659
- }
660
-
661
- const TEXT_COLORS = [
662
- { label: 'Preto', value: '#1C242D' },
663
- { label: 'Vermelho', value: '#DC2626' },
664
- { label: 'Azul', value: '#2563EB' },
665
- { label: 'Verde', value: '#16A34A' },
666
- { label: 'Laranja', value: '#EA580C' },
667
- ]
668
-
669
- function applyColor(color: string) {
670
- editor.value?.chain().focus().setMark('inlineStyle', { style: `color: ${color}` }).run()
671
- }
672
-
673
- function clearColor() {
674
- editor.value?.chain().focus().unsetMark('inlineStyle').run()
675
- }
676
-
677
- // --- variáveis de template: gatilho `/`, botão da toolbar e seletor ---
678
-
679
- const hasVariables = computed(() => (props.variables?.length ?? 0) > 0)
680
- const variablePickerRef = ref<any>(null)
681
- /**
682
- * `from` = posição do `/` que abriu o seletor (apagado na inserção); `null`
683
- * quando veio do botão da toolbar, que não digitou nada no documento.
684
- * `query === undefined` faz o popover usar o próprio campo de busca.
685
- */
686
- const variablePicker = ref<{ top: number, left: number, from: number | null, query: string | undefined } | null>(null)
687
-
688
- /**
689
- * Coordenada do documento → coordenada dentro da folha.
690
- *
691
- * A folha tem `zoom` CSS: `coordsAtPos` devolve px de TELA e o `top`/`left` do
692
- * popover é interpretado em px de LAYOUT lá dentro. Sem dividir pelo zoom o
693
- * seletor desgruda do cursor em qualquer zoom diferente de 100%.
694
- */
695
- function sheetCoords(pos: number): { top: number, left: number } | null {
696
- const ed = editor.value
697
- const sheet = sheetRef.value
698
- if (!ed || ed.isDestroyed || !sheet)
699
- return null
700
- let coords
701
- try {
702
- coords = ed.view.coordsAtPos(pos)
703
- }
704
- catch {
705
- return null
706
- }
707
- const rect = sheet.getBoundingClientRect()
708
- const z = zoom.value || 1
709
- return {
710
- top: (coords.bottom - rect.top) / z + 6,
711
- left: Math.max(8, Math.min((coords.left - rect.left) / z, A4_WIDTH_PX - 330)),
712
- }
713
- }
714
-
715
- /**
716
- * `/` no início de palavra abre o seletor; o que vem depois filtra.
717
- *
718
- * Exige delimitador à esquerda para não disparar dentro de uma data ("12/03")
719
- * nem de uma referência de artigo ("art. 5º, II/CF") — onde a barra é conteúdo,
720
- * não comando.
721
- */
722
- const VARIABLE_TRIGGER_RE = /(?:^|[\s(\[«"'])\/([\p{L}\d_]*)$/u
723
- /** acima disto não é mais busca, é texto — o gatilho desiste */
724
- const VARIABLE_TRIGGER_MAX_QUERY = 24
725
-
726
- function closeVariablePicker() {
727
- variablePicker.value = null
728
- }
729
-
730
- /**
731
- * Reavalia o gatilho a cada transação. Só ABRE quando o documento mudou (foi
732
- * alguém digitando); mover o cursor para perto de uma barra que já estava no
733
- * texto não abre nada, mas fecha o que estiver aberto.
734
- */
735
- function syncVariableTrigger(docChanged: boolean) {
736
- const ed = editor.value
737
- if (!ed || ed.isDestroyed || !hasVariables.value || !props.editable) {
738
- if (variablePicker.value)
739
- closeVariablePicker()
740
- return
741
- }
742
- // seletor aberto pela toolbar não tem gatilho no texto para reavaliar
743
- if (variablePicker.value && variablePicker.value.from === null)
744
- return
745
-
746
- const { from, empty } = ed.state.selection
747
- if (!empty) {
748
- closeVariablePicker()
749
- return
750
- }
85
+ watch(() => props.editable, value => instance?.setEditable(value))
751
86
 
752
- const blockStart = ed.state.doc.resolve(from).start()
753
- // '\ufffc' (object replacement) para nós atômicos: um chip antes do cursor
754
- // conta como 1 caractere e não some do texto, senão o `^` casaria errado
755
- const textBefore = ed.state.doc.textBetween(blockStart, from, '\n', '\uFFFC')
756
- const match = textBefore.match(VARIABLE_TRIGGER_RE)
757
-
758
- if (!match || (match[1]?.length ?? 0) > VARIABLE_TRIGGER_MAX_QUERY) {
759
- closeVariablePicker()
760
- return
761
- }
762
- if (!variablePicker.value && !docChanged)
763
- return
764
-
765
- const slashPos = from - match[0].length + match[0].indexOf('/')
766
- const coords = sheetCoords(slashPos)
767
- if (!coords) {
768
- closeVariablePicker()
769
- return
770
- }
771
- variablePicker.value = { ...coords, from: slashPos, query: match[1] ?? '' }
772
- }
773
-
774
- /**
775
- * Botão da toolbar: mesmo seletor, ancorado no cursor, com campo de busca
776
- * próprio.
777
- *
778
- * NÃO devolve o foco ao editor aqui. O `focus()` do Tiptap é adiado por um
779
- * `requestAnimationFrame`, então ele chegava DEPOIS do popover montar e roubava
780
- * o foco do campo de busca — o que a pessoa digitava virava texto do documento
781
- * e o Enter inseria o primeiro item de uma lista que nunca chegou a ser
782
- * filtrada. A posição do cursor não depende do foco: `state.selection`
783
- * continua onde estava (ou no começo, se ninguém nunca clicou no documento).
784
- */
785
- function openVariablePicker() {
786
- const ed = editor.value
787
- if (!ed || ed.isDestroyed || !hasVariables.value)
788
- return
789
- const coords = sheetCoords(ed.state.selection.from)
790
- if (!coords)
791
- return
792
- variablePicker.value = { ...coords, from: null, query: undefined }
793
- }
87
+ watch(
88
+ () => [props.chromeText.header, props.chromeText.footer, props.chromeText.headerScope, props.chromeText.footerScope, props.chromeText.showPageNumber],
89
+ () => instance?.setChrome({ ...props.chromeText }),
90
+ )
91
+ watch(() => [props.header.logoUrl, props.header.position], () => instance?.setHeader({ ...props.header }))
92
+ watch(() => props.variables, () => instance?.setVariables(() => props.variables ?? []))
794
93
 
795
- function insertVariable(variable: MinutaVariableDef) {
796
- const ed = editor.value
797
- if (!ed || ed.isDestroyed)
798
- return
799
- const trigger = variablePicker.value
800
- const chain = ed.chain().focus()
801
- // some com o `/consulta` que abriu o seletor — o placeholder toma o lugar dele
802
- if (trigger?.from != null)
803
- chain.deleteRange({ from: trigger.from, to: ed.state.selection.from })
804
- chain.insertContent({
805
- type: 'templateVariable',
806
- attrs: { varKey: variable.key, label: variable.label },
807
- }).run()
808
- closeVariablePicker()
94
+ function schedulePaginate() {
95
+ instance?.schedulePaginate()
809
96
  }
810
97
 
811
- /**
812
- * Com o seletor aberto o foco continua NO EDITOR (o usuário está digitando o
813
- * texto do filtro), então as setas e o Enter têm que ser interceptados aqui —
814
- * senão andam o cursor e quebram o parágrafo.
815
- */
816
- function onEditorKeyDown(_view: unknown, event: KeyboardEvent): boolean {
817
- const picker = variablePickerRef.value
818
- if (!variablePicker.value || !picker)
819
- return false
820
- if (event.key === 'ArrowDown') {
821
- picker.move(1)
822
- return true
823
- }
824
- if (event.key === 'ArrowUp') {
825
- picker.move(-1)
826
- return true
827
- }
828
- if (event.key === 'Escape') {
829
- closeVariablePicker()
830
- return true
831
- }
832
- if (event.key === 'Enter' || event.key === 'Tab') {
833
- // sem resultado o Enter é Enter: quebrar o parágrafo é o que a pessoa quis
834
- if (!picker.hasResults())
835
- return false
836
- picker.choose()
837
- return true
838
- }
839
- return false
98
+ function refreshChrome() {
99
+ instance?.refreshChrome()
840
100
  }
841
101
 
842
- // catálogo esvaziado (ou editor virou read-only) com o seletor aberto
843
- watch(() => [hasVariables.value, props.editable], () => {
844
- if (variablePicker.value && (!hasVariables.value || !props.editable))
845
- closeVariablePicker()
846
- })
847
-
848
102
  defineExpose({ editor, schedulePaginate, refreshChrome, containerRef })
849
103
  </script>
850
104
 
851
105
  <template>
852
- <div class="h-full w-full flex flex-col poc-editor-root" :class="{ 'poc-no-block-origin': props.showBlockOrigin === false }">
853
- <!-- toolbar de edição (estilo Google Docs) -->
854
- <div v-if="editable" class="poc-toolbar">
855
- <button class="poc-tool" title="Desfazer" @click="editor?.chain().focus().undo().run()">
856
- <MinutaIcon name="i-ph-arrow-u-up-left" />
857
- </button>
858
- <button class="poc-tool" title="Refazer" @click="editor?.chain().focus().redo().run()">
859
- <MinutaIcon name="i-ph-arrow-u-up-right" />
860
- </button>
861
- <div class="poc-tool-sep" />
862
- <select class="poc-tool-select w-118px" title="Fonte" :value="currentFontFamily ?? ''" @change="setFontFamily">
863
- <option value="" disabled>
864
- Fonte
865
- </option>
866
- <option v-if="currentFontFamily && !DOC_FONTS.includes(currentFontFamily)" :value="currentFontFamily">
867
- {{ currentFontFamily }}
868
- </option>
869
- <option v-for="font in DOC_FONTS" :key="font" :value="font" :style="{ fontFamily: font }">
870
- {{ font }}
871
- </option>
872
- </select>
873
-
874
- <select class="poc-tool-select w-64px" title="Tamanho da fonte" :value="currentFontSizePt ?? ''" @change="setFontSize">
875
- <option value="" disabled>
876
- Tam.
877
- </option>
878
- <option v-if="currentFontSizePt != null && !FONT_SIZES.includes(currentFontSizePt)" :value="currentFontSizePt">
879
- {{ currentFontSizePt }}
880
- </option>
881
- <option v-for="size in FONT_SIZES" :key="size" :value="size">
882
- {{ size }}
883
- </option>
884
- </select>
885
- <div class="poc-tool-sep" />
886
- <button class="poc-tool font-bold" :class="{ 'poc-tool--on': active.bold }" title="Negrito" @click="editor?.chain().focus().toggleBold().run()">
887
- B
888
- </button>
889
- <button class="poc-tool italic" :class="{ 'poc-tool--on': active.italic }" title="Itálico" @click="editor?.chain().focus().toggleItalic().run()">
890
- I
891
- </button>
892
- <button class="poc-tool underline" :class="{ 'poc-tool--on': active.underline }" title="Sublinhado" @click="editor?.chain().focus().toggleMark('underline').run()">
893
- U
894
- </button>
895
- <button class="poc-tool line-through" :class="{ 'poc-tool--on': active.strike }" title="Tachado" @click="editor?.chain().focus().toggleStrike().run()">
896
- S
897
- </button>
898
- <div class="poc-tool-sep" />
899
- <div class="relative">
900
- <button class="poc-tool" title="Cor do texto" @click="openPalette = openPalette === 'color' ? null : 'color'">
901
- <span class="font-semibold">A</span>
902
- <span class="poc-color-underline" />
903
- </button>
904
- <div v-if="openPalette === 'color'" class="poc-palette">
905
- <button v-for="c in PALETTE" :key="c" class="poc-palette-swatch" :style="{ background: c }" :title="c" @click="setTextColor(c)" />
906
- <label class="poc-palette-custom">
907
- Personalizada
908
- <input type="color" @change="setTextColor(($event.target as HTMLInputElement).value)">
909
- </label>
910
- </div>
911
- </div>
912
- <div class="relative">
913
- <button class="poc-tool" title="Cor de destaque" @click="openPalette = openPalette === 'highlight' ? null : 'highlight'">
914
- <MinutaIcon name="i-ph-highlighter" />
915
- </button>
916
- <div v-if="openPalette === 'highlight'" class="poc-palette">
917
- <button v-for="c in PALETTE" :key="c" class="poc-palette-swatch" :style="{ background: c }" :title="c" @click="setHighlight(c)" />
918
- <label class="poc-palette-custom">
919
- Personalizada
920
- <input type="color" @change="setHighlight(($event.target as HTMLInputElement).value)">
921
- </label>
922
- </div>
923
- </div>
924
- <div class="poc-tool-sep" />
925
- <button class="poc-tool" title="Alinhar à esquerda" @click="setAlignment('left')">
926
- <MinutaIcon name="i-ph-text-align-left" />
927
- </button>
928
- <button class="poc-tool" title="Centralizar" @click="setAlignment('center')">
929
- <MinutaIcon name="i-ph-text-align-center" />
930
- </button>
931
- <button class="poc-tool" title="Alinhar à direita" @click="setAlignment('right')">
932
- <MinutaIcon name="i-ph-text-align-right" />
933
- </button>
934
- <button class="poc-tool" title="Justificar" @click="setAlignment('justify')">
935
- <MinutaIcon name="i-ph-text-align-justify" />
936
- </button>
937
- <div class="poc-tool-sep" />
938
- <button class="poc-tool" :class="{ 'poc-tool--on': active.bullet }" title="Lista com marcadores" @click="editor?.chain().focus().toggleBulletList().run()">
939
- <MinutaIcon name="i-ph-list-bullets" />
940
- </button>
941
- <button class="poc-tool" :class="{ 'poc-tool--on': active.ordered }" title="Lista numerada" @click="editor?.chain().focus().toggleOrderedList().run()">
942
- <MinutaIcon name="i-ph-list-numbers" />
943
- </button>
944
- <div class="poc-tool-sep" />
945
- <button class="poc-tool" title="Inserir imagem" @click="pickImage">
946
- <MinutaIcon name="i-ph-image" />
947
- </button>
948
- <input ref="imageInput" type="file" accept="image/*" class="hidden" @change="onImageFile">
949
- <button
950
- v-if="hasVariables"
951
- class="poc-tool"
952
- :class="{ 'poc-tool--on': !!variablePicker }"
953
- title="Inserir variável (ou digite / no texto)"
954
- @click="openVariablePicker"
955
- >
956
- <MinutaIcon name="i-ph-brackets-curly" />
957
- </button>
958
- <button class="poc-tool" title="Inserir nota de rodapé" @click="insertFootnote">
959
- <MinutaIcon name="i-ph-note" />
960
- </button>
961
- <button class="poc-tool" title="Inserir quebra de página" @click="insertPageBreak">
962
- <MinutaIcon name="i-ph-arrows-out-line-vertical" />
963
- </button>
964
- <button class="poc-tool" title="Limpar formatação" @click="clearFormatting">
965
- <MinutaIcon name="i-ph-eraser" />
966
- </button>
967
- </div>
968
-
969
- <div ref="containerRef" class="relative min-h-0 w-full flex-1 overflow-y-auto bg-gray-120 py-24px" @click="openPalette = null; closeVariablePicker()">
970
- <div ref="sheetRef" class="poc-sheet relative mx-auto bg-white shadow-md" :style="sheetStyle" @click="onNotesClick">
971
- <!-- cabeçalho da 1ª página (na margem superior da folha) -->
972
- <div class="poc-first-header" :style="firstHeaderStyle" title="Clique para editar o cabeçalho" @click="editChrome('header')">
973
- <img
974
- v-if="header.logoUrl"
975
- :src="header.logoUrl"
976
- :style="{ maxHeight: `${Math.max(24, margins.top - 36)}px`, maxWidth: '50%' }"
977
- class="inline-block align-middle"
978
- >
979
- <!-- HTML persistido → sanitizado (stored XSS) -->
980
- <!-- eslint-disable-next-line vue/no-v-html -->
981
- <div v-if="isChromeCanvas(chromeText.header)" class="poc-chrome-canvas" :style="chromeCanvasStyle" v-html="sanitizeRichHtml(chromeText.header)" />
982
- <!-- eslint-disable-next-line vue/no-v-html -->
983
- <div v-else class="poc-chrome-text" v-html="sanitizeRichHtml(chromeText.header)" />
984
- </div>
985
-
986
- <p v-if="createError" class="body-13-regular text-red-600">
987
- Falha ao montar o editor: {{ createError }}
988
- </p>
989
- <div ref="editorHost" :class="{ 'poc-review-cursor': !editable }" @click="onSheetClick" />
990
-
991
- <!-- notas cujas chamadas caem na última página (as demais moram no chrome) -->
992
- <div v-if="lastPageNotes.length" class="poc-page-notes poc-last-notes" :style="lastNotesStyle">
993
- <!-- conteúdo da nota já sai sanitizado do render; re-sanitiza porque
994
- também pode vir de edição salva no servidor -->
995
- <!-- eslint-disable-next-line vue/no-v-html -->
996
- <p
997
- v-for="note in lastPageNotes"
998
- :key="note.n"
999
- class="poc-page-note"
1000
- :style="note.size ? { fontSize: `${note.size}pt` } : undefined"
1001
- v-html="`<sup>${note.n}</sup> ${sanitizeFootnoteHtml(note.text)}`"
1002
- />
1003
- </div>
1004
-
1005
- <!-- rodapé da última página (na margem inferior da folha) -->
1006
- <div class="poc-last-footer" :style="lastFooterStyle" title="Clique para editar o rodapé" @click="editChrome('footer')">
1007
- <template v-if="!(chromeText.footerScope === 'first' && totalPages > 1)">
1008
- <!-- eslint-disable-next-line vue/no-v-html -->
1009
- <div v-if="isChromeCanvas(chromeText.footer)" class="poc-chrome-canvas" :style="chromeCanvasStyle" v-html="sanitizeRichHtml(chromeText.footer)" />
1010
- <!-- eslint-disable-next-line vue/no-v-html -->
1011
- <div v-else class="poc-chrome-text" v-html="sanitizeRichHtml(chromeText.footer)" />
1012
- </template>
1013
- <div v-else class="poc-chrome-text" />
1014
- <span v-if="chromeText.showPageNumber !== false" class="ml-auto">Página {{ totalPages }} de {{ totalPages }}</span>
1015
- </div>
1016
- <MinutaVariablePicker
1017
- v-if="variablePicker"
1018
- ref="variablePickerRef"
1019
- :variables="props.variables ?? []"
1020
- :top="variablePicker.top"
1021
- :left="variablePicker.left"
1022
- :query="variablePicker.query"
1023
- @select="insertVariable"
1024
- @close="closeVariablePicker"
1025
- />
1026
-
1027
- <slot name="gutter" />
1028
-
1029
- <!-- popover de edição da nota de rodapé (ancorado na chamada) -->
1030
- <div
1031
- v-if="footnoteEditor"
1032
- class="poc-footnote-pop"
1033
- :style="{ top: `${footnoteEditor.top}px`, left: `${footnoteEditor.left}px` }"
1034
- @click.stop
1035
- >
1036
- <div class="poc-footnote-pop__head">
1037
- Nota de rodapé
1038
- </div>
1039
- <div class="poc-footnote-pop__toolbar">
1040
- <button class="poc-footnote-pop__tool font-bold" type="button" title="Negrito" @mousedown.prevent @click="footnoteExec('bold')">
1041
- B
1042
- </button>
1043
- <button class="poc-footnote-pop__tool italic" type="button" title="Itálico" @mousedown.prevent @click="footnoteExec('italic')">
1044
- I
1045
- </button>
1046
- <button class="poc-footnote-pop__tool underline" type="button" title="Sublinhado" @mousedown.prevent @click="footnoteExec('underline')">
1047
- U
1048
- </button>
1049
- <span class="poc-footnote-pop__sep" />
1050
- <select class="poc-footnote-pop__select" title="Fonte do trecho selecionado" @mousedown.stop @change="onFootnoteFontChange">
1051
- <option value="">
1052
- Fonte
1053
- </option>
1054
- <option v-for="font in DOC_FONTS" :key="font" :value="font" :style="{ fontFamily: font }">
1055
- {{ font }}
1056
- </option>
1057
- </select>
1058
- <select class="poc-footnote-pop__select poc-footnote-pop__select--narrow" title="Tamanho do trecho selecionado" @mousedown.stop @change="onFootnoteSizeChange">
1059
- <option value="">
1060
- Tam.
1061
- </option>
1062
- <option v-for="size in FOOTNOTE_SIZES" :key="size" :value="size">
1063
- {{ size }}
1064
- </option>
1065
- </select>
1066
- <span class="poc-footnote-pop__sep" />
1067
- <button
1068
- v-for="color in FOOTNOTE_COLORS"
1069
- :key="color"
1070
- class="poc-footnote-pop__swatch"
1071
- type="button"
1072
- :style="{ background: color }"
1073
- :title="`Cor ${color}`"
1074
- @mousedown.prevent
1075
- @click="applyFootnoteStyle('color', color)"
1076
- />
1077
- </div>
1078
- <div
1079
- ref="footnoteInputRef"
1080
- class="poc-footnote-pop__input"
1081
- contenteditable="true"
1082
- data-placeholder="Escreva a nota…"
1083
- @keydown.enter.exact.prevent="saveFootnote"
1084
- @keydown.esc.prevent="cancelFootnote"
1085
- />
1086
- <div class="poc-footnote-pop__actions">
1087
- <button v-if="!footnoteEditor.isNew" class="poc-footnote-pop__remove" type="button" @click="removeFootnote">
1088
- Remover
1089
- </button>
1090
- <span class="poc-footnote-pop__spacer" />
1091
- <button class="poc-footnote-pop__cancel" type="button" @click="cancelFootnote">
1092
- Cancelar
1093
- </button>
1094
- <button class="poc-footnote-pop__save" type="button" @click="saveFootnote">
1095
- Salvar
1096
- </button>
1097
- </div>
1098
- </div>
1099
- </div>
1100
-
1101
- <TiptapBubbleMenu v-if="editor && editable" :editor="editor" :tippy-options="{ duration: 100 }">
1102
- <div class="flex items-center gap-2px rounded-8px border border-gray-200 bg-white p-2px shadow-lg">
1103
- <button
1104
- class="poc-bubble-btn font-bold"
1105
- :class="{ 'poc-bubble-btn--active': editor.isActive('bold') }"
1106
- @click="editor.chain().focus().toggleBold().run()"
1107
- >
1108
- B
1109
- </button>
1110
- <button
1111
- class="poc-bubble-btn italic"
1112
- :class="{ 'poc-bubble-btn--active': editor.isActive('italic') }"
1113
- @click="editor.chain().focus().toggleItalic().run()"
1114
- >
1115
- I
1116
- </button>
1117
- <button
1118
- class="poc-bubble-btn underline"
1119
- :class="{ 'poc-bubble-btn--active': editor.isActive('underline') }"
1120
- @click="editor.chain().focus().toggleMark('underline').run()"
1121
- >
1122
- U
1123
- </button>
1124
- <div class="mx-2px h-16px w-1px bg-gray-200" />
1125
- <button
1126
- v-for="color in TEXT_COLORS"
1127
- :key="color.value"
1128
- class="poc-color-swatch"
1129
- :style="{ background: color.value }"
1130
- :title="color.label"
1131
- @click="applyColor(color.value)"
1132
- />
1133
- <button class="poc-bubble-btn" title="Remover cor" @click="clearColor">
1134
- <MinutaIcon name="i-ph-eraser" />
1135
- </button>
1136
- </div>
1137
- </TiptapBubbleMenu>
1138
- </div>
1139
- </div>
106
+ <div ref="host" class="minuta-docx-editor" />
107
+ <!-- o slot continua sendo renderizado DENTRO da folha, como antes: o gutter
108
+ se posiciona em relação a ela -->
109
+ <Teleport v-if="sheetEl" :to="sheetEl">
110
+ <slot name="gutter" />
111
+ </Teleport>
1140
112
  </template>
1141
113
 
1142
114
  <style scoped>
1143
- :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{align-items:center;background:#fafbfc;border-bottom:.5px solid #dee3e7;display:flex;flex-wrap:wrap;gap:2px;padding:4px 12px}@media (max-width:640px){.poc-toolbar{flex-wrap:nowrap;overflow-x:auto;padding:4px 8px;scrollbar-width:none}.poc-toolbar::-webkit-scrollbar{display:none}.poc-toolbar>*{flex:none}}.poc-tool{align-items:center;border-radius:6px;display:flex;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){display:block;pointer-events:none;text-align:initial;text-indent:0;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:100%}: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:24px;font-weight:640;letter-spacing:-.3px;line-height:32px;margin:0 0 20px}:deep(h2){color:#0f172a;font-size:19px;font-weight:620;letter-spacing:-.2px;line-height:26px;margin:28px 0 14px}:deep(h3){color:#0f172a;font-size:17px;font-weight:600;letter-spacing:-.1px;line-height:24px;margin:24px 0 12px}:deep(h1):first-child,:deep(h2):first-child,:deep(h3):first-child{margin-top:0}:deep(strong){font-weight:540}:deep(p){color:#000;font-size:18px;font-weight:400;letter-spacing:-.4px;line-height:28px;line-height:1.7em;margin-bottom:.75em;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}
1144
- </style>
1145
-
1146
- <style scoped>
1147
- /* utilities que no CDB vêm do UnoCSS — aqui o package se basta */
1148
- .h-full { height: 100%; }
1149
- .w-full { width: 100%; }
1150
- .w-1px { width: 1px; }
1151
- .w-64px { width: 64px; }
1152
- .h-16px { height: 16px; }
1153
- .flex { display: flex; }
1154
- .flex-col { flex-direction: column; }
1155
- .flex-1 { flex: 1 1 0%; }
1156
- .items-center { align-items: center; }
1157
- .gap-2px { gap: 2px; }
1158
- .min-h-0 { min-height: 0; }
1159
- .overflow-y-auto { overflow-y: auto; }
1160
- .relative { position: relative; }
1161
- .hidden { display: none; }
1162
- .inline-block { display: inline-block; }
1163
- .align-middle { vertical-align: middle; }
1164
- .ml-auto { margin-left: auto; }
1165
- .mx-auto { margin-left: auto; margin-right: auto; }
1166
- .mx-2px { margin-left: 2px; margin-right: 2px; }
1167
- .p-2px { padding: 2px; }
1168
- .py-24px { padding-top: 24px; padding-bottom: 24px; }
1169
- .bg-white { background: #fff; }
1170
- .bg-gray-120 { background: #ebedf0; }
1171
- .bg-gray-200 { background: #e5e7eb; }
1172
- .border { border-width: 1px; border-style: solid; }
1173
- .border-gray-200 { border-color: #e5e7eb; }
1174
- .rounded-8px { border-radius: 8px; }
1175
- .shadow-md { box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); }
1176
- .shadow-lg { box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); }
1177
- .font-bold { font-weight: 700; }
1178
- .font-semibold { font-weight: 600; }
1179
- .italic { font-style: italic; }
1180
- .underline { text-decoration: underline; }
1181
- .line-through { text-decoration: line-through; }
1182
- .text-red-600 { color: #dc2626; }
1183
- .body-13-regular { font-size: 13px; font-weight: 400; }
1184
- </style>
1185
-
1186
- <style scoped>
1187
- /* acabamento de paridade com o defesa.ai (hover states + fonte + detalhes
1188
- que o conversor de utilities não cobre) */
1189
- .poc-editor-root {
1190
- font-family: 'Inter Variable', Inter, -apple-system, 'Segoe UI', Roboto, sans-serif;
1191
- }
1192
-
1193
- .poc-toolbar {
1194
- background: #fafbfc;
1195
- border-bottom: 0.5px solid #dee3e7;
1196
- }
1197
-
1198
- .poc-tool {
1199
- border: none;
1200
- background: transparent;
1201
- cursor: pointer;
1202
- }
1203
-
1204
- .poc-tool:hover {
1205
- background: #ebedf0;
1206
- }
1207
-
1208
- .poc-tool--on {
1209
- background: #e2e8f0;
1210
- }
1211
-
1212
- .poc-tool-select {
1213
- border: 0.5px solid #d4d9df;
1214
- background: #fff;
1215
- cursor: pointer;
1216
- }
1217
-
1218
- .poc-editor-root :deep(.ProseMirror) {
1219
- outline: none;
1220
- }
1221
-
1222
- .poc-editor-root :deep(td),
1223
- .poc-editor-root :deep(th) {
1224
- border: 0.5px solid #d4d9df;
1225
- }
1226
-
1227
- /* popover de nota de rodapé (ancorado na chamada, estilo comentário) */
1228
- .poc-footnote-pop {
1229
- position: absolute;
1230
- z-index: 40;
1231
- width: 364px;
1232
- background: #fff;
1233
- border: 0.5px solid #d4d9df;
1234
- border-radius: 10px;
1235
- padding: 10px 12px;
1236
- box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.18);
1237
- }
1238
-
1239
- .poc-footnote-pop__head {
1240
- font-size: 11px;
1241
- font-weight: 650;
1242
- letter-spacing: 0.04em;
1243
- text-transform: uppercase;
1244
- color: #6b7280;
1245
- margin-bottom: 6px;
1246
- }
1247
-
1248
- .poc-footnote-pop__toolbar {
1249
- display: flex;
1250
- align-items: center;
1251
- gap: 4px;
1252
- margin-bottom: 6px;
1253
- }
1254
-
1255
- .poc-footnote-pop__tool {
1256
- width: 24px;
1257
- height: 24px;
1258
- border: none;
1259
- border-radius: 6px;
1260
- background: transparent;
1261
- color: #374151;
1262
- font-size: 12px;
1263
- cursor: pointer;
1264
- }
1265
-
1266
- .poc-footnote-pop__tool:hover {
1267
- background: rgba(28, 36, 45, 0.06);
1268
- }
1269
-
1270
- .poc-footnote-pop__sep {
1271
- width: 1px;
1272
- height: 16px;
1273
- background: #e5e7eb;
1274
- margin: 0 2px;
1275
- }
1276
-
1277
- .poc-footnote-pop__select {
1278
- height: 24px;
1279
- border: 1px solid #e5e7eb;
1280
- border-radius: 6px;
1281
- background: #fff;
1282
- font-size: 11px;
1283
- color: #374151;
1284
- max-width: 108px;
1285
- outline: none;
1286
- }
1287
-
1288
- .poc-footnote-pop__select--narrow {
1289
- max-width: 58px;
1290
- }
1291
-
1292
- .poc-footnote-pop__swatch {
1293
- width: 14px;
1294
- height: 14px;
1295
- border: 1px solid rgb(0 0 0 / 0.15);
1296
- border-radius: 4px;
1297
- padding: 0;
1298
- cursor: pointer;
1299
- }
1300
-
1301
- .poc-footnote-pop__input {
1302
- width: 100%;
1303
- min-height: 58px;
1304
- max-height: 160px;
1305
- overflow-y: auto;
1306
- border: 1px solid #e5e7eb;
1307
- border-radius: 8px;
1308
- padding: 6px 8px;
1309
- font-size: 12.5px;
1310
- line-height: 1.5;
1311
- color: #111827;
1312
- outline: none;
1313
- }
1314
-
1315
- .poc-footnote-pop__input:focus {
1316
- border-color: #a5b4fc;
1317
- }
1318
-
1319
- .poc-footnote-pop__input:empty::before {
1320
- content: attr(data-placeholder);
1321
- color: #9ca3af;
1322
- }
1323
-
1324
- .poc-footnote-pop__actions {
1325
- display: flex;
1326
- align-items: center;
1327
- gap: 8px;
1328
- margin-top: 8px;
1329
- }
1330
-
1331
- .poc-footnote-pop__spacer {
1332
- flex: 1;
1333
- }
1334
-
1335
- .poc-footnote-pop__actions button {
1336
- border: none;
1337
- border-radius: 8px;
1338
- padding: 4px 12px;
1339
- font-size: 12px;
1340
- font-weight: 500;
1341
- cursor: pointer;
1342
- background: transparent;
1343
- color: #565d69;
1344
- }
1345
-
1346
- .poc-footnote-pop__remove {
1347
- color: #b91c1c !important;
1348
- }
1349
-
1350
- .poc-footnote-pop__save {
1351
- background: rgba(0, 0, 0, 0.88) !important;
1352
- color: #fff !important;
1353
- }
115
+ .minuta-docx-editor{display:flex;flex-direction:column;height:100%;min-height:0;width:100%}
1354
116
  </style>