@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.
- package/README.md +106 -0
- package/dist/module.cjs +5 -0
- package/dist/module.d.mts +18 -0
- package/dist/module.d.ts +18 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +45 -0
- package/dist/runtime/components/MinutaAnnotationsGutter.vue +425 -0
- package/dist/runtime/components/MinutaChatEditCard.vue +124 -0
- package/dist/runtime/components/MinutaChromeCanvas.vue +549 -0
- package/dist/runtime/components/MinutaChromeEditor.vue +100 -0
- package/dist/runtime/components/MinutaDocumentViewer.vue +90 -0
- package/dist/runtime/components/MinutaDocxEditor.vue +1158 -0
- package/dist/runtime/components/MinutaEmbed.vue +783 -0
- package/dist/runtime/components/MinutaIcon.vue +53 -0
- package/dist/runtime/composables/annotationAnchors.d.ts +73 -0
- package/dist/runtime/composables/annotationAnchors.js +243 -0
- package/dist/runtime/composables/chatEditOps.d.ts +37 -0
- package/dist/runtime/composables/chatEditOps.js +61 -0
- package/dist/runtime/composables/docxExport.d.ts +24 -0
- package/dist/runtime/composables/docxExport.js +1292 -0
- package/dist/runtime/composables/editorExtensions.d.ts +56 -0
- package/dist/runtime/composables/editorExtensions.js +466 -0
- package/dist/runtime/composables/paginationPlugin.d.ts +126 -0
- package/dist/runtime/composables/paginationPlugin.js +421 -0
- package/dist/runtime/composables/useMinutaApi.d.ts +112 -0
- package/dist/runtime/composables/useMinutaApi.js +68 -0
- package/dist/runtime/composables/vaultImages.d.ts +12 -0
- package/dist/runtime/composables/vaultImages.js +85 -0
- package/dist/runtime/composables/versionDiff.d.ts +34 -0
- package/dist/runtime/composables/versionDiff.js +62 -0
- package/dist/runtime/server/api/minuta/[...path].d.ts +2 -0
- package/dist/runtime/server/api/minuta/[...path].js +6 -0
- package/dist/runtime/server/api/minuta/index.d.ts +2 -0
- package/dist/runtime/server/api/minuta/index.js +3 -0
- package/dist/runtime/server/utils/minuta-proxy.d.ts +10 -0
- package/dist/runtime/server/utils/minuta-proxy.js +63 -0
- package/dist/runtime/utils/footnoteHtml.d.ts +26 -0
- package/dist/runtime/utils/footnoteHtml.js +117 -0
- package/dist/runtime/utils/pocKeepWithNext.d.ts +44 -0
- package/dist/runtime/utils/pocKeepWithNext.js +9 -0
- package/dist/runtime/utils/sanitizeRichHtml.d.ts +10 -0
- package/dist/runtime/utils/sanitizeRichHtml.js +73 -0
- package/dist/types.d.mts +7 -0
- package/dist/types.d.ts +7 -0
- 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>
|