@meistrari/minuta-nuxt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +106 -0
  2. package/dist/module.cjs +5 -0
  3. package/dist/module.d.mts +18 -0
  4. package/dist/module.d.ts +18 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +45 -0
  7. package/dist/runtime/components/MinutaAnnotationsGutter.vue +425 -0
  8. package/dist/runtime/components/MinutaChatEditCard.vue +124 -0
  9. package/dist/runtime/components/MinutaChromeCanvas.vue +549 -0
  10. package/dist/runtime/components/MinutaChromeEditor.vue +100 -0
  11. package/dist/runtime/components/MinutaDocumentViewer.vue +90 -0
  12. package/dist/runtime/components/MinutaDocxEditor.vue +1158 -0
  13. package/dist/runtime/components/MinutaEmbed.vue +783 -0
  14. package/dist/runtime/components/MinutaIcon.vue +53 -0
  15. package/dist/runtime/composables/annotationAnchors.d.ts +73 -0
  16. package/dist/runtime/composables/annotationAnchors.js +243 -0
  17. package/dist/runtime/composables/chatEditOps.d.ts +37 -0
  18. package/dist/runtime/composables/chatEditOps.js +61 -0
  19. package/dist/runtime/composables/docxExport.d.ts +24 -0
  20. package/dist/runtime/composables/docxExport.js +1292 -0
  21. package/dist/runtime/composables/editorExtensions.d.ts +56 -0
  22. package/dist/runtime/composables/editorExtensions.js +466 -0
  23. package/dist/runtime/composables/paginationPlugin.d.ts +126 -0
  24. package/dist/runtime/composables/paginationPlugin.js +421 -0
  25. package/dist/runtime/composables/useMinutaApi.d.ts +112 -0
  26. package/dist/runtime/composables/useMinutaApi.js +68 -0
  27. package/dist/runtime/composables/vaultImages.d.ts +12 -0
  28. package/dist/runtime/composables/vaultImages.js +85 -0
  29. package/dist/runtime/composables/versionDiff.d.ts +34 -0
  30. package/dist/runtime/composables/versionDiff.js +62 -0
  31. package/dist/runtime/server/api/minuta/[...path].d.ts +2 -0
  32. package/dist/runtime/server/api/minuta/[...path].js +6 -0
  33. package/dist/runtime/server/api/minuta/index.d.ts +2 -0
  34. package/dist/runtime/server/api/minuta/index.js +3 -0
  35. package/dist/runtime/server/utils/minuta-proxy.d.ts +10 -0
  36. package/dist/runtime/server/utils/minuta-proxy.js +63 -0
  37. package/dist/runtime/utils/footnoteHtml.d.ts +26 -0
  38. package/dist/runtime/utils/footnoteHtml.js +117 -0
  39. package/dist/runtime/utils/pocKeepWithNext.d.ts +44 -0
  40. package/dist/runtime/utils/pocKeepWithNext.js +9 -0
  41. package/dist/runtime/utils/sanitizeRichHtml.d.ts +10 -0
  42. package/dist/runtime/utils/sanitizeRichHtml.js +73 -0
  43. package/dist/types.d.mts +7 -0
  44. package/dist/types.d.ts +7 -0
  45. package/package.json +100 -0
@@ -0,0 +1,124 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import type { MinutaChatEdit } from '../composables/chatEditOps'
4
+
5
+ /**
6
+ * Card renderizado dentro do chat para os blocos minuta-edit do agente.
7
+ * O clique em "Aplicar" dispara o CustomEvent `minuta-nuxt:chat-edit`; o
8
+ * MinutaEmbed escuta, aplica as operações no editor e devolve o resultado
9
+ * pelo callback `onResult`.
10
+ */
11
+
12
+ const props = defineProps<{
13
+ /** nó do markstream: `content` traz o JSON bruto dentro da tag */
14
+ node?: { content?: string }
15
+ }>()
16
+
17
+ const state = ref<'idle' | 'applying' | 'done' | 'error'>('idle')
18
+ const resultMessage = ref('')
19
+
20
+ const edit = computed<MinutaChatEdit | null>(() => {
21
+ const raw = (props.node?.content ?? '').trim()
22
+ if (!raw)
23
+ return null
24
+ try {
25
+ const parsed = JSON.parse(raw)
26
+ if (parsed && typeof parsed === 'object')
27
+ return parsed as MinutaChatEdit
28
+ return null
29
+ }
30
+ catch {
31
+ // streaming: o JSON ainda pode estar chegando
32
+ return null
33
+ }
34
+ })
35
+
36
+ const opsCount = computed(() => edit.value?.ops?.length ?? 0)
37
+ const annosCount = computed(() => edit.value?.anotacoes?.length ?? 0)
38
+
39
+ const OP_LABEL: Record<string, string> = {
40
+ substituir: 'substituição',
41
+ inserir_apos: 'inserção',
42
+ remover: 'remoção',
43
+ }
44
+
45
+ const opsSummary = computed(() => {
46
+ const counts = new Map<string, number>()
47
+ for (const op of edit.value?.ops ?? [])
48
+ counts.set(op.op, (counts.get(op.op) ?? 0) + 1)
49
+ return [...counts.entries()]
50
+ .map(([op, count]) => `${count} ${OP_LABEL[op] ?? op}${count > 1 ? (OP_LABEL[op] ? 'ões' : '') : ''}`)
51
+ .join(', ')
52
+ .replace(/çãoões/g, 'ções')
53
+ })
54
+
55
+ function apply() {
56
+ if (!edit.value || state.value === 'applying' || state.value === 'done')
57
+ return
58
+ state.value = 'applying'
59
+ window.dispatchEvent(new CustomEvent('minuta-nuxt:chat-edit', {
60
+ detail: {
61
+ edit: edit.value,
62
+ onResult(result: { applied: number, failures: string[] }) {
63
+ if (result.failures.length) {
64
+ state.value = result.applied > 0 ? 'done' : 'error'
65
+ resultMessage.value = result.failures.join(' · ')
66
+ }
67
+ else {
68
+ state.value = 'done'
69
+ resultMessage.value = ''
70
+ }
71
+ },
72
+ },
73
+ }))
74
+ // sem host ouvindo (ex.: preview fora do editor), evita spinner infinito
75
+ setTimeout(() => {
76
+ if (state.value === 'applying') {
77
+ state.value = 'error'
78
+ resultMessage.value = 'O editor não respondeu — abra a minuta para aplicar.'
79
+ }
80
+ }, 4000)
81
+ }
82
+ </script>
83
+
84
+ <template>
85
+ <div class="minuta-chat-edit">
86
+ <template v-if="edit">
87
+ <div class="minuta-chat-edit__head">
88
+ <span class="minuta-chat-edit__badge">Edição</span>
89
+ <span class="minuta-chat-edit__desc">{{ edit.descricao || 'Edição proposta' }}</span>
90
+ </div>
91
+ <p class="minuta-chat-edit__meta">
92
+ {{ opsSummary || 'sem operações' }}<template v-if="annosCount">
93
+ · {{ annosCount }} {{ annosCount === 1 ? 'anotação nova' : 'anotações novas' }}
94
+ </template>
95
+ </p>
96
+ <p v-if="resultMessage" class="minuta-chat-edit__result" :class="{ 'minuta-chat-edit__result--error': state === 'error' }">
97
+ {{ resultMessage }}
98
+ </p>
99
+ <button
100
+ class="minuta-chat-edit__apply"
101
+ type="button"
102
+ :disabled="state === 'applying' || state === 'done' || !opsCount && !annosCount"
103
+ @click="apply"
104
+ >
105
+ <template v-if="state === 'done'">
106
+ Aplicada ✓
107
+ </template>
108
+ <template v-else-if="state === 'applying'">
109
+ Aplicando…
110
+ </template>
111
+ <template v-else>
112
+ Aplicar na minuta
113
+ </template>
114
+ </button>
115
+ </template>
116
+ <p v-else class="minuta-chat-edit__loading">
117
+ Preparando edição…
118
+ </p>
119
+ </div>
120
+ </template>
121
+
122
+ <style scoped>
123
+ .minuta-chat-edit{background:#fafbfc;border:.5px solid #dee3e7;border-radius:10px;margin:8px 0;padding:10px 12px}.minuta-chat-edit__head{align-items:center;display:flex;gap:8px;min-width:0}.minuta-chat-edit__badge{background:hsla(220,9%,46%,.12);border-radius:999px;color:#565d69;flex-shrink:0;font-size:9.5px;font-weight:650;letter-spacing:.05em;padding:1px 7px;text-transform:uppercase}.minuta-chat-edit__desc{color:#111827;font-size:12.5px;font-weight:590;line-height:1.35}.minuta-chat-edit__meta{color:#6b7280;font-size:11px;margin:4px 0 0}.minuta-chat-edit__result{color:#92400e;font-size:11px;line-height:1.4;margin:6px 0 0}.minuta-chat-edit__result--error{color:#b91c1c}.minuta-chat-edit__apply{background:rgba(0,0,0,.88);border:none;border-radius:8px;color:#fff;cursor:pointer;font-size:12px;font-weight:500;margin-top:8px;padding:4px 12px}.minuta-chat-edit__apply:disabled{background:#e5e7eb;color:#6b7280;cursor:default}.minuta-chat-edit__loading{color:#9ca3af;font-size:11.5px;margin:0}
124
+ </style>
@@ -0,0 +1,549 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, reactive, ref, shallowRef, watch } from 'vue'
3
+ import { sanitizeRichHtml } from '../utils/sanitizeRichHtml'
4
+ import MinutaIcon from './MinutaIcon.vue'
5
+
6
+ /**
7
+ * Editor CANVAS de cabeçalho/rodapé estilo Word: elementos livres (caixa de
8
+ * texto, imagem, retângulo) posicionados por arrasto, com resize por alças e
9
+ * fonte própria por caixa de texto.
10
+ *
11
+ * Formato persistido (HTML, coordenadas absolutas no frame da PÁGINA A4 —
12
+ * 794px de largura × altura da margem):
13
+ * <div data-el="text" style="position:absolute;left:..;top:..;width:..;font-family:..;font-size:..">…</div>
14
+ * <img data-el="img" style="position:absolute;left:..;top:..;width:..;height:.." src="data:…">
15
+ * <div data-el="rect" style="position:absolute;left:..;top:..;width:..;height:..;background:#…"></div>
16
+ *
17
+ * O preview (paginação) e o export DOCX (imagens flutuantes + parágrafos com
18
+ * frame) entendem esse formato. Conteúdo legado (flow) é migrado para uma
19
+ * caixa de texto na primeira edição.
20
+ */
21
+
22
+ const props = withDefaults(defineProps<{
23
+ html: string
24
+ widthPx: number
25
+ heightPx: number
26
+ docFont?: string
27
+ /** texto fantasma (ex.: "Página 1 de 12" no rodapé) */
28
+ ghost?: string
29
+ placeholder?: string
30
+ }>(), {
31
+ docFont: 'Arial',
32
+ ghost: '',
33
+ placeholder: 'Adicione texto, imagem ou forma pela barra acima',
34
+ })
35
+
36
+ const emit = defineEmits<{ change: [html: string] }>()
37
+
38
+ const FONT_OPTIONS = ['Arial', 'Times New Roman', 'Calibri', 'Cambria', 'Georgia', 'Garamond', 'Verdana', 'Courier New']
39
+ const SIZE_OPTIONS_PT = [7, 8, 9, 10, 11, 12, 14, 16, 18]
40
+
41
+ const canvasRef = ref<HTMLElement | null>(null)
42
+ const wrapperRef = ref<HTMLElement | null>(null)
43
+ const selected = shallowRef<HTMLElement | null>(null)
44
+ const editingText = ref(false)
45
+ const imageInput = ref<HTMLInputElement | null>(null)
46
+ const uiTick = ref(0)
47
+
48
+ // --- carga / migração ---
49
+
50
+ function migrate(html: string): string {
51
+ const clean = sanitizeRichHtml(html)
52
+ if (!clean.trim())
53
+ return ''
54
+ if (clean.includes('data-el='))
55
+ return clean
56
+ // conteúdo legado (flow) → vira uma caixa de texto posicionada
57
+ return `<div data-el="text" style="position:absolute;left:24px;top:8px;max-width:70%;font-size:11px;color:#64748b;font-family:${props.docFont}">${clean}</div>`
58
+ }
59
+
60
+ let lastEmitted: string | null = null
61
+
62
+ function loadHtml() {
63
+ if (!canvasRef.value)
64
+ return
65
+ canvasRef.value.innerHTML = migrate(props.html)
66
+ selected.value = null
67
+ editingText.value = false
68
+ }
69
+
70
+ onMounted(loadHtml)
71
+ watch(() => props.html, (next) => {
72
+ // ignora o eco do próprio emit
73
+ if (next === lastEmitted)
74
+ return
75
+ loadHtml()
76
+ })
77
+
78
+ function serialize() {
79
+ if (!canvasRef.value)
80
+ return
81
+ const clone = canvasRef.value.cloneNode(true) as HTMLElement
82
+ for (const el of [...clone.querySelectorAll('[contenteditable]')])
83
+ el.removeAttribute('contenteditable')
84
+ for (const el of [...clone.querySelectorAll('[data-selected]')])
85
+ el.removeAttribute('data-selected')
86
+ const html = sanitizeRichHtml(clone.innerHTML)
87
+ lastEmitted = html
88
+ emit('change', html)
89
+ }
90
+
91
+ // --- seleção + overlay de alças ---
92
+
93
+ const overlay = reactive({ visible: false, left: 0, top: 0, width: 0, height: 0 })
94
+
95
+ function refreshOverlay() {
96
+ const el = selected.value
97
+ if (!el || !canvasRef.value) {
98
+ overlay.visible = false
99
+ return
100
+ }
101
+ overlay.visible = !editingText.value
102
+ overlay.left = el.offsetLeft
103
+ overlay.top = el.offsetTop
104
+ overlay.width = el.offsetWidth
105
+ overlay.height = el.offsetHeight
106
+ }
107
+
108
+ function select(el: HTMLElement | null) {
109
+ if (selected.value && selected.value !== el)
110
+ exitTextEditing()
111
+ selected.value = el
112
+ uiTick.value++
113
+ nextTick(refreshOverlay)
114
+ }
115
+
116
+ function exitTextEditing() {
117
+ if (!editingText.value)
118
+ return
119
+ editingText.value = false
120
+ const el = selected.value
121
+ if (el?.getAttribute('data-el') === 'text') {
122
+ el.removeAttribute('contenteditable')
123
+ serialize()
124
+ }
125
+ nextTick(refreshOverlay)
126
+ }
127
+
128
+ // --- drag (mover) ---
129
+
130
+ interface DragState { el: HTMLElement, startX: number, startY: number, left: number, top: number, width: number, height: number, mode: 'move' | string, aspect: number }
131
+ let drag: DragState | null = null
132
+
133
+ function onCanvasPointerDown(event: PointerEvent) {
134
+ const target = (event.target as HTMLElement).closest('[data-el]') as HTMLElement | null
135
+ if (!target || !canvasRef.value?.contains(target)) {
136
+ select(null)
137
+ exitTextEditing()
138
+ return
139
+ }
140
+ if (target === selected.value && editingText.value)
141
+ return // digitando dentro da caixa — não arrasta
142
+ select(target)
143
+ event.preventDefault()
144
+ startInteraction(event, target, 'move')
145
+ }
146
+
147
+ function startInteraction(event: PointerEvent, el: HTMLElement, mode: string) {
148
+ drag = {
149
+ el,
150
+ mode,
151
+ startX: event.clientX,
152
+ startY: event.clientY,
153
+ left: el.offsetLeft,
154
+ top: el.offsetTop,
155
+ width: el.offsetWidth,
156
+ height: el.offsetHeight,
157
+ aspect: el.offsetWidth / Math.max(1, el.offsetHeight),
158
+ }
159
+ window.addEventListener('pointermove', onPointerMove)
160
+ window.addEventListener('pointerup', onPointerUp, { once: true })
161
+ }
162
+
163
+ /** snap às bordas da página e ao centro (listra de ponta a ponta sem pixel-hunting) */
164
+ const SNAP = 6
165
+ function snapAxis(value: number, size: number, total: number): number {
166
+ if (Math.abs(value) <= SNAP)
167
+ return 0 // borda esquerda/topo
168
+ if (Math.abs(value + size - total) <= SNAP)
169
+ return total - size // borda direita/fundo
170
+ if (Math.abs(value + size / 2 - total / 2) <= SNAP)
171
+ return Math.round((total - size) / 2) // centro
172
+ return value
173
+ }
174
+
175
+ function onPointerMove(event: PointerEvent) {
176
+ if (!drag)
177
+ return
178
+ const dx = event.clientX - drag.startX
179
+ const dy = event.clientY - drag.startY
180
+ const el = drag.el
181
+ if (drag.mode === 'move') {
182
+ const rawLeft = Math.min(props.widthPx - 8, Math.max(-drag.width + 8, drag.left + dx))
183
+ const rawTop = Math.min(props.heightPx - 6, Math.max(-drag.height + 6, drag.top + dy))
184
+ el.style.left = `${Math.round(snapAxis(rawLeft, drag.width, props.widthPx))}px`
185
+ el.style.top = `${Math.round(snapAxis(rawTop, drag.height, props.heightPx))}px`
186
+ }
187
+ else {
188
+ // resize pelas alças; imagens mantêm proporção nos cantos
189
+ const isImg = el.tagName === 'IMG'
190
+ let { left, top, width, height } = drag
191
+ if (drag.mode.includes('e')) {
192
+ width = Math.max(8, drag.width + dx)
193
+ // snap da borda direita à borda da página (listra de ponta a ponta)
194
+ if (Math.abs(left + width - props.widthPx) <= SNAP)
195
+ width = props.widthPx - left
196
+ }
197
+ if (drag.mode.includes('s')) {
198
+ height = Math.max(4, drag.height + dy)
199
+ if (Math.abs(top + height - props.heightPx) <= SNAP)
200
+ height = props.heightPx - top
201
+ }
202
+ if (drag.mode.includes('w')) {
203
+ width = Math.max(8, drag.width - dx)
204
+ left = drag.left + (drag.width - width)
205
+ if (Math.abs(left) <= SNAP) {
206
+ width += left
207
+ left = 0
208
+ }
209
+ }
210
+ if (drag.mode.includes('n')) {
211
+ height = Math.max(4, drag.height - dy)
212
+ top = drag.top + (drag.height - height)
213
+ if (Math.abs(top) <= SNAP) {
214
+ height += top
215
+ top = 0
216
+ }
217
+ }
218
+ if (isImg && drag.mode.length === 2) {
219
+ // canto de imagem: proporcional (largura manda)
220
+ height = Math.max(4, Math.round(width / drag.aspect))
221
+ if (drag.mode.includes('n'))
222
+ top = drag.top + (drag.height - height)
223
+ }
224
+ el.style.left = `${Math.round(left)}px`
225
+ el.style.top = `${Math.round(top)}px`
226
+ el.style.width = `${Math.round(width)}px`
227
+ if (el.getAttribute('data-el') !== 'text' || drag.mode.includes('s') || drag.mode.includes('n'))
228
+ el.style.height = `${Math.round(height)}px`
229
+ }
230
+ refreshOverlay()
231
+ }
232
+
233
+ function onPointerUp() {
234
+ window.removeEventListener('pointermove', onPointerMove)
235
+ if (drag) {
236
+ drag = null
237
+ serialize()
238
+ refreshOverlay()
239
+ }
240
+ }
241
+
242
+ function onHandleDown(event: PointerEvent, mode: string) {
243
+ if (!selected.value)
244
+ return
245
+ event.preventDefault()
246
+ event.stopPropagation()
247
+ startInteraction(event, selected.value, mode)
248
+ }
249
+
250
+ // --- edição de texto (duplo clique) ---
251
+
252
+ function onCanvasDblClick(event: MouseEvent) {
253
+ const target = (event.target as HTMLElement).closest('[data-el="text"]') as HTMLElement | null
254
+ if (!target)
255
+ return
256
+ select(target)
257
+ editingText.value = true
258
+ target.setAttribute('contenteditable', 'true')
259
+ target.focus()
260
+ refreshOverlay()
261
+ }
262
+
263
+ // --- teclado ---
264
+
265
+ function onKeydown(event: KeyboardEvent) {
266
+ if (editingText.value) {
267
+ if (event.key === 'Escape')
268
+ exitTextEditing()
269
+ return
270
+ }
271
+ const el = selected.value
272
+ if (!el)
273
+ return
274
+ if (event.key === 'Delete' || event.key === 'Backspace') {
275
+ event.preventDefault()
276
+ el.remove()
277
+ select(null)
278
+ serialize()
279
+ return
280
+ }
281
+ const step = event.shiftKey ? 10 : 1
282
+ const nudge: Record<string, [number, number]> = {
283
+ ArrowLeft: [-step, 0],
284
+ ArrowRight: [step, 0],
285
+ ArrowUp: [0, -step],
286
+ ArrowDown: [0, step],
287
+ }
288
+ if (nudge[event.key]) {
289
+ event.preventDefault()
290
+ el.style.left = `${el.offsetLeft + nudge[event.key][0]}px`
291
+ el.style.top = `${el.offsetTop + nudge[event.key][1]}px`
292
+ refreshOverlay()
293
+ serialize()
294
+ }
295
+ }
296
+
297
+ // --- inserção de elementos ---
298
+
299
+ function centerPos(w: number, h: number) {
300
+ return {
301
+ left: Math.max(8, Math.round((props.widthPx - w) / 3)),
302
+ top: Math.max(2, Math.round((props.heightPx - h) / 2)),
303
+ }
304
+ }
305
+
306
+ function addText() {
307
+ if (!canvasRef.value)
308
+ return
309
+ const el = document.createElement('div')
310
+ el.setAttribute('data-el', 'text')
311
+ const { left, top } = centerPos(160, 18)
312
+ el.style.cssText = `position:absolute;left:${left}px;top:${top}px;min-width:40px;font-size:11px;color:#334155;font-family:${props.docFont};line-height:1.4;`
313
+ el.textContent = 'Texto'
314
+ canvasRef.value.appendChild(el)
315
+ select(el)
316
+ serialize()
317
+ }
318
+
319
+ function pickImage() {
320
+ imageInput.value?.click()
321
+ }
322
+
323
+ function onImageFile(event: Event) {
324
+ const file = (event.target as HTMLInputElement).files?.[0]
325
+ if (!file || !file.type.startsWith('image/'))
326
+ return
327
+ const reader = new FileReader()
328
+ reader.onload = () => {
329
+ const probe = new Image()
330
+ probe.onload = () => {
331
+ if (!canvasRef.value)
332
+ return
333
+ const maxH = Math.max(20, props.heightPx - 8)
334
+ const scale = Math.min(1, maxH / probe.naturalHeight, 300 / probe.naturalWidth)
335
+ const w = Math.round(probe.naturalWidth * scale)
336
+ const h = Math.round(probe.naturalHeight * scale)
337
+ const img = document.createElement('img')
338
+ img.setAttribute('data-el', 'img')
339
+ const { left, top } = centerPos(w, h)
340
+ img.style.cssText = `position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;`
341
+ img.src = String(probe.src)
342
+ canvasRef.value.appendChild(img)
343
+ select(img)
344
+ serialize()
345
+ }
346
+ probe.src = String(reader.result)
347
+ }
348
+ reader.readAsDataURL(file)
349
+ ;(event.target as HTMLInputElement).value = ''
350
+ }
351
+
352
+ const rectColor = ref('#00B0F0')
353
+
354
+ function addRect() {
355
+ if (!canvasRef.value)
356
+ return
357
+ const el = document.createElement('div')
358
+ el.setAttribute('data-el', 'rect')
359
+ const { left, top } = centerPos(180, 10)
360
+ el.style.cssText = `position:absolute;left:${left}px;top:${top}px;width:180px;height:10px;background:${rectColor.value};`
361
+ canvasRef.value.appendChild(el)
362
+ select(el)
363
+ serialize()
364
+ }
365
+
366
+ // --- formatação do elemento selecionado ---
367
+
368
+ const selectedKind = computed(() => {
369
+ void uiTick.value
370
+ return selected.value?.getAttribute('data-el') ?? null
371
+ })
372
+
373
+ const selectedFont = computed(() => {
374
+ void uiTick.value
375
+ const family = selected.value?.style.fontFamily?.split(',')[0]?.replace(/["']/g, '').trim()
376
+ return family || props.docFont.split(',')[0].trim()
377
+ })
378
+
379
+ const selectedSizePt = computed(() => {
380
+ void uiTick.value
381
+ const raw = selected.value?.style.fontSize || '11px'
382
+ const num = Number.parseFloat(raw)
383
+ if (!Number.isFinite(num))
384
+ return 8
385
+ return Math.round(raw.endsWith('pt') ? num : num * 0.75)
386
+ })
387
+
388
+ function applyStyle(prop: string, value: string) {
389
+ const el = selected.value
390
+ if (!el)
391
+ return
392
+ el.style.setProperty(prop, value)
393
+ uiTick.value++
394
+ serialize()
395
+ nextTick(refreshOverlay)
396
+ }
397
+
398
+ function toggleInline(command: 'bold' | 'italic' | 'underline') {
399
+ if (editingText.value) {
400
+ document.execCommand(command)
401
+ serialize()
402
+ return
403
+ }
404
+ const el = selected.value
405
+ if (!el || el.getAttribute('data-el') !== 'text')
406
+ return
407
+ if (command === 'bold')
408
+ el.style.fontWeight = el.style.fontWeight === 'bold' ? '' : 'bold'
409
+ if (command === 'italic')
410
+ el.style.fontStyle = el.style.fontStyle === 'italic' ? '' : 'italic'
411
+ if (command === 'underline')
412
+ el.style.textDecoration = el.style.textDecoration.includes('underline') ? '' : 'underline'
413
+ uiTick.value++
414
+ serialize()
415
+ }
416
+
417
+ /** estica o elemento selecionado de ponta a ponta da página (listras) */
418
+ function stretchFullWidth() {
419
+ const el = selected.value
420
+ if (!el)
421
+ return
422
+ el.style.left = '0px'
423
+ el.style.width = `${props.widthPx}px`
424
+ uiTick.value++
425
+ serialize()
426
+ nextTick(refreshOverlay)
427
+ }
428
+
429
+ function deleteSelected() {
430
+ selected.value?.remove()
431
+ select(null)
432
+ serialize()
433
+ }
434
+
435
+ const isEmpty = computed(() => {
436
+ void uiTick.value
437
+ return !migrate(props.html).trim() && !canvasRef.value?.childElementCount
438
+ })
439
+
440
+ const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const
441
+ </script>
442
+
443
+ <template>
444
+ <div class="ccv-root">
445
+ <!-- toolbar -->
446
+ <div class="ccv-toolbar" @mousedown.prevent>
447
+ <button class="ccv-btn" title="Caixa de texto" @mousedown.stop @click="addText">
448
+ <MinutaIcon name="i-ph-text-t" /> <span class="ccv-btn-label">Texto</span>
449
+ </button>
450
+ <button class="ccv-btn" title="Imagem" @mousedown.stop @click="pickImage">
451
+ <MinutaIcon name="i-ph-image" /> <span class="ccv-btn-label">Imagem</span>
452
+ </button>
453
+ <button class="ccv-btn" title="Retângulo" @mousedown.stop @click="addRect">
454
+ <MinutaIcon name="i-ph-rectangle" /> <span class="ccv-btn-label">Forma</span>
455
+ </button>
456
+ <input ref="imageInput" type="file" accept="image/*" class="ccv-hidden" @change="onImageFile">
457
+
458
+ <template v-if="selectedKind === 'text'">
459
+ <div class="ccv-sep" />
460
+ <select class="ccv-select" :value="selectedFont" title="Fonte" @mousedown.stop @change="applyStyle('font-family', ($event.target as HTMLSelectElement).value)">
461
+ <option v-for="font in FONT_OPTIONS" :key="font" :value="font" :style="{ fontFamily: font }">
462
+ {{ font }}
463
+ </option>
464
+ </select>
465
+ <select class="ccv-select ccv-select--narrow" :value="selectedSizePt" title="Tamanho (pt)" @mousedown.stop @change="applyStyle('font-size', `${($event.target as HTMLSelectElement).value}pt`)">
466
+ <option v-for="pt in SIZE_OPTIONS_PT" :key="pt" :value="pt">
467
+ {{ pt }}
468
+ </option>
469
+ </select>
470
+ <button class="ccv-btn font-bold" title="Negrito" @click="toggleInline('bold')">
471
+ B
472
+ </button>
473
+ <button class="ccv-btn italic" title="Itálico" @click="toggleInline('italic')">
474
+ I
475
+ </button>
476
+ <button class="ccv-btn underline" title="Sublinhado" @click="toggleInline('underline')">
477
+ U
478
+ </button>
479
+ <input
480
+ class="ccv-color" type="color" title="Cor do texto"
481
+ :value="selected?.style.color ? '#000000' : '#334155'"
482
+ @mousedown.stop
483
+ @input="applyStyle('color', ($event.target as HTMLInputElement).value)"
484
+ >
485
+ </template>
486
+ <template v-else-if="selectedKind === 'rect'">
487
+ <div class="ccv-sep" />
488
+ <input
489
+ class="ccv-color" type="color" title="Cor da forma"
490
+ @mousedown.stop
491
+ @input="applyStyle('background', ($event.target as HTMLInputElement).value)"
492
+ >
493
+ </template>
494
+
495
+ <template v-if="selectedKind">
496
+ <div class="ccv-sep" />
497
+ <button class="ccv-btn" title="Esticar de ponta a ponta da página" @click="stretchFullWidth">
498
+ <MinutaIcon name="i-ph-arrows-out-line-horizontal" />
499
+ </button>
500
+ <button class="ccv-btn ccv-btn--danger" title="Excluir (Delete)" @click="deleteSelected">
501
+ <MinutaIcon name="i-ph-trash" />
502
+ </button>
503
+ </template>
504
+
505
+ <span class="ccv-hint">{{ selectedKind ? 'arraste para mover · alças para redimensionar · duplo clique edita texto' : 'clique num elemento para selecionar' }}</span>
506
+ </div>
507
+
508
+ <!-- viewport (faixa em tamanho real, com scroll horizontal se faltar espaço) -->
509
+ <div class="ccv-viewport">
510
+ <div
511
+ ref="wrapperRef"
512
+ class="ccv-band"
513
+ :style="{ width: `${widthPx}px`, height: `${heightPx}px`, fontFamily: docFont }"
514
+ tabindex="0"
515
+ @keydown="onKeydown"
516
+ >
517
+ <div
518
+ ref="canvasRef"
519
+ class="ccv-canvas"
520
+ @pointerdown="onCanvasPointerDown"
521
+ @dblclick="onCanvasDblClick"
522
+ @input="serialize()"
523
+ @blur.capture="exitTextEditing"
524
+ />
525
+ <span v-if="ghost" class="ccv-ghost">{{ ghost }}</span>
526
+ <span v-if="isEmpty" class="ccv-placeholder">{{ placeholder }}</span>
527
+
528
+ <!-- overlay de seleção -->
529
+ <div
530
+ v-if="overlay.visible"
531
+ class="ccv-overlay"
532
+ :style="{ left: `${overlay.left}px`, top: `${overlay.top}px`, width: `${overlay.width}px`, height: `${overlay.height}px` }"
533
+ >
534
+ <span
535
+ v-for="handle in HANDLES"
536
+ :key="handle"
537
+ class="ccv-handle"
538
+ :class="`ccv-handle--${handle}`"
539
+ @pointerdown="onHandleDown($event, handle)"
540
+ />
541
+ </div>
542
+ </div>
543
+ </div>
544
+ </div>
545
+ </template>
546
+
547
+ <style scoped>
548
+ .ccv-root{display:flex;flex-direction:column;max-width:100%;width:-moz-fit-content;width:fit-content}.ccv-toolbar{background:#fafbfc;border:.5px solid #e6e8f0;border-bottom:none;border-radius:8px 8px 0 0;flex-wrap:wrap;gap:2px;padding:4px 6px}.ccv-btn,.ccv-toolbar{align-items:center;display:flex}.ccv-btn{border-radius:6px;color:#475569;font-size:13px;gap:4px;height:26px;justify-content:center;min-width:26px;padding:0 6px;transition:background .15s ease}.ccv-btn:hover{background:#eef1f4}.ccv-btn--danger:hover{background:#fee2e2;color:#dc2626}.ccv-btn-label{font-size:12px}.ccv-sep{background:#e6e8f0;height:16px;margin:0 4px;width:1px}.ccv-select{background:#fff;border:.5px solid #e5e7eb;border-radius:6px;color:#374151;font-size:12px;max-width:130px;outline:none;padding:2px 4px}.ccv-select--narrow{max-width:56px}.ccv-color{background:none;border:1px solid #e5e7eb;border-radius:4px;cursor:pointer;height:22px;padding:0;width:26px}.ccv-hint{color:#9ca3af;font-size:11px;margin-left:auto;padding-right:6px}.ccv-viewport{border:.5px solid #e6e8f0;border-radius:0 0 8px 8px;max-width:100%;overflow-x:auto}.ccv-band{background:#fff;background-image:repeating-linear-gradient(45deg,transparent,transparent 12px,rgba(99,102,241,.02) 0,rgba(99,102,241,.02) 24px);box-sizing:border-box;outline:none;overflow:hidden;position:relative}.ccv-canvas{inset:0;position:absolute}.ccv-canvas :deep([data-el]){cursor:move;-webkit-user-select:none;-moz-user-select:none;user-select:none}.ccv-canvas :deep([data-el=text]){color:#64748b;font-size:11px;line-height:1.4;min-height:12px;min-width:12px;white-space:pre-wrap}.ccv-canvas :deep([data-el=text][contenteditable=true]){background:rgba(99,102,241,.03);cursor:text;outline:1px dashed #c7d2fe;-webkit-user-select:text;-moz-user-select:text;user-select:text}.ccv-ghost{bottom:8px;color:#94a3b8;font-size:11px;opacity:.65;right:24px}.ccv-ghost,.ccv-placeholder{pointer-events:none;position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none}.ccv-placeholder{color:#b6bec7;font-size:12px;left:24px;top:50%;transform:translateY(-50%)}.ccv-overlay{pointer-events:none}.ccv-handle,.ccv-overlay{border:1px solid #6366f1;position:absolute}.ccv-handle{background:#fff;border-radius:2px;height:8px;pointer-events:auto;width:8px}.ccv-handle--nw{cursor:nwse-resize;left:-4px;top:-4px}.ccv-handle--n{cursor:ns-resize;left:calc(50% - 4px);top:-4px}.ccv-handle--ne{cursor:nesw-resize;right:-4px;top:-4px}.ccv-handle--e{cursor:ew-resize;right:-4px;top:calc(50% - 4px)}.ccv-handle--se{bottom:-4px;cursor:nwse-resize;right:-4px}.ccv-handle--s{bottom:-4px;cursor:ns-resize;left:calc(50% - 4px)}.ccv-handle--sw{bottom:-4px;cursor:nesw-resize;left:-4px}.ccv-handle--w{cursor:ew-resize;left:-4px;top:calc(50% - 4px)}.ccv-hidden{display:none}
549
+ </style>