feat: Agregar visualizador de preview para templates
- Nuevo composable usePreview.ts para procesar operaciones en líneas de preview - Nuevo componente PaperSimulator.vue que simula el papel térmico - Nuevo modal PreviewModal.vue para vista previa con edición inline de variables - Botón "Vista previa" agregado a TemplateCard.vue - Integración del modal en TemplateList.vue
This commit is contained in:
190
app/components/preview/PaperSimulator.vue
Normal file
190
app/components/preview/PaperSimulator.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PreviewLine, TextSegment, FontType } from '~/composables/usePreview'
|
||||||
|
import { CHAR_LIMITS } from '~/composables/usePreview'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lines: PreviewLine[]
|
||||||
|
font: FontType
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'edit-variable': [variableName: string, currentValue: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Ancho del papel según la fuente
|
||||||
|
const paperWidth = computed(() => {
|
||||||
|
return props.font === 'font_b' ? '40ch' : '33ch'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Obtener clases CSS para una línea
|
||||||
|
function getLineClasses(line: PreviewLine): string[] {
|
||||||
|
const classes: string[] = []
|
||||||
|
|
||||||
|
if (line.bold) classes.push('font-bold')
|
||||||
|
if (line.underline) classes.push('underline')
|
||||||
|
if (line.exceedsLimit) classes.push('line-exceeds')
|
||||||
|
if (line.doubleWidth) classes.push('text-double-width')
|
||||||
|
if (line.doubleHeight) classes.push('text-double-height')
|
||||||
|
|
||||||
|
// Alineación
|
||||||
|
if (line.align === 'center') classes.push('text-center')
|
||||||
|
else if (line.align === 'right') classes.push('text-right')
|
||||||
|
else classes.push('text-left')
|
||||||
|
|
||||||
|
return classes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar click en variable
|
||||||
|
function handleVariableClick(segment: TextSegment) {
|
||||||
|
if (segment.type === 'variable' && segment.variableName) {
|
||||||
|
emit('edit-variable', segment.variableName, segment.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="paper-simulator"
|
||||||
|
:style="{ width: paperWidth }"
|
||||||
|
>
|
||||||
|
<template v-for="(line, index) in lines" :key="index">
|
||||||
|
<!-- Línea vacía (feed) -->
|
||||||
|
<div v-if="line.isFeed" class="line-feed"> </div>
|
||||||
|
|
||||||
|
<!-- Línea con contenido -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="line"
|
||||||
|
:class="getLineClasses(line)"
|
||||||
|
>
|
||||||
|
<template v-for="(segment, segIndex) in line.segments" :key="segIndex">
|
||||||
|
<!-- Segmento de variable (clickeable) -->
|
||||||
|
<span
|
||||||
|
v-if="segment.type === 'variable'"
|
||||||
|
class="variable"
|
||||||
|
@click="handleVariableClick(segment)"
|
||||||
|
:title="`Variable: ${segment.variableName} (click para editar)`"
|
||||||
|
>{{ segment.content }}</span>
|
||||||
|
|
||||||
|
<!-- Segmento de texto normal -->
|
||||||
|
<span v-else>{{ segment.content }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Indicador de papel vacío -->
|
||||||
|
<div v-if="lines.length === 0" class="empty-paper">
|
||||||
|
<span class="text-gray-400">Vista previa vacía</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Contenedor del papel - simula papel térmico */
|
||||||
|
.paper-simulator {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
background: linear-gradient(to bottom, #fefefe 0%, #f8f8f8 100%);
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.1),
|
||||||
|
inset 0 0 20px rgba(0, 0, 0, 0.02);
|
||||||
|
/* Simular textura de papel */
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to bottom, #fefefe 0%, #f8f8f8 100%),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 1px,
|
||||||
|
rgba(0, 0, 0, 0.01) 1px,
|
||||||
|
rgba(0, 0, 0, 0.01) 2px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cada línea del ticket */
|
||||||
|
.line {
|
||||||
|
white-space: pre; /* Preservar espacios */
|
||||||
|
min-height: 1.4em;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Línea vacía (feed) */
|
||||||
|
.line-feed {
|
||||||
|
height: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Doble ancho - escalar horizontalmente */
|
||||||
|
.text-double-width {
|
||||||
|
transform: scaleX(2);
|
||||||
|
transform-origin: left;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Doble alto - escalar verticalmente */
|
||||||
|
.text-double-height {
|
||||||
|
transform: scaleY(1.5);
|
||||||
|
transform-origin: top;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Línea que excede el límite */
|
||||||
|
.line-exceeds {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-exceeds::after {
|
||||||
|
content: '⚠';
|
||||||
|
position: absolute;
|
||||||
|
right: -20px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variable editable - fondo azul suave */
|
||||||
|
.variable {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Papel vacío */
|
||||||
|
.empty-paper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar estilizado */
|
||||||
|
.paper-simulator::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-simulator::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-simulator::-webkit-scrollbar-thumb {
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-simulator::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #aaa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
228
app/components/preview/PreviewModal.vue
Normal file
228
app/components/preview/PreviewModal.vue
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrintTemplate } from '~/composables/useTemplates'
|
||||||
|
import type { Operation } from '~/composables/usePrintQueue'
|
||||||
|
import { usePreview, CHAR_LIMITS } from '~/composables/usePreview'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
template: PrintTemplate | null
|
||||||
|
modelValue: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'print': [operations: Operation[], variables: Record<string, string>]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { previewState, variableValues, generatePreview, updateVariable, extractVariables } = usePreview()
|
||||||
|
|
||||||
|
// Estado local para edición de variables
|
||||||
|
const editingVariable = ref<string | null>(null)
|
||||||
|
const editingValue = ref('')
|
||||||
|
const editInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
// Variables extraídas del template
|
||||||
|
const variables = computed(() => {
|
||||||
|
if (!props.template) return []
|
||||||
|
return extractVariables(props.template.operations)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inicializar preview cuando se abre el modal
|
||||||
|
watch(() => props.modelValue, (isOpen) => {
|
||||||
|
if (isOpen && props.template) {
|
||||||
|
// Inicializar valores de variables con defaults
|
||||||
|
const initialValues: Record<string, string> = {}
|
||||||
|
for (const v of variables.value) {
|
||||||
|
initialValues[v.name] = v.defaultValue || v.name
|
||||||
|
}
|
||||||
|
generatePreview(props.template.operations, initialValues)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Manejar click en variable para editar
|
||||||
|
function handleEditVariable(variableName: string, currentValue: string) {
|
||||||
|
editingVariable.value = variableName
|
||||||
|
editingValue.value = currentValue
|
||||||
|
// Focus en el input después de renderizar
|
||||||
|
nextTick(() => {
|
||||||
|
editInputRef.value?.focus()
|
||||||
|
editInputRef.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmar edición de variable
|
||||||
|
function confirmEdit() {
|
||||||
|
if (editingVariable.value && props.template) {
|
||||||
|
updateVariable(editingVariable.value, editingValue.value, props.template.operations)
|
||||||
|
}
|
||||||
|
editingVariable.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelar edición
|
||||||
|
function cancelEdit() {
|
||||||
|
editingVariable.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar teclas en el input
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
confirmEdit()
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
cancelEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cerrar modal
|
||||||
|
function closeModal() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imprimir
|
||||||
|
function handlePrint() {
|
||||||
|
if (props.template) {
|
||||||
|
emit('print', props.template.operations, { ...variableValues.value })
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info del preview
|
||||||
|
const previewInfo = computed(() => {
|
||||||
|
if (!previewState.value) return null
|
||||||
|
const limits = CHAR_LIMITS[previewState.value.font]
|
||||||
|
return {
|
||||||
|
font: previewState.value.font === 'font_a' ? 'Font A' : 'Font B',
|
||||||
|
chars: limits.normal,
|
||||||
|
lines: previewState.value.totalLines,
|
||||||
|
warnings: previewState.value.warnings.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
:ui="{
|
||||||
|
width: 'max-w-2xl'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between w-full">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UIcon name="i-heroicons-eye" class="text-primary-500" />
|
||||||
|
<span class="font-medium">Vista Previa</span>
|
||||||
|
<span v-if="template" class="text-gray-500">- {{ template.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Editor de variable inline (aparece sobre el paper) -->
|
||||||
|
<div
|
||||||
|
v-if="editingVariable"
|
||||||
|
class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||||
|
{{ editingVariable }}:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref="editInputRef"
|
||||||
|
v-model="editingValue"
|
||||||
|
type="text"
|
||||||
|
class="flex-1 px-2 py-1 text-sm border border-blue-300 dark:border-blue-600 rounded bg-white dark:bg-gray-800 font-mono"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@blur="confirmEdit"
|
||||||
|
/>
|
||||||
|
<UButton size="xs" color="primary" @click="confirmEdit">
|
||||||
|
<UIcon name="i-heroicons-check" />
|
||||||
|
</UButton>
|
||||||
|
<UButton size="xs" variant="ghost" @click="cancelEdit">
|
||||||
|
<UIcon name="i-heroicons-x-mark" />
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Simulador de papel -->
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<PreviewPaperSimulator
|
||||||
|
v-if="previewState"
|
||||||
|
:lines="previewState.lines"
|
||||||
|
:font="previewState.font"
|
||||||
|
@edit-variable="handleEditVariable"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barra de información -->
|
||||||
|
<div
|
||||||
|
v-if="previewInfo"
|
||||||
|
class="flex items-center justify-center gap-4 text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<UIcon name="i-heroicons-document-text" class="w-4 h-4" />
|
||||||
|
{{ previewInfo.font }} ({{ previewInfo.chars }} chars)
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<UIcon name="i-heroicons-bars-3" class="w-4 h-4" />
|
||||||
|
{{ previewInfo.lines }} líneas
|
||||||
|
</span>
|
||||||
|
<template v-if="previewInfo.warnings > 0">
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span class="flex items-center gap-1 text-amber-500">
|
||||||
|
<UIcon name="i-heroicons-exclamation-triangle" class="w-4 h-4" />
|
||||||
|
{{ previewInfo.warnings }} {{ previewInfo.warnings === 1 ? 'advertencia' : 'advertencias' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span class="flex items-center gap-1 text-green-500">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="w-4 h-4" />
|
||||||
|
OK
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de warnings -->
|
||||||
|
<div
|
||||||
|
v-if="previewState?.warnings.length"
|
||||||
|
class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<UIcon name="i-heroicons-exclamation-triangle" class="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="font-medium text-amber-700 dark:text-amber-300">Advertencias:</p>
|
||||||
|
<ul class="mt-1 space-y-1 text-amber-600 dark:text-amber-400">
|
||||||
|
<li v-for="(warning, i) in previewState.warnings" :key="i">
|
||||||
|
{{ warning }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tip de uso -->
|
||||||
|
<p class="text-xs text-center text-gray-400 dark:text-gray-500">
|
||||||
|
<UIcon name="i-heroicons-light-bulb" class="w-3 h-3 inline" />
|
||||||
|
Click en los valores resaltados en azul para editarlos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton variant="ghost" @click="closeModal">
|
||||||
|
Cancelar
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
icon="i-heroicons-printer"
|
||||||
|
@click="handlePrint"
|
||||||
|
:disabled="!previewState || previewState.lines.length === 0"
|
||||||
|
>
|
||||||
|
Imprimir
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
load: [id: string]
|
load: [id: string]
|
||||||
|
preview: [id: string]
|
||||||
duplicate: [id: string]
|
duplicate: [id: string]
|
||||||
delete: [id: string]
|
delete: [id: string]
|
||||||
}>()
|
}>()
|
||||||
@@ -70,14 +71,24 @@ function formatDate(date: string | number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-eye"
|
||||||
|
variant="outline"
|
||||||
|
class="flex-1"
|
||||||
|
@click="$emit('preview', template.id)"
|
||||||
|
>
|
||||||
|
Vista previa
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-play"
|
icon="i-heroicons-play"
|
||||||
color="primary"
|
color="primary"
|
||||||
block
|
class="flex-1"
|
||||||
@click="$emit('load', template.id)"
|
@click="$emit('load', template.id)"
|
||||||
>
|
>
|
||||||
Cargar en cola
|
Cargar
|
||||||
</UButton>
|
</UButton>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PrintTemplate } from '~/composables/useTemplates'
|
import type { PrintTemplate } from '~/composables/useTemplates'
|
||||||
|
import type { Operation } from '~/composables/usePrintQueue'
|
||||||
import { resolveVariables } from '~/composables/useTemplates'
|
import { resolveVariables } from '~/composables/useTemplates'
|
||||||
|
|
||||||
const templates = useTemplates()
|
const templates = useTemplates()
|
||||||
@@ -10,6 +11,10 @@ const toast = useToast()
|
|||||||
const variablesDrawerOpen = ref(false)
|
const variablesDrawerOpen = ref(false)
|
||||||
const selectedTemplate = ref<PrintTemplate | null>(null)
|
const selectedTemplate = ref<PrintTemplate | null>(null)
|
||||||
|
|
||||||
|
// Estado para el modal de preview
|
||||||
|
const previewModalOpen = ref(false)
|
||||||
|
const previewTemplate = ref<PrintTemplate | null>(null)
|
||||||
|
|
||||||
// Cargar templates al montar
|
// Cargar templates al montar
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
templates.fetchTemplates()
|
templates.fetchTemplates()
|
||||||
@@ -62,6 +67,40 @@ async function deleteTemplate(id: string) {
|
|||||||
toast.add({ title: 'Error al eliminar template', color: 'error' })
|
toast.add({ title: 'Error al eliminar template', color: 'error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Abrir preview de un template
|
||||||
|
function openPreview(id: string) {
|
||||||
|
const template = templates.templates.value.find(t => t.id === id)
|
||||||
|
if (template) {
|
||||||
|
previewTemplate.value = template
|
||||||
|
previewModalOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imprimir desde el preview
|
||||||
|
async function handlePrintFromPreview(operations: Operation[], variables: Record<string, string>) {
|
||||||
|
// Resolver variables en las operaciones
|
||||||
|
const resolvedOps = resolveVariables(operations, variables)
|
||||||
|
|
||||||
|
// Enviar a imprimir directamente
|
||||||
|
try {
|
||||||
|
const result = await $fetch('/api/print', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { operations: resolvedOps }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
toast.add({ title: 'Enviado a imprimir', color: 'success' })
|
||||||
|
} else {
|
||||||
|
toast.add({ title: result.error || 'Error al imprimir', color: 'error' })
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.add({ title: error.message || 'Error al imprimir', color: 'error' })
|
||||||
|
}
|
||||||
|
|
||||||
|
previewModalOpen.value = false
|
||||||
|
previewTemplate.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -95,6 +134,7 @@ async function deleteTemplate(id: string) {
|
|||||||
:key="template.id"
|
:key="template.id"
|
||||||
:template="template"
|
:template="template"
|
||||||
@load="loadTemplate"
|
@load="loadTemplate"
|
||||||
|
@preview="openPreview"
|
||||||
@duplicate="duplicateTemplate"
|
@duplicate="duplicateTemplate"
|
||||||
@delete="deleteTemplate"
|
@delete="deleteTemplate"
|
||||||
/>
|
/>
|
||||||
@@ -107,5 +147,12 @@ async function deleteTemplate(id: string) {
|
|||||||
@update:open="variablesDrawerOpen = $event"
|
@update:open="variablesDrawerOpen = $event"
|
||||||
@load="handleLoadWithVariables"
|
@load="handleLoadWithVariables"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Modal de vista previa -->
|
||||||
|
<PreviewModal
|
||||||
|
v-model="previewModalOpen"
|
||||||
|
:template="previewTemplate"
|
||||||
|
@print="handlePrintFromPreview"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
266
app/composables/usePreview.ts
Normal file
266
app/composables/usePreview.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import type { Operation } from './usePrintQueue'
|
||||||
|
|
||||||
|
// Configuración de caracteres por línea según fuente y ancho
|
||||||
|
export const CHAR_LIMITS = {
|
||||||
|
font_a: { normal: 33, doubleWidth: 16 },
|
||||||
|
font_b: { normal: 40, doubleWidth: 20 }
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type FontType = 'font_a' | 'font_b'
|
||||||
|
export type AlignType = 'left' | 'center' | 'right'
|
||||||
|
|
||||||
|
// Segmento de texto: puede ser texto normal o una variable
|
||||||
|
export interface TextSegment {
|
||||||
|
type: 'text' | 'variable'
|
||||||
|
content: string
|
||||||
|
variableName?: string // Solo para type='variable'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Una línea en el preview
|
||||||
|
export interface PreviewLine {
|
||||||
|
segments: TextSegment[]
|
||||||
|
fullText: string // Texto completo para calcular longitud
|
||||||
|
align: AlignType
|
||||||
|
bold: boolean
|
||||||
|
underline: boolean
|
||||||
|
doubleWidth: boolean
|
||||||
|
doubleHeight: boolean
|
||||||
|
font: FontType
|
||||||
|
charLimit: number
|
||||||
|
exceedsLimit: boolean
|
||||||
|
isFeed: boolean // true si es una línea vacía (feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado del preview
|
||||||
|
export interface PreviewState {
|
||||||
|
lines: PreviewLine[]
|
||||||
|
font: FontType
|
||||||
|
warnings: string[]
|
||||||
|
totalLines: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex para detectar variables: {{nombre}} o {{nombre:label:default}}
|
||||||
|
const VARIABLE_REGEX = /\{\{(\w+)(?::([^:}]+))?(?::([^}]+))?\}\}/g
|
||||||
|
|
||||||
|
// Parsear texto para extraer segmentos (texto normal y variables)
|
||||||
|
export function parseTextSegments(text: string): TextSegment[] {
|
||||||
|
const segments: TextSegment[] = []
|
||||||
|
let lastIndex = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
// Reset regex
|
||||||
|
VARIABLE_REGEX.lastIndex = 0
|
||||||
|
|
||||||
|
while ((match = VARIABLE_REGEX.exec(text)) !== null) {
|
||||||
|
// Agregar texto antes de la variable
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
segments.push({
|
||||||
|
type: 'text',
|
||||||
|
content: text.slice(lastIndex, match.index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar la variable
|
||||||
|
const [fullMatch, name, _label, defaultValue] = match
|
||||||
|
segments.push({
|
||||||
|
type: 'variable',
|
||||||
|
content: defaultValue || name, // Mostrar default o nombre
|
||||||
|
variableName: name
|
||||||
|
})
|
||||||
|
|
||||||
|
lastIndex = match.index + fullMatch.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar texto restante
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
segments.push({
|
||||||
|
type: 'text',
|
||||||
|
content: text.slice(lastIndex)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no hay segmentos, el texto completo es normal
|
||||||
|
if (segments.length === 0 && text) {
|
||||||
|
segments.push({ type: 'text', content: text })
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener el texto completo de los segmentos
|
||||||
|
export function getFullText(segments: TextSegment[]): string {
|
||||||
|
return segments.map(s => s.content).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular límite de caracteres
|
||||||
|
export function getCharLimit(font: FontType, doubleWidth: boolean): number {
|
||||||
|
const limits = CHAR_LIMITS[font]
|
||||||
|
return doubleWidth ? limits.doubleWidth : limits.normal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar operaciones en líneas de preview
|
||||||
|
export function processOperations(
|
||||||
|
operations: Operation[],
|
||||||
|
variableValues: Record<string, string> = {}
|
||||||
|
): PreviewState {
|
||||||
|
const lines: PreviewLine[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
|
// Estado actual (se modifica con cada operación de estilo)
|
||||||
|
let currentState = {
|
||||||
|
font: 'font_a' as FontType,
|
||||||
|
align: 'left' as AlignType,
|
||||||
|
bold: false,
|
||||||
|
underline: false,
|
||||||
|
doubleWidth: false,
|
||||||
|
doubleHeight: false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const op of operations) {
|
||||||
|
switch (op.op) {
|
||||||
|
case 'text': {
|
||||||
|
let text = op.value || ''
|
||||||
|
|
||||||
|
// Reemplazar variables con valores proporcionados
|
||||||
|
const segments = parseTextSegments(text)
|
||||||
|
const resolvedSegments = segments.map(seg => {
|
||||||
|
if (seg.type === 'variable' && seg.variableName && variableValues[seg.variableName]) {
|
||||||
|
return {
|
||||||
|
...seg,
|
||||||
|
content: variableValues[seg.variableName]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seg
|
||||||
|
})
|
||||||
|
|
||||||
|
const fullText = getFullText(resolvedSegments)
|
||||||
|
const charLimit = getCharLimit(currentState.font, currentState.doubleWidth)
|
||||||
|
const exceedsLimit = fullText.length > charLimit
|
||||||
|
|
||||||
|
if (exceedsLimit) {
|
||||||
|
warnings.push(`Línea excede ${charLimit} chars: "${fullText.slice(0, 20)}..."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
segments: resolvedSegments,
|
||||||
|
fullText,
|
||||||
|
...currentState,
|
||||||
|
charLimit,
|
||||||
|
exceedsLimit,
|
||||||
|
isFeed: false
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'textAlign':
|
||||||
|
currentState.align = (op.align as AlignType) || 'left'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'textFont':
|
||||||
|
if (op.font === 'font_a' || op.font === 'font_b') {
|
||||||
|
currentState.font = op.font
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'textSize':
|
||||||
|
currentState.doubleWidth = (op.width || 1) >= 2
|
||||||
|
currentState.doubleHeight = (op.height || 1) >= 2
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'textDouble':
|
||||||
|
currentState.doubleWidth = op.dw === true
|
||||||
|
currentState.doubleHeight = op.dh === true
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'textStyle':
|
||||||
|
if (op.em !== undefined) currentState.bold = op.em
|
||||||
|
if (op.ul !== undefined) currentState.underline = op.ul
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'feed':
|
||||||
|
case 'feedLine': {
|
||||||
|
const lineCount = op.line || op.lines || 1
|
||||||
|
for (let i = 0; i < lineCount; i++) {
|
||||||
|
lines.push({
|
||||||
|
segments: [],
|
||||||
|
fullText: '',
|
||||||
|
...currentState,
|
||||||
|
charLimit: getCharLimit(currentState.font, currentState.doubleWidth),
|
||||||
|
exceedsLimit: false,
|
||||||
|
isFeed: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cut':
|
||||||
|
// El corte no agrega líneas visuales, solo indica fin
|
||||||
|
break
|
||||||
|
|
||||||
|
// Ignorar operaciones que no afectan el preview visual
|
||||||
|
case 'pulse':
|
||||||
|
case 'textRotate':
|
||||||
|
case 'textLineSpace':
|
||||||
|
case 'feedUnit':
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
font: currentState.font,
|
||||||
|
warnings,
|
||||||
|
totalLines: lines.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composable para el preview
|
||||||
|
export function usePreview() {
|
||||||
|
const previewState = ref<PreviewState | null>(null)
|
||||||
|
const variableValues = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
// Procesar operaciones y generar preview
|
||||||
|
function generatePreview(operations: Operation[], initialValues: Record<string, string> = {}) {
|
||||||
|
variableValues.value = { ...initialValues }
|
||||||
|
previewState.value = processOperations(operations, variableValues.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar valor de una variable
|
||||||
|
function updateVariable(name: string, value: string, operations: Operation[]) {
|
||||||
|
variableValues.value[name] = value
|
||||||
|
previewState.value = processOperations(operations, variableValues.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener todas las variables únicas de las operaciones
|
||||||
|
function extractVariables(operations: Operation[]): Array<{ name: string; defaultValue: string }> {
|
||||||
|
const variables = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const op of operations) {
|
||||||
|
if (op.op === 'text' && op.value) {
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
VARIABLE_REGEX.lastIndex = 0
|
||||||
|
|
||||||
|
while ((match = VARIABLE_REGEX.exec(op.value)) !== null) {
|
||||||
|
const [, name, , defaultValue] = match
|
||||||
|
if (!variables.has(name)) {
|
||||||
|
variables.set(name, defaultValue || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(variables.entries()).map(([name, defaultValue]) => ({
|
||||||
|
name,
|
||||||
|
defaultValue
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewState: readonly(previewState),
|
||||||
|
variableValues: readonly(variableValues),
|
||||||
|
generatePreview,
|
||||||
|
updateVariable,
|
||||||
|
extractVariables,
|
||||||
|
CHAR_LIMITS
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user