first commit

This commit is contained in:
brusnitsyn
2025-10-31 16:48:05 +09:00
commit 8b650558e2
143 changed files with 24664 additions and 0 deletions

62
resources/css/app.css Normal file
View File

@@ -0,0 +1,62 @@
@import 'tailwindcss';
@reference "tailwindcss";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@plugin 'tailwind-scrollbar';
@font-face {
font-family: "Golos Sans";
font-weight: 400;
src: url("/assets/fonts/Golos-Text_Regular.woff2");
}
@font-face {
font-family: "Golos Sans";
font-weight: 500;
src: url("/assets/fonts/Golos-Text_Medium.woff2");
}
@font-face {
font-family: "Golos Sans";
font-weight: 600;
src: url("/assets/fonts/Golos-Text_DemiBold.woff2");
}
@font-face {
font-family: "Golos Sans";
font-weight: 700;
src: url("/assets/fonts/Golos-Text_Bold.woff2");
}
@font-face {
font-family: "Golos Sans";
font-weight: 800;
src: url("../fonts/Golos-Text_Black.woff2");
}
@theme {
--font-sans: 'Golos Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
/*hover:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 bg-transparent dark:bg-white/5*/
*::-webkit-scrollbar {
@apply w-2;
}
*::-webkit-scrollbar-track {
@apply bg-transparent;
}
*::-webkit-scrollbar-thumb {
@apply bg-white/10 dark:bg-white/10 rounded-lg;
}
*::-webkit-scrollbar-thumb:hover {
@apply bg-white/10 dark:bg-white/10 rounded-lg;
}

View File

@@ -0,0 +1,38 @@
<script setup>
import { ref, provide, onUnmounted } from 'vue'
const props = defineProps({
// ID элемента, который открыт по умолчанию
opened: {
type: [String, Number],
default: null
}
})
const activeItem = ref(props.opened)
const registeredItems = ref(new Set())
const accordionManager = {
activeItem,
open: (id) => {
activeItem.value = id
},
close: () => {
activeItem.value = null
},
registerItem: (id) => {
registeredItems.value.add(id)
},
unregisterItem: (id) => {
registeredItems.value.delete(id)
}
};
provide('accordionManager', accordionManager)
</script>
<template>
<div class="space-y-4">
<slot />
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup>
import {computed, ref} from "vue";
const props = defineProps({
variant: {
type: String,
validator(value, props) {
return ['success', 'warning', 'danger', 'info'].includes(value)
}
}
})
const baseClasses = [
'inline-flex',
'items-center',
'gap-x-1.5',
'rounded-md',
'px-1.5',
'py-0.5',
'text-sm/5',
'font-medium',
'sm:text-xs/5',
'forced-colors:outline',
]
const successColorClasses = [
'bg-lime-400/20',
'text-lime-700',
'group-data-hover:bg-lime-400/30',
'dark:bg-lime-400/10',
'dark:text-lime-300',
'dark:group-data-hover:bg-lime-400/15'
]
const warningColorClasses = [
'bg-amber-400/20',
'text-amber-700',
'group-data-hover:bg-amber-400/25',
'dark:bg-amber-400/10',
'dark:text-amber-400',
'dark:group-data-hover:bg-amber-400/20'
]
const dangerColorClasses = [
'bg-rose-400/20',
'text-rose-700',
'group-data-hover:bg-rose-400/25',
'dark:bg-rose-400/10',
'dark:text-rose-400',
'dark:group-data-hover:bg-rose-400/20'
]
const infoColorClasses = [
'bg-sky-400/20',
'text-sky-700',
'group-data-hover:bg-sky-400/25',
'dark:bg-sky-400/10',
'dark:text-sky-400',
'dark:group-data-hover:bg-sky-400/20'
]
const primaryColorClasses = [
'bg-orange-400/20',
'text-orange-700',
'group-data-hover:bg-orange-400/25',
'dark:bg-orange-400/10',
'dark:text-orange-400',
'dark:group-data-hover:bg-orange-400/20'
]
const colorClasses = {
success: successColorClasses,
warning: warningColorClasses,
danger: dangerColorClasses,
info: infoColorClasses,
primary: primaryColorClasses
}
const computedClasses = computed(() => {
return [
...baseClasses,
...(colorClasses[props.variant] || [])
]
})
</script>
<template>
<div :data-variant="variant" :class="computedClasses">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,212 @@
<script setup>
import {computed, ref, watch} from "vue";
const emits = defineEmits([
'click'
])
const props = defineProps({
tag: {
type: [String, Object],
default: 'button'
},
href: {
type: String,
default: null
},
icon: {
type: Boolean,
default: false
},
block: {
type: Boolean,
default: false
},
variant: {
type: String,
default: 'default'
},
loading: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
textAlign: {
type: String,
default: 'left'
},
iconLeft: {
type: Boolean,
default: false
},
iconRight: {
type: Boolean,
default: false
},
maxWidth: {
type: [String, Number],
default: 'none'
},
})
const baseClasses = [
'group', 'cursor-pointer', 'relative', 'block', 'appearance-none', 'rounded-lg', 'text-left', 'text-base/6',
'sm:text-sm/6', 'border', 'active:scale-[.99]',
'transition-all'
]
const paddingClasses = [
'py-[calc(--spacing(2.5)-1px)]', 'sm:py-[calc(--spacing(1.5)-1px)]',
'px-[calc(--spacing(3.5)-1px)]', 'sm:px-[calc(--spacing(2.5)-1px)]',
]
const paddingClassesIcon = [
'py-[calc(--spacing(2.5)-1px)]', 'sm:py-[calc(--spacing(2.5)-1px)]',
'px-[calc(--spacing(2.5)-1px)]', 'sm:px-[calc(--spacing(2.5)-1px)]',
]
const variants = {
default: [
'text-zinc-950', 'placeholder:text-zinc-500', 'dark:text-white', 'border-zinc-950/10',
'hover:border-zinc-950/20', 'dark:border-white/10', 'dark:hover:border-white/20', 'bg-transparent',
'dark:bg-white/5'
],
warning: [
'text-white', 'placeholder:text-amber-500', 'border-amber-900',
'hover:border-amber-400', 'dark:border-amber-700', 'dark:hover:border-amber-600', 'bg-transparent',
'dark:bg-amber-800'
],
danger: [
'placeholder:text-rose-500', 'border-rose-900',
'hover:border-rose-400', 'dark:border-rose-600', 'dark:hover:border-rose-400', 'bg-transparent',
'dark:bg-rose-800'
],
ghost: [
'bg-transparent', 'border-transparent', 'hover:border-zinc-950/20', 'dark:hover:border-white/20',
]
}
const textContainerClasses = computed(() => {
let base = ['flex', 'flex-row', 'gap-x-2', 'relative']
if (props.textAlign) {
const align = {
center: 'justify-center',
left: 'justify-start',
right: 'justify-end',
}
const textAlign = align[props.textAlign]
base = base.concat([textAlign])
}
return base
})
const classes = computed(() => {
let base = [...baseClasses]
if (props.icon) {
base = base.concat(paddingClassesIcon)
} else {
base = base.concat(paddingClasses)
}
if (props.block) {
base = base.concat(['w-full'])
}
if (props.variant) {
base = base.concat(variants[props.variant])
}
return base
})
const textClasses = computed(() => {
let base = ['min-w-0', 'w-full']
// Автоматически рассчитываем максимальную ширину если есть иконки
if (props.maxWidth === 'none') {
if (props.iconLeft && props.iconRight) {
base = base.concat(['max-w-[calc(100%-8rem)]']) // минус 2 иконки
} else if (props.iconLeft || props.iconRight) {
base = base.concat(['max-w-[calc(100%-1.5rem)]']) // минус 1 иконка
}
} else if (props.maxWidth) {
base = base.concat([`max-w-[${props.maxWidth}]`])
}
base = base.concat(['truncate'])
return base
})
const handleClick = (event) => {
if (props.loading || props.disabled) {
event.preventDefault()
return
}
emits('click', event)
}
</script>
<template>
<component :is="tag"
:href="href"
@click="handleClick"
:class="classes"
:disabled="loading || disabled"
v-bind="$attrs">
<div :class="textContainerClasses">
<!-- Спиннер загрузки -->
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center"
>
<div class="flex items-center gap-x-2">
<!-- Анимированный спиннер -->
<div class="flex space-x-1">
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0s"></div>
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
</div>
</div>
</div>
<!-- Основной контент кнопки (скрывается при loading) -->
<div
:class="[
'flex flex-row gap-x-2 items-center transition-opacity duration-200 w-full',
loading ? 'opacity-0' : 'opacity-100'
]"
>
<div
v-if="($slots.iconLeft || (iconLeft && $slots.icon))"
class="shrink-0 size-6 stroke-zinc-500 group-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400"
>
<slot name="iconLeft">
<slot name="icon" />
</slot>
</div>
<div :class="textClasses">
<slot />
</div>
<div
v-if="$slots.iconRight || (iconRight && $slots.icon)"
class="shrink-0 size-6 stroke-zinc-500 group-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400"
>
<slot name="iconRight">
<slot name="icon" />
</slot>
</div>
</div>
</div>
</component>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,433 @@
<script setup>
import { computed, ref, watch } from 'vue'
import {
format,
parse,
startOfMonth,
endOfMonth,
eachDayOfInterval,
startOfWeek,
endOfWeek,
isSameDay,
isSameMonth,
isToday,
addMonths,
subMonths,
isValid
} from 'date-fns'
import { ru } from 'date-fns/locale'
const props = defineProps({
modelValue: {
type: [Date, String, null],
default: null
},
placeholder: {
type: String,
default: 'Выберите дату'
},
disabled: {
type: Boolean,
default: false
},
variant: {
type: String,
default: 'default'
},
format: { // Пользователь вводит любой формат date-fns
type: String,
default: 'dd.MM.yyyy'
},
returnFormatted: {
type: Boolean,
default: true
},
locale: {
type: Object,
default: () => ru
},
block: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const baseClasses = [
'group', 'cursor-pointer', 'relative', 'block', 'appearance-none', 'rounded-lg', 'text-left', 'text-base/6',
'sm:text-sm/6', 'border', 'transition-all', 'disabled:cursor-not-allowed', 'disabled:opacity-50'
]
const paddingClasses = [
'py-[calc(--spacing(2.5)-1px)]', 'sm:py-[calc(--spacing(1.5)-1px)]',
'px-[calc(--spacing(3.5)-1px)]', 'sm:px-[calc(--spacing(2.5)-1px)]',
]
const variants = {
default: [
'text-zinc-950', 'placeholder:text-zinc-500', 'dark:text-white', 'border-zinc-950/10',
'hover:border-zinc-950/20', 'dark:border-white/10', 'dark:hover:border-white/20', 'bg-transparent',
'dark:bg-white/5'
],
warning: [
'text-white', 'placeholder:text-amber-500', 'border-amber-900',
'hover:border-amber-400', 'dark:border-amber-700', 'dark:hover:border-amber-600', 'bg-transparent',
'dark:bg-amber-800'
],
danger: [
'placeholder:text-rose-500', 'border-rose-900',
'hover:border-rose-400', 'dark:border-rose-600', 'dark:hover:border-rose-400', 'bg-transparent',
'dark:bg-rose-800'
],
ghost: [
'bg-transparent', 'border-transparent', 'hover:border-zinc-950/20', 'dark:hover:border-white/20',
'hover:dark:bg-white/5'
]
}
const classes = computed(() => {
let base = [...baseClasses, ...paddingClasses]
if (props.variant) {
base = base.concat(variants[props.variant])
}
if (props.block) base.push('w-full')
return base
})
// Парсинг входящего значения
const parseInputValue = (value) => {
if (!value) return null
if (value instanceof Date) {
return isValid(value) ? value : null
}
if (typeof value === 'string') {
try {
// Пробуем распарсить с текущим форматом
const parsed = parse(value, props.format, new Date(), { locale: props.locale })
if (isValid(parsed)) return parsed
// Пробуем стандартные форматы как fallback
const standardFormats = [
'dd.MM.yyyy',
'yyyy-MM-dd',
'd MMMM yyyy',
'd MMM yyyy',
'dd/MM/yyyy',
'MM/dd/yyyy'
]
for (const fmt of standardFormats) {
const parsed = parse(value, fmt, new Date(), { locale: props.locale })
if (isValid(parsed)) return parsed
}
// Последняя попытка - стандартный парсинг
const parsedDate = new Date(value)
if (isValid(parsedDate)) return parsedDate
} catch (e) {
console.warn('Не удалось распарсить дату:', value)
}
}
return null
}
// Состояния
const isOpen = ref(false)
const selectedDate = ref(props.modelValue ? parseInputValue(props.modelValue) : null)
const currentMonth = ref(selectedDate.value || new Date())
const today = new Date()
// Дни недели
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
// Функция для экранирования текста в формате
const escapeFormat = (formatString) => {
// Если формат уже содержит экранированный текст, оставляем как есть
if (formatString.includes("'")) {
return formatString
}
// Ищем текстовые части и экранируем их
const tokens = formatString.split(/(\s+)/)
let result = ''
let inText = false
for (const token of tokens) {
if (token.trim() === '') {
result += token
continue
}
// Проверяем, является ли токен текстом (не паттерном date-fns)
const isPattern = /^(dd?|MM?M?M?|yy?yy?|EEE?E?|QQ?Q?|ww?|HH?|hh?|mm?|ss?|SSS?|aaaa?|xxxx?|XXXX?|ZZZ?)$/.test(token)
if (!isPattern && !inText) {
result += `'${token}`
inText = true
} else if (!isPattern && inText) {
result += ` ${token}`
} else if (isPattern && inText) {
result += `' ${token}`
inText = false
} else {
result += token
}
}
// Закрываем последнюю текстовую часть
if (inText) {
result += "'"
}
return result
}
// Форматирование даты с помощью date-fns
const formatDate = (date) => {
if (!date || !isValid(date)) return ''
try {
// Пробуем форматировать как есть
return format(date, props.format, { locale: props.locale })
} catch (error) {
console.warn('Ошибка форматирования даты:', error)
// Fallback: пробуем заменить проблемные символы
try {
// Экранируем весь текст, который не является паттерном date-fns
const safeFormat = props.format
.replace(/([^dMyYwWDEHhmsSaZXx']+|'.*?')/g, "'$1'")
.replace(/''/g, "'")
return format(date, safeFormat, { locale: props.locale })
} catch (e) {
// Ultimate fallback
return format(date, 'dd.MM.yyyy', { locale: props.locale })
}
}
}
// Получить дни для календаря
const calendarDays = computed(() => {
const start = startOfWeek(startOfMonth(currentMonth.value), { weekStartsOn: 1 })
const end = endOfWeek(endOfMonth(currentMonth.value), { weekStartsOn: 1 })
return eachDayOfInterval({ start, end })
})
// Текущий месяц и год
const currentMonthYear = computed(() => {
return format(currentMonth.value, 'LLLL yyyy', { locale: props.locale })
})
// Форматированная дата для отображения
const formattedDate = computed(() => {
if (!selectedDate.value) return props.placeholder
return formatDate(selectedDate.value)
})
// Методы
const selectDate = (date) => {
if (!date || props.disabled) return
selectedDate.value = date
if (props.returnFormatted) {
const formattedValue = formatDate(date)
emit('update:modelValue', formattedValue)
} else {
emit('update:modelValue', date)
}
isOpen.value = false
}
const prevMonth = () => {
currentMonth.value = subMonths(currentMonth.value, 1)
}
const nextMonth = () => {
currentMonth.value = addMonths(currentMonth.value, 1)
}
// Вспомогательные методы для проверок
const isDateToday = (date) => isToday(date)
const isDateSelected = (date) => selectedDate.value && isSameDay(date, selectedDate.value)
const isDateCurrentMonth = (date) => isSameMonth(date, currentMonth.value)
// Методы для внешнего использования
const getFormattedDate = (date = null, customFormat = null) => {
const targetDate = date || selectedDate.value
if (!targetDate || !isValid(targetDate)) return null
const formatToUse = customFormat || props.format
try {
return format(targetDate, formatToUse, { locale: props.locale })
} catch (error) {
console.warn('Ошибка форматирования:', error)
return format(targetDate, 'dd.MM.yyyy', { locale: props.locale })
}
}
const getDateObject = () => selectedDate.value
defineExpose({
getFormattedDate,
getDateObject
})
// Закрытие по клику вне компонента
const closeOnClickOutside = (event) => {
if (!event.target.closest('.calendar-container')) {
isOpen.value = false
}
}
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
document.addEventListener('click', closeOnClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', closeOnClickOutside)
})
// Следим за изменением modelValue
watch(() => props.modelValue, (newValue) => {
const parsed = parseInputValue(newValue)
if (parsed && (!selectedDate.value || !isSameDay(parsed, selectedDate.value))) {
selectedDate.value = parsed
if (parsed) {
currentMonth.value = startOfMonth(parsed)
}
}
})
// Следим за изменением формата
watch(() => props.format, (newFormat) => {
// Если дата уже выбрана, переформатируем её
if (selectedDate.value && props.returnFormatted) {
const formattedValue = formatDate(selectedDate.value)
emit('update:modelValue', formattedValue)
}
})
</script>
<template>
<div class="calendar-container relative">
<!-- Триггер -->
<button
:class="classes"
@click="isOpen = !isOpen"
:disabled="disabled"
type="button"
>
<div class="flex flex-row gap-x-2 items-center justify-between w-full">
<span :class="!selectedDate ? 'text-zinc-500 dark:text-zinc-400' : ''">
{{ formattedDate }}
</span>
<svg
class="size-4 stroke-zinc-500 group-disabled:stroke-zinc-600 dark:stroke-zinc-400 transition-transform duration-200 flex-shrink-0"
:class="{ 'rotate-180': isOpen }"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
<!-- Выпадающий календарь -->
<div
v-if="isOpen"
class="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-zinc-900 border border-zinc-950/10 dark:border-white/10 rounded-lg shadow-lg p-4 min-w-64"
>
<!-- Заголовок с навигацией -->
<div class="flex items-center justify-between mb-4">
<button
@click="prevMonth"
class="p-1 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
<svg class="size-4 stroke-current" fill="none" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="text-sm font-medium text-zinc-900 dark:text-white">
{{ currentMonthYear }}
</span>
<button
@click="nextMonth"
class="p-1 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
<svg class="size-4 stroke-current" fill="none" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Дни недели -->
<div class="grid grid-cols-7 gap-1 mb-2">
<div
v-for="day in weekDays"
:key="day"
class="text-xs text-center text-zinc-500 dark:text-zinc-400 py-1"
>
{{ day }}
</div>
</div>
<!-- Дни месяца -->
<div class="grid grid-cols-7 gap-1">
<button
v-for="date in calendarDays"
:key="date.getTime()"
@click="selectDate(date)"
:disabled="disabled"
class="aspect-square p-1 text-sm rounded transition-all duration-200"
:class="[
!isDateCurrentMonth(date) ? 'text-zinc-400 dark:text-zinc-600' : '',
isDateSelected(date)
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
: isDateToday(date)
? 'border border-zinc-900 dark:border-white'
: 'hover:bg-zinc-100 dark:hover:bg-zinc-800',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
]"
>
{{ format(date, 'd', { locale }) }}
</button>
</div>
<!-- Быстрый выбор -->
<div class="flex justify-between mt-4 pt-3 border-t border-zinc-200 dark:border-zinc-700">
<button
@click="selectDate(today)"
class="text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
:disabled="disabled"
>
Сегодня
</button>
<button
@click="selectDate(null)"
class="text-xs text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 transition-colors"
:disabled="disabled"
>
Очистить
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,65 @@
<script setup>
import CardHeader from "./CardHeader.vue";
import CardBack from "./CardBack.vue";
import {computed} from "vue";
const props = defineProps({
header: String,
contentScroll: {
type: Boolean,
default: true
},
contentRelative: {
type: Boolean,
default: true
},
mergeContentClass: {
type: String,
default: ''
}
})
const contentClass = computed(() => {
const classes = ['p-3 h-full']
props.contentRelative ? classes.push('relative') : delete classes.find(cls => cls === 'relative')
if (props.contentScroll) {
classes.push('overflow-y-auto')
delete classes.find(cls => cls === 'overflow-y-clip')
} else {
classes.push('overflow-y-clip')
delete classes.find(cls => cls === 'overflow-y-auto')
}
if (props.mergeContentClass) {
const mergeClasses = props.mergeContentClass.split(' ')
classes.push(...mergeClasses)
}
return classes
})
</script>
<template>
<div class="h-full flex flex-col justify-between overflow-clip rounded-lg bg-white lg:shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
<div class="p-3">
<CardHeader>
<slot v-if="$slots.header" name="header" />
<span v-else>
{{ header }}
</span>
</CardHeader>
</div>
<div :class="contentClass">
<slot />
</div>
<div v-if="$slots.footer" class="p-3">
<slot name="footer" />
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,32 @@
<script setup>
import Button from "../Button/Button.vue"
const emits = defineEmits([
'click'
])
const props = defineProps({
tag: {
type: [String, Object],
default: 'button'
}
})
</script>
<template>
<Button :tag="tag" v-bind:href="$attrs.href" @click="emits('click')" icon-left>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M5 12l6 6"></path>
<path d="M5 12l6-6"></path>
</g>
</svg>
</template>
Вернуться назад
</Button>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<div class="text-sm lg:rounded-lg lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-800 dark:lg:ring-white/10 px-3.5 py-2">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,5 @@
<template>
<div class="text-sm lg:rounded-lg bg-zinc-100 lg:ring-1 lg:ring-zinc-950/10 dark:lg:bg-zinc-800 dark:lg:ring-white/10 px-3.5 py-2">
<slot />
</div>
</template>

View File

@@ -0,0 +1,130 @@
<script setup>
import {ref, computed, inject, onUnmounted} from "vue";
const props = defineProps({
header: String,
defaultOpen: {
type: Boolean,
default: false
},
contentScroll: {
type: Boolean,
default: true
},
contentRelative: {
type: Boolean,
default: true
},
mergeContentClass: {
type: String,
default: ''
},
id: {
type: [String, Number],
default: null
}
});
// Инжектим управление аккордеоном из родителя
const accordionManager = inject('accordionManager', null);
const localOpen = ref(props.defaultOpen)
const isOpen = computed(() => {
if (accordionManager) {
return accordionManager.activeItem.value === props.id;
}
return localOpen.value;
});
const toggle = () => {
if (accordionManager) {
if (isOpen.value) {
accordionManager.close();
} else {
accordionManager.open(props.id);
}
} else {
localOpen.value = !localOpen.value
}
};
// Регистрируем элемент в аккордеоне при монтировании
if (accordionManager && props.id) {
accordionManager.registerItem(props.id);
onUnmounted(() => {
accordionManager.unregisterItem(props.id);
});
}
const contentClass = computed(() => {
const classes = ['h-full transition-all duration-300 ease-in-out'];
if (isOpen.value) {
classes.push('opacity-100 max-h-[1000px]'); // max-h достаточно большой для контента
} else {
classes.push('opacity-0 max-h-0');
}
props.contentRelative ? classes.push('relative') : null;
if (props.contentScroll && isOpen.value) {
classes.push('overflow-y-auto');
} else {
classes.push('overflow-y-clip');
}
if (props.mergeContentClass) {
const mergeClasses = props.mergeContentClass.split(' ');
classes.push(...mergeClasses);
}
return classes;
});
const containerClass = computed(() => {
return [
'h-full flex flex-col justify-between overflow-clip lg:rounded-lg lg:bg-white lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10',
isOpen.value ? 'min-h-[100px]' : ''
];
});
</script>
<template>
<div :class="containerClass" :data-br-id="props.id">
<div class="p-1.5 px-2 pr-2">
<div class="flex items-center justify-between">
<div class="inline-flex gap-x-2 items-center">
<slot v-if="$slots.icon" name="icon" />
<div class="flex-1 cursor-pointer" @click="toggle">
<slot v-if="$slots.header" name="header" />
<span v-else class="block text-sm font-medium">
{{ header }}
</span>
</div>
</div>
<div class="inline-flex gap-x-2">
<slot v-if="$slots['header-extra']" name="header-extra" />
<div class="flex items-center justify-center w-6 h-6 transition-transform duration-300 cursor-pointer"
@click="toggle"
:class="{ 'rotate-180': isOpen }">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</div>
</div>
</div>
</div>
<div :class="contentClass">
<div v-if="isOpen" class="p-3 pt-0">
<slot />
</div>
</div>
<div v-if="$slots.footer && isOpen" class="p-3">
<slot name="footer" />
</div>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
cover: {
type: Boolean,
default: false
}
})
const defaultClasses = [
'w-[21cm] h-[29.7cm] bg-white text-black rounded-sm outline-none'
]
const defaultCoverClasses = [
'p-[2.5cm]'
]
const coverClasses = [
'pt-[2cm] pl-[3cm] pb-[2cm] pr-[1.5cm]'
]
const classes = computed(() => {
let base = [...defaultClasses]
if (props.cover)
base = base.concat(coverClasses)
else
base = base.concat(defaultCoverClasses)
return base
})
</script>
<template>
<div :class="classes">
<div class="min-h-[calc(29.7cm-5cm)] relative">
<slot />
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,61 @@
<script setup>
import {computed, onMounted} from "vue"
import DocumentWarning from "./DocumentWarning.vue"
import DocumentHtml from "./DocumentHtml.vue"
import DocumentHeading from "./DocumentHeading.vue"
import DocumentParagraph from "./DocumentParagraph.vue";
import DocumentTextRun from "./DocumentTextRun.vue";
import DocumentLineBreak from "./DocumentLineBreak.vue";
import DocumentTable from "./DocumentTable.vue";
const props = defineProps({
element: Object
})
const emit = defineEmits(['variable-click', 'is-mounted'])
const componentMap = {
'heading': DocumentHeading,
'html': DocumentHtml,
// 'warning': DocumentWarning,
'paragraph': DocumentParagraph,
'text_run': DocumentTextRun,
'line_break': DocumentLineBreak,
'table': DocumentTable
}
const componentType = computed(() => {
return componentMap[props.element.type] || 'div'
})
const elementClass = computed(() => {
return `element-${props.element.type}`
})
const compiledContent = computed(() => {
return props.element.compiledContent || props.element.content
})
const handleVariableClick = (variableName) => {
emit('variable-click', variableName)
}
onMounted(() => {
emit('is-mounted', true)
})
</script>
<template>
<div class="document-element" :class="elementClass">
<component
:is="componentType"
:content="compiledContent"
:element="element"
@variable-click="handleVariableClick"
/>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,54 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
content: String,
element: Object
})
const styles = computed(() => {
const styles = {}
const style = props.element.style || {}
// Выравнивание
if (style.align) {
styles.textAlign = style.align
}
// Междустрочный интервал
if (style.lineHeight) {
styles.lineHeight = style.lineHeight
}
// Отступы
if (style.spaceBefore) {
styles.marginTop = `${style.spaceBefore}pt`
}
if (style.spaceAfter) {
styles.marginBottom = `${style.spaceAfter}pt`
}
if (style.indent) {
if (style.indent.left) {
styles.marginLeft = `${style.indent.left}pt`
}
if (style.indent.right) {
styles.marginRight = `${style.indent.right}pt`
}
if (style.indent.firstLine) {
styles.textIndent = `${style.indent.firstLine}pt`
}
}
return styles
})
</script>
<template>
<h2 :data-element-id="element.id" :style="styles" class="font-bold" v-html="content"></h2>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,35 @@
<script setup>
defineProps({
content: String
})
</script>
<template>
<div class="html-content mb-4" v-html="content"></div>
</template>
<style scoped>
.html-content {
text-align: justify;
}
.html-content ::v-deep(.placeholder) {
background-color: #fffacd;
border-bottom: 1px dashed #ccc;
cursor: pointer;
padding: 0 2px;
border-radius: 2px;
}
.html-content ::v-deep(table) {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
.html-content ::v-deep(table, th, td) {
border: 1px solid black;
padding: 8px;
text-align: left;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup>
const props = defineProps({
element: {
type: Object,
default: {}
}
})
</script>
<template>
<br :data-element-id="element.id" class="block content-[\'\'] m-0 p-0 h-0" />
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,139 @@
<script setup>
import { computed } from 'vue';
import DocumentText from './DocumentText.vue';
// import DocumentVariable from './DocumentVariable.vue';
import DocumentTextRun from './DocumentTextRun.vue';
import DocumentLineBreak from './DocumentLineBreak.vue';
const props = defineProps({
element: Object,
formData: Object
});
defineEmits(['variable-click']);
const elementComponents = {
text: DocumentText,
// variable: DocumentVariable,
text_run: DocumentTextRun,
line_break: DocumentLineBreak
};
const paragraphStyles = computed(() => {
const styles = {};
const paragraphStyle = props.element.style || {};
// Выравнивание
if (paragraphStyle.align) {
styles.textAlign = paragraphStyle.align;
}
// Междустрочный интервал
if (paragraphStyle.lineHeight) {
styles.lineHeight = paragraphStyle.lineHeight;
}
// Отступы
if (paragraphStyle.spaceBefore) {
styles.marginTop = `${paragraphStyle.spaceBefore}pt`;
}
if (paragraphStyle.spaceAfter) {
styles.marginBottom = `${paragraphStyle.spaceAfter}pt`;
}
if (paragraphStyle.indent) {
if (paragraphStyle.indent.left) {
styles.marginLeft = `${paragraphStyle.indent.left}pt`;
}
if (paragraphStyle.indent.right) {
styles.marginRight = `${paragraphStyle.indent.right}pt`;
}
if (paragraphStyle.indent.firstLine) {
styles.textIndent = `${paragraphStyle.indent.firstLine}pt`;
}
}
return styles;
});
const paragraphClasses = computed(() => {
const classes = ['paragraph-element'];
if (props.element.style?.align) {
classes.push(`align-${props.element.style.align}`);
}
return classes;
});
const getComponentForElement = (element) => {
return elementComponents[element.type] || 'span';
};
</script>
<template>
<p
class="document-paragraph"
:style="paragraphStyles"
:class="paragraphClasses"
>
<template v-if="element.elements" v-for="element in element.elements" :key="element.id">
<component
:is="getComponentForElement(element)"
:element="element"
:form-data="formData"
:is-inline="true"
@variable-click="$emit('variable-click', $event)"
/>
</template>
<template v-else>
{{ element.content }}
</template>
</p>
</template>
<style scoped>
.document-paragraph {
margin: 0.5em 0;
text-align: left;
}
.document-paragraph.align-center {
text-align: center;
}
.document-paragraph.align-right {
text-align: right;
}
.document-paragraph.align-justify {
text-align: justify;
}
.document-paragraph.align-distribute {
text-align: justify;
text-justify: distribute;
}
/* Наследование стилей для вложенных элементов */
.document-paragraph ::v-deep(.text-element) {
display: inline;
}
.document-paragraph ::v-deep(.variable-element) {
display: inline;
}
.document-paragraph ::v-deep(.text-run) {
display: inline;
}
/* Стили для печати */
@media print {
.document-paragraph {
text-align: justify;
margin: 0.75em 0;
}
}
</style>

View File

@@ -0,0 +1,107 @@
<script setup>
import DocumentElement from "./DocumentElement.vue"
import A4 from "./A4.vue";
import {computed, getCurrentInstance, nextTick, onMounted, ref, watch, watchEffect} from "vue";
import {useDynamicA4Layout} from "../../Composables/useDynamicA4Layout.js";
import {waitForAllComponentsMounted} from "../../Utils/heightCalculator.js";
const props = defineProps({
structure: Array,
editable: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['variable-click'])
const {calculateDynamicLayout, componentHeights, a4Pages} = useDynamicA4Layout()
const allElements = computed(() => props.structure.elements)
const componentRefs = ref(new Map())
const handleVariableClick = (variableName) => {
emit('variable-click', variableName)
}
const elementRefs = ref(new Map())
const addToRefs = (el, id) => {
if (el)
elementRefs.value.set(id, el.$el)
}
const hasAllMounted = computed(() => {
// Array.from(elementRefs.value.values())
// .every(ref => ref.value?.$.vnode.el?.isConnected)
})
// watch(() => hasAllMounted, async (newSize, oldSize) => {
// const expectedCount = props.structure?.elements?.length || 0
//
// if (newSize === expectedCount && newSize > 0 && newSize !== oldSize) {
// // Даём время на полное монтирование
// await nextTick()
// await nextTick() // Двойной nextTick для надёжности
//
// console.log(hasAllMounted.value)
//
// // try {
// // await waitForAllComponentsMounted(getCurrentInstance())
// // console.log('Все компоненты смонтированы!')
// // await calculateDynamicLayout(props.structure.elements, () => elementRefs.value)
// // } catch (error) {
// // console.error('Ошибка при ожидании монтирования:', error)
// // }
// }
// })
onMounted(() => {
// console.log(Array.from(elementRefs.value.values())[0].)
})
watch(() => elementRefs.value.size, async () => {
const expectedCount = props.structure?.elements?.length || 0
const currentSize = elementRefs.value.size
if (currentSize > 0 && currentSize === expectedCount) {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 5000))
// console.log('Все компоненты смонтированы!')
await calculateDynamicLayout(props.structure.elements, () => elementRefs.value)
}
}, {
flush: 'post'
})
</script>
<template>
<A4 class="absolute -left-[9999px] -top-[9999px] section collapse document-content overflow-hidden">
<DocumentElement
v-for="element in allElements"
:ref="el => addToRefs(el, element.id)"
:element="element"
/>
</A4>
<A4 v-for="paginatedElement in a4Pages" cover class="section document-content my-2 overflow-hidden">
<template v-for="item in paginatedElement.items" :key="item.id">
<DocumentElement
:contenteditable="editable"
:class="editable ? 'overflow-hidden outline-none' : ''"
:element="item"
@variable-click="handleVariableClick"
/>
</template>
</A4>
</template>
<style scoped>
.document-content {
line-height: 1.2;
font-family: 'Times New Roman', serif;
font-size: 14px;
}
.section {
page-break-inside: avoid;
}
</style>

View File

@@ -0,0 +1,40 @@
<script setup>
import {computed, ref} from "vue";
import DocumentElement from "./DocumentElement.vue";
import DocumentTableRow from "./DocumentTableRow.vue";
const props = defineProps({
content: String,
element: Object,
})
const rows = computed(() => props.element.rows || [])
const cols = computed(() => props.element.cols || 0)
</script>
<template>
<div>
<table :data-element-id="element.id" class="table-element">
<tbody>
<DocumentTableRow @is-mounted="" :rows="rows" />
<!-- <tr-->
<!-- v-for="(row, rowIndex) in rows"-->
<!-- :key="`row-${rowIndex}`"-->
<!-- >-->
<!-- <td-->
<!-- v-for="(cell, colIndex) in row.cells"-->
<!-- :key="`td-${rowIndex}-${colIndex}`"-->
<!-- :style="`width: ${cell.width}px`"-->
<!-- >-->
<!-- <DocumentElement v-for="element in cell.elements" :element="element" :key="element.id" />-->
<!-- </td>-->
<!-- </tr>-->
</tbody>
</table>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
element: Object
})
</script>
<template>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,29 @@
<script setup>
import DocumentElement from "./DocumentElement.vue";
import {onMounted} from "vue";
const props = defineProps({
rows: Array
})
const emits = defineEmits(['is-mounted'])
onMounted(() => {
emits('is-mounted', true)
})
</script>
<template>
<tr
v-for="(row, rowIndex) in rows"
:key="`row-${rowIndex}`"
>
<td v-for="cell in row.cells" :style="`width: ${cell.width}px`">
<DocumentElement v-for="element in cell.elements" :element="element" />
</td>
</tr>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,93 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
parentElement: Object,
element: Object,
formData: Object,
isInline: {
type: Boolean,
default: false
}
});
const displayText = computed(() => {
return props.element.content || '';
});
const textStyles = computed(() => {
const styles = {};
const formatting = props.element.formatting || {};
if (formatting.fontSize) {
styles.fontSize = formatting.fontSize;
}
if (formatting.fontFamily) {
styles.fontFamily = formatting.fontFamily;
}
if (formatting.fontColor) {
styles.color = formatting.fontColor;
}
return styles;
});
const textClasses = computed(() => {
const classes = [];
const formatting = props.element.formatting || {};
if (formatting.bold) {
classes.push('text-bold');
}
if (formatting.italic) {
classes.push('text-italic');
}
if (formatting.underline !== 'none') {
classes.push('text-underline');
}
return classes;
});
</script>
<template>
<span
:data-element-id="element.id"
class="text-element"
:style="textStyles"
:class="textClasses"
>
{{ displayText }}
</span>
</template>
<style scoped>
.text-element {
display: inline;
white-space: pre-wrap;
}
.text-bold {
font-weight: bold;
}
.text-italic {
font-style: italic;
}
.text-underline {
text-decoration: underline;
}
/* Стили для печати */
@media print {
.text-element {
color: black !important;
font-family: 'Times New Roman', serif !important;
}
}
</style>

View File

@@ -0,0 +1,141 @@
<script setup>
import {computed, ref} from 'vue';
import DocumentText from './DocumentText.vue';
// import DocumentVariable from './DocumentVariable.vue';
import DocumentLineBreak from './DocumentLineBreak.vue';
const props = defineProps({
element: Object,
formData: Object
});
defineEmits(['variable-click']);
const elementComponents = {
text: DocumentText,
// variable: DocumentVariable,
line_break: DocumentLineBreak
}
const textRunRef = ref()
const combinedStyles = computed(() => {
const styles = {}
const formatting = props.element.formatting || {}
const paragraphStyle = props.element.style || {}
// Стили шрифта
if (formatting.fontSize) {
styles.fontSize = formatting.fontSize;
}
if (formatting.fontFamily) {
styles.fontFamily = formatting.fontFamily;
}
if (formatting.fontColor) {
styles.color = formatting.fontColor;
}
if (formatting.backgroundColor) {
styles.backgroundColor = formatting.backgroundColor;
}
// Стили параграфа (если есть)
if (paragraphStyle.align) {
styles.textAlign = paragraphStyle.align;
}
if (paragraphStyle.lineHeight) {
styles.lineHeight = paragraphStyle.lineHeight;
}
if (paragraphStyle.indent) {
// console.log(paragraphStyle.indent)
if (paragraphStyle.indent.firstLine)
styles['--first-line-indent'] = `${paragraphStyle.indent.firstLine}px`
}
return styles;
});
const textRunClasses = computed(() => {
// const classes = []
// const style = props.element.style
// if (style && style.indent) {
// if (style.indent.firstLine)
// classes.push(`first:pl-[${style.indent.firstLine}px]`)
// }
//
// return classes
// const classes = ['text-run-element']
//
// const formatting = props.element.formatting || {}
// if (formatting.bold) classes.push('text-bold')
// if (formatting.italic) classes.push('text-italic')
// if (formatting.underline !== 'none') classes.push('text-underline')
// if (formatting.strikethrough) classes.push('text-strikethrough')
//
// return classes;
});
const getComponentForElement = (element) => {
return elementComponents[element.type] || 'span';
};
</script>
<template>
<div ref="textRunRef" :data-element-id="element.id" :style="combinedStyles" >
<template v-for="children in element.elements" :key="children.id" >
<DocumentText
class="first:pl-[var(--first-line-indent)]"
:element="children"
:form-data="formData"
@variable-click="$emit('variable-click', $event)"
/>
</template>
</div>
</template>
<style scoped>
.text-run {
display: inline;
line-height: inherit;
}
.text-run-element {
display: inline;
}
.text-bold {
font-weight: bold;
}
.text-italic {
font-style: italic;
}
.text-underline {
text-decoration: underline;
}
.text-strikethrough {
text-decoration: line-through;
}
/* Наследование стилей для вложенных элементов */
.text-run ::v-deep(.text-element) {
display: inline;
}
.text-run ::v-deep(.variable-element) {
display: inline;
}
.text-run ::v-deep(.line-break) {
display: block;
height: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,24 @@
<script setup>
defineProps({
content: String
})
</script>
<template>
<div class="warning-note bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700" v-html="content"></p>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,136 @@
<script setup>
import Input from "../../Input/Input.vue";
import {computed, watch} from "vue";
const numberModel = defineModel('number')
const textModel = defineModel('text')
// Функция для преобразования числа в текст
const numberToWords = (num) => {
if (num === 0) return 'ноль рублей'
if (num < 0) return 'минус ' + numberToWords(Math.abs(num))
const units = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
const teens = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', 'пятнадцать', 'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать']
const tens = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят', 'восемьдесят', 'девяносто']
const hundreds = ['', 'сто', 'двести', 'триста', 'четыреста', 'пятьсот', 'шестьсот', 'семьсот', 'восемьсот', 'девятьсот']
const thousands = ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
const millions = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
const billions = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
// Функция для преобразования трехзначного числа
const convertThreeDigit = (n, isFemale = false) => {
if (n === 0) return ''
let result = ''
const hundred = Math.floor(n / 100)
const remainder = n % 100
if (hundred > 0) {
result += hundreds[hundred] + ' '
}
if (remainder >= 20) {
const ten = Math.floor(remainder / 10)
const unit = remainder % 10
result += tens[ten] + ' '
if (unit > 0) {
if (isFemale && unit <= 2) {
result += (unit === 1 ? 'одна' : 'две') + ' '
} else {
result += units[unit] + ' '
}
}
} else if (remainder >= 10) {
result += teens[remainder - 10] + ' '
} else if (remainder > 0) {
if (isFemale && remainder <= 2) {
result += (remainder === 1 ? 'одна' : 'две') + ' '
} else {
result += units[remainder] + ' '
}
}
return result.trim()
}
// Функция для получения правильного окончания
const getEnding = (num, forms) => {
const lastDigit = num % 10
const lastTwoDigits = num % 100
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
return forms[2]
}
if (lastDigit === 1) {
return forms[0]
}
if (lastDigit >= 2 && lastDigit <= 4) {
return forms[1]
}
return forms[2]
}
let result = ''
let remaining = num
// Миллиарды
const billionsPart = Math.floor(remaining / 1000000000)
if (billionsPart > 0) {
result += convertThreeDigit(billionsPart) + ' '
result += getEnding(billionsPart, ['миллиард', 'миллиарда', 'миллиардов']) + ' '
remaining %= 1000000000
}
// Миллионы
const millionsPart = Math.floor(remaining / 1000000)
if (millionsPart > 0) {
result += convertThreeDigit(millionsPart) + ' '
result += getEnding(millionsPart, ['миллион', 'миллиона', 'миллионов']) + ' '
remaining %= 1000000
}
// Тысячи
const thousandsPart = Math.floor(remaining / 1000)
if (thousandsPart > 0) {
result += convertThreeDigit(thousandsPart, true) + ' '
result += getEnding(thousandsPart, ['тысяча', 'тысячи', 'тысяч']) + ' '
remaining %= 1000
}
// Сотни, десятки, единицы
if (remaining > 0) {
result += convertThreeDigit(remaining) + ' '
}
// Добавляем рубли с правильным окончанием
result += getEnding(num, ['рубль', 'рубля', 'рублей'])
return result.trim().replace(/\s+/g, ' ')
}
// Вычисляемое свойство для текстового представления
const amountInWords = computed(() => {
const num = Number(numberModel.value) || 0
return numberToWords(num)
})
// Следим за изменениями и обновляем модель
watch(amountInWords, (newValue) => {
textModel.value = `${numberModel.value} руб. (${newValue})`
}, { immediate: true })
// Обработчик изменения ввода
const handleInput = (value) => {
numberModel.value = value
}
</script>
<template>
<Input v-model:value="numberModel" @update:value="handleInput" placeholder="Введите сумму" type="number" />
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,753 @@
<template>
<div class="editor" ref="editor">
<!-- Page overlays (headers, footers, page numbers, ...) -->
<div v-if="overlay" class="overlays" ref="overlays">
<div v-for="(page, page_idx) in pages" class="overlay" :key="page.uuid+'-overlay'" :ref="(elt) => (pages_overlay_refs[page.uuid] = elt)"
v-html="overlay(page_idx+1, pages.length)" :style="page_style(page_idx, false)">
</div>
</div>
<!-- Document editor -->
<div class="content" ref="content" :contenteditable="editable" :style="page_style(-1)" @input="input" @keyup="e => processElement(e)">
<!-- This is a Vue "hoisted" static <div> which contains every page of the document and can be modified by the DOM -->
</div>
<!-- Items related to the document editor (widgets, ...) can be inserted here -->
</div>
</template>
<script setup>
import {computed, defineCustomElement, onBeforeUpdate, onMounted, onUnmounted, ref, watch} from 'vue';
import { move_children_forward_recursively, move_children_backwards_with_merging } from '../Utils/pageTransitionMgmt.js';
const props = defineProps({
// This contains the initial content of the document that can be synced
// It must be an Array: each array item is a new set of pages containing the
// item (string or component). You can see that as predefined page breaks.
// See the Demo.vue file for a good usage example.
// content: {
// type: Array,
// required: true
// },
// Display mode of the pages
display: {
type: String,
default: "grid" // ["grid", "horizontal", "vertical"]
},
// Sets whether document text can be modified
editable: {
type: Boolean,
default: true
},
// Overlay function returning page headers and footers in HTML
overlay: Function,
// Pages format in mm (should be an array containing [width, height])
page_format_mm: {
type: Array,
default: () => [210, 297]
},
// Page margins in CSS
page_margins: {
type: [String, Function],
default: "10mm 15mm"
},
// Display zoom. Only acts on the screen display
zoom: {
type: Number,
default: 1.0
},
// "Do not break" test function: should return true on elements you don't want to be split over multiple pages but rather be moved to the next page
do_not_break: Function
})
const emits = defineEmits(['update:content', 'update:current-style', 'update:activeElement'])
const model = defineModel()
const editor = ref()
const content = ref()
const overlays = ref()
const pages = ref([]) // contains {uuid, content_idx, prev_html, template, props, elt} for each pages of the document
const pages_overlay_refs = ref({}) // contains page overlay ref elements indexed by uuid
const pages_height = ref(0) // real measured page height in px (corresponding to page_format_mm[1])
const editor_width = ref(0) // real measured with of an empty editor <div> in px
const prevent_next_content_update_from_parent = ref(false) // workaround to avoid infinite update loop
const current_text_style = ref(false) // contains the style at caret position
const activeElement = defineModel('active-element')
const activeElements = defineModel('active-elements')
const printing_mode = ref(false) // flag set when page is rendering in printing mode
const reset_in_progress = ref(false)
const fit_in_progress = ref(false)
const _page_body = ref()
const css_media_style = computed(() => {
const style = document.createElement("style");
document.head.appendChild(style);
return style;
})
onMounted(() => {
update_editor_width();
update_css_media_style();
reset_content();
window.addEventListener("resize", update_editor_width);
window.addEventListener("click", processElement);
window.addEventListener("beforeprint", before_print);
window.addEventListener("afterprint", after_print);
})
onBeforeUpdate(() => {
pages_overlay_refs.value = []
})
onUnmounted(() => {
window.removeEventListener("resize", update_editor_width);
window.removeEventListener("click", processElement);
window.removeEventListener("beforeprint", before_print);
window.removeEventListener("afterprint", after_print);
})
// Computes a random 5-char UUID
const new_uuid = () => Math.random().toString(36).slice(-5)
// Resets all content from the content property
const reset_content = () => {
// Prevent launching this function multiple times
if(reset_in_progress.value) return;
reset_in_progress.value = true;
// If provided content is empty, initialize it first and exit
if(!model.value.length) {
reset_in_progress.value = false;
model.value = [""]
// emits("update:content", [""]);
return;
}
// Delete all pages and set one new page per content item
pages.value = model.value.map((content, content_idx) => ({
uuid: new_uuid(),
content_idx,
template: content.template,
props: content.props
}));
update_pages_elts();
// Get page height from first empty page
const first_page_elt = pages.value[0].elt;
if(!content.value.contains(first_page_elt)) content.value.appendChild(first_page_elt); // restore page in DOM in case it was removed
pages_height.value = first_page_elt.clientHeight + 1; // allow one pixel precision
// Initialize text pages
for(const page of pages.value) {
// set raw HTML content
if(!model.value[page.content_idx]) page.elt.innerHTML = "<div><br></div>"; // ensure empty pages are filled with at least <div><br></div>, otherwise editing fails on Chrome
else if(typeof model.value[page.content_idx] == "string") page.elt.innerHTML = "<div>"+model.value[page.content_idx]+"</div>";
else if(page.template) {
const componentElement = defineCustomElement(page.template);
customElements.define('component-'+page.uuid, componentElement);
page.elt.appendChild(new componentElement({ modelValue: page.props }));
}
// restore page in DOM in case it was removed
if(!content.value.contains(page.elt)) content.value.appendChild(page.elt);
}
// Spread content over several pages if it overflows
fit_content_over_pages();
// Remove the text cursor from the content, if any (its position is lost anyway)
content.value.blur();
// Clear "reset in progress" flag
reset_in_progress.value = false;
}
// Spreads the HTML content over several pages until it fits
const fit_content_over_pages = () => {
// Data variable pages_height.value must have been set before calling this function
if(!pages_height.value) return;
// Prevent launching this function multiple times
if(fit_in_progress.value) return;
fit_in_progress.value = true;
// Check pages that were deleted from the DOM (start from the end)
for(let page_idx = pages.value.length - 1; page_idx >= 0; page_idx--) {
const page = pages.value[page_idx];
// if user deleted the page from the DOM, then remove it from pages.value array
if(!page.elt || !document.body.contains(page.elt)) pages.value.splice(page_idx, 1);
}
// If all the document was wiped out, start a new empty document
if(!pages.value.length){
fit_in_progress.value = false; // clear "fit in progress" flag
model.value = [""]
// emits("update:content", [""]);
return;
}
// Save current selection (or cursor position) by inserting empty HTML elements at the start and the end of it
const selection = window.getSelection();
const start_marker = document.createElement("null");
const end_marker = document.createElement("null");
// don't insert markers in case selection fails (if we are editing in components in the shadow-root it selects the page <div> as anchorNode)
if(selection && selection.rangeCount && selection.anchorNode && !(selection.anchorNode.dataset && selection.anchorNode.dataset.isVDEPage != null)) {
const range = selection.getRangeAt(0);
range.insertNode(start_marker);
range.collapse(false);
range.insertNode(end_marker);
}
// Browse every remaining page
let prev_page_modified_flag = false;
for(let page_idx = 0; page_idx < pages.value.length; page_idx++) { // page length can grow inside this loop
const page = pages.value[page_idx];
let next_page = pages.value[page_idx + 1];
let next_page_elt = next_page ? next_page.elt : null;
// check if this page, the next page, or any previous page content has been modified by the user (don't apply to template pages)
if(!page.template && (prev_page_modified_flag || page.elt.innerHTML !== page.prev_innerHTML
|| (next_page_elt && !next_page.template && next_page_elt.innerHTML !== next_page.prev_innerHTML))){
prev_page_modified_flag = true;
// BACKWARD-PROPAGATION
// check if content doesn't overflow, and that next page exists and has the same content_idx
if(page.elt.clientHeight <= pages_height.value && next_page && next_page.content_idx === page.content_idx) {
// try to append every node from the next page until it doesn't fit
move_children_backwards_with_merging(page.elt, next_page_elt, () => page.elt.clientHeight > pages_height.value || !next_page_elt.childNodes.length);
}
// FORWARD-PROPAGATION
// check if content overflows
if(page.elt.clientHeight > pages_height.value) {
// if there is no next page for the same content, create it
if(!next_page || next_page.content_idx !== page.content_idx) {
next_page = { uuid: new_uuid(), content_idx: page.content_idx };
pages.value.splice(page_idx + 1, 0, next_page);
update_pages_elts();
next_page_elt = next_page.elt;
}
console.log(next_page_elt)
// move the content step by step to the next page, until it fits
move_children_forward_recursively(page.elt, next_page_elt, () => (page.elt.clientHeight <= pages_height.value), props.do_not_break);
}
// CLEANING
// remove next page if it is empty
if(next_page_elt && next_page.content_idx === page.content_idx && !next_page_elt.childNodes.length) {
pages.value.splice(page_idx + 1, 1);
}
}
// update pages in the DOM
update_pages_elts();
}
// Normalize pages HTML content
for(const page of pages.value) {
if(!page.template) page.elt.normalize(); // normalize HTML (merge text nodes) - don't touch template pages or it can break Vue
}
// Restore selection and remove empty elements
if(document.body.contains(start_marker)){
const range = document.createRange();
range.setStart(start_marker, 0);
if(document.body.contains(end_marker)) range.setEnd(end_marker, 0);
selection.removeAllRanges();
selection.addRange(range);
}
if(start_marker.parentElement) start_marker.parentElement.removeChild(start_marker);
if(end_marker.parentElement) end_marker.parentElement.removeChild(end_marker);
// Store pages HTML content
for(const page of pages.value) {
page.prev_innerHTML = page.elt.innerHTML; // store current pages innerHTML for next call
}
// Clear "fit in progress" flag
fit_in_progress.value = false;
}
// Input event
const input = (e) => {
if(!e) return; // check that event is set
fit_content_over_pages(); // fit content according to modifications
emit_new_content(); // emit content modification
if(e.inputType !== "insertText") processElement(); // update current style if it has changed
}
// Emit content change to parent
const emit_new_content = () => {
let removed_pages_flag = false; // flag to call reset_content if some pages were removed by the user
// process the new content
const new_content = model.value.map((item, content_idx) => {
// select pages that correspond to this content item (represented by its index in the array)
const pgs = pages.value.filter(page => (page.content_idx === content_idx));
// if there are no pages representing this content (because deleted by the user), mark item as false to remove it
if(!pgs.length) {
removed_pages_flag = true;
return false;
}
// if item is a string, concatenate each page content and set that
else if(typeof item == "string") {
return pgs.map(page => {
// remove any useless <div> surrounding the content
let elt = page.elt;
while(elt.children.length === 1 && elt.firstChild.tagName && elt.firstChild.tagName.toLowerCase() === "div" && !elt.firstChild.getAttribute("style")) {
elt = elt.firstChild;
}
return ((elt.innerHTML === "<br>" || elt.innerHTML === "<!---->") ? "" : elt.innerHTML); // treat a page containing a single <br> or an empty comment as an empty content
}).join('');
}
// if item is a component, just clone the item
else return { template: item.template, props: { ...item.props }};
}).filter(item => (item !== false)); // remove empty items
// avoid calling reset_content after the parent content is updated (infinite loop)
if(!removed_pages_flag) prevent_next_content_update_from_parent.value = true;
// send event to parent to update the synced content
model.value = new_content
// emits("update:content", new_content);
}
// Sets current_text_style with CSS style at caret position
const processElement = (e) => {
process_current_text_style()
processCurrentElement(e)
processSelectedElements(e)
}
const processSelectedElements = () => {
const selection = window.getSelection()
const elements = new Set()
for (let i = 0; i < selection.rangeCount; i++) {
const range = selection.getRangeAt(i)
// Получаем общий контейнер выделения
const commonAncestor = range.commonAncestorContainer
// Если это текстовый узел, берем его родителя
if (commonAncestor.nodeType === Node.TEXT_NODE) {
elements.add(commonAncestor.parentElement)
} else {
// Ищем все текстовые узлы в диапазоне
const treeWalker = document.createTreeWalker(
commonAncestor,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
return range.intersectsNode(node) ?
NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
}
}
)
let textNode
while (textNode = treeWalker.nextNode()) {
elements.add(textNode.parentElement)
}
}
}
const hasEditorParent = []
for (const element of elements) {
hasEditorParent.push(checkForEditorParent(element))
}
if (hasEditorParent.every(i => i === true) && elements.size > 1) {
activeElements.value = Array.from(elements)
return
}
activeElements.value = []
}
const process_current_text_style = () => {
let style = false;
const sel = window.getSelection();
if(sel.focusNode) {
const element = sel.focusNode.tagName ? sel.focusNode : sel.focusNode.parentElement;
if(element && element.isContentEditable) {
style = window.getComputedStyle(element);
// compute additional properties
style.textDecorationStack = []; // array of text-decoration strings from parent elements
style.headerLevel = 0;
style.isList = false;
let parent = element;
while(parent){
const parent_style = window.getComputedStyle(parent);
// stack CSS text-decoration as it is not overridden by children
style.textDecorationStack.push(parent_style.textDecoration);
// check if one parent is a list-item
if(parent_style.display === "list-item") style.isList = true;
// get first header level, if any
if(!style.headerLevel){
for(let i = 1; i <= 6; i++){
if(parent.tagName.toUpperCase() === "H"+i) {
style.headerLevel = i;
break;
}
}
}
parent = parent.parentElement;
}
}
}
emits('update:current-style', style)
current_text_style.value = style;
}
const processCurrentElement = (e) => {
let element = false;
const sel = window.getSelection();
if(sel.focusNode) {
element = sel.focusNode.tagName ? sel.focusNode : sel.focusNode.parentElement;
}
const hasEditorParent = checkForEditorParent(element)
// console.log(element)
if (hasEditorParent) {
emits('update:activeElement', element)
activeElement.value = element
}
}
const checkForEditorParent = (element) => {
if (!element) return false;
let currentElement = element;
// Поднимаемся вверх по DOM дереву и проверяем родителей
while (currentElement && currentElement !== document.body) {
// Проверяем классы
if (currentElement.classList && currentElement.classList.contains('editor')) {
return true;
}
// Проверяем id
if (currentElement.id === 'editor') {
return true;
}
// Переходим к родительскому элементу
currentElement = currentElement.parentElement;
}
return false;
}
// Process the specific style (position and size) of each page <div> and content <div>
const page_style = (page_idx, allow_overflow) => {
const px_in_mm = 0.2645833333333;
const page_width = props.page_format_mm[0] / px_in_mm;
const page_spacing_mm = 10;
const page_with_plus_spacing = (page_spacing_mm + props.page_format_mm[0]) * props.zoom / px_in_mm;
const view_padding = 20;
const inner_width = editor_width.value - 2 * view_padding;
let nb_pages_x = 1, page_column, x_pos, x_ofx, left_px, top_mm, bkg_width_mm, bkg_height_mm;
if(props.display === "horizontal") {
if(inner_width > (pages.value.length * page_with_plus_spacing)){
nb_pages_x = Math.floor(inner_width / page_with_plus_spacing);
left_px = inner_width / (nb_pages_x * 2) * (1 + page_idx * 2) - page_width / 2;
} else {
nb_pages_x = pages.value.length;
left_px = page_with_plus_spacing * page_idx + page_width / 2 * (props.zoom - 1);
}
top_mm = 0;
bkg_width_mm = props.zoom * (props.page_format_mm[0] * nb_pages_x + (nb_pages_x - 1) * page_spacing_mm);
bkg_height_mm = props.page_format_mm[1] * props.zoom;
} else { // "grid", vertical
nb_pages_x = Math.floor(inner_width / page_with_plus_spacing);
if(nb_pages_x < 1 || props.display === "vertical") nb_pages_x = 1;
page_column = (page_idx % nb_pages_x);
x_pos = inner_width / (nb_pages_x * 2) * (1 + page_column * 2) - page_width / 2;
x_ofx = Math.max(0, (page_width * props.zoom - inner_width) / 2);
left_px = x_pos + x_ofx;
top_mm = ((props.page_format_mm[1] + page_spacing_mm) * props.zoom) * Math.floor(page_idx / nb_pages_x);
const nb_pages_y = Math.ceil(pages.value.length / nb_pages_x);
bkg_width_mm = props.zoom * (props.page_format_mm[0] * nb_pages_x + (nb_pages_x - 1) * page_spacing_mm);
bkg_height_mm = props.zoom * (props.page_format_mm[1] * nb_pages_y + (nb_pages_y - 1) * page_spacing_mm);
}
if(page_idx >= 0) {
const style = {
position: "absolute",
left: "calc("+ left_px +"px + "+ view_padding +"px)",
top: "calc("+ top_mm +"mm + "+ view_padding +"px)",
width: props.page_format_mm[0]+"mm",
// "height" is set below
padding: (typeof props.page_margins == "function") ? props.page_margins(page_idx + 1, pages.value.length) : props.page_margins,
transform: "scale("+ props.zoom +")"
};
style[allow_overflow ? "minHeight" : "height"] = props.page_format_mm[1]+"mm";
return style;
} else {
// Content/background <div> is sized so it lets a margin around pages when scrolling at the end
return { width: "calc("+ bkg_width_mm +"mm + "+ (2*view_padding) +"px)", height: "calc("+ bkg_height_mm +"mm + "+ (2*view_padding) +"px)" };
}
}
// Utility to convert page_style to CSS string
const css_to_string = (css) => Object.entries(css).map(([k, v]) => k.replace(/[A-Z]/g, match => ("-"+match.toLowerCase()))+":"+v).join(';')
// Update pages <div> from pages.value data
const update_pages_elts = () => {
// Removing deleted pages
const deleted_pages = [...content.value.children].filter((page_elt) => !pages.value.find(page => (page.elt === page_elt)));
for(const page_elt of deleted_pages) { page_elt.remove(); }
// Adding / updating pages
for(const [page_idx, page] of pages.value.entries()) {
// Get either existing page_elt or create it
if(!page.elt) {
page.elt = document.createElement("div");
page.elt.className = "page";
page.elt.dataset.isVDEPage = "";
const next_page = pages.value[page_idx + 1];
content.value.insertBefore(page.elt, next_page ? next_page.elt : null);
}
// Update page properties
page.elt.dataset.contentIdx = page.content_idx;
if(!printing_mode.value) page.elt.style = Object.entries(page_style(page_idx, page.template ? false : true)).map(([k, v]) => k.replace(/[A-Z]/g, match => ("-"+match.toLowerCase()))+":"+v).join(';'); // (convert page_style to CSS string)
page.elt.contentEditable = (props.editable && !page.template) ? true : false;
}
}
// Get and store empty editor <div> width
const update_editor_width = () => {
editor.value.classList.add("hide_children");
editor_width.value = editor.value.clientWidth;
update_pages_elts();
editor.value.classList.remove("hide_children");
}
const update_css_media_style = () => {
css_media_style.innerHTML = "@media print { @page { size: "+props.page_format_mm[0]+"mm "+props.page_format_mm[1]+"mm; margin: 0 !important; } .hidden-print { display: none !important; } }";
}
// Prepare content before opening the native print box
const before_print = () => {
// set the printing mode flag
printing_mode.value = true;
console.log('start printing')
// store the current body aside
_page_body.value = document.body;
// create a new body for the print and overwrite CSS
const print_body = document.createElement("body");
print_body.style.margin = "0";
print_body.style.padding = "0";
print_body.style.background = "white";
print_body.style.font = window.getComputedStyle(editor.value).font;
print_body.className = editor.value.className;
// move each page to the print body
for(const [page_idx, page] of pages.value.entries()){
//const page_clone = page_elt.cloneNode(true);
page.elt.style = ""; // reset page style for the clone
page.elt.style.position = "relative";
page.elt.style.padding = (typeof props.page_margins == "function") ? props.page_margins(page_idx + 1, pages.value.length) : props.page_margins;
page.elt.style.breakBefore = page_idx ? "page" : "auto";
page.elt.style.width = "calc("+props.page_format_mm[0]+"mm - 2px)";
page.elt.style.height = "calc("+props.page_format_mm[1]+"mm - 2px)";
page.elt.style.boxSizing = "border-box";
page.elt.style.overflow = "hidden";
// add overlays if any
const overlay_elt = pages_overlay_refs[page.uuid];
if(overlay_elt){
overlay_elt.style.position = "absolute";
overlay_elt.style.left = "0";
overlay_elt.style.top = "0";
overlay_elt.style.transform = "none";
overlay_elt.style.padding = "0";
overlay_elt.style.overflow = "hidden";
page.elt.prepend(overlay_elt);
}
print_body.append(page.elt);
}
// display a return arrow to let the user restore the original body in case the navigator doesn't call after_print() (it happens sometimes in Chrome)
// const return_overlay = document.createElement("div");
// return_overlay.className = "hidden-print"; // css managed in update_css_media_style method
// return_overlay.style.position = "fixed";
// return_overlay.style.left = "0";
// return_overlay.style.top = "0";
// return_overlay.style.right = "0";
// return_overlay.style.bottom = "0";
// return_overlay.style.display = "flex";
// return_overlay.style.alignItems = "center";
// return_overlay.style.justifyContent = "center";
// return_overlay.style.background = "rgba(255, 255, 255, 0.95)";
// return_overlay.style.cursor = "pointer";
// return_overlay.innerHTML = '<svg width="220" height="220"><path fill="rgba(0, 0, 0, 0.7)" d="M120.774,179.271v40c47.303,0,85.784-38.482,85.784-85.785c0-47.3-38.481-85.782-85.784-85.782H89.282L108.7,28.286L80.417,0L12.713,67.703l67.703,67.701l28.283-28.284L89.282,87.703h31.492c25.246,0,45.784,20.538,45.784,45.783C166.558,158.73,146.02,179.271,120.774,179.271z"/></svg>'
// return_overlay.addEventListener("click", after_print);
// print_body.append(return_overlay);
// replace current body by the print body
document.body = print_body;
}
// Restore content after closing the native print box
const after_print = () => {
// clear the printing mode flag
printing_mode.value = false;
// restore pages and overlays
for(const [page_idx, page] of pages.value.entries()){
page.elt.style = css_to_string(page_style(page_idx, page.template ? false : true));
content.value.append(page.elt);
const overlay_elt = pages_overlay_refs[page.uuid];
if(overlay_elt) {
overlay_elt.style = css_to_string(page_style(page_idx, false));
overlays.value.append(overlay_elt);
}
}
document.body = _page_body.value;
// recompute editor with and reposition elements
update_editor_width();
}
watch(model, () => {
if(prevent_next_content_update_from_parent.value) {
prevent_next_content_update_from_parent.value = false;
} else reset_content();
}, {
deep: true
})
watch(props.display, () => {
update_pages_elts()
})
watch(props.page_format_mm, () => {
update_css_media_style()
reset_content()
})
watch(props.page_margins, () => {
reset_content()
})
watch(props.zoom, () => {
update_pages_elts()
})
</script>
<style>
body {
/* Enable printing of background colors */
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
</style>
<style scoped>
.editor {
display: block;
font-family: 'Times New Roman', serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
cursor: default;
}
.editor ::-webkit-scrollbar {
width: 16px;
height: 16px;
}
.editor ::-webkit-scrollbar-track,
.editor ::-webkit-scrollbar-corner {
display: none;
}
.editor ::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.5);
border: 5px solid transparent;
border-radius: 16px;
background-clip: content-box;
}
.editor ::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.8);
}
.editor .hide_children > * {
display: none;
}
.editor > .content {
position: relative;
outline: none;
margin: 0;
padding: 0;
min-width: 100%;
pointer-events: none;
color: black;
}
.editor > .content > :deep(.page) {
position: absolute;
box-sizing: border-box;
left: 50%;
transform-origin: center top;
background: var(--page-background, white);
box-shadow: var(--page-box-shadow, 0 1px 3px 1px rgba(60, 64, 67, 0.15));
border: var(--page-border);
border-radius: var(--page-border-radius);
transition: left 0.3s, top 0.3s;
overflow: hidden;
pointer-events: all;
}
/* Переменные */
.editor > .content[brs-variable],
.editor > .content :deep(*[brs-variable]) {
background-color: yellow;
text-decoration: underline;
text-decoration-style: dotted;
}
.editor > .content[contenteditable],
.editor > .content :deep(*[contenteditable]) {
cursor: text;
}
.editor > .content :deep(*[contenteditable=false]) {
cursor: default;
}
.editor > .overlays {
position: relative;
margin: 0;
padding: 0;
min-width: 100%;
pointer-events: none;
}
.editor > .overlays > .overlay {
position: absolute;
box-sizing: border-box;
left: 50%;
transform-origin: center top;
transition: left 0.3s, top 0.3s;
overflow: hidden;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
label: {
type: String,
default: null
},
position: {
type: String,
default: 'top'
}
})
const labelPositions = {
left: [
'flex flex-row gap-x-2 items-center'
],
top: [
'flex flex-col gap-y-1'
]
}
const labelPositionClass = computed(() => {
if (props.label)
return labelPositions[props.position]
})
</script>
<template>
<div :class="labelPositionClass">
<label v-if="label" class="text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6 dark:text-white">
{{ label }}
</label>
<div class="grow">
<slot />
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,35 @@
<script setup>
import Input from "./Input.vue"
import {ref} from "vue";
const props = defineProps({
accept: {
type: String,
default: ''
}
})
const fileRef = ref(null)
const file = defineModel('file')
const fileList = defineModel('fileList')
const onFileChanged = (e) => {
const target = e.target
if (target && target.files) {
fileList.value = target.files
file.value = target.files[0]
}
}
</script>
<template>
<Input ref="fileRef"
type="file"
@change="(e) => onFileChanged(e)"
:accept="accept"
/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,27 @@
<script setup>
const value = defineModel('value')
const props = defineProps({
label: String,
disabled: {
type: Boolean,
default: false
}
})
</script>
<template>
<div class="flex flex-col">
<span v-if="label" class="text-sm mb-0.5">
{{ label }}
</span>
<input v-model="value"
v-bind="$attrs"
:disabled="disabled"
:data-disabled="disabled"
class="relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 hover:border-zinc-950/20 focus:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 dark:focus:border-white/20 bg-transparent dark:bg-white/5 focus:outline-hidden data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600 data-[disabled=true]:border-zinc-950/20 dark:data-[disabled=true]:border-white/15 data-[disabled=true]:text-zinc-950/35 dark:data-[disabled=true]:text-white/35 dark:data-[disabled=true]:bg-white/2.5 dark:data-hover:data-disabled:border-white/15 dark:scheme-dark" />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,92 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import {useDebounceFn} from "@vueuse/core";
const props = defineProps({
placeholders: {
type: Array,
default: () => [
'Поиск по шаблонам...',
]
},
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const value = ref(props.modelValue)
const currentPlaceholderIndex = ref(0)
const isAnimating = ref(false)
const showPlaceholder = ref(true)
const debounceUpdate = useDebounceFn((value) => {
emit('update:modelValue', value)
}, 800)
// Автоматическое обновление modelValue
watch(value, (newVal) => {
debounceUpdate(newVal)
showPlaceholder.value = newVal === ''
})
watch(() => props.modelValue, (newVal) => {
value.value = newVal
})
// Анимация placeholder
const animatePlaceholder = () => {
if (isAnimating.value) return
isAnimating.value = true
setTimeout(() => {
currentPlaceholderIndex.value = (currentPlaceholderIndex.value + 1) % props.placeholders.length
isAnimating.value = false
}, 3000) // Меняем каждые 3 секунды
}
onMounted(() => {
animatePlaceholder()
setInterval(animatePlaceholder, 3500) // Интервал анимации
})
const currentPlaceholder = computed(() => props.placeholders[currentPlaceholderIndex.value])
</script>
<template>
<div class="relative w-full">
<input
v-model="value"
type="text"
:placeholder="currentPlaceholder"
class="relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 hover:border-zinc-950/20 focus:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 dark:focus:border-white/20 bg-transparent dark:bg-white/5 focus:outline-hidden data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600 data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15 dark:scheme-dark"
/>
<div v-if="showPlaceholder" class="absolute left-1 inset-0 pointer-events-none overflow-hidden">
<div
v-for="(ph, index) in placeholders"
:key="ph"
:class="[
'absolute inset-0 flex items-center px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)] text-zinc-500 transition-all duration-500',
index === currentPlaceholderIndex ? 'translate-y-0 opacity-100' : 'translate-y-6 opacity-0'
]"
>
<span class="text-base/6 sm:text-sm/6">{{ ph }}</span>
</div>
</div>
</div>
</template>
<style scoped>
/* Дополнительные стили для плавной анимации */
.absolute > div {
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
input::placeholder {
opacity: 0; /* Скрываем стандартный placeholder */
}
</style>

View File

@@ -0,0 +1,42 @@
<script setup>
const value = defineModel('value')
const props = defineProps({
label: String,
disabled: {
type: Boolean,
default: false
},
rows: {
type: Number,
default: 4
},
maxLength: Number,
resize: {
type: Boolean,
default: true
}
})
</script>
<template>
<div class="flex flex-col">
<span v-if="label" class="text-sm mb-0.5">
{{ label }}
</span>
<textarea v-model="value"
v-bind="$attrs"
:disabled="disabled"
:data-disabled="disabled"
:rows="rows"
:maxlength="maxLength"
:class="{ 'resize-none': !resize }"
class="relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 hover:border-zinc-950/20 focus:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 dark:focus:border-white/20 bg-transparent dark:bg-white/5 focus:outline-hidden data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600 data-[disabled=true]:border-zinc-950/20 dark:data-[disabled=true]:border-white/15 data-[disabled=true]:text-zinc-950/35 dark:data-[disabled=true]:text-white/35 dark:data-[disabled=true]:bg-white/2.5 dark:data-hover:data-disabled:border-white/15 dark:scheme-dark resize-vertical min-h-[80px]">
</textarea>
</div>
</template>
<style scoped>
textarea {
font-family: inherit;
}
</style>

View File

@@ -0,0 +1,35 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
vertical: {
type: String,
default: 'vertical'
}
})
const verticalClasses = [
'flex', 'flex-col', 'gap-y-2'
]
const horizontalClasses = [
'grid', 'grid-cols-3', 'gap-y-2', 'gap-x-2', 'grow', 'items-start'
]
const classes = computed(() => {
if (props.vertical)
return verticalClasses
return horizontalClasses
})
</script>
<template>
<div :class="classes">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,31 @@
<script setup>
import {onMounted, ref} from "vue";
const componentRef = ref()
</script>
<template>
<div ref="componentRef"
@mouseenter="componentRef.setAttribute('data-hover', '')"
@mouseleave="componentRef.removeAttribute('data-hover')"
class="flex flex-col max-lg:flex-row gap-y-1 px-2 sm:py-2 py-2.5 rounded-md transition-all data-hover:bg-zinc-950/5 dark:data-hover:bg-white/5 active:scale-[.99]">
<div v-if="$slots.header || $slots.actions" class="flex justify-between items-center">
<div v-if="$slots.header" class="text-sm font-medium">
<slot name="header" />
</div>
<div v-if="$slots.actions">
<slot name="actions" />
</div>
</div>
<div v-if="$slots.default">
<slot />
</div>
<div v-if="$slots.footer">
<slot name="footer" />
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,21 @@
<script setup>
const props = defineProps({
header: {
type: String,
}
})
</script>
<template>
<div class="px-3 pt-2 pb-4 first:rounded-t-lg first:rounded-b-sm lg:ring-1 lg:ring-zinc-950/10 dark:lg:ring-white/10 last:rounded-b-lg last:rounded-t-sm not-last:not-first:rounded-sm not-last:not-first:my-1 bg-white dark:text-white lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-800">
<slot v-if="$slots.header" name="header" />
<span v-else class="block text-sm font-medium mb-2">
{{ header }}
</span>
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,180 @@
<script setup>
import {computed, ref, watch} from 'vue'
const props = defineProps({
open: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
titleId: {
type: String,
default: () => `modal-title-${Math.random().toString(36).substr(2, 9)}`
},
descriptionId: {
type: String,
default: () => `modal-description-${Math.random().toString(36).substr(2, 9)}`
},
panelId: {
type: String,
default: () => `modal-panel-${Math.random().toString(36).substr(2, 9)}`
},
closeButton: {
type: Boolean,
default: true
},
width: {
type: Number,
default: 512
}
})
const emit = defineEmits(['close', 'beforeClose', 'afterClose'])
// Блокировка скролла при открытии модального окна
watch(() => props.open, (isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
// Ширина модального окна
const modalWidth = computed(() => {
if (props.width === 0 || props.width === null)
return `width: 512px`
else
return `width: ${props.width}px`
})
// Очистка при размонтировании
import { onUnmounted } from 'vue'
import Button from "../Button/Button.vue";
onUnmounted(() => {
document.body.style.overflow = ''
})
// Стили модального окна
const styles = computed(() => [
modalWidth.value
])
const close = () => {
emit('beforeClose')
emit('close')
emit('afterClose')
}
</script>
<template>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="open"
role="dialog"
tabindex="-1"
aria-modal="true"
:data-headlessui-state="open ? 'open' : 'closed'"
:aria-labelledby="titleId"
:aria-describedby="descriptionId"
class="fixed inset-0 z-50"
>
<!-- Backdrop -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="open"
class="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
aria-hidden="true"
:data-headlessui-state="open ? 'open' : 'closed'"
@click="$emit('close')"
/>
</transition>
<!-- Modal content -->
<div class="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
<div class="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-12 opacity-0 sm:translate-y-0 sm:scale-95"
enter-to-class="translate-y-0 opacity-100 sm:scale-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100 sm:scale-100"
leave-to-class="translate-y-12 opacity-0 sm:translate-y-0 sm:scale-95"
>
<div
v-if="open"
class="transition-[width] delay-0 duration-300 row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline will-change-transform"
:style="styles"
:id="panelId"
:data-headlessui-state="open ? 'open' : 'closed'"
>
<div class="flex flex-row justify-between items-center">
<!-- Title slot -->
<slot name="title" :titleId="titleId">
<h2
v-if="title"
class="text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white"
:id="titleId"
>
{{ title }}
</h2>
</slot>
<slot name="close-button">
<Button icon v-if="closeButton" @click="close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3.5 w-3.5"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>
</Button>
</slot>
</div>
<!-- Description slot -->
<slot name="description" :descriptionId="descriptionId">
<p
v-if="description"
class="mt-2 text-pretty text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400"
:id="descriptionId"
>
{{ description }}
</p>
</slot>
<!-- Default content slot -->
<div class="mt-6 max-h-[520px] overflow-y-auto p-0.5 pr-2">
<slot></slot>
</div>
<!-- Actions slot -->
<div class="mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto">
<slot name="actions"></slot>
</div>
</div>
</transition>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,142 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import Card from '../Card/Card.vue'
import Button from "../Button/Button.vue";
import Collapsible from "../Collapsible/Collapsible.vue";
// Пропсы
const props = defineProps({
customTitle: {
type: String,
default: ''
},
customDescription: {
type: String,
default: ''
},
autoShow: {
type: Boolean,
default: true
}
})
// Реактивные данные
const isVisible = ref(false)
const headerText = 'Уведомление о сборе данных'
const title = computed(() => props.customTitle || 'Мониторинг ошибок')
const description = computed(() => props.customDescription || 'Мы используем Sentry для отслеживания и исправления ошибок на сайте. Это помогает нам улучшать качество сервиса.')
// Собираемые данные
const collectedData = ref([
'Текст ошибки и стектрейс',
'Тип браузера и версия',
'Операционная система',
'URL страницы где произошла ошибка',
'Временная метка ошибки',
'Действия пользователя перед ошибкой',
'Размер экрана устройства',
'Анонимизированный идентификатор сессии'
])
// События
const emit = defineEmits(['accept', 'learnMore', 'close'])
const handleAccept = () => {
localStorage.setItem('sentry-notification-accepted', 'true')
isVisible.value = false
emit('accept')
}
const handleLearnMore = () => {
emit('learnMore')
window.open('https://sentry.io/features/error-monitoring/', '_blank')
}
// Показывать уведомление только если пользователь еще не соглашался
onMounted(() => {
if (props.autoShow && !localStorage.getItem('sentry-notification-accepted')) {
isVisible.value = true
}
})
// Экспортируем методы для управления видимостью
defineExpose({
show: () => isVisible.value = true,
hide: () => isVisible.value = false
})
</script>
<template>
<div v-if="isVisible" class="fixed bottom-12 right-5 z-50 w-96">
<Card
:header="headerText"
:content-scroll="false"
>
<!-- Компактный заголовок -->
<div class="flex items-center space-x-3 p-3">
<div class="flex-shrink-0 w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 text-slate-900">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M3 18a1.93 1.93 0 0 0 .306 1.076a2 2 0 0 0 1.584 .924c.646 .033 -.537 0 .11 0h3a4.992 4.992 0 0 0 -3.66 -4.81c.558 -.973 1.24 -2.149 2.04 -3.531a9 9 0 0 1 5.62 8.341h4c.663 0 2.337 0 3 0a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-1.84 3.176c4.482 2.05 7.6 6.571 7.6 11.824" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-slate-900 dark:text-white truncate">
{{ title }}
</h3>
<p class="text-xs text-slate-600 dark:text-slate-300 truncate">
Мы используем Sentry для анализа ошибок
</p>
</div>
</div>
<!-- Собираемые данные в аккордеоне -->
<div class="px-3 pb-3">
<Collapsible header="Какие данные собираем">
<div class="space-y-1 max-h-32 overflow-y-auto">
<div
v-for="(item, index) in collectedData"
:key="index"
class="flex items-start space-x-2 text-xs"
>
<div class="flex-shrink-0 w-3 h-3 text-emerald-500 mt-0.5">
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</div>
<span class="text-slate-600 dark:text-slate-300 leading-relaxed">
{{ item }}
</span>
</div>
</div>
</Collapsible>
</div>
<!-- Футер с действиями -->
<template #footer>
<div class="flex space-x-2">
<!-- <Button-->
<!-- variant="ghost"-->
<!-- text-align="center"-->
<!-- block-->
<!-- @click="handleLearnMore"-->
<!-- >-->
<!-- Подробнее-->
<!-- </Button>-->
<Button
text-align="center"
block
@click="handleAccept"
>
Понятно
</Button>
</div>
</template>
</Card>
</div>
</template>
<style scoped>
/* Дополнительные кастомные стили если нужно */
</style>

View File

@@ -0,0 +1,14 @@
<script setup>
</script>
<template>
<div class="mx-auto max-w-3xl">
<slot name="header" />
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<div class="py-2">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,16 @@
<script setup>
</script>
<template>
<div>
<h1 class="text-2xl/8 font-semibold text-zinc-950 sm:text-xl/8 dark:text-white">
<slot />
</h1>
<hr role="presentation" class="mt-6 w-full border-t border-zinc-950/10 dark:border-white/10">
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,347 @@
<script setup>
import {computed, nextTick, ref} from 'vue';
const props = defineProps({
element: Object,
pageIndex: Number,
elementIndex: Number
});
const emit = defineEmits(['update', 'remove', 'split']);
const isEditing = ref(false);
const elementRef = ref()
const isTextElement = computed(() =>
['paragraph', 'heading'].includes(props.element.type)
);
const elementClasses = computed(() => [
`element-${props.element.type}`,
{ 'editing': isEditing.value }
]);
const elementStyles = computed(() => {
const styles = {};
if (props.element.type === 'heading') {
styles.fontSize = `${props.element.level * 4 + 12}px`;
styles.fontWeight = 'bold';
}
return styles;
});
const displayContent = computed(() => {
if (typeof props.element.content === 'string') {
return props.element.content;
}
return '';
});
const tableRows = computed(() => {
if (props.element.type === 'table' && props.element.content.rows) {
return props.element.content.rows;
}
return [];
});
// Обработчики текстовых элементов
const onTextInput = (event) => {
isEditing.value = true;
console.log(event.target.textContent)
emit('update', {
...props.element,
content: event.target.textContent
});
};
const onTextBlur = () => {
isEditing.value = false;
};
// Обработчики таблиц
const onTableCellInput = (rowIndex, cellIndex, event) => {
const newRows = [...tableRows.value];
newRows[rowIndex][cellIndex] = event.target.textContent;
emit('update', {
...props.element,
content: {
...props.element.content,
rows: newRows
}
});
};
const onTableBlur = () => {
// Автосохранение таблицы
};
const addTableRow = () => {
const cols = props.element.content.cols || 2;
const newRow = Array(cols).fill('Новая ячейка');
const newRows = [...tableRows.value, newRow];
emit('update', {
...props.element,
content: {
...props.element.content,
rows: newRows
}
});
};
const addTableColumn = () => {
const newRows = tableRows.value.map(row => [...row, 'Новая ячейка']);
emit('update', {
...props.element,
content: {
rows: newRows,
cols: (props.element.content.cols || 2) + 1
}
});
};
const removeTableRow = () => {
if (tableRows.value.length > 1) {
const newRows = tableRows.value.slice(0, -1);
emit('update', {
...props.element,
content: {
...props.element.content,
rows: newRows
}
});
}
};
const removeTableColumn = () => {
if (props.element.content.cols > 1) {
const newRows = tableRows.value.map(row => row.slice(0, -1));
emit('update', {
...props.element,
content: {
rows: newRows,
cols: props.element.content.cols - 1
}
});
}
};
// Обработчики переменных
const onVariableUpdate = () => {
emit('update', {
...props.element,
content: `{{${props.element.variableName}}}`
});
};
</script>
<template>
<div
class="page-element"
:class="elementClasses"
:style="elementStyles"
@dblclick="$emit('split')"
>
<div class="element-content">
<!-- Текстовые элементы -->
<div
v-if="isTextElement"
ref="elementRef"
class="text-element"
contenteditable="true"
@input="onTextInput"
@blur="onTextBlur"
v-html="displayContent"
></div>
<!-- Таблица -->
<div v-else-if="element.type === 'table'" class="table-element">
<table class="element-table">
<tbody>
<tr v-for="(row, rowIndex) in tableRows" :key="rowIndex">
<td
v-for="(cell, cellIndex) in row.cells"
ref="elementRef"
:key="cellIndex"
contenteditable="true"
@input="onTableCellInput(rowIndex, cellIndex, $event)"
@blur="onTableBlur"
>
{{ cell }}
</td>
</tr>
</tbody>
</table>
<div class="table-controls">
<button @click="addTableRow" class="btn btn-sm"> Строка</button>
<button @click="addTableColumn" class="btn btn-sm"> Столбец</button>
<button @click="removeTableRow" class="btn btn-sm btn-danger"> Строка</button>
<button @click="removeTableColumn" class="btn btn-sm btn-danger"> Столбец</button>
</div>
</div>
<!-- Переменная -->
<div v-else-if="element.type === 'variable'" class="variable-element">
<span class="variable-tag">{{ element.variableName }}</span>
<input
v-model="element.variableName"
@blur="onVariableUpdate"
class="variable-input"
placeholder="имя_переменной"
/>
</div>
</div>
<div class="element-controls">
<button @click="$emit('remove')" class="control-btn" title="Удалить">🗑</button>
<button @click="$emit('split')" class="control-btn" title="Разорвать"></button>
</div>
</div>
</template>
<style scoped>
.page-element {
position: relative;
margin-bottom: 1rem;
padding: 0.5rem;
border: 1px solid transparent;
border-radius: 4px;
transition: all 0.2s;
}
.page-element:hover {
border-color: #e1e5e9;
background: #f8f9fa;
}
.page-element.editing {
border-color: #007bff;
background: #e3f2fd;
}
.element-content {
min-height: 1.5em;
}
.text-element {
outline: none;
min-height: 1.5em;
}
.text-element:focus {
background: white;
}
.table-element {
margin: 0.5rem 0;
}
.element-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 0.5rem;
}
.element-table td {
border: 1px solid #dee2e6;
padding: 0.5rem;
min-width: 100px;
outline: none;
}
.element-table td:focus {
background: #f8f9fa;
}
.table-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.variable-element {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: #fffacd;
border: 1px dashed #c4a657;
border-radius: 4px;
}
.variable-tag {
font-weight: bold;
color: #8b6f1d;
}
.variable-input {
border: 1px solid #c4a657;
border-radius: 3px;
padding: 0.25rem;
background: white;
}
.element-controls {
position: absolute;
top: -10px;
right: -10px;
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s;
}
.page-element:hover .element-controls {
opacity: 1;
}
.control-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 3px;
background: white;
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.control-btn:hover {
background: #f8f9fa;
}
.btn {
padding: 0.25rem 0.5rem;
border: 1px solid #e1e5e9;
border-radius: 3px;
background: white;
cursor: pointer;
font-size: 0.8rem;
}
.btn:hover {
background: #f8f9fa;
}
.btn-sm {
padding: 0.2rem 0.4rem;
font-size: 0.7rem;
}
.btn-danger {
border-color: #dc3545;
color: #dc3545;
}
.btn-danger:hover {
background: #fee;
}
</style>

View File

@@ -0,0 +1,162 @@
<script setup>
import {computed, onMounted, onUnmounted, ref} from "vue";
import {normalizeOptions} from "../../Utils/optionsFormatter.js";
const props = defineProps({
options: {
type: Array,
required: true,
default: () => []
},
placeholder: {
type: String,
default: 'Выберите вариант'
},
disabled: {
type: Boolean,
default: false
}
})
const model = defineModel('value')
const emit = defineEmits(['update:modelValue', 'change'])
const isOpen = ref(false)
const containerRef = ref(null)
// Формирование options
const normalizedOptions = computed(() => {
return normalizeOptions(props.options)
})
// Находим выбранную опцию
const selectedOption = computed(() => {
if (!model.value) return null
return normalizedOptions.value.find(opt => opt.value === model.value) || null
})
// Проверяем, выбрана ли опция
const isOptionSelected = (option) => {
return model.value === option.value
}
// Переключаем dropdown
const toggleDropdown = () => {
if (!props.disabled) {
isOpen.value = !isOpen.value
}
}
// Выбираем опцию
const selectOption = (option) => {
model.value = option.value
emit('change', option)
isOpen.value = false
}
// Закрываем dropdown при клике вне компонента
const handleClickOutside = (event) => {
if (containerRef.value && !containerRef.value.contains(event.target)) {
isOpen.value = false
}
}
// Закрываем dropdown при нажатии Escape
const handleEscapeKey = (event) => {
if (event.key === 'Escape') {
isOpen.value = false
}
}
// Устанавливаем обработчики событий
onMounted(() => {
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscapeKey)
})
// Убираем обработчики при размонтировании
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscapeKey)
})
</script>
<template>
<div class="relative" ref="containerRef">
<!-- Кнопка-триггер -->
<button
type="button"
class="group relative block w-full before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm dark:before:hidden focus:outline-hidden after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-blue-500 data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none"
:class="{
'opacity-50 cursor-not-allowed': disabled,
'ring-2 ring-blue-500 rounded-lg': isOpen
}"
@click="toggleDropdown"
:disabled="disabled"
>
<span class="relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)] min-h-11 sm:min-h-9 pr-[calc(--spacing(7)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pl-[calc(--spacing(3)-1px)] text-left text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 group-hover:border-zinc-950/20 dark:border-white/10 dark:group-hover:border-white/20 bg-transparent dark:bg-white/5">
<div class="flex min-w-0 items-center">
<span class="ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0">
{{ selectedOption?.label || placeholder }}
</span>
</div>
</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
class="size-5 stroke-zinc-500 group-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400"
viewBox="0 0 16 16"
aria-hidden="true"
fill="none"
:class="{ 'transform rotate-180': isOpen }"
>
<path d="M5.75 10.75L8 13L10.25 10.75" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M10.25 5.25L8 3L5.75 5.25" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</span>
</button>
<!-- Dropdown меню -->
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="isOpen"
class="absolute z-50 mt-1 w-full rounded-lg bg-white shadow-lg border border-zinc-200 dark:bg-zinc-900 dark:border-zinc-700 max-h-60 overflow-auto"
>
<ul class="py-1">
<li
v-for="option in normalizedOptions"
:key="option.value"
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-zinc-600/10 hover:text-white text-zinc-900 dark:text-white dark:hover:bg-zinc-700"
@click="selectOption(option)"
>
<span class="block truncate text-sm">
{{ option.label }}
</span>
<span
v-if="isOptionSelected(option)"
class="absolute inset-y-0 right-0 flex items-center pr-4 text-white/20 hover:text-white/20"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</span>
</li>
</ul>
</div>
</transition>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<nav class="fixed inset-y-0 left-0 w-64 max-lg:hidden">
<slot />
</nav>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<div class="flex flex-1 flex-col overflow-y-auto p-4 [&>[data-slot=section]+[data-slot=section]]:mt-8">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<div class="max-lg:hidden flex flex-col border-t border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<div class="flex flex-col border-b border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,19 @@
<script setup>
import {Link} from '@inertiajs/vue3'
import {ref} from "vue";
const componentRef = ref()
</script>
<template>
<Link ref="componentRef"
@mouseenter="componentRef.$el.setAttribute('data-hover', '')"
@mouseleave="componentRef.$el.removeAttribute('data-hover')"
class="flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5 *:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5 *:last:data-[slot=icon]:ml-auto *:last:data-[slot=icon]:size-5 sm:*:last:data-[slot=icon]:size-4 *:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 sm:*:data-[slot=avatar]:size-6 data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950 data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950 data-current:*:data-[slot=icon]:fill-zinc-950 dark:text-white dark:*:data-[slot=icon]:fill-zinc-400 dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white dark:data-current:*:data-[slot=icon]:fill-white">
<slot />
</Link>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<span class="truncate">
<slot />
</span>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<div data-slot="section" class="flex flex-col gap-0.5">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,107 @@
// composables/useSmartApiForm.js
import { ref, reactive, watch } from 'vue'
import axios from 'axios'
export function useApiForm(initialData = {}) {
const loading = ref(false)
const errors = ref({})
const progress = ref(0)
// Для обычных полей формы
const formData = ref({ ...initialData })
// Для файлов
const files = ref({})
const setFile = (fieldName, file) => {
files.value[fieldName] = file
// Автоматически очищаем ошибку для этого поля
clearError(fieldName)
}
const clearError = (field) => {
if (errors.value[field]) {
const newErrors = { ...errors.value }
delete newErrors[field]
errors.value = newErrors
}
}
const clearAllErrors = () => {
errors.value = {}
}
const submit = async (url, method = 'post', config = {}) => {
loading.value = true
progress.value = 0
clearAllErrors()
try {
// Создаем FormData
const formDataToSend = new FormData()
// Добавляем обычные поля формы
Object.keys(formData.value).forEach(key => {
if (formData.value[key] !== null && formData.value[key] !== undefined) {
formDataToSend.append(key, formData.value[key])
}
})
// Добавляем файлы
Object.keys(files.value).forEach(key => {
if (files.value[key]) {
formDataToSend.append(key, files.value[key])
}
})
const response = await axios({
method,
url,
data: formDataToSend,
headers: {
'Content-Type': 'multipart/form-data',
...config.headers
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
progress.value = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
}
},
...config
})
return response.data
} catch (error) {
if (error.response?.status === 422) {
errors.value = error.response.data.errors || {}
}
throw error
} finally {
loading.value = false
}
}
const reset = () => {
// Сбрасываем обычные поля
Object.keys(initialData).forEach(key => {
formData.value[key] = initialData[key]
})
// Сбрасываем файлы
files.value = {}
clearAllErrors()
}
return {
formData,
files,
errors,
loading,
progress,
submit,
setFile,
clearError,
clearAllErrors,
reset
}
}

View File

@@ -0,0 +1,113 @@
import {
ref,
reactive,
onMounted,
onUpdated,
nextTick
} from "vue"
import {useElementSize} from "@vueuse/core";
export function useDynamicA4Layout() {
const A4_HEIGHT = 933
const A4_WIDTH = 623
const a4Pages = reactive([])
const isCalculating = ref(false)
const componentHeights = ref(new Map())
const waitForComponentRender = (componentRef, itemType) => {
return new Promise((resolve) => {
if (!componentRef) {
resolve(0)
return
}
const checkRender = () => {
requestAnimationFrame(() => {
if (componentRef.offsetHeight > 0) {
resolve(getComponentHeight(componentRef))
} else {
resolve(0)
// setTimeout(checkRender, 1000)
}
})
}
checkRender()
})
}
const getComponentHeight = (element) => {
if (!element) return 0
const styles = getComputedStyle(element)
return element.offsetHeight
+ parseInt(styles.marginTop)
+ parseInt(styles.marginBottom)
+ parseInt(styles.borderTopWidth)
+ parseInt(styles.borderBottomWidth)
}
const calculateDynamicLayout = async(items, getComponentRefs) => {
if (isCalculating.value) return
isCalculating.value = true
try {
await nextTick()
const componentRefs = getComponentRefs()
a4Pages.splice(0, a4Pages.length)
const currentPage = {items: [], totalHeight: 0, id: Date.now()}
a4Pages.push(currentPage)
const heightPromises = items.map(async (item, index) => {
const componentRef = componentRefs.get(item.id)
const height = await waitForComponentRender(componentRef, item.type)
componentHeights.value.set(item.id || index, height)
return height
})
const heights = await Promise.all(heightPromises)
let currentHeight = 0
const pageMargin = 0
items.forEach((item, index) => {
// console.log(item)
const itemHeight = heights[index]
const availableHeight = A4_HEIGHT - (pageMargin * 2) - currentHeight
console.log(currentHeight > 0 && availableHeight < itemHeight)
if (currentHeight > 0 && availableHeight < itemHeight) {
const newPage = {
items: [item],
totalHeight: itemHeight,
id: Date.now() + index
}
a4Pages.push(newPage)
currentHeight = itemHeight
} else {
const page = a4Pages[a4Pages.length - 1]
page.items.push(item)
page.totalHeight += itemHeight
currentHeight += itemHeight
}
})
} catch(error) {
console.error('Ошибка вычисления размера')
} finally {
isCalculating.value = false
}
}
return {
a4Pages,
A4_HEIGHT,
A4_WIDTH,
calculateDynamicLayout,
isCalculating,
componentHeights
}
}

View File

@@ -0,0 +1,66 @@
// composables/useFileDownload.js
export const useFileDownload = () => {
const downloadFile = async (url, data, filename = 'file', method = 'post') => {
try {
const response = await axios({
method,
url,
data: method === 'post' ? data : null,
params: method === 'get' ? data : null,
responseType: 'blob'
});
// Проверяем, что это действительно файл, а не ошибка
const contentType = response.headers['content-type'];
if (contentType.includes('application/json')) {
// Это JSON ошибка, а не файл
const errorData = JSON.parse(await response.data.text());
throw new Error(errorData.error || 'Download failed');
}
// Создаем blob URL
const blob = new Blob([response.data], {
type: response.headers['content-type']
});
const downloadUrl = window.URL.createObjectURL(blob);
// Создаем временную ссылку для скачивания
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
// Имитируем клик для скачивания
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Очищаем URL
window.URL.revokeObjectURL(downloadUrl);
return true;
} catch (error) {
console.error('Download error:', error);
throw error;
}
};
const getFileNameFromResponse = (response) => {
const contentDisposition = response.headers['content-disposition'];
let fileName = 'document.docx';
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/);
if (filenameMatch && filenameMatch[1]) {
fileName = filenameMatch[1].replace(/"/g, '');
}
}
return fileName;
};
return {
downloadFile
};
};

View File

@@ -0,0 +1,38 @@
<script setup>
import SentryNotification from "../Components/Notifications/SentryNotification.vue";
</script>
<template>
<main class="relative isolate h-screen flex flex-col w-full bg-white dark:text-white lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950 p-2 ">
<div class="grid lg:grid-cols-[320px_1fr_380px] gap-2 h-[calc(100vh-42px)]">
<!-- Left Sidebar -->
<div v-if="$slots.leftbar" class="min-h-0">
<slot name="leftbar" />
</div>
<!-- Main Content -->
<div class="h-full flex flex-col min-h-0">
<slot />
<!-- <div class="flex-1 p-4 lg:rounded-lg lg:bg-white lg:p-8 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10 overflow-y-auto">-->
<!-- -->
<!-- </div>-->
</div>
<!-- Right Sidebar -->
<div v-if="$slots.rightbar" class="min-h-0">
<slot name="rightbar" />
</div>
</div>
<div class="flex w-full justify-center mt-2.5 text-sm/4">
<a href="https://aokb28.su" target="_blank">
ГОСУДАРСТВЕННОЕ АВТОНОМНОЕ УЧРЕЖДЕНИЕ ЗДРАВООХРАНЕНИЯ АМУРСКОЙ ОБЛАСТИ "АМУРСКАЯ ОБЛАСТНАЯ КЛИНИЧЕСКАЯ БОЛЬНИЦА", 2025 год
</a>
</div>
<SentryNotification />
</main>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,25 @@
<script setup>
import SentryNotification from "../Components/Notifications/SentryNotification.vue";
</script>
<template>
<main class="relative isolate flex min-h-screen max-h-screen w-full bg-white dark:text-white max-lg:flex-col lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
<slot name="sidebar" />
<div class="flex min-h-screen max-h-screen flex-col p-2 w-full">
<div class="grow p-4 lg:rounded-lg max-h-[calc(100vh-16px)] lg:bg-white lg:p-8 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
<slot />
</div>
<div class="flex w-full justify-center mt-2.5 text-sm/4">
<a href="https://aokb28.su" target="_blank">
ГОСУДАРСТВЕННОЕ АВТОНОМНОЕ УЧРЕЖДЕНИЕ ЗДРАВООХРАНЕНИЯ АМУРСКОЙ ОБЛАСТИ "АМУРСКАЯ ОБЛАСТНАЯ КЛИНИЧЕСКАЯ БОЛЬНИЦА", 2025 год
</a>
</div>
</div>
<SentryNotification />
</main>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,447 @@
<script setup>
import {computed, nextTick, onMounted, ref, useTemplateRef, watch} from "vue"
import {useDateFormat, useDebounceFn} from "@vueuse/core"
import Sections from "../Layouts/Sections.vue";
import Input from '../Components/Input/Input.vue'
import Select from "../Components/Select/Select.vue";
import Card from "../Components/Card/Card.vue";
import Button from "../Components/Button/Button.vue";
import ListStrate from "../Components/List/ListStrate.vue";
import CardBack from "../Components/Card/CardBack.vue";
import {Link, router} from "@inertiajs/vue3";
import Editor from "../Components/Editor.vue";
import VuePdfEmbed, { useVuePdfEmbed } from 'vue-pdf-embed'
import {useFileDownload} from "../Composables/useFileDownload.js";
import PriceInput from "../Components/Document/InputVariable/PriceInput.vue";
import TextArea from "../Components/Input/TextArea.vue";
import Calendar from "../Components/Calendar/Calendar.vue";
import Collapsible from "../Components/Collapsible/Collapsible.vue";
import Accordion from "../Components/Accordion/Accordion.vue";
const { downloadFile } = useFileDownload()
const props = defineProps({
template: Object,
})
const editorRef = ref(null)
const content = ref(props.template.content ?? [])
const formData = ref([])
const documentStructure = ref(props.template.content || [])
const prepareVariables = (variables) => {
for (const variable of variables) {
formData.value.push(variable)
}
}
// Форматируем ключ переменной в читаемый name
const formatLabel = (key) => {
return key
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const viewer = useTemplateRef('viewer')
onMounted(async() => {
await preview()
prepareVariables(props.template.variables)
})
const previewLoading = ref(true)
const previewUrl = ref()
const preview = async () => {
previewLoading.value = true
await axios.post(`/contract-generator/${props.template.id}/preview`, {
variables: formData.value
}, {
responseType: 'blob'
}).then(res => {
previewUrl.value = URL.createObjectURL(res.data)
})
}
const updatePreview = async () => {
await preview()
}
const onChangeVariableTextValue = (variableId, value) => {
console.log(variableId, value)
changeVariableValue(variableId, value)
}
const onChangeVariableSelectValue = (variableId, option) => {
changeVariableValue(variableId, option.value)
}
const changeVariableValue = (variableId, value) => {
if (content.value && Array.isArray(content.value)) {
const updatedContent = content.value.map(htmlString => {
return htmlString.replace(
new RegExp(`(<span[^>]*brs-element-id="${variableId}"[^>]*>)[^<]*(</span>)`, 'g'),
`$1${value}$2`
)
})
content.value = updatedContent
}
}
const onPrint = () => {
if (viewer.value)
viewer.value.print(200, props.template.name, true)
}
const onDownloadDocx = async () => {
try {
await downloadFile(
`/contract-generator/${props.template.id}/download`,
{ variables: formData.value },
`${props.template.name}.docx`
)
} catch (e) {
console.error('Ошибка при скачивании docx файла: ', e.message)
}
}
// function scrollToElementByText(text, options = {}) {
// const {
// behavior = 'smooth',
// block = 'start',
// inline = 'nearest',
// partialMatch = false,
// caseSensitive = false
// } = options
//
// // Ищем все элементы, содержащие текст
// const elements = Array.from(document.querySelectorAll('*')).filter(element => {
// const elementText = caseSensitive
// ? element.textContent
// : element.textContent.toLowerCase()
// const searchText = caseSensitive
// ? text
// : text.toLowerCase()
//
// return partialMatch
// ? elementText.includes(searchText)
// : elementText.trim() === searchText
// })
//
// if (elements.length > 0) {
// elements[0].scrollIntoView({
// behavior,
// block,
// inline
// })
// return elements[0]
// }
//
// return null
// }
const searchAndScroll = (targetText) => {
if (!targetText.trim()) return
const result = scrollToElementByText(targetText)
if (result) {
console.log(result)
highlightElement(result.element)
}
}
const scrollToElementByText = (targetText) => {
const elementContainers = document.querySelectorAll('.textLayer')
if (!elementContainers) return null
const elementsOfContainers = []
for (const container of elementContainers) {
elementsOfContainers.push(...container.children)
}
const allElements = Array.from(elementsOfContainers)
.filter(el => el.textContent && el.textContent.trim())
.filter(el => {
const style = window.getComputedStyle(el)
return style.display !== 'none' && style.visibility !== 'hidden'
})
// Сначала ищем точное совпадение
for (const el of allElements) {
if (el.textContent.trim() === targetText) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
return { element: el, foundText: targetText, isComposite: false }
}
}
// Ищем составной текст в соседних элементах
for (let i = 0; i < allElements.length - 1; i++) {
const current = allElements[i]
const next = allElements[i + 1]
const combined = current.textContent.trim() + next.textContent.trim()
if (combined === targetText) {
current.scrollIntoView({ behavior: 'smooth', block: 'center' })
return {
element: [current, next],
foundText: targetText,
isComposite: true,
parts: [current.textContent.trim(), next.textContent.trim()]
}
}
}
return null
}
const highlightElement = (element) => {
// Убираем предыдущую подсветку
document.querySelectorAll('.search-highlight').forEach(el => {
el.classList.remove('search-highlight')
})
if (Array.isArray(element)) {
for (const el of element) {
el.classList.add('search-highlight')
setTimeout(() => {
el.classList.remove('search-highlight')
}, 3000)
}
} else {
element.classList.add('search-highlight')
setTimeout(() => {
element.classList.remove('search-highlight')
}, 3000)
}
}
</script>
<template>
<Sections>
<template #leftbar>
<Card header="Информация о документе">
<div>
<ListStrate header="Наименование">
<span class="block text-sm">
{{ template.name }}
</span>
</ListStrate>
<ListStrate header="Дата обновления">
<span class="text-sm">
{{ useDateFormat(template.updated_at, 'DD.MM.YYYY HH:mm:ss') }}
</span>
</ListStrate>
<ListStrate header="Дата создания">
<span class="text-sm">
{{ useDateFormat(template.created_at, 'DD.MM.YYYY HH:mm:ss') }}
</span>
</ListStrate>
</div>
<template #footer>
<div class="flex flex-col gap-y-1">
<Button block @click="onPrint" :loading="previewLoading" icon-left>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 17h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2"></path><path d="M17 9V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v4"></path><rect x="7" y="13" width="10" height="8" rx="2"></rect></g></svg>
</template>
Печать документа
</Button>
<Button block @click="onDownloadDocx" :loading="previewLoading" icon-left>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"></path><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"></path><path d="M12 11v6"></path><path d="M9 14l3 3l3-3"></path></g></svg>
</template>
Скачать docx
</Button>
<CardBack :tag="Link" href="/" class="mt-2" />
</div>
</template>
</Card>
</template>
<Card header="Предпросмотр" :content-scroll="!previewLoading" :content-relative>
<div class="flex flex-col items-center justify-center">
<VuePdfEmbed width="793.701" text-layer ref="viewer" :source="previewUrl" @rendered="previewLoading = false" />
<div v-if="previewLoading" class="absolute inset-0 backdrop-blur-xs h-full flex items-center justify-center z-10">
<div class="text-center space-y-4">
<div class="flex items-center justify-center">
<div class="relative">
<div class="w-8 h-8 border-3 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
<div class="absolute inset-0 w-8 h-8 border-3 border-transparent border-r-blue-300 rounded-full animate-spin" style="animation-duration: 1.5s"></div>
</div>
</div>
</div>
</div>
</div>
</Card>
<template #rightbar>
<Card header="Свойства документа" :content-relative>
<div v-if="previewLoading" class="absolute inset-0 flex items-center justify-center">
<div class="flex items-center justify-center">
<div class="relative">
<div class="w-8 h-8 border-3 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
<div class="absolute inset-0 w-8 h-8 border-3 border-transparent border-r-blue-300 rounded-full animate-spin" style="animation-duration: 1.5s"></div>
</div>
</div>
</div>
<template v-for="(data, key) in formData" :key="key">
<div v-if="data.type === 'group'">
<Collapsible :header="data.label">
<ListStrate v-for="(variable, key) in data.children" :key="key" :header="variable.label">
<Input
v-if="variable.type === 'text'"
:id="key"
@focus="searchAndScroll(variable.name)"
v-model:value="variable.value"
@update:value="value => onChangeVariableTextValue(key, value)"
:placeholder="`Введите ${formatLabel(variable.label)}`"
/>
<TextArea
v-if="variable.type === 'textarea'"
:rows="8"
:resize="false"
:id="key"
@focus="searchAndScroll(variable.name)"
v-model:value="variable.value"
@update:value="value => onChangeVariableTextValue(key, value)"
:placeholder="`Введите ${formatLabel(variable.label)}`"
/>
<!-- Select поле -->
<Select
v-else-if="variable.type === 'select'"
:id="key"
@focus="searchAndScroll(variable.name)"
@change="value => onChangeVariableSelectValue(key, value)"
v-model:value="variable.value"
:options="variable.options"
/>
<!-- Radio кнопки -->
<div v-else-if="variable.type === 'radio'" class="space-y-2">
<label
v-for="(optionLabel, optionValue) in variable.options"
:key="optionValue"
class="flex items-center"
>
<input
type="radio"
:name="key"
:value="optionValue"
v-model="formData[key]"
@change="updatePreview"
class="mr-2"
>
{{ optionLabel }}
</label>
</div>
<PriceInput v-else-if="variable.type === 'price-input'"
v-model:number="variable.number"
v-model:text="variable.value"
@focus="searchAndScroll(variable.name)"
/>
<Calendar v-else-if="variable.type === 'calendar'"
v-model="variable.value"
:format="variable.format"
block
@focus="searchAndScroll(variable.name)"
/>
</ListStrate>
</Collapsible>
</div>
<ListStrate v-else :key="key" :header="data.label">
<Input
v-if="data.type === 'text'"
:id="key"
@focus="searchAndScroll(data.name)"
v-model:value="data.value"
@update:value="value => onChangeVariableTextValue(key, value)"
:placeholder="`Введите ${formatLabel(data.label)}`"
/>
<TextArea
v-if="data.type === 'textarea'"
:rows="8"
:resize="false"
:id="key"
@focus="searchAndScroll(data.name)"
v-model:value="data.value"
@update:value="value => onChangeVariableTextValue(key, value)"
:placeholder="`Введите ${formatLabel(data.label)}`"
/>
<!-- Select поле -->
<Select
v-else-if="data.type === 'select'"
:id="key"
@focus="searchAndScroll(data.name)"
@change="value => onChangeVariableSelectValue(key, value)"
v-model:value="data.value"
:options="data.options"
/>
<!-- Radio кнопки -->
<div v-else-if="data.type === 'radio'" class="space-y-2">
<label
v-for="(optionLabel, optionValue) in data.options"
:key="optionValue"
class="flex items-center"
>
<input
type="radio"
:name="key"
:value="optionValue"
v-model="formData[key]"
@change="updatePreview"
class="mr-2"
>
{{ optionLabel }}
</label>
</div>
<PriceInput v-else-if="data.type === 'price-input'"
v-model:number="data.number"
v-model:text="data.value"
@focus="searchAndScroll(data.name)"
/>
<Calendar v-else-if="data.type === 'calendar'"
v-model="data.value"
:format="data.format"
block
@focus="searchAndScroll(data.name)"
/>
</ListStrate>
</template>
<template #footer>
<Button :loading="previewLoading" block @click="updatePreview">
Обновить предпросмотр
</Button>
</template>
</Card>
</template>
</Sections>
</template>
<style scoped>
@reference "tailwindcss";
:deep(.search-highlight) {
@apply border border-dashed border-yellow-500 bg-yellow-200 text-black;
}
:deep(.vue-pdf-embed) {
margin: 0 auto;
}
:deep(.vue-pdf-embed .vue-pdf-embed__page) {
margin-bottom: 20px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 4px;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,160 @@
<script setup>
import Workspace from "../Layouts/Workspace.vue";
import PageHeader from "../Components/Page/PageHeader.vue";
import Page from "../Components/Page/Page.vue";
import List from "../Components/List/List.vue";
import ListItem from "../Components/List/ListItem.vue";
import PageBody from "../Components/Page/PageBody.vue";
import Badge from "../Components/Badge/Badge.vue";
import {Link} from "@inertiajs/vue3"
import AnimateSearch from "../Components/Input/Search/AnimateSearch.vue";
import {ref} from "vue";
import Button from "../Components/Button/Button.vue";
import ImportDocumentModal from "./Parts/ImportDocumentModal.vue";
import EditDocumentModal from "./Parts/EditDocumentModal.vue";
const props = defineProps({
templates: {
type: Array,
default: []
}
})
const searchValue = ref()
const showModalImport = ref(false)
const showModalEdit = ref(false)
const editTemplateId = ref(null)
const vertical = ref(true)
const onChangeLayoutList = () => {
vertical.value = !vertical.value
}
const onShowModalEdit = (template) => {
showModalEdit.value = true
editTemplateId.value = template.id
}
const onCloseModalEdit = () => {
showModalEdit.value = false
editTemplateId.value = null
}
</script>
<template>
<Workspace>
<Page>
<template #header>
<PageHeader>
Доступные шаблоны документов
</PageHeader>
</template>
<PageBody>
<div class="flex flex-col gap-y-2">
<div class="flex flex-row gap-x-2">
<!-- <Calendar v-model="date" format="dd MMMM yyyy год" :return-formatted />-->
<AnimateSearch v-model="searchValue" />
<!-- <Button :tag="Link" icon href="/editor">-->
<!-- <template #icon>-->
<!-- <svg xmlns="http://www.w3.org/2000/svg"-->
<!-- viewBox="0 0 24 24">-->
<!-- <g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">-->
<!-- <path d="M14 3v4a1 1 0 0 0 1 1h4"></path>-->
<!-- <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"></path>-->
<!-- <path d="M12 11v6"></path>-->
<!-- <path d="M9 14h6"></path>-->
<!-- </g>-->
<!-- </svg>-->
<!-- </template>-->
<!-- </Button>-->
<Button icon @click="showModalImport = true">
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" class="w-3.5 h-3.5">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"></path>
<path d="M12 11v6"></path>
<path d="M9 14h6"></path>
</g>
</svg>
</Button>
<Button icon @click="onChangeLayoutList">
<svg v-if="vertical" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" class="w-3.5 h-3.5">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16"></path>
<path d="M4 20h16"></path>
<rect x="6" y="9" width="12" height="6" rx="2"></rect>
</g>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" class="w-3.5 h-3.5">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4v16"></path>
<path d="M20 4v16"></path>
<rect x="9" y="6" width="6" height="12" rx="2"></rect>
</g>
</svg>
</Button>
</div>
<List :vertical="vertical" class="h-[calc(100vh-224px)] overflow-y-auto pr-1">
<div v-for="template in templates"
:key="template.id"
class="relative"
>
<Link :href="`/contract-generator/${template.id}`"
class="relative"
>
<ListItem>
<template v-slot:header>
{{ template.name }}
</template>
<div class="relative">
<span v-if="template.description">
{{ template.description }}
</span>
</div>
<!-- <template v-slot:actions>-->
<!-- <Button :tag="Link" variant="ghost" icon :href="`/editor?templateId=${template.id}`">-->
<!-- <template #icon>-->
<!-- <svg xmlns="http://www.w3.org/2000/svg"-->
<!-- viewBox="0 0 24 24">-->
<!-- <g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">-->
<!-- <path d="M14 3v4a1 1 0 0 0 1 1h4"></path>-->
<!-- <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"></path>-->
<!-- <path d="M12 11v6"></path>-->
<!-- <path d="M9 14h6"></path>-->
<!-- </g>-->
<!-- </svg>-->
<!-- </template>-->
<!-- </Button>-->
<!-- </template>-->
<template v-slot:footer>
<div class="flex gap-x-1.5">
<Badge variant="primary">
Экономический отдел
</Badge>
</div>
</template>
</ListItem>
</Link>
<div class="absolute right-2 top-1/2 -translate-y-1/2 z-10">
<Button icon @click="onShowModalEdit(template)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4" />
<path d="M13.5 6.5l4 4" />
</svg>
</Button>
</div>
</div>
</List>
</div>
</PageBody>
</Page>
<ImportDocumentModal v-model:open="showModalImport" @close="showModalImport = false" />
<EditDocumentModal v-model:open="showModalEdit" :templateId="editTemplateId" @close="onCloseModalEdit" />
</Workspace>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,681 @@
<script setup>
import Modal from "../../Components/Modal/Modal.vue";
import {computed, ref, watch} from "vue";
import FileUpload from "../../Components/Input/FileUpload.vue";
import Button from "../../Components/Button/Button.vue";
import {router} from "@inertiajs/vue3";
import Select from "../../Components/Select/Select.vue";
import ListStrate from "../../Components/List/ListStrate.vue";
import Input from "../../Components/Input/Input.vue";
import {useApiForm} from "../../Composables/useApiForm.js";
import Card from "../../Components/Card/Card.vue";
import FormGroup from "../../Components/Form/FormGroup.vue";
import Calendar from "../../Components/Calendar/Calendar.vue";
import Collapsible from "../../Components/Collapsible/Collapsible.vue";
const props = defineProps({
templateId: {
type: [String, Number],
default: null
}
})
const open = defineModel('open')
const stage = ref('upload')
const description = ref('')
const uploadedFile = ref(null)
const templateVariables = ref(null)
const formTitle = ref(null)
const isTemplateLoaded = ref(false)
const isUpdateFile = ref(false)
// Drag and drop состояния
const dragItem = ref(null)
const dragOverItem = ref(null)
const dragOverGroup = ref(null)
const dragSource = ref(null)
const lastDropPosition = ref({ targetIndex: null, group: null }) // Добавляем
watch(() => props.templateId, async (newTemplateId) => {
if (newTemplateId) {
await loadTemplateData(newTemplateId)
}
}, { immediate: true })
const loadTemplateData = async (templateId) => {
await axios.get(`/api/templates/${templateId}`)
.then(async res => {
const template = res.data
formTitle.value = template.name
description.value = template.description || ''
uploadForm.value.id = template.id
uploadForm.value.name = template.name
uploadForm.value.description = template.description || ''
uploadForm.value.variables = template.variables || []
isTemplateLoaded.value = true
})
}
const { formData: uploadForm, errors, reset, loading, submit, setFile: setFileToForm } = useApiForm({
name: '',
description: '',
file: null,
variables: []
})
watch(() => stage.value, (value) => {
if (value === 'variables') description.value = 'Опишите найденные в документе переменные'
else description.value = ''
})
const uploadFile = async () => {
if (isUpdateFile.value) {
try {
setFileToForm('doc_file', uploadedFile.value)
await submit('/api/import/variables').then(res => {
uploadForm.value.variables = res.variables.map(itm => ({
label: itm.label,
name: itm.name,
type: 'text'
}))
templateVariables.value = res.variables
if (templateVariables.value.length > 0) {
stage.value = 'variables'
selectedVariable.value = uploadForm.value.variables[0]
}
else errors.value = {
file: [
'В документе отсутствуют переменные'
]
}
})
} catch (err) {
console.log(err)
}
} else {
templateVariables.value = uploadForm.value.variables
if (templateVariables.value.length > 0) {
stage.value = 'variables'
selectedVariable.value = uploadForm.value.variables[0]
}
}
}
const variableTypes = [
{
key: 'Однострочное поле',
value: 'text'
},
{
key: 'Многострочное поле',
value: 'textarea'
},
{
key: 'Поле выбора',
value: 'select'
},
{
key: 'Поле ввода стоимости',
value: 'price-input'
},
{
key: 'Календарь',
value: 'calendar'
},
]
const submitForm = () => {
uploadForm.value.file = uploadedFile.value
router.post(`/templates/update`, uploadForm.value, {
onSuccess: () => {
open.value = false
}
})
}
const afterCloseModal = () => {
stage.value = 'upload'
uploadedFile.value = null
reset()
}
const widthOfStage = computed(() => {
if (stage.value === 'upload')
return 0
else if (stage.value === 'variables')
return 980
})
const calendarNowDate = ref(new Date())
const selectedVariable = ref()
const activeVariable = computed(() => {
for (const item of uploadForm.value.variables) {
if (item.name === selectedVariable.value?.name) {
return item;
}
else if (Array.isArray(item.children)) {
const foundChild = item.children.find(child => child.name === selectedVariable.value?.name);
if (foundChild) {
return foundChild;
}
}
}
return undefined;
})
const inputVariableOptions = (value) => {
activeVariable.value.options = value.split(',').map(item => item.trim())
}
const changeTypeValue = (type) => {
if (type !== 'select') {
delete activeVariable.value.options
delete activeVariable.value.textOptions
}
}
const clickToVariable = (variable) => {
selectedVariable.value = variable
calendarNowDate.value = new Date()
}
const createVariableGroup = () => {
const groupCount = templateVariables.value.filter(itm => itm.isGroup === true)
const group = {
label: `Группа ${groupCount.length + 1}`,
children: [],
isGroup: true,
type: 'group',
name: `group-${Date.now()}`
}
templateVariables.value.push(group)
uploadForm.value.variables = [...templateVariables.value]
}
// Drag and Drop функции
const dragStart = (event, variable, index, sourceGroup = null) => {
dragSource.value = sourceGroup ? 'group' : 'root'
dragItem.value = { variable, index, sourceGroup }
// Делаем оригинальный элемент полупрозрачным
event.currentTarget.style.opacity = '0.4'
event.dataTransfer.effectAllowed = 'move'
}
const dragEnd = () => {
// Восстанавливаем прозрачность всех элементов
document.querySelectorAll('.drag-handle').forEach(el => {
el.style.opacity = '1'
})
dragItem.value = null
dragOverItem.value = null
dragOverGroup.value = null
dragSource.value = null
lastDropPosition.value = { targetIndex: null, group: null }
}
const dragOver = (event, targetIndex, group = null) => {
event.preventDefault()
event.stopPropagation()
console.log('dragOver:', { targetIndex, group, dragItem: dragItem.value })
// Сохраняем последнюю позицию
lastDropPosition.value = { targetIndex, group }
// Не показывать индикатор если перетаскиваемый элемент тот же самый
if (dragItem.value) {
const isSameElement = group ?
(dragItem.value.sourceGroup === group && dragItem.value.index === targetIndex) :
(dragItem.value.index === targetIndex)
if (isSameElement) {
dragOverItem.value = null
dragOverGroup.value = null
return
}
}
dragOverItem.value = targetIndex
dragOverGroup.value = group
}
const handleGlobalDrop = (event) => {
event.preventDefault()
event.stopPropagation()
if (!dragItem.value) return
// Используем последнюю сохраненную позицию для глобального дропа
const { targetIndex, group } = lastDropPosition.value
if (targetIndex !== null) {
drop(event, targetIndex, group)
} else {
// Если позиция не определена, сбрасываем
dragEnd()
}
}
const drop = (event, targetIndex, group = null) => {
event.preventDefault()
event.stopPropagation()
if (!dragItem.value) return
// Используем последнюю сохраненную позицию, если текущие параметры null
const finalTargetIndex = targetIndex ?? lastDropPosition.value.targetIndex
const finalGroup = group ?? lastDropPosition.value.group
console.log('drop:', {
targetIndex,
group,
finalTargetIndex,
finalGroup,
lastDropPosition: lastDropPosition.value,
dragItem: dragItem.value
})
const sourceVariable = dragItem.value.variable
const sourceGroup = dragItem.value.sourceGroup
// Обработка для targetIndex = -2 (начало группы)
const actualTargetIndex = targetIndex === -2 ? 0 : targetIndex
// Если перетаскиваем ГРУППУ (изменение порядка групп)
if (sourceVariable.isGroup && !group) {
const sourceIndex = dragItem.value.index
if (sourceIndex !== actualTargetIndex) {
const [removed] = templateVariables.value.splice(sourceIndex, 1)
// Корректируем targetIndex если удалили элемент перед целевой позицией
const adjustedTargetIndex = sourceIndex < actualTargetIndex
? actualTargetIndex - 1
: actualTargetIndex
templateVariables.value.splice(adjustedTargetIndex, 0, removed)
}
}
// Если перетаскиваем из группы в корень
else if (sourceGroup && !group) {
// Удаляем из группы
const sourceIndex = sourceGroup.children.findIndex(v => v.name === sourceVariable.name)
if (sourceIndex > -1) {
sourceGroup.children.splice(sourceIndex, 1)
// Добавляем в корень
if (actualTargetIndex === -1) {
templateVariables.value.push(sourceVariable)
} else {
templateVariables.value.splice(actualTargetIndex, 0, sourceVariable)
}
}
}
// Если перетаскиваем из корня в группу
else if (!sourceGroup && group && !sourceVariable.isGroup) {
// Удаляем из корня
const sourceIndex = templateVariables.value.findIndex(v => v.name === sourceVariable.name)
if (sourceIndex > -1) {
templateVariables.value.splice(sourceIndex, 1)
// Добавляем в группу
if (!group.children) group.children = []
if (actualTargetIndex === -1) {
group.children.push(sourceVariable)
} else {
group.children.splice(actualTargetIndex, 0, sourceVariable)
}
}
}
// Если перетаскиваем из группы в другую группу
else if (sourceGroup && group && sourceGroup !== group && !sourceVariable.isGroup) {
// Удаляем из исходной группы
const sourceIndex = sourceGroup.children.findIndex(v => v.name === sourceVariable.name)
if (sourceIndex > -1) {
sourceGroup.children.splice(sourceIndex, 1)
// Добавляем в целевую группу
if (!group.children) group.children = []
if (actualTargetIndex === -1) {
group.children.push(sourceVariable)
} else {
group.children.splice(actualTargetIndex, 0, sourceVariable)
}
}
}
// Если перетаскиваем между элементами в корне
else if (!sourceGroup && !group && !sourceVariable.isGroup) {
const sourceIndex = dragItem.value.index
const [removed] = templateVariables.value.splice(sourceIndex, 1)
templateVariables.value.splice(actualTargetIndex, 0, removed)
}
// Если перетаскиваем внутри одной группы
else if (sourceGroup && group && sourceGroup === group && !sourceVariable.isGroup) {
const sourceIndex = dragItem.value.index
const [removed] = group.children.splice(sourceIndex, 1)
group.children.splice(actualTargetIndex, 0, removed)
}
// Сбрасываем состояния
dragItem.value = null
dragOverItem.value = null
dragOverGroup.value = null
dragSource.value = null
}
// Удаление группы
const removeGroup = (group) => {
const index = templateVariables.value.findIndex(v => v === group)
if (index > -1) {
// Перемещаем детей группы в корень
if (group.children && group.children.length) {
templateVariables.value.splice(index, 1, ...group.children)
} else {
templateVariables.value.splice(index, 1)
}
}
}
// Редактирование группы
const editGroup = (group) => {
selectedVariable.value = group
}
// Получение индекса переменной в корневом списке
const getRootIndex = (variable) => {
return templateVariables.value.findIndex(v => v.name === variable.name)
}
</script>
<template>
<Modal v-model:open="open" :title="formTitle" :description="description" @after-close="afterCloseModal" :width="widthOfStage">
<div v-if="!isTemplateLoaded" class="h-[376px] relative">
<div class="absolute inset-1/2">
<div class="flex items-center gap-x-2">
<div class="flex space-x-1">
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0s"></div>
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
</div>
</div>
</div>
</div>
<div v-else>
<div v-if="stage === 'upload'" class="flex flex-col gap-y-1">
<Input v-model:value="uploadForm.name" label="Наименование" />
<Input v-model:value="uploadForm.description" label="Описание" />
<div class="mt-2.5">
<Button block text-align="center" v-if="!isUpdateFile" @click="isUpdateFile = true">
Загрузить обновленный шаблон
</Button>
<FileUpload v-else v-model:file="uploadedFile" accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
</div>
</div>
<div v-else class="grid grid-cols-[280px_1fr] gap-x-2 ">
<Card header="Переменные документа">
<Button class="mb-2 -mt-2" block @click="createVariableGroup" icon-left>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 4h6v6h-6zm10 0h6v6h-6zm-10 10h6v6h-6zm10 3h6m-3 -3v6" />
</svg>
</template>
Добавить группу
</Button>
<div class="flex flex-col gap-y-0.5 max-h-[396px] overflow-y-auto">
<template v-for="(variable, index) in templateVariables" :key="variable.name || variable.label">
<!-- Визуальный дубликат перетаскиваемого элемента -->
<div v-if="dragItem && dragOverItem === index && !dragOverGroup && dragItem.variable !== variable"
@dragover="dragOver($event, index)"
@drop="drop($event, index)"
class="opacity-60 transform">
<Button
icon-left
block
class="cursor-grabbing bg-blue-50 border-blue-200"
disabled>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</template>
{{ dragItem.variable.label }}
</Button>
</div>
<div v-if="variable.isGroup"
class="pl-px pt-px pb-px pr-px"
:class="{ 'bg-blue-50/10 rounded': dragOverGroup === variable }"
@dragover="dragOver($event, index)"
@drop="drop($event, index)">
<Collapsible class="drag-handle"
draggable="true"
@dragstart="dragStart($event, variable, index)"
@dragend="dragEnd">
<template #icon>
<div class="cursor-grab active:cursor-grabbing">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</div>
</template>
<template #header>
<div class="flex items-center justify-between w-full">
<div class="flex items-center flex-1 drag-handle">
<span class="block text-sm font-medium truncate">{{variable.label}}</span>
</div>
</div>
</template>
<template #header-extra>
<button @click="editGroup(variable)" class="text-white hover:text-zinc-300 flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4" />
<path d="M13.5 6.5l4 4" />
</svg>
</button>
<button @click="removeGroup(variable)" class="text-red-500 hover:text-red-700 flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
</svg>
</button>
</template>
<div class="min-h-2 flex flex-col gap-y-0.5"
@dragover="dragOver($event, -2, variable)"
@drop="drop($event, -2, variable)">
<template v-if="variable.children && variable.children.length > 0">
<div v-for="(child, childIndex) in variable.children"
:key="child.name"
class="relative"
@dragover="dragOver($event, childIndex, variable)"
@drop="drop($event, childIndex, variable)"
>
<!-- Визуальный дубликат между элементами в группе -->
<div v-if="dragItem && dragOverItem === childIndex && dragOverGroup === variable"
class="opacity-60 transform">
<Button
icon-left
block
class="cursor-grabbing bg-blue-50 border-blue-200"
disabled>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</template>
{{ dragItem.variable.label }}
</Button>
</div>
<Button
icon-left
block
@click="clickToVariable(child)"
draggable="true"
@dragstart="dragStart($event, child, childIndex, variable)"
@dragend="dragEnd"
class="drag-handle cursor-grab active:cursor-grabbing">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</template>
<span class="truncate">{{ child.label }}</span>
</Button>
</div>
</template>
<div v-else
class="text-center text-gray-400 py-2 text-sm border-2 border-dashed border-gray-300 rounded mx-2"
@dragover="dragOver($event, -2, variable)"
@drop="drop($event, -2, variable)">
Перетащите переменные сюда
</div>
</div>
</Collapsible>
</div>
<div v-else
class="relative"
:class="{ 'bg-blue-50/10': dragOverItem === index && !dragOverGroup }"
@dragover="dragOver($event, index)"
@drop="drop($event, index)">
<Button
icon-left
block
@click="clickToVariable(variable)"
draggable="true"
@dragstart="dragStart($event, variable, index)"
@dragend="dragEnd"
class="drag-handle cursor-grab active:cursor-grabbing">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</template>
{{ variable.label }}
</Button>
</div>
</template>
<!-- Визуальный дубликат в конце корневого списка -->
<div v-if="dragItem && dragOverItem === -1 && !dragOverGroup"
class="opacity-60 transform scale-105">
<Button
icon-left
block
class="cursor-grabbing bg-blue-50 border-blue-200"
disabled>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</template>
{{ dragItem.variable.label }}
</Button>
</div>
</div>
</Card>
<Card :header="selectedVariable?.label || 'Выберите переменную'">
<div class="pr-2" v-if="selectedVariable">
<div class="flex flex-col gap-y-1">
<FormGroup label="Наименование переменной" position="top">
<Input v-model:value="activeVariable.name" disabled />
</FormGroup>
<FormGroup label="Отображаемое наименование" position="top">
<Input v-model:value="activeVariable.label" />
</FormGroup>
<FormGroup v-if="activeVariable.type !== 'group'" label="Тип ввода" position="top">
<Select :options="variableTypes" v-model:value="activeVariable.type" @update:value="changeTypeValue(value)" placeholder="Выберите тип" />
</FormGroup>
<FormGroup v-if="activeVariable.type === 'select'" label="Значения для выбора" position="top">
<Input v-model:value="activeVariable.textOptions" @update:value="value => inputVariableOptions(value)" />
</FormGroup>
<FormGroup v-if="activeVariable.type === 'calendar'" label="Формат выводимой даты" position="top">
<Input v-model:value="activeVariable.format" placeholder="К примеру dd.MM.yyyy" />
</FormGroup>
<FormGroup v-if="activeVariable.type === 'calendar'" label="Предпросмотр даты" position="top">
<Calendar v-model="calendarNowDate" :format="activeVariable.format" block disabled />
</FormGroup>
</div>
</div>
<div v-else class="text-center text-gray-400 py-8">
Выберите переменную для редактирования
</div>
</Card>
</div>
<div v-if="errors" class="absolute translate-x-full top-2 -right-2 flex flex-col gap-y-1">
<template v-for="errorContainer in errors">
<template v-for="error in errorContainer">
<div class="flex flex-row items-center bg-rose-300 rounded-md gap-x-1.5 py-2 px-3">
<div class="h-5 w-5 text-red-500">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M9 10h.01"></path><path d="M15 10h.01"></path><path d="M9.5 15.25a3.5 3.5 0 0 1 5 0"></path></g></svg>
</div>
<span class="text-red-500 text-sm">
{{ error }}
</span>
</div>
</template>
</template>
</div>
</div>
<template #actions>
<Button v-if="stage === 'upload'" @click="uploadFile">
Далее
</Button>
<Button v-else @click="submitForm">
Завершить
</Button>
</template>
</Modal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,203 @@
<script setup>
import Modal from "../../Components/Modal/Modal.vue";
import {computed, ref, watch} from "vue";
import FileUpload from "../../Components/Input/FileUpload.vue";
import Button from "../../Components/Button/Button.vue";
import {router} from "@inertiajs/vue3";
import Select from "../../Components/Select/Select.vue";
import ListStrate from "../../Components/List/ListStrate.vue";
import Input from "../../Components/Input/Input.vue";
import {useApiForm} from "../../Composables/useApiForm.js";
import Card from "../../Components/Card/Card.vue";
import FormGroup from "../../Components/Form/FormGroup.vue";
import Calendar from "../../Components/Calendar/Calendar.vue";
const open = defineModel('open')
const stage = ref('upload')
const description = ref('')
const uploadedFile = ref(null)
const templateVariables = ref(null)
const { formData: uploadForm, errors, reset, loading, submit, setFile: setFileToForm } = useApiForm({
name: '',
description: '',
file: null,
variables: []
})
watch(() => stage.value, (value) => {
if (value === 'variables') description.value = 'Опишите найденные в документе переменные'
else description.value = ''
})
const uploadFile = async () => {
try {
setFileToForm('doc_file', uploadedFile.value)
await submit('/api/import/variables').then(res => {
uploadForm.value.variables = res.variables.map(itm => ({
label: itm.label,
name: itm.name,
type: 'text'
}))
templateVariables.value = res.variables
if (templateVariables.value.length > 0) {
stage.value = 'variables'
selectedVariable.value = uploadForm.value.variables[0]
}
else errors.value = {
file: [
'В документе отсутствуют переменные'
]
}
})
} catch (err) {
console.log(err)
}
}
const variableTypes = [
{
key: 'Однострочное поле',
value: 'text'
},
{
key: 'Многострочное поле',
value: 'textarea'
},
{
key: 'Поле выбора',
value: 'select'
},
{
key: 'Поле ввода стоимости',
value: 'price-input'
},
{
key: 'Календарь',
value: 'calendar'
},
]
const submitForm = () => {
uploadForm.value.file = uploadedFile.value
router.post('/templates/import', uploadForm.value, {
onSuccess: () => {
open.value = false
}
})
}
const afterCloseModal = () => {
stage.value = 'upload'
uploadedFile.value = null
reset()
}
const widthOfStage = computed(() => {
if (stage.value === 'upload')
return 0
else if (stage.value === 'variables')
return 980
})
const calendarNowDate = ref(new Date())
const selectedVariable = ref()
const activeVariable = computed(() => {
return uploadForm.value.variables.find(itm => itm.name === selectedVariable.value?.name)
})
const inputVariableOptions = (value) => {
activeVariable.value.options = value.split(',').map(item => item.trim())
}
const changeTypeValue = (type) => {
if (type !== 'select') {
delete activeVariable.value.options
delete activeVariable.value.textOptions
}
}
const clickToVariable = (variable) => {
selectedVariable.value = variable
calendarNowDate.value = new Date()
}
</script>
<template>
<Modal v-model:open="open" title="Импорт документа" :description="description" @after-close="afterCloseModal" :width="widthOfStage">
<div v-if="stage === 'upload'" class="flex flex-col gap-y-1">
<Input v-model:value="uploadForm.name" label="Наименование" />
<Input v-model:value="uploadForm.description" label="Описание" />
<FileUpload class="mt-2.5" v-model:file="uploadedFile" accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
</div>
<div v-else class="grid grid-cols-[280px_1fr] gap-x-2 ">
<Card header="Переменные документа">
<div class="flex flex-col gap-y-0.5 max-h-[420px] pr-2 overflow-y-auto">
<Button v-for="variable in templateVariables" block @click="clickToVariable(variable)">
{{ variable.label }}
</Button>
</div>
<!-- <ListStrate v-for="variable in uploadForm.variables" :header="variable.label">-->
<!-- <Select :options="variableTypes" v-model:value="variable.type" placeholder="Выберите тип" />-->
<!-- </ListStrate>-->
</Card>
<Card :header="selectedVariable.label">
<div class="pr-2">
<div class="flex flex-col gap-y-1">
<FormGroup label="Наименование переменной" position="top">
<Input v-model:value="activeVariable.name" disabled />
</FormGroup>
<FormGroup label="Отображаемое наименование" position="top">
<Input v-model:value="activeVariable.label" />
</FormGroup>
<FormGroup label="Тип ввода" position="top">
<Select :options="variableTypes" v-model:value="activeVariable.type" @update:value="changeTypeValue(value)" placeholder="Выберите тип" />
</FormGroup>
<FormGroup v-if="activeVariable.type === 'select'" label="Значения для выбора" position="top">
<Input v-model:value="activeVariable.textOptions" @update:value="value => inputVariableOptions(value)" />
</FormGroup>
<FormGroup v-if="activeVariable.type === 'calendar'" label="Формат выводимой даты" position="top">
<Input v-model:value="activeVariable.format" placeholder="К примеру dd.MM.yyyy" />
</FormGroup>
<FormGroup v-if="activeVariable.type === 'calendar'" label="Предпросмотр даты" position="top">
<Calendar v-model="calendarNowDate" :format="activeVariable.format" block disabled />
</FormGroup>
</div>
</div>
</Card>
</div>
<div v-if="errors" class="absolute translate-x-full top-2 -right-2 flex flex-col gap-y-1">
<template v-for="errorContainer in errors">
<template v-for="error in errorContainer">
<div class="flex flex-row items-center bg-rose-300 rounded-md gap-x-1.5 py-2 px-3">
<div class="h-5 w-5 text-red-500">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M9 10h.01"></path><path d="M15 10h.01"></path><path d="M9.5 15.25a3.5 3.5 0 0 1 5 0"></path></g></svg>
</div>
<span class="text-red-500 text-sm">
{{ error }}
</span>
</div>
</template>
</template>
</div>
<template #actions>
<Button v-if="stage === 'upload'" @click="uploadFile">
Далее
</Button>
<Button v-else @click="submitForm">
Завершить
</Button>
</template>
</Modal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,97 @@
<script setup>
import Modal from "../../Components/Modal/Modal.vue";
import Input from "../../Components/Input/Input.vue"
import {ref, watch} from "vue";
import FormGroup from "../../Components/Form/FormGroup.vue";
import Select from "../../Components/Select/Select.vue";
import Button from "../../Components/Button/Button.vue";
const props = defineProps({
variable: Object
})
const emit = defineEmits(['save']);
watch(() => props.variable, (newVal) => {
if (newVal) {
localVariable.value = { ...newVal }
}
}, { immediate: true })
const addOption = () => {
localVariable.value.options.push({ value: '', label: '' })
}
const removeOption = (index) => {
localVariable.value.options.splice(index, 1)
}
const save = () => {
emit('save', { ...localVariable.value });
};
const open = defineModel('open', {
type: Boolean,
default: false
})
const localVariable = ref({
name: '',
label: '',
type: 'text',
options: [],
default: ''
})
const typeOptions = [
{
text: 'Текст'
}
]
</script>
<template>
<Modal v-model:open="open" @close="open = false" title="Добавить переменную">
<div class="flex flex-col gap-y-2">
<FormGroup label="Имя переменной:">
<Input v-model:value="localVariable.name" placeholder="Например: your_name" />
</FormGroup>
<FormGroup label="Отображаемое имя:">
<Input v-model:value="localVariable.label" placeholder="Например: Ваше имя" />
</FormGroup>
<FormGroup label="Тип:">
<Select v-model="localVariable.type" :options="typeOptions" />
</FormGroup>
</div>
<template #actions>
<div class="flex flex-row justify-end gap-x-2">
<Button variant="danger" @click="open = false">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18"></path>
<path d="M6 6l12 12"></path>
</g>
</svg>
</template>
Отмена
</Button>
<Button @click="save">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
<path d="M5 12l5 5L20 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</template>
Сохранить
</Button>
</div>
</template>
</Modal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,406 @@
<script setup>
import Editor from "../Components/Editor.vue";
import Sections from "../Layouts/Sections.vue";
import Card from "../Components/Card/Card.vue";
import {computed, ref, watch} from "vue";
import Button from "../Components/Button/Button.vue";
import ListStrate from "../Components/List/ListStrate.vue";
import Input from "../Components/Input/Input.vue";
import {now} from "@vueuse/core";
import Select from "../Components/Select/Select.vue";
import {Link, router} from "@inertiajs/vue3";
import CardBack from "../Components/Card/CardBack.vue";
const props = defineProps({
template: Object
})
const editor = ref()
const content = ref(props.template?.content ?? [])
const zoom = 1
const zoom_min = 0.10
const zoom_max = 5.0
const page_format_mm = [210, 297]
const page_margins = "2cm 1.5cm 2cm 3cm"
const display = "grid" // ["grid", "vertical", "horizontal"]
const mounted = false // will be true after this component is mounted
const undo_count = -1 // contains the number of times user can undo (= current position in content_history)
const content_history = [] // contains the content states for undo/redo operations
const variables = ref(props.template?.variables_config ?? {})
const variablesItems = [
{
key: 'Поле ввода',
value: 'text'
},
{
key: 'Поле выбора',
value: 'select'
}
]
const elementInfo = ref({})
const activeElement = ref()
const activeElements = ref()
const currentTextStyle = ref('')
const getCurrentTextStyle = (style) => {
currentTextStyle.value = style
}
const mappedTags = {
'P': 'Текст',
'H1': 'Заголовок 1',
'H2': 'Заголовок 2',
'VAR': 'Переменная'
}
const formatAlignLeft = () => {
document.execCommand("justifyLeft")
}
const formatAlignCenter = () => {
document.execCommand("justifyCenter")
}
const formatAlignRight = () => {
document.execCommand("justifyRight")
}
const formatAlignJustify = () => {
document.execCommand("justifyFull")
}
const formatTextBold = () => {
document.execCommand("bold")
}
const formatTextItalic = () => {
document.execCommand("italic")
}
const formatTextUnderline = () => {
document.execCommand("underline")
}
const formatTextStrikethrough = () => {
document.execCommand("strikethrough")
}
const formatFirstLine = () => {
if (activeElement.value.style.textIndent) {
activeElement.value.style.textIndent = ''
return
} else if (activeElement.value.parentElement.tagName === 'P' &&
activeElement.value.parentElement.style.textIndent) {
activeElement.value.parentElement.style.textIndent = ''
return
}
if (activeElement.value.tagName === 'P') {
activeElement.value.style.textIndent = '1.25cm'
} else if (activeElement.value.parentElement.tagName === 'P') {
activeElement.value.parentElement.style.textIndent = '1.25cm'
}
}
const clearBackground = () => {
activeElement.value.style.background = ''
}
const isVariable = computed(() => {
return activeElement.value.getAttribute('brs-variable')
})
const createVariable = () => {
const selection = window.getSelection();
// Проверяем, есть ли выделение
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const selectedText = range.toString();
// Проверяем, что текст действительно выделен
if (!selectedText) return;
if (isVariable.value === 'true') {
const elementId = activeElement.value.getAttribute('brs-element-id')
delete variables.value[elementId]
activeElement.value.removeAttribute('brs-variable')
activeElement.value.removeAttribute('brs-type')
activeElement.value.removeAttribute('brs-element-id')
} else {
// Создаем span элемент
const span = document.createElement('span');
const spanId = now().toString()
span.textContent = selectedText;
span.setAttribute('brs-variable', 'true');
span.setAttribute('brs-type', 'text');
span.setAttribute('brs-element-id', spanId);
// Удаляем выделенный текст и вставляем span
range.deleteContents();
range.insertNode(span);
variables.value = {
...variables.value,
[spanId]: {
name: selectedText,
type: 'text',
label: selectedText
}
}
}
// Очищаем выделение
selection.removeAllRanges();
console.log('Создан span для текста:', selectedText);
}
watch(activeElement, (element) => {
if (!element) return
elementInfo.value = {}
if (element.getAttribute('brs-variable') === 'true') {
const elementId = element.getAttribute('brs-element-id')
elementInfo.value.id = elementId
elementInfo.value.element = 'VAR'
elementInfo.value.name = mappedTags[elementInfo.value.element]
elementInfo.value.type = element.getAttribute('brs-type')
elementInfo.value.value = ''
return
}
elementInfo.value.name = mappedTags[element.tagName]
elementInfo.value.element = element.tagName
})
const updateVariableType = (elementId, type) => {
const variableKeys = Object.keys(variables.value[elementId])
if (variableKeys.includes('values')) {
if (type !== 'select') {
delete variables.value[elementId].values
delete variables.value[elementId].value
}
} else if (type === 'select'){
variables.value[elementId].values = []
}
}
const updateVariableValue = (elementId, value) => {
const variableType = variables.value[elementId].type
if (variableType === 'select') {
variables.value[elementId].value = value
variables.value[elementId].values = value.split(',').map(item => item.trim());
}
}
const saveTemplate = () => {
const data = {
...props.template,
content: content.value,
variables_config: variables.value
}
router.post('/editor', data)
}
const documentPrint = () => {
window.print()
}
</script>
<template>
<Sections>
<template #leftbar>
<Card>
<template #footer>
<CardBack :tag="Link" href="/" />
</template>
</Card>
</template>
<Card>
<template #header>
<div class="flex flex-row gap-x-3">
<div class="flex flex-row gap-x-1">
<Button icon @click="formatAlignLeft">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M4 6h16"></path>
<path d="M4 12h10"></path>
<path d="M4 18h14"></path>
</g>
</svg>
</template>
</Button>
<Button icon @click="formatAlignCenter">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M4 6h16"></path>
<path d="M8 12h8"></path>
<path d="M6 18h12"></path>
</g>
</svg>
</template>
</Button>
<Button icon @click="formatAlignRight">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M4 6h16"></path>
<path d="M10 12h10"></path>
<path d="M6 18h14"></path>
</g>
</svg>
</template>
</Button>
<Button icon @click="formatAlignJustify">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M4 6h16"></path>
<path d="M4 12h16"></path>
<path d="M4 18h12"></path>
</g>
</svg>
</template>
</Button>
</div>
<div class="flex flex-row gap-x-1">
<Button icon @click="formatTextBold">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7z"></path>
<path d="M13 12h1a3.5 3.5 0 0 1 0 7H7v-7"></path>
</g>
</svg>
</template>
</Button>
<Button icon @click="formatTextItalic">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M11 5h6"></path>
<path d="M7 19h6"></path>
<path d="M14 5l-4 14"></path>
</g>
</svg>
</template>
</Button>
<Button icon @click="formatTextUnderline">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M7 5v5a5 5 0 0 0 10 0V5"></path>
<path d="M5 19h14"></path>
</g>
</svg>
</template>
</Button>
<Button icon @click="formatTextStrikethrough">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M5 12h14"></path>
<path
d="M16 6.5A4 2 0 0 0 12 5h-1a3.5 3.5 0 0 0 0 7h2a3.5 3.5 0 0 1 0 7h-1.5a4 2 0 0 1-4-1.5"></path>
</g>
</svg>
</template>
</Button>
</div>
<div class="flex flex-row gap-x-1">
<Button icon @click="formatFirstLine">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6H9"></path><path d="M20 12h-7"></path><path d="M20 18H9"></path><path d="M4 8l4 4l-4 4"></path></g></svg>
</template>
</Button>
<Button icon @click="documentPrint">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 17h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2"></path><path d="M17 9V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v4"></path><rect x="7" y="13" width="10" height="8" rx="2"></rect></g></svg>
</template>
</Button>
<Button icon @click="createVariable">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4C2.5 9 2.5 14 5 20M19 4c2.5 5 2.5 10 0 16M9 9h1c1 0 1 1 2.016 3.527C13 15 13 16 14 16h1"></path><path d="M8 16c1.5 0 3-2 4-3.5S14.5 9 16 9"></path></g></svg>
</template>
</Button>
<Button icon @click="clearBackground">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M5 12h14"></path>
<path
d="M16 6.5A4 2 0 0 0 12 5h-1a3.5 3.5 0 0 0 0 7h2a3.5 3.5 0 0 1 0 7h-1.5a4 2 0 0 1-4-1.5"></path>
</g>
</svg>
</template>
</Button>
</div>
</div>
</template>
<Editor id="editor"
ref="editor"
v-model="content"
:zoom="zoom"
:page_format_mm="page_format_mm"
:page_margins="page_margins"
:display="display"
@update:current-style="getCurrentTextStyle"
v-model:active-element="activeElement"
v-model:active-elements="activeElements"
/>
</Card>
<template #rightbar>
<Card>
<template #header>
<span>
{{ elementInfo.name ?? 'Нет активного элемента' }}
</span>
</template>
<template v-if="elementInfo.name">
<ListStrate header="Параметры">
<div class="flex flex-col gap-y-1">
<Input v-if="elementInfo?.id" label="Идентификатор" v-model:value="elementInfo.id" disabled />
<!-- <Input v-if="elementInfo?.type" label="Тип" v-model:value="elementInfo.type" disabled />-->
<Input v-if="variables[elementInfo.id].label" label="Наименование" v-model:value="variables[elementInfo.id].label" />
</div>
</ListStrate>
<ListStrate v-if="elementInfo.element === 'VAR'" header="Заполнение">
<div class="flex flex-col gap-y-1">
<Select label="Тип" v-model:value="variables[elementInfo.id].type" @update:value="value => updateVariableType(elementInfo.id, value)" :options="variablesItems" />
<Input label="Значения" v-if="variables[elementInfo.id].type === 'select'" v-model:value="variables[elementInfo.id].value" @update:value="value => updateVariableValue(elementInfo.id, value)" />
</div>
</ListStrate>
<Button v-if="elementInfo.element === 'VAR'" block>
Создать раздел
</Button>
</template>
<template #footer>
<Button block @click="saveTemplate">
Сохранить
</Button>
</template>
</Card>
</template>
</Sections>
</template>

View File

@@ -0,0 +1,84 @@
import {nextTick} from "vue";
export function waitForAllComponentsMounted(vm, timeout = 5000) {
return new Promise((resolve, reject) => {
const startTime = Date.now()
function checkComponents() {
// Проверяем, смонтирован ли текущий компонент
if (!vm.isMounted) {
if (Date.now() - startTime > timeout) {
reject(new Error('Timeout waiting for component to mount'))
return
}
nextTick(checkComponents)
return
}
// Рекурсивно проверяем дочерние компоненты
const checkChildren = (component) => {
const promises = []
// Проверяем прямых потомков (в Vue 3 используем subTree)
if (component.subTree && component.subTree.children) {
const processChildren = (children) => {
children.forEach(child => {
if (child.component) {
const childInstance = child.component.proxy
if (childInstance && !childInstance.isMounted) {
promises.push(new Promise((resolveChild, rejectChild) => {
const childStartTime = Date.now()
function checkChild() {
if (childInstance.isMounted) {
resolveChild()
} else if (Date.now() - childStartTime > timeout) {
rejectChild(new Error('Timeout waiting for child component to mount'))
} else {
nextTick(checkChild)
}
}
checkChild()
}))
}
// Рекурсивно проверяем потомков
if (childInstance.subTree && childInstance.subTree.children) {
promises.push(checkChildren(childInstance))
}
}
})
}
processChildren(component.subTree.children)
}
return Promise.all(promises)
}
checkChildren(vm)
.then(resolve)
.catch(reject)
}
checkComponents()
})
}
export const generateElementHTML = (element, formData = {}) => {
switch (element.type) {
case 'heading':
return `<h${element.level} data-element-id="${element.id}">${element.content}</h${element.level}>`
case 'paragraph':
return `<p data-element-id="${element.id}">${element.content}</p>`
case 'variable':
return `<span class="variable" data-element-id="${element.id}" data-variable="${element.variableName}">[${element.variableName}]</span>`
case 'warning':
return `<div class="warning" data-element-id="${element.id}">⚠️ ${element.content}</div>`
case 'table':
const tableHtml = element.content.replace('<table', `<table data-element-id="${element.id}"`)
return tableHtml
default:
return element.content || ''
}
}

View File

@@ -0,0 +1,78 @@
// utils/optionsFormatter.js
/**
* Преобразует массив [key: value] в массив объектов {value: key, label: value}
*/
export const formatOptions = (optionsArray) => {
if (!optionsArray || !Array.isArray(optionsArray)) return []
return optionsArray.map((item, index) => {
// Если это объект с ключами key и value
if (item && typeof item === 'object' && item.key !== undefined && item.value !== undefined) {
return {
value: item.value,
label: item.key
}
}
// Если это массив [key, value]
if (Array.isArray(item) && item.length >= 2) {
return {
value: item[0],
label: item[1]
}
}
// Если это простой массив значений
if (typeof item === 'string' || typeof item === 'number') {
return {
value: item,
label: String(item)
}
}
// Если это объект с произвольными ключами
if (item && typeof item === 'object') {
const keys = Object.keys(item)
if (keys.length > 0) {
return {
value: keys[0],
label: item[keys[0]]
}
}
}
// Fallback для непредвиденных форматов
return {
value: index,
label: String(item)
}
})
}
/**
* Преобразует объект {key: value, key: value} в массив {value: key, label: value}
*/
export const formatObjectOptions = (optionsObject) => {
if (!optionsObject || typeof optionsObject !== 'object') return []
return Object.entries(optionsObject).map(([key, value]) => ({
value: key,
label: value
}))
}
/**
* Универсальная функция, которая определяет тип данных и форматирует соответствующим образом
*/
export const normalizeOptions = (input) => {
if (Array.isArray(input)) {
return formatOptions(input)
}
if (typeof input === 'object' && input !== null) {
return formatObjectOptions(input)
}
return []
}

View File

@@ -0,0 +1,225 @@
/**
* Utility function that acts like an Array.filter on childNodes of "container"
* @param {HTMLElement} container
* @param {string} s_tag
*/
function find_sub_child_sibling_node (container, s_tag){
if(!container || !s_tag) return false;
const child_nodes = container.childNodes;
for(let i = 0; i < child_nodes.length; i++) {
if(child_nodes[i].s_tag === s_tag) return child_nodes[i];
}
return false;
}
/**
* This function moves every sub-child of argument "child" to the start of the "child_sibling"
* argument, beginning from the last child, with word splitting and format preserving.
* Typically, "child" is the current page which content overflows, and "child_sibling" is the
* next page.
* @param {HTMLElement} child Element to take children from (current page)
* @param {HTMLElement} child_sibling Element to copy children to (next page)
* @param {function} stop_condition Check function that returns a boolean if content doesn't overflow anymore
* @param {function(HTMLElement):boolean?} do_not_break Optional function that receives the current child element and should return true if the child should not be split over two pages but rather be moved directly to the next page
* @param {boolean?} not_first_child Should be unset. Used internally to let at least one child in the page
*/
function move_children_forward_recursively (child, child_sibling, stop_condition, do_not_break, not_first_child) {
// if the child still has nodes and the current page still overflows
while(child.childNodes.length && !stop_condition()){
// check if page has only one child tree left
not_first_child = not_first_child || (child.childNodes.length !== 1);
// select the last sub-child
const sub_child = child.lastChild;
// if it is a text node, move its content to next page word(/space) by word
if(sub_child.nodeType === Node.TEXT_NODE){
const sub_child_hashes = sub_child.textContent.match(/(\s|\S+)/g);
const sub_child_continuation = document.createTextNode('');
child_sibling.prepend(sub_child_continuation);
const l = sub_child_hashes ? sub_child_hashes.length : 0;
for(let i = 0; i < l; i++) {
if(i === l - 1 && !not_first_child) return; // never remove the first word of the page
sub_child.textContent = sub_child_hashes.slice(0, l - i - 1).join('');
sub_child_continuation.textContent = sub_child_hashes.slice(l - i - 1, l).join('');
if(stop_condition()) return;
}
}
// we simply move it to the next page if it is either:
// - a node with no content (e.g. <img>)
// - a header title (e.g. <h1>)
// - a table row (e.g. <tr>)
// - any element on whose user-custom `do_not_break` function returns true
else if(!sub_child.childNodes.length ||
sub_child.tagName.match(/h\d/i) ||
sub_child.tagName.match(/tr|table|thead|tbody|tfoot|th|td/i) ||
(typeof do_not_break === "function" && do_not_break(sub_child))) {
// just prevent moving the last child of the page
if(!not_first_child){
console.log("Move-forward: first child reached with no stop condition. Aborting");
return;
}
child_sibling.prepend(sub_child);
}
// for every other node that is not text and not the first child, clone it recursively to next page
else {
// check if sub child has already been cloned before
let sub_child_sibling = find_sub_child_sibling_node(child_sibling, sub_child.s_tag);
// if not, create it and watermark the relationship with a random tag
if(!sub_child_sibling) {
if(!sub_child.s_tag) {
const new_random_tag = Math.random().toString(36).slice(2, 8);
sub_child.s_tag = new_random_tag;
}
sub_child_sibling = sub_child.cloneNode(false);
sub_child_sibling.s_tag = sub_child.s_tag;
child_sibling.prepend(sub_child_sibling);
}
// then move/clone its children and sub-children recursively
move_children_forward_recursively(sub_child, sub_child_sibling, stop_condition, do_not_break, not_first_child);
sub_child_sibling.normalize(); // merge consecutive text nodes
}
// if sub_child was a container that was cloned and is now empty, we clean it
if(child.contains(sub_child)){
if(sub_child.childNodes.length === 0 || sub_child.innerHTML === "") child.removeChild(sub_child);
else if(!stop_condition()) {
// the only case when it can be non empty should be when stop_condition is now true
console.log("sub_child:", sub_child, "that is in child:", child);
throw Error("Document editor is trying to remove a non-empty sub-child. This "
+ "is a bug and should not happen.");
}
}
}
}
/**
* This function moves the first element from "next_page_html_div" to the end of "page_html_div", with
* merging sibling tags previously watermarked by "move_children_forward_recursively", if any.
* @param {HTMLElement} page_html_div Current page element
* @param {HTMLElement} next_page_html_div Next page element
* @param {function} stop_condition Check function that returns a boolean if content overflows
*/
function move_children_backwards_with_merging (page_html_div, next_page_html_div, stop_condition) {
// loop until content is overflowing
// while(!stop_condition()){
//
// // find first child of next page
// const first_child = next_page_html_div.firstChild;
//
// // merge it at the end of the current page
// // let merge_recursively = (container, elt) => {
// // // check if child had been splitted (= has a sibling on previous page)
// // const elt_sibling = find_sub_child_sibling_node(container, elt.s_tag);
// // if(elt_sibling && elt.childNodes.length) {
// // // then dig for deeper children, in case of
// // merge_recursively(elt_sibling, elt.firstChild);
// // }
// // // else move the child inside the right container at current page
// // else {
// // container.append(elt);
// // container.normalize();
// // }
// // }
// let merge_recursively = (container, elt) => {
// // check if child had been splitted (= has a sibling on previous page)
// const elt_sibling = find_sub_child_sibling_node(container, elt.s_tag);
//
// if(elt_sibling && elt.childNodes.length) {
// // then dig for deeper children, in case of
// merge_recursively(elt_sibling, elt.firstChild);
// }
// else {
// // Для текстовых узлов - объединяем с последним текстовым узлом, если возможно
// if (elt.nodeType === Node.TEXT_NODE &&
// container.lastChild &&
// container.lastChild.nodeType === Node.TEXT_NODE) {
//
// // Объединяем текстовые узлы
// container.lastChild.textContent += elt.textContent;
// elt.remove(); // удаляем оригинальный узел
// }
// else if (elt.nodeType === Node.TEXT_NODE &&
// container.lastChild &&
// container.lastChild.nodeType === Node.ELEMENT_NODE &&
// container.lastChild.lastChild &&
// container.lastChild.lastChild.nodeType === Node.TEXT_NODE) {
//
// // Если последний дочерний элемент заканчивается текстовым узлом
// container.lastChild.lastChild.textContent += elt.textContent;
// elt.remove();
// }
// else {
// // Для остальных случаев - просто перемещаем
// container.append(elt);
// }
// container.normalize(); // merge consecutive text nodes
// }
// }
//
// merge_recursively(page_html_div, first_child);
//
// // Если после перемещения следующая страница пустая, выходим
// if (!next_page_html_div.childNodes.length) break;
// }
while(!stop_condition() && next_page_html_div.firstChild){
const first_child = next_page_html_div.firstChild;
// Для табличных элементов - особая логика
if (first_child.tagName && first_child.tagName.match(/table|thead|tbody|tfoot|tr|th|td/i)) {
// Таблицы и их части перемещаем целиком
page_html_div.append(first_child);
page_html_div.normalize();
continue;
}
// Для текстовых узлов - пробуем объединить с последним текстовым узлом
if (first_child.nodeType === Node.TEXT_NODE) {
const last_child = page_html_div.lastChild;
if (last_child && last_child.nodeType === Node.TEXT_NODE) {
last_child.textContent += first_child.textContent;
first_child.remove();
} else {
page_html_div.append(first_child);
}
}
// Для элементов с s_tag - ищем брата для объединения
else if (first_child.s_tag && first_child.childNodes.length) {
const elt_sibling = find_sub_child_sibling_node(page_html_div, first_child.s_tag);
if (elt_sibling) {
// Перемещаем всех детей к брату
while(first_child.firstChild && !stop_condition()) {
elt_sibling.append(first_child.firstChild);
}
elt_sibling.normalize();
} else {
page_html_div.append(first_child);
}
}
// Для остальных случаев - просто перемещаем
else {
page_html_div.append(first_child);
}
page_html_div.normalize();
// Быстрая проверка условия остановки
if (!next_page_html_div.childNodes.length) break;
}
}
export {
move_children_forward_recursively,
move_children_backwards_with_merging
};

18
resources/js/app.js Normal file
View File

@@ -0,0 +1,18 @@
import './bootstrap';
import '../css/app.css'
import 'vue-pdf-embed/dist/styles/textLayer.css'
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true })
return pages[`./Pages/${name}.vue`]
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})

4
resources/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Конструктор документов</title>
@vite('resources/js/app.js')
@inertiaHead
</head>
<body>
@inertia
</body>
</html>