mirror of https://github.com/docusealco/docuseal
parent
b50d982497
commit
37196ff89f
@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
|
||||
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
|
||||
style="top: -25px; height: 25px"
|
||||
@mousedown.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<FieldSubmitter
|
||||
v-if="field.type != 'heading' && field.type != 'strikethrough'"
|
||||
v-model="field.submitter_uuid"
|
||||
class="border-r roles-dropdown"
|
||||
:compact="true"
|
||||
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
|
||||
:allow-add-new="!defaultSubmitters.length"
|
||||
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
|
||||
:submitters="template.submitters"
|
||||
@update:model-value="$emit('change')"
|
||||
@click="selectedAreasRef.value = [area]"
|
||||
/>
|
||||
<FieldType
|
||||
v-model="field.type"
|
||||
:button-width="27"
|
||||
:editable="editable && !defaultField"
|
||||
:button-classes="'px-1'"
|
||||
:menu-classes="'bg-white rounded-t-none'"
|
||||
@update:model-value="[maybeUpdateOptions(), $emit('change')]"
|
||||
@click="selectedAreasRef.value = [area]"
|
||||
/>
|
||||
<span
|
||||
v-if="field.type !== 'checkbox' || field.name"
|
||||
ref="name"
|
||||
:contenteditable="editable && !defaultField && field.type !== 'heading'"
|
||||
dir="auto"
|
||||
class="pr-1 cursor-text outline-none block"
|
||||
style="min-width: 2px"
|
||||
@paste.prevent="onPaste"
|
||||
@keydown.enter.prevent="onNameEnter"
|
||||
@focus="onNameFocus"
|
||||
@blur="onNameBlur"
|
||||
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
|
||||
<div
|
||||
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
|
||||
class="flex items-center ml-1.5"
|
||||
>
|
||||
<input
|
||||
v-if="!isValueInput && !isSelectInput"
|
||||
:id="`required-checkbox-${field.uuid}`"
|
||||
v-model="field.required"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs no-animation rounded"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<label
|
||||
v-if="!isValueInput && !isSelectInput"
|
||||
:for="`required-checkbox-${field.uuid}`"
|
||||
class="label text-xs"
|
||||
@click.prevent="field.required = !field.required"
|
||||
@mousedown.prevent
|
||||
>{{ t('required') }}</label>
|
||||
<input
|
||||
v-if="isValueInput || isSelectInput"
|
||||
:id="`readonly-checkbox-${field.uuid}`"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs no-animation rounded"
|
||||
:checked="!(field.readonly ?? true)"
|
||||
@change="field.readonly = !(field.readonly ?? true)"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<label
|
||||
v-if="isValueInput || isSelectInput"
|
||||
:for="`readonly-checkbox-${field.uuid}`"
|
||||
class="label text-xs"
|
||||
@click.prevent="field.readonly = !(field.readonly ?? true)"
|
||||
@mousedown.prevent
|
||||
>{{ t('editable') }}</label>
|
||||
<span
|
||||
v-if="field.type !== 'payment' && !isValueInput"
|
||||
class="dropdown dropdown-end field-area-settings-dropdown"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<label
|
||||
ref="settingsButton"
|
||||
tabindex="0"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer flex items-center"
|
||||
style="height: 25px"
|
||||
@focus="isSettingsFocus = true"
|
||||
@blur="maybeBlurSettings"
|
||||
>
|
||||
<IconDotsVertical class="w-5 h-5" />
|
||||
</label>
|
||||
<ul
|
||||
v-if="renderDropdown"
|
||||
ref="settingsDropdown"
|
||||
tabindex="0"
|
||||
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
|
||||
:style="{ backgroundColor: 'white' }"
|
||||
@dragstart.prevent.stop
|
||||
@click="closeDropdown"
|
||||
@focusout="maybeBlurSettings"
|
||||
>
|
||||
<FieldSettings
|
||||
v-if="isMobile"
|
||||
:field="field"
|
||||
:default-field="defaultField"
|
||||
:editable="editable"
|
||||
:background-color="'white'"
|
||||
:with-required="false"
|
||||
:with-areas="false"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-font="isShowFontModal = true"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@add-custom-field="$emit('add-custom-field')"
|
||||
@click-condition="isShowConditionsModal = true"
|
||||
@save="$emit('change')"
|
||||
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="whitespace-normal"
|
||||
>
|
||||
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
|
||||
</div>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="editable"
|
||||
class="pr-1"
|
||||
:title="t('remove')"
|
||||
@click.prevent="$emit('remove')"
|
||||
>
|
||||
<IconX width="14" />
|
||||
</button>
|
||||
<Teleport
|
||||
v-if="isShowFormulaModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FormulaModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="$emit('change')"
|
||||
@close="isShowFormulaModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowFontModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FontModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="$emit('change')"
|
||||
@close="isShowFontModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowConditionsModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<ConditionsModal
|
||||
:item="field"
|
||||
:build-default-name="buildDefaultName"
|
||||
:default-field="defaultField"
|
||||
@save="$emit('change')"
|
||||
@close="isShowConditionsModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowDescriptionModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<DescriptionModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="$emit('change')"
|
||||
@close="isShowDescriptionModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import FieldType from './field_type'
|
||||
import Field from './field'
|
||||
import FieldSettings from './field_settings'
|
||||
import FormulaModal from './formula_modal'
|
||||
import FontModal from './font_modal'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import DescriptionModal from './description_modal'
|
||||
import { IconX, IconDotsVertical } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'AreaTitle',
|
||||
components: {
|
||||
FieldType,
|
||||
FieldSettings,
|
||||
FormulaModal,
|
||||
FontModal,
|
||||
IconDotsVertical,
|
||||
DescriptionModal,
|
||||
ConditionsModal,
|
||||
FieldSubmitter,
|
||||
IconX
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
selectedAreasRef: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
getFieldTypeIndex: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
area: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
field: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
defaultField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withPrefillable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isValueInput: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isSelectInput: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['remove', 'scroll-to', 'add-custom-field', 'change'],
|
||||
data () {
|
||||
return {
|
||||
isShowFormulaModal: false,
|
||||
isShowFontModal: false,
|
||||
isShowConditionsModal: false,
|
||||
isShowDescriptionModal: false,
|
||||
isSettingsFocus: false,
|
||||
renderDropdown: false,
|
||||
isNameFocus: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
submitter () {
|
||||
return this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
|
||||
},
|
||||
isSelected () {
|
||||
return this.selectedAreasRef.value.includes(this.area)
|
||||
},
|
||||
isInMultiSelection () {
|
||||
return this.selectedAreasRef.value.length >= 2 && this.isSelected
|
||||
},
|
||||
optionIndexText () {
|
||||
if (this.area.option_uuid && this.field.options) {
|
||||
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
defaultName () {
|
||||
return this.buildDefaultName(this.field)
|
||||
},
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName: Field.methods.buildDefaultName,
|
||||
closeDropdown () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
},
|
||||
maybeBlurSettings (e) {
|
||||
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
|
||||
this.isSettingsFocus = false
|
||||
}
|
||||
},
|
||||
onNameFocus (e) {
|
||||
this.selectedAreasRef.value = [this.area]
|
||||
|
||||
this.isNameFocus = true
|
||||
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
|
||||
|
||||
if (!this.field.name) {
|
||||
setTimeout(() => {
|
||||
this.$refs.name.innerText = ' '
|
||||
}, 1)
|
||||
}
|
||||
},
|
||||
onNameBlur (e) {
|
||||
if (e.relatedTarget === this.$refs.settingsButton) {
|
||||
this.isSettingsFocus = true
|
||||
}
|
||||
|
||||
const text = this.$refs.name.innerText.trim()
|
||||
|
||||
this.isNameFocus = false
|
||||
this.$refs.name.style.minWidth = ''
|
||||
|
||||
if (text) {
|
||||
this.field.name = text
|
||||
} else {
|
||||
this.field.name = ''
|
||||
this.$refs.name.innerText = this.defaultName
|
||||
}
|
||||
|
||||
this.$emit('change')
|
||||
},
|
||||
onNameEnter (e) {
|
||||
this.$refs.name.blur()
|
||||
},
|
||||
onPaste (e) {
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
|
||||
const selection = this.$el.getRootNode().getSelection()
|
||||
|
||||
if (selection.rangeCount) {
|
||||
selection.deleteFromDocument()
|
||||
selection.getRangeAt(0).insertNode(document.createTextNode(text))
|
||||
selection.collapseToEnd()
|
||||
}
|
||||
},
|
||||
maybeUpdateOptions () {
|
||||
delete this.field.default_value
|
||||
|
||||
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
|
||||
delete this.field.options
|
||||
}
|
||||
|
||||
if (this.field.type === 'heading') {
|
||||
this.field.readonly = true
|
||||
}
|
||||
|
||||
if (this.field.type === 'strikethrough') {
|
||||
this.field.readonly = true
|
||||
this.field.default_value = true
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
|
||||
this.field.options ||= [{ value: '', uuid: v4() }]
|
||||
}
|
||||
|
||||
(this.field.areas || []).forEach((area) => {
|
||||
if (this.field.type === 'cells') {
|
||||
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
|
||||
} else {
|
||||
delete area.cell_w
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<span
|
||||
class="items-center select-none cursor-pointer relative overflow-visible text-base-content/80 font-sans"
|
||||
:class="[bgColorClass, iconOnlyField ? 'justify-center' : '']"
|
||||
:draggable="editable"
|
||||
:style="[nodeStyle]"
|
||||
@mousedown="selectArea"
|
||||
@click.stop
|
||||
@dragstart="onDragStart"
|
||||
@contextmenu.prevent.stop="onContextMenu"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-0 pointer-events-none border-solid"
|
||||
:class="borderColorClass"
|
||||
:style="{ borderWidth: (isSelected ? 1 : 0) + 'px' }"
|
||||
/>
|
||||
<component
|
||||
:is="fieldIcons[field?.type || 'text']"
|
||||
v-if="field && !field.default_value"
|
||||
width="100%"
|
||||
height="100%"
|
||||
:stroke-width="1.5"
|
||||
:class="iconOnlyField ? 'shrink min-h-0 max-h-full max-w-6 opacity-70 m-auto p-0.5' : 'shrink min-h-0 max-h-full max-w-4 opacity-70 mx-0.5 pl-0.5'"
|
||||
/>
|
||||
<span
|
||||
v-if="field?.default_value"
|
||||
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 font-normal pl-0.5"
|
||||
>{{ field.default_value }}</span>
|
||||
<span
|
||||
v-else-if="field && !iconOnlyField"
|
||||
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5"
|
||||
>{{ displayLabel }}</span>
|
||||
<span
|
||||
class="absolute rounded-full bg-white border border-gray-400 shadow-md cursor-nwse-resize z-10"
|
||||
:style="{ width: resizeHandleSize + 'px', height: resizeHandleSize + 'px', right: (-4 / zoom) + 'px', bottom: (-4 / zoom) + 'px' }"
|
||||
@pointerdown.prevent.stop="onResizeStart"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldArea from './area'
|
||||
import FieldType from './field_type'
|
||||
|
||||
export default {
|
||||
name: 'DynamicArea',
|
||||
props: {
|
||||
fieldUuid: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
areaUuid: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
template: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
nodeStyle: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
selectedAreasRef: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
getPos: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
editor: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
getZoom: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
onAreaContextMenu: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
onAreaResize: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
onAreaDragStart: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
t: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
findFieldArea: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getFieldTypeIndex: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isResizing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldArea () {
|
||||
return this.findFieldArea(this.areaUuid)
|
||||
},
|
||||
area () {
|
||||
return this.fieldArea?.area
|
||||
},
|
||||
field () {
|
||||
return this.fieldArea?.field
|
||||
},
|
||||
fieldIcons: FieldArea.computed.fieldIcons,
|
||||
fieldNames: FieldArea.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
borderColors () {
|
||||
return [
|
||||
'border-red-500/80',
|
||||
'border-sky-500/80',
|
||||
'border-emerald-500/80',
|
||||
'border-yellow-300/80',
|
||||
'border-purple-600/80',
|
||||
'border-pink-500/80',
|
||||
'border-cyan-500/80',
|
||||
'border-orange-500/80',
|
||||
'border-lime-500/80',
|
||||
'border-indigo-500/80'
|
||||
]
|
||||
},
|
||||
bgColors () {
|
||||
return [
|
||||
'bg-red-100',
|
||||
'bg-sky-100',
|
||||
'bg-emerald-100',
|
||||
'bg-yellow-100',
|
||||
'bg-purple-100',
|
||||
'bg-pink-100',
|
||||
'bg-cyan-100',
|
||||
'bg-orange-100',
|
||||
'bg-lime-100',
|
||||
'bg-indigo-100'
|
||||
]
|
||||
},
|
||||
isSelected () {
|
||||
return this.selectedAreasRef.value.some((a) => a === this.area)
|
||||
},
|
||||
zoom () {
|
||||
return this.getZoom()
|
||||
},
|
||||
submitterIndex () {
|
||||
if (!this.field) return 0
|
||||
|
||||
const submitter = this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
|
||||
|
||||
return submitter ? this.template.submitters.indexOf(submitter) : 0
|
||||
},
|
||||
borderColorClass () {
|
||||
return this.borderColors[this.submitterIndex % this.borderColors.length]
|
||||
},
|
||||
bgColorClass () {
|
||||
return this.bgColors[this.submitterIndex % this.bgColors.length]
|
||||
},
|
||||
resizeHandleSize () {
|
||||
return this.zoom > 0 ? Math.round(10 / this.zoom) : 10
|
||||
},
|
||||
iconOnlyField () {
|
||||
return ['radio', 'multiple', 'checkbox', 'initials'].includes(this.field?.type)
|
||||
},
|
||||
defaultName () {
|
||||
if (!this.field) return 'text'
|
||||
|
||||
const typeIndex = this.getFieldTypeIndex(this.field)
|
||||
|
||||
return `${this.fieldLabels[this.field.type] || this.fieldNames[this.field.type] || this.field.type} ${typeIndex + 1}`
|
||||
},
|
||||
displayLabel () {
|
||||
return this.field?.name || this.defaultName
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectArea () {
|
||||
this.editor.commands.setNodeSelection(this.getPos())
|
||||
},
|
||||
onDragStart (e) {
|
||||
if (this.isResizing) {
|
||||
e.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const pos = this.getPos()
|
||||
|
||||
if (pos == null) {
|
||||
e.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const root = this.$el
|
||||
const rect = root.getBoundingClientRect()
|
||||
const zoom = this.zoom || 1
|
||||
const clone = root.cloneNode(true)
|
||||
|
||||
clone.querySelector('[class*="cursor-nwse-resize"]')?.remove()
|
||||
clone.style.cssText = `position:fixed;top:-1000px;width:${rect.width / zoom}px;height:${rect.height / zoom}px;display:${root.style.display};vertical-align:${root.style.verticalAlign};zoom:${zoom}`
|
||||
|
||||
document.body.appendChild(clone)
|
||||
|
||||
e.dataTransfer.setDragImage(clone, e.offsetX, e.offsetY)
|
||||
|
||||
requestAnimationFrame(() => clone.remove())
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
|
||||
this.onAreaDragStart()
|
||||
},
|
||||
onContextMenu (e) {
|
||||
this.onAreaContextMenu(this.area, e)
|
||||
},
|
||||
onResizeStart (e) {
|
||||
if (!this.editable) return
|
||||
|
||||
this.isResizing = true
|
||||
|
||||
this.selectArea()
|
||||
|
||||
const handle = e.target
|
||||
|
||||
handle.setPointerCapture(e.pointerId)
|
||||
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const startWidth = this.$el.offsetWidth
|
||||
const startHeight = this.$el.offsetHeight
|
||||
|
||||
const onResizeMove = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
this.nodeStyle.width = startWidth + (e.clientX - startX) / this.zoom + 'px'
|
||||
this.nodeStyle.height = startHeight + (e.clientY - startY) / this.zoom + 'px'
|
||||
|
||||
this.onAreaResize(this.$el.getBoundingClientRect())
|
||||
}
|
||||
|
||||
const onResizeEnd = () => {
|
||||
if (!this.isResizing) return
|
||||
|
||||
this.isResizing = false
|
||||
|
||||
handle.removeEventListener('pointermove', onResizeMove)
|
||||
handle.removeEventListener('pointerup', onResizeEnd)
|
||||
|
||||
const pos = this.getPos()
|
||||
|
||||
const tr = this.editor.view.state.tr.setNodeMarkup(pos, undefined, {
|
||||
...this.editor.view.state.doc.nodeAt(pos)?.attrs,
|
||||
width: this.nodeStyle.width,
|
||||
height: this.nodeStyle.height
|
||||
})
|
||||
|
||||
this.editor.view.dispatch(tr)
|
||||
this.editor.commands.setNodeSelection(pos)
|
||||
}
|
||||
|
||||
handle.addEventListener('pointermove', onResizeMove)
|
||||
handle.addEventListener('pointerup', onResizeEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="relative"
|
||||
style="container-type: inline-size;"
|
||||
>
|
||||
<div ref="shadow" />
|
||||
<template
|
||||
v-for="style in styles"
|
||||
:key="style.innerText"
|
||||
>
|
||||
<Teleport
|
||||
v-if="shadow"
|
||||
:to="style.innerText.includes('@font-face {') ? 'head' : shadow"
|
||||
>
|
||||
<component :is="'style'">
|
||||
{{ style.innerText }}
|
||||
</component>
|
||||
</Teleport>
|
||||
</template>
|
||||
<Teleport
|
||||
v-if="shadow"
|
||||
:to="shadow"
|
||||
>
|
||||
<DynamicSection
|
||||
v-for="section in sections"
|
||||
:ref="setSectionRefs"
|
||||
:key="section.id"
|
||||
:container="$refs.container"
|
||||
:editable="editable"
|
||||
:section="section"
|
||||
:container-width="containerWidth"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:drag-field="dragField"
|
||||
:attachment-uuid="document.uuid"
|
||||
@update="onSectionUpdate(section, $event)"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DynamicSection from './dynamic_section.vue'
|
||||
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
|
||||
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
|
||||
|
||||
export default {
|
||||
name: 'TemplateDynamicDocument',
|
||||
components: {
|
||||
DynamicSection
|
||||
},
|
||||
inject: ['baseFetch', 'template'],
|
||||
props: {
|
||||
document: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
selectedSubmitter: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
dragField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update'],
|
||||
data () {
|
||||
return {
|
||||
containerWidth: 1040,
|
||||
isMounted: false,
|
||||
sectionRefs: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
attachmentsIndex () {
|
||||
return (this.document.attachments || []).reduce((acc, att) => {
|
||||
acc[att.uuid] = att.url
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
bodyDom () {
|
||||
return new DOMParser().parseFromString(this.document.body, 'text/html')
|
||||
},
|
||||
headDom () {
|
||||
return new DOMParser().parseFromString(this.document.head, 'text/html')
|
||||
},
|
||||
sections () {
|
||||
return this.bodyDom.querySelectorAll('section')
|
||||
},
|
||||
styles () {
|
||||
return this.headDom.querySelectorAll('style')
|
||||
},
|
||||
shadow () {
|
||||
if (this.isMounted) {
|
||||
return this.$refs.shadow.attachShadow({ mode: 'open' })
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.isMounted = true
|
||||
|
||||
this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet)
|
||||
|
||||
this.containerWidth = this.$refs.container.clientWidth
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.$refs.container) {
|
||||
this.containerWidth = this.$refs.container.clientWidth
|
||||
}
|
||||
})
|
||||
|
||||
this.resizeObserver.observe(this.$refs.container)
|
||||
|
||||
window.addEventListener('beforeunload', this.onBeforeUnload)
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('beforeunload', this.onBeforeUnload)
|
||||
|
||||
this.resizeObserver.unobserve(this.$refs.container)
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.sectionRefs = []
|
||||
},
|
||||
methods: {
|
||||
mergeSchemaProperties,
|
||||
setSectionRefs (ref) {
|
||||
if (ref) {
|
||||
this.sectionRefs.push(ref)
|
||||
}
|
||||
},
|
||||
onBeforeUnload (event) {
|
||||
if (this.saveTimer) {
|
||||
event.preventDefault()
|
||||
|
||||
event.returnValue = ''
|
||||
|
||||
return ''
|
||||
}
|
||||
},
|
||||
scrollToArea (area) {
|
||||
this.sectionRefs.forEach(({ editor }) => {
|
||||
const el = editor.view.dom.querySelector(`[data-area-uuid="${area.uuid}"]`)
|
||||
|
||||
if (el) {
|
||||
editor.chain().focus().setNodeSelection(editor.view.posAtDOM(el, 0)).run()
|
||||
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
},
|
||||
onSectionUpdate (section, { editor }) {
|
||||
clearTimeout(this.saveTimer)
|
||||
|
||||
this.saveTimer = setTimeout(async () => {
|
||||
await this.updateSectionAndSave(section, editor)
|
||||
|
||||
delete this.saveTimer
|
||||
}, 1000)
|
||||
},
|
||||
updateVariablesSchema () {
|
||||
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
|
||||
},
|
||||
updateSectionAndSave (section, editor) {
|
||||
const target = this.bodyDom.getElementById(section.id)
|
||||
|
||||
if (target) {
|
||||
target.innerHTML = editor.getHTML()
|
||||
}
|
||||
|
||||
this.document.body = this.bodyDom.body.innerHTML
|
||||
|
||||
this.updateVariablesSchema()
|
||||
|
||||
this.$emit('update', this.document)
|
||||
|
||||
return this.saveBody()
|
||||
},
|
||||
updateAndSave () {
|
||||
this.update()
|
||||
|
||||
return this.saveBody()
|
||||
},
|
||||
update () {
|
||||
clearTimeout(this.saveTimer)
|
||||
|
||||
delete this.saveTimer
|
||||
|
||||
this.sectionRefs.forEach(({ section, editor }) => {
|
||||
const target = this.bodyDom.getElementById(section.id)
|
||||
|
||||
target.innerHTML = editor.getHTML()
|
||||
})
|
||||
|
||||
this.document.body = this.bodyDom.body.innerHTML
|
||||
|
||||
this.updateVariablesSchema()
|
||||
|
||||
this.$emit('update', this.document)
|
||||
},
|
||||
saveBody () {
|
||||
clearTimeout(this.saveTimer)
|
||||
|
||||
delete this.saveTimer
|
||||
|
||||
return this.baseFetch(`/templates/${this.template.id}/dynamic_documents/${this.document.uuid}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ body: this.bodyDom.body.innerHTML }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,768 @@
|
||||
import { Editor, Extension, Node, Mark } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import HardBreak from '@tiptap/extension-hard-break'
|
||||
import History from '@tiptap/extension-history'
|
||||
import Gapcursor from '@tiptap/extension-gapcursor'
|
||||
import Dropcursor from '@tiptap/extension-dropcursor'
|
||||
import { createApp, reactive } from 'vue'
|
||||
import DynamicArea from './dynamic_area.vue'
|
||||
import styles from './dynamic_styles.scss'
|
||||
|
||||
export const dynamicStylesheet = new CSSStyleSheet()
|
||||
|
||||
dynamicStylesheet.replaceSync(styles[0][1])
|
||||
|
||||
export const tiptapStylesheet = new CSSStyleSheet()
|
||||
|
||||
tiptapStylesheet.replaceSync(
|
||||
`.ProseMirror {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
white-space: break-spaces;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
|
||||
}
|
||||
|
||||
.ProseMirror [contenteditable="false"] {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ProseMirror [contenteditable="false"] [contenteditable="true"] {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
img.ProseMirror-separator {
|
||||
display: inline !important;
|
||||
border: none !important;
|
||||
margin: 0 !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid black;
|
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes ProseMirror-cursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::-moz-selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection * {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
.variable-highlight {
|
||||
background-color: #fef3c7;
|
||||
}`)
|
||||
|
||||
function collectDomAttrs (dom) {
|
||||
const attrs = {}
|
||||
|
||||
for (let i = 0; i < dom.attributes.length; i++) {
|
||||
attrs[dom.attributes[i].name] = dom.attributes[i].value
|
||||
}
|
||||
|
||||
return { htmlAttrs: attrs }
|
||||
}
|
||||
|
||||
function collectSpanDomAttrs (dom) {
|
||||
const result = collectDomAttrs(dom)
|
||||
|
||||
if (result.htmlAttrs.style) {
|
||||
const temp = document.createElement('span')
|
||||
|
||||
temp.style.cssText = result.htmlAttrs.style
|
||||
|
||||
if (['bold', '700'].includes(temp.style.fontWeight)) {
|
||||
temp.style.removeProperty('font-weight')
|
||||
}
|
||||
|
||||
if (temp.style.fontStyle === 'italic') {
|
||||
temp.style.removeProperty('font-style')
|
||||
}
|
||||
|
||||
if (temp.style.textDecoration === 'underline') {
|
||||
temp.style.removeProperty('text-decoration')
|
||||
}
|
||||
|
||||
if (temp.style.cssText) {
|
||||
result.htmlAttrs.style = temp.style.cssText
|
||||
} else {
|
||||
delete result.htmlAttrs.style
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function createBlockNode (name, tag, content) {
|
||||
return Node.create({
|
||||
name,
|
||||
group: 'block',
|
||||
content: content || 'block+',
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag, getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return [tag, node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const CustomParagraph = Node.create({
|
||||
name: 'paragraph',
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'p', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['p', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const CustomHeading = Node.create({
|
||||
name: 'heading',
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} },
|
||||
level: { default: 1 }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [1, 2, 3, 4, 5, 6].map((level) => ({
|
||||
tag: `h${level}`,
|
||||
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
|
||||
}))
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const SectionNode = createBlockNode('section', 'section')
|
||||
const ArticleNode = createBlockNode('article', 'article')
|
||||
const DivNode = createBlockNode('div', 'div')
|
||||
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
|
||||
const PreNode = createBlockNode('pre', 'pre')
|
||||
const OrderedListNode = createBlockNode('orderedList', 'ol', '(listItem | block)+')
|
||||
const BulletListNode = createBlockNode('bulletList', 'ul', '(listItem | block)+')
|
||||
|
||||
const ListItemNode = Node.create({
|
||||
name: 'listItem',
|
||||
content: 'block+',
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'li', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['li', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableNode = Node.create({
|
||||
name: 'table',
|
||||
group: 'block',
|
||||
content: '(colgroup | tableHead | tableBody | tableRow)+',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'table', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['table', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableHead = Node.create({
|
||||
name: 'tableHead',
|
||||
content: 'tableRow+',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'thead', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['thead', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableBody = Node.create({
|
||||
name: 'tableBody',
|
||||
content: 'tableRow+',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'tbody', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['tbody', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableRow = Node.create({
|
||||
name: 'tableRow',
|
||||
content: '(tableCell | tableHeader)+',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'tr', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['tr', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableCell = Node.create({
|
||||
name: 'tableCell',
|
||||
content: 'block*',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'td', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['td', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableHeader = Node.create({
|
||||
name: 'tableHeader',
|
||||
content: 'block*',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'th', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['th', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const ImageNode = Node.create({
|
||||
name: 'image',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
draggable: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'img', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['img', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const ColGroupNode = Node.create({
|
||||
name: 'colgroup',
|
||||
group: 'block',
|
||||
content: 'col*',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'colgroup', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['colgroup', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const ColNode = Node.create({
|
||||
name: 'col',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'col', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['col', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const CustomBold = Mark.create({
|
||||
name: 'bold',
|
||||
parseHTML () {
|
||||
return [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }]
|
||||
},
|
||||
renderHTML () {
|
||||
return ['strong', 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
toggleBold: () => ({ commands }) => commands.toggleMark(this.name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
'Mod-b': () => this.editor.commands.toggleBold()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CustomItalic = Mark.create({
|
||||
name: 'italic',
|
||||
parseHTML () {
|
||||
return [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }]
|
||||
},
|
||||
renderHTML () {
|
||||
return ['em', 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
toggleItalic: () => ({ commands }) => commands.toggleMark(this.name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
'Mod-i': () => this.editor.commands.toggleItalic()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CustomUnderline = Mark.create({
|
||||
name: 'underline',
|
||||
parseHTML () {
|
||||
return [{ tag: 'u' }, { style: 'text-decoration=underline' }]
|
||||
},
|
||||
renderHTML () {
|
||||
return ['u', 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
toggleUnderline: () => ({ commands }) => commands.toggleMark(this.name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
'Mod-u': () => this.editor.commands.toggleUnderline()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CustomStrike = Mark.create({
|
||||
name: 'strike',
|
||||
parseHTML () {
|
||||
return [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }]
|
||||
},
|
||||
renderHTML () {
|
||||
return ['s', 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
toggleStrike: () => ({ commands }) => commands.toggleMark(this.name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
'Mod-Shift-s': () => this.editor.commands.toggleStrike()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const EmptySpanNode = Node.create({
|
||||
name: 'emptySpan',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{
|
||||
tag: 'span',
|
||||
priority: 60,
|
||||
getAttrs (dom) {
|
||||
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
|
||||
return collectDomAttrs(dom)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['span', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const SpanMark = Mark.create({
|
||||
name: 'span',
|
||||
excludes: '',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['span', mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const LinkMark = Mark.create({
|
||||
name: 'link',
|
||||
excludes: '',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'a', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['a', mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const SubscriptMark = Mark.create({
|
||||
name: 'subscript',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'sub', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['sub', mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const SuperscriptMark = Mark.create({
|
||||
name: 'superscript',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'sup', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['sup', mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TabHandler = Extension.create({
|
||||
name: 'tabHandler',
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
Tab: () => {
|
||||
this.editor.commands.insertContent('\t')
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const variableHighlightKey = new PluginKey('variableHighlight')
|
||||
|
||||
function buildDecorations (doc) {
|
||||
const decorations = []
|
||||
const regex = /\[\[[^\]]*\]\]/g
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return
|
||||
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(node.text)) !== null) {
|
||||
const from = pos + match.index
|
||||
const to = from + match[0].length
|
||||
|
||||
decorations.push(Decoration.inline(from, to, { class: 'variable-highlight' }))
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
const VariableHighlight = Extension.create({
|
||||
name: 'variableHighlight',
|
||||
addProseMirrorPlugins () {
|
||||
return [
|
||||
new Plugin({
|
||||
key: variableHighlightKey,
|
||||
state: {
|
||||
init (_, { doc }) {
|
||||
return buildDecorations(doc)
|
||||
},
|
||||
apply (tr, oldSet) {
|
||||
if (tr.docChanged) {
|
||||
return buildDecorations(tr.doc)
|
||||
}
|
||||
|
||||
return oldSet
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations (state) {
|
||||
return this.getState(state)
|
||||
},
|
||||
handleTextInput (view, from, to, text) {
|
||||
if (text !== '[') return false
|
||||
|
||||
const { state } = view
|
||||
const charBefore = state.doc.textBetween(Math.max(from - 1, 0), from)
|
||||
|
||||
if (charBefore !== '[') return false
|
||||
|
||||
const tr = state.tr.insertText('[]]', from, to)
|
||||
|
||||
tr.setSelection(state.selection.constructor.create(tr.doc, from + 1))
|
||||
|
||||
view.dispatch(tr)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop, onFieldDestroy, editorOptions }) {
|
||||
const FieldNode = Node.create({
|
||||
name: 'fieldNode',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
addAttributes () {
|
||||
return {
|
||||
uuid: { default: null },
|
||||
areaUuid: { default: null },
|
||||
width: { default: '124px' },
|
||||
height: { default: null },
|
||||
verticalAlign: { default: 'text-bottom' },
|
||||
display: { default: 'inline-flex' }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{
|
||||
tag: 'dynamic-field',
|
||||
getAttrs (dom) {
|
||||
return {
|
||||
uuid: dom.getAttribute('uuid'),
|
||||
areaUuid: dom.getAttribute('area-uuid'),
|
||||
width: dom.style.width,
|
||||
height: dom.style.height,
|
||||
display: dom.style.display,
|
||||
verticalAlign: dom.style.verticalAlign
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['dynamic-field', {
|
||||
uuid: node.attrs.uuid,
|
||||
'area-uuid': node.attrs.areaUuid,
|
||||
style: `width: ${node.attrs.width}; height: ${node.attrs.height}; display: ${node.attrs.display}; vertical-align: ${node.attrs.verticalAlign};`
|
||||
}]
|
||||
},
|
||||
addNodeView () {
|
||||
return ({ node, getPos, editor }) => {
|
||||
const dom = document.createElement('span')
|
||||
|
||||
const nodeStyle = reactive({
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
verticalAlign: node.attrs.verticalAlign,
|
||||
display: node.attrs.display
|
||||
})
|
||||
|
||||
dom.dataset.areaUuid = node.attrs.areaUuid
|
||||
|
||||
const shadow = dom.attachShadow({ mode: 'open' })
|
||||
|
||||
shadow.adoptedStyleSheets = [dynamicStylesheet]
|
||||
|
||||
const app = createApp(DynamicArea, {
|
||||
fieldUuid: node.attrs.uuid,
|
||||
areaUuid: node.attrs.areaUuid,
|
||||
nodeStyle,
|
||||
getPos,
|
||||
editor,
|
||||
editable: editorOptions.editable,
|
||||
...dynamicAreaProps
|
||||
})
|
||||
|
||||
app.mount(shadow)
|
||||
|
||||
return {
|
||||
dom,
|
||||
update (updatedNode) {
|
||||
if (updatedNode.attrs.areaUuid === node.attrs.areaUuid) {
|
||||
nodeStyle.width = updatedNode.attrs.width
|
||||
nodeStyle.height = updatedNode.attrs.height
|
||||
nodeStyle.verticalAlign = updatedNode.attrs.verticalAlign
|
||||
nodeStyle.display = updatedNode.attrs.display
|
||||
}
|
||||
},
|
||||
destroy () {
|
||||
onFieldDestroy(node)
|
||||
|
||||
app.unmount()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const FieldDropPlugin = Extension.create({
|
||||
name: 'fieldDrop',
|
||||
addProseMirrorPlugins () {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('fieldDrop'),
|
||||
props: {
|
||||
handleDrop: onFieldDrop
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const DynamicImageNode = ImageNode.extend({
|
||||
renderHTML ({ node }) {
|
||||
const { loading, ...attrs } = node.attrs.htmlAttrs
|
||||
|
||||
return ['img', attrs]
|
||||
},
|
||||
addNodeView () {
|
||||
return ({ node }) => {
|
||||
const dom = document.createElement('img')
|
||||
|
||||
const attrs = { ...node.attrs.htmlAttrs }
|
||||
|
||||
const blobUuid = attrs.src?.startsWith('blob:') && attrs.src.slice(5)
|
||||
|
||||
if (blobUuid && attachmentsIndex[blobUuid]) {
|
||||
attrs.src = attachmentsIndex[blobUuid]
|
||||
}
|
||||
|
||||
dom.setAttribute('loading', 'lazy')
|
||||
|
||||
Object.entries(attrs).forEach(([k, v]) => dom.setAttribute(k, v))
|
||||
|
||||
return { dom }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
HardBreak,
|
||||
History,
|
||||
Gapcursor,
|
||||
Dropcursor,
|
||||
CustomBold,
|
||||
CustomItalic,
|
||||
CustomUnderline,
|
||||
CustomStrike,
|
||||
CustomParagraph,
|
||||
CustomHeading,
|
||||
SectionNode,
|
||||
ArticleNode,
|
||||
DivNode,
|
||||
BlockquoteNode,
|
||||
PreNode,
|
||||
OrderedListNode,
|
||||
BulletListNode,
|
||||
ListItemNode,
|
||||
TableNode,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
ColGroupNode,
|
||||
ColNode,
|
||||
DynamicImageNode,
|
||||
EmptySpanNode,
|
||||
LinkMark,
|
||||
SpanMark,
|
||||
SubscriptMark,
|
||||
SuperscriptMark,
|
||||
VariableHighlight,
|
||||
TabHandler,
|
||||
FieldNode,
|
||||
FieldDropPlugin
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
style: 'outline: none'
|
||||
}
|
||||
},
|
||||
parseOptions: {
|
||||
preserveWhitespace: true
|
||||
},
|
||||
injectCSS: false,
|
||||
...editorOptions
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="absolute z-10 flex items-center gap-0.5 px-1.5 py-1 bg-white border border-base-300 rounded-lg shadow select-none"
|
||||
:style="{ top: (coords.top - 42) + 'px', left: coords.left + 'px', transform: 'translateX(-50%)' }"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isBold ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Bold"
|
||||
@click="toggleBold"
|
||||
>
|
||||
<IconBold
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isItalic ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Italic"
|
||||
@click="toggleItalic"
|
||||
>
|
||||
<IconItalic
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isUnderline ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Underline"
|
||||
@click="toggleUnderline"
|
||||
>
|
||||
<IconUnderline
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isStrike ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Strikethrough"
|
||||
@click="toggleStrike"
|
||||
>
|
||||
<IconStrikethrough
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-base-300 mx-1" />
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
|
||||
title="Wrap in variable"
|
||||
@click="wrapVariable"
|
||||
>
|
||||
<IconBracketsContain
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span class="px-0.5">
|
||||
Variable
|
||||
</span>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-base-300 mx-1" />
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
|
||||
title="Wrap in condition"
|
||||
@click="wrapCondition"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="tabler-icon tabler-icon-brackets-contain"
|
||||
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" />
|
||||
<text
|
||||
x="12"
|
||||
y="16.5"
|
||||
text-anchor="middle"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
font-size="14"
|
||||
font-weight="600"
|
||||
font-family="ui-sans-serif, system-ui, sans-serif"
|
||||
>if</text>
|
||||
</svg>
|
||||
<span class="px-0.5">
|
||||
Condition
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'DynamicMenu',
|
||||
components: {
|
||||
IconBold,
|
||||
IconItalic,
|
||||
IconUnderline,
|
||||
IconStrikethrough,
|
||||
IconBracketsContain
|
||||
},
|
||||
props: {
|
||||
editor: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
coords: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['add-variable', 'add-condition'],
|
||||
data () {
|
||||
return {
|
||||
isMouseDown: false,
|
||||
isBold: this.editor.isActive('bold'),
|
||||
isItalic: this.editor.isActive('italic'),
|
||||
isUnderline: this.editor.isActive('underline'),
|
||||
isStrike: this.editor.isActive('strike')
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
visible () {
|
||||
return !!this.coords && !this.isMouseDown
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.editor.view.dom.addEventListener('mousedown', this.onMouseDown)
|
||||
|
||||
document.addEventListener('mouseup', this.onMouseUp)
|
||||
|
||||
this.editor.on('transaction', this.onTransaction)
|
||||
},
|
||||
beforeUnmount () {
|
||||
if (!this.editor.isDestroyed) {
|
||||
this.editor.view.dom.removeEventListener('mousedown', this.onMouseDown)
|
||||
this.editor.off('transaction', this.onTransaction)
|
||||
}
|
||||
|
||||
document.removeEventListener('mouseup', this.onMouseUp)
|
||||
},
|
||||
methods: {
|
||||
toggleBold () {
|
||||
this.editor.chain().focus().toggleBold().run()
|
||||
},
|
||||
toggleItalic () {
|
||||
this.editor.chain().focus().toggleItalic().run()
|
||||
},
|
||||
toggleUnderline () {
|
||||
this.editor.chain().focus().toggleUnderline().run()
|
||||
},
|
||||
toggleStrike () {
|
||||
this.editor.chain().focus().toggleStrike().run()
|
||||
},
|
||||
wrapVariable () {
|
||||
const { from, to } = this.editor.state.selection
|
||||
const replacement = '[[variable]]'
|
||||
const varFrom = from + 2
|
||||
const varTo = varFrom + 8
|
||||
|
||||
this.editor.chain().focus()
|
||||
.insertContentAt({ from, to }, replacement)
|
||||
.setTextSelection({ from: varFrom, to: varTo })
|
||||
.run()
|
||||
|
||||
this.$emit('add-variable')
|
||||
},
|
||||
wrapCondition () {
|
||||
const { from, to } = this.editor.state.selection
|
||||
const endText = '[[end]]'
|
||||
const ifText = '[[if:variable]]'
|
||||
|
||||
this.editor.chain().focus()
|
||||
.insertContentAt(to, endText)
|
||||
.insertContentAt(from, ifText)
|
||||
.setTextSelection({ from: from + 5, to: from + 13 })
|
||||
.run()
|
||||
|
||||
this.$emit('add-condition')
|
||||
},
|
||||
onMouseDown () {
|
||||
this.isMouseDown = true
|
||||
},
|
||||
onMouseUp () {
|
||||
setTimeout(() => {
|
||||
this.isMouseDown = false
|
||||
}, 1)
|
||||
},
|
||||
onTransaction () {
|
||||
this.isBold = this.editor.isActive('bold')
|
||||
this.isItalic = this.editor.isActive('italic')
|
||||
this.isUnderline = this.editor.isActive('underline')
|
||||
this.isStrike = this.editor.isActive('strike')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,487 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
|
||||
>
|
||||
<div :style="{ zoom: containerWidth / sectionWidthPx }">
|
||||
<section
|
||||
:id="section.id"
|
||||
ref="editorElement"
|
||||
:class="section.classList.value"
|
||||
:style="section.style.cssText"
|
||||
/>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="editor"
|
||||
:to="container"
|
||||
>
|
||||
<div
|
||||
v-if="areaToolbarCoords && selectedField && selectedArea && !isAreaDrag"
|
||||
class="absolute z-10"
|
||||
:style="{ left: areaToolbarCoords.left + 'px', top: areaToolbarCoords.top + 'px' }"
|
||||
>
|
||||
<AreaTitle
|
||||
:area="selectedArea"
|
||||
:field="selectedField"
|
||||
:editable="editable"
|
||||
:template="template"
|
||||
:selected-areas-ref="selectedAreasRef"
|
||||
:get-field-type-index="getFieldTypeIndex"
|
||||
@remove="onRemoveSelectedArea"
|
||||
@change="onSelectedAreaChange"
|
||||
/>
|
||||
</div>
|
||||
<DynamicMenu
|
||||
v-if="editable"
|
||||
v-show="!selectedAreasRef.value.length"
|
||||
:editor="editor"
|
||||
:coords="dynamicMenuCoords"
|
||||
@add-variable="dynamicMenuCoords = null"
|
||||
@add-condition="dynamicMenuCoords = null"
|
||||
/>
|
||||
<FieldContextMenu
|
||||
v-if="contextMenu && contextMenuField"
|
||||
:context-menu="contextMenu"
|
||||
:field="contextMenuField"
|
||||
:with-copy-to-all-pages="false"
|
||||
@close="closeContextMenu"
|
||||
@delete="onContextMenuDelete"
|
||||
@save="save"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { shallowRef } from 'vue'
|
||||
import { v4 } from 'uuid'
|
||||
import FieldContextMenu from './field_context_menu.vue'
|
||||
import AreaTitle from './area_title.vue'
|
||||
import DynamicMenu from './dynamic_menu.vue'
|
||||
import { buildEditor } from './dynamic_editor.js'
|
||||
|
||||
export default {
|
||||
name: 'DynamicSection',
|
||||
components: {
|
||||
DynamicMenu,
|
||||
FieldContextMenu,
|
||||
AreaTitle
|
||||
},
|
||||
inject: ['template', 'save', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'],
|
||||
props: {
|
||||
section: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
container: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
containerWidth: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
attachmentsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
selectedSubmitter: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
dragField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
attachmentUuid: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update'],
|
||||
data () {
|
||||
return {
|
||||
isAreaDrag: false,
|
||||
areaToolbarCoords: null,
|
||||
dynamicMenuCoords: null,
|
||||
contextMenu: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
defaultHeight () {
|
||||
return CSS.supports('height', '1lh') ? '1lh' : '1em'
|
||||
},
|
||||
fieldAreaIndex () {
|
||||
return (this.template.fields || []).reduce((acc, field) => {
|
||||
field.areas?.forEach((area) => {
|
||||
acc[area.uuid] = { area, field }
|
||||
})
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
defaultSizes () {
|
||||
return {
|
||||
checkbox: { width: '18px', height: '18px' },
|
||||
radio: { width: '18px', height: '18px' },
|
||||
multiple: { width: '18px', height: '18px' },
|
||||
signature: { width: '140px', height: '50px' },
|
||||
initials: { width: '40px', height: '32px' },
|
||||
stamp: { width: '150px', height: '80px' },
|
||||
kba: { width: '150px', height: '80px' },
|
||||
verification: { width: '150px', height: '80px' },
|
||||
image: { width: '200px', height: '100px' },
|
||||
date: { width: '100px', height: this.defaultHeight },
|
||||
text: { width: '120px', height: this.defaultHeight },
|
||||
cells: { width: '120px', height: this.defaultHeight },
|
||||
file: { width: '120px', height: this.defaultHeight },
|
||||
payment: { width: '120px', height: this.defaultHeight },
|
||||
number: { width: '80px', height: this.defaultHeight },
|
||||
select: { width: '120px', height: this.defaultHeight },
|
||||
phone: { width: '120px', height: this.defaultHeight }
|
||||
}
|
||||
},
|
||||
editorRef: () => shallowRef(),
|
||||
editor () {
|
||||
return this.editorRef.value
|
||||
},
|
||||
sectionWidthPx () {
|
||||
const pt = parseFloat(this.section.style.width)
|
||||
|
||||
return pt * (96 / 72)
|
||||
},
|
||||
zoom () {
|
||||
return this.containerWidth / this.sectionWidthPx
|
||||
},
|
||||
isDraggingField () {
|
||||
return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField)
|
||||
},
|
||||
selectedArea () {
|
||||
return this.selectedAreasRef.value[0]
|
||||
},
|
||||
selectedField () {
|
||||
if (this.selectedArea) {
|
||||
return this.fieldAreaIndex[this.selectedArea.uuid]?.field
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
contextMenuField () {
|
||||
if (this.contextMenu?.areaUuid) {
|
||||
return this.fieldAreaIndex[this.contextMenu.areaUuid].field
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
containerWidth () {
|
||||
this.closeContextMenu()
|
||||
|
||||
if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) {
|
||||
this.$nextTick(() => this.setDynamicMenuCoords(this.editor))
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.initEditor()
|
||||
},
|
||||
beforeUnmount () {
|
||||
if (this.editor) {
|
||||
this.editor.destroy()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initEditor () {
|
||||
this.editorRef.value = buildEditor({
|
||||
dynamicAreaProps: {
|
||||
template: this.template,
|
||||
t: this.t,
|
||||
selectedAreasRef: this.selectedAreasRef,
|
||||
getFieldTypeIndex: this.getFieldTypeIndex,
|
||||
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
|
||||
getZoom: () => this.zoom,
|
||||
onAreaContextMenu: this.onAreaContextMenu,
|
||||
onAreaResize: this.onAreaResize,
|
||||
onAreaDragStart: this.onAreaDragStart
|
||||
},
|
||||
attachmentsIndex: this.attachmentsIndex,
|
||||
onFieldDrop: this.onFieldDrop,
|
||||
onFieldDestroy: this.onFieldDestroy,
|
||||
editorOptions: {
|
||||
element: this.$refs.editorElement,
|
||||
editable: this.editable,
|
||||
content: this.section.innerHTML,
|
||||
onUpdate: (event) => this.$emit('update', event),
|
||||
onSelectionUpdate: this.onSelectionUpdate,
|
||||
onBlur: () => { this.dynamicMenuCoords = null }
|
||||
}
|
||||
})
|
||||
},
|
||||
findAreaNodePos (areaUuid) {
|
||||
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
|
||||
|
||||
return this.editor.view.posAtDOM(el, 0)
|
||||
},
|
||||
removeArea (area) {
|
||||
const { field } = this.fieldAreaIndex[area.uuid]
|
||||
const areaIndex = field.areas.indexOf(area)
|
||||
|
||||
if (areaIndex !== -1) {
|
||||
field.areas.splice(areaIndex, 1)
|
||||
}
|
||||
|
||||
if (field.areas.length === 0) {
|
||||
this.template.fields.splice(this.template.fields.indexOf(field), 1)
|
||||
}
|
||||
|
||||
const pos = this.findAreaNodePos(area.uuid)
|
||||
|
||||
this.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
|
||||
|
||||
this.save()
|
||||
},
|
||||
onSelectionUpdate ({ editor }) {
|
||||
const { selection } = editor.state
|
||||
|
||||
if (selection.node?.type.name === 'fieldNode') {
|
||||
const { areaUuid } = selection.node.attrs
|
||||
|
||||
const field = this.fieldAreaIndex[areaUuid]?.field
|
||||
|
||||
if (field) {
|
||||
const area = field.areas.find((a) => a.uuid === areaUuid)
|
||||
|
||||
if (area) {
|
||||
const dom = editor.view.nodeDOM(selection.from)
|
||||
const areaEl = dom.shadowRoot.firstElementChild
|
||||
|
||||
if (areaEl) {
|
||||
const rect = areaEl.getBoundingClientRect()
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
|
||||
this.areaToolbarCoords = {
|
||||
left: rect.left - containerRect.left,
|
||||
top: rect.top - containerRect.top
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedAreasRef.value = [area]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.areaToolbarCoords = null
|
||||
this.selectedAreasRef.value = []
|
||||
|
||||
if (editor.state.selection.empty) {
|
||||
this.dynamicMenuCoords = null
|
||||
} else {
|
||||
this.setDynamicMenuCoords(editor)
|
||||
}
|
||||
}
|
||||
},
|
||||
setDynamicMenuCoords (editor) {
|
||||
const { from, to } = editor.state.selection
|
||||
const view = editor.view
|
||||
const start = view.coordsAtPos(from)
|
||||
const end = view.coordsAtPos(to)
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
const left = (start.left + end.right) / 2 - containerRect.left
|
||||
|
||||
this.dynamicMenuCoords = {
|
||||
top: Math.min(start.top, end.top) - containerRect.top,
|
||||
left: Math.max(80, Math.min(left, containerRect.width - 80))
|
||||
}
|
||||
},
|
||||
onFieldDestroy (node) {
|
||||
this.selectedAreasRef.value = []
|
||||
|
||||
const { areaUuid } = node.attrs
|
||||
|
||||
let nodeExistsInDoc = false
|
||||
|
||||
this.editor.state.doc.descendants((docNode) => {
|
||||
if (docNode.attrs.areaUuid === areaUuid) {
|
||||
nodeExistsInDoc = true
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (nodeExistsInDoc) return
|
||||
|
||||
const fieldArea = this.fieldAreaIndex[areaUuid]
|
||||
|
||||
if (!fieldArea) return
|
||||
|
||||
const field = fieldArea.field
|
||||
|
||||
const areaIndex = field.areas.findIndex((a) => a.uuid === areaUuid)
|
||||
|
||||
if (areaIndex !== -1) {
|
||||
field.areas.splice(areaIndex, 1)
|
||||
}
|
||||
|
||||
if (!field.areas?.length) {
|
||||
this.template.fields.splice(this.template.fields.indexOf(field), 1)
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
onAreaResize (rect) {
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
|
||||
this.areaToolbarCoords = {
|
||||
left: rect.left - containerRect.left,
|
||||
top: rect.top - containerRect.top
|
||||
}
|
||||
},
|
||||
onAreaDragStart () {
|
||||
this.isAreaDrag = true
|
||||
},
|
||||
onAreaContextMenu (area, e) {
|
||||
this.contextMenu = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
areaUuid: area.uuid
|
||||
}
|
||||
},
|
||||
deselectArea () {
|
||||
this.areaToolbarCoords = null
|
||||
this.selectedAreasRef.value = []
|
||||
},
|
||||
closeContextMenu () {
|
||||
this.contextMenu = null
|
||||
},
|
||||
onContextMenuDelete () {
|
||||
const menu = this.contextMenu
|
||||
const fieldArea = this.fieldAreaIndex[menu.areaUuid]
|
||||
|
||||
if (fieldArea) {
|
||||
this.removeArea(fieldArea.area)
|
||||
}
|
||||
|
||||
this.closeContextMenu()
|
||||
this.deselectArea()
|
||||
},
|
||||
onRemoveSelectedArea () {
|
||||
this.removeArea(this.selectedArea)
|
||||
|
||||
this.deselectArea()
|
||||
this.save()
|
||||
},
|
||||
onSelectedAreaChange () {
|
||||
this.save()
|
||||
},
|
||||
onFieldDrop (view, event, _slice, moved) {
|
||||
this.isAreaDrag = false
|
||||
|
||||
if (moved) {
|
||||
return
|
||||
}
|
||||
|
||||
const draggedField = this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField
|
||||
|
||||
if (!draggedField) return false
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
||||
|
||||
if (!pos) return false
|
||||
|
||||
const fieldType = draggedField.type || 'text'
|
||||
const dims = this.defaultSizes[fieldType] || this.defaultSizes.text
|
||||
const areaUuid = v4()
|
||||
|
||||
const existingField = this.fieldsDragFieldRef?.value
|
||||
|
||||
if (existingField) {
|
||||
if (!this.template.fields.includes(existingField)) {
|
||||
this.template.fields.push(existingField)
|
||||
}
|
||||
|
||||
existingField.areas = existingField.areas || []
|
||||
existingField.areas.push({ uuid: areaUuid, attachment_uuid: this.attachmentUuid })
|
||||
|
||||
const nodeType = view.state.schema.nodes.fieldNode
|
||||
const fieldNode = nodeType.create({
|
||||
uuid: existingField.uuid,
|
||||
areaUuid,
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
})
|
||||
|
||||
const tr = view.state.tr.insert(pos.pos, fieldNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
} else {
|
||||
const newField = {
|
||||
name: draggedField.name || '',
|
||||
uuid: v4(),
|
||||
required: fieldType !== 'checkbox',
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
type: fieldType,
|
||||
areas: [{ uuid: areaUuid, attachment_uuid: this.attachmentUuid }]
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(fieldType)) {
|
||||
if (draggedField.options?.length) {
|
||||
newField.options = draggedField.options.map((opt) => ({
|
||||
value: typeof opt === 'string' ? opt : opt.value,
|
||||
uuid: v4()
|
||||
}))
|
||||
} else {
|
||||
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldType === 'datenow') {
|
||||
newField.type = 'date'
|
||||
newField.readonly = true
|
||||
newField.default_value = '{{date}}'
|
||||
}
|
||||
|
||||
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
|
||||
newField.readonly = true
|
||||
|
||||
if (fieldType === 'strikethrough') {
|
||||
newField.default_value = true
|
||||
}
|
||||
}
|
||||
|
||||
this.template.fields.push(newField)
|
||||
|
||||
const nodeType = view.state.schema.nodes.fieldNode
|
||||
const fieldNode = nodeType.create({
|
||||
uuid: newField.uuid,
|
||||
areaUuid,
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
})
|
||||
|
||||
const tr = view.state.tr.insert(pos.pos, fieldNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
}
|
||||
|
||||
this.fieldsDragFieldRef.value = null
|
||||
this.customDragFieldRef.value = null
|
||||
|
||||
this.editor.chain().focus().setNodeSelection(pos.pos).run()
|
||||
|
||||
this.save()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,22 @@
|
||||
@config "../../../tailwind.dynamic.config.js";
|
||||
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
::before,
|
||||
::after {
|
||||
--tw-content: '';
|
||||
}
|
||||
|
||||
:host {
|
||||
all: initial;
|
||||
}
|
||||
@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div class="group">
|
||||
<div class="flex items-center justify-between py-1.5 px-0.5">
|
||||
<div class="flex items-center space-x-1 min-w-0">
|
||||
<FieldType
|
||||
:model-value="formType"
|
||||
:editable="editable"
|
||||
:button-width="18"
|
||||
:menu-classes="'mt-1.5'"
|
||||
:menu-style="{ backgroundColor: dropdownBgColor }"
|
||||
@update:model-value="onTypeChange"
|
||||
/>
|
||||
<span
|
||||
class="truncate"
|
||||
:title="path"
|
||||
>{{ displayName }}</span>
|
||||
<span
|
||||
v-if="isArray"
|
||||
class="text-xs bg-base-200 rounded px-1 flex-shrink-0"
|
||||
>{{ t('list') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="editable"
|
||||
class="flex items-center flex-shrink-0"
|
||||
>
|
||||
<span
|
||||
class="dropdown dropdown-end"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||
>
|
||||
<IconSettings
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</label>
|
||||
<ul
|
||||
v-if="renderDropdown"
|
||||
tabindex="0"
|
||||
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<div
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
|
||||
@change="onTypeChange($event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="varType in variableTypes"
|
||||
:key="varType"
|
||||
:value="varType"
|
||||
:selected="varType === formType"
|
||||
>{{ t(varType) }}</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>{{ t('type') }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="formType === 'number'"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
|
||||
@change="[schema.format = $event.target.value, save()]"
|
||||
>
|
||||
<option
|
||||
v-for="format in numberFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
:selected="format === schema.format || (format === 'none' && !schema.format)"
|
||||
>{{ formatNumber(123456789.567, format) }}</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>{{ t('format') }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="['text', 'number'].includes(formType)"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="schema.default_value"
|
||||
:type="formType === 'number' ? 'number' : 'text'"
|
||||
:placeholder="t('default_value')"
|
||||
dir="auto"
|
||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
|
||||
@blur="save"
|
||||
>
|
||||
<label
|
||||
v-if="schema.default_value"
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>{{ t('default_value') }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="formType === 'date'"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
:value="schema.format || 'MM/DD/YYYY'"
|
||||
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
|
||||
@change="[schema.format = $event.target.value, save()]"
|
||||
>
|
||||
<option
|
||||
v-for="format in dateFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>{{ formatDate(new Date(), format) }}</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>{{ t('format') }}</label>
|
||||
</div>
|
||||
<li
|
||||
v-if="formType === 'date'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
:checked="schema.default_value === '{{date}}'"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@change="[schema.default_value = $event.target.checked ? '{{date}}' : undefined, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('current_date') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<div
|
||||
v-if="['radio', 'select'].includes(formType)"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
dir="auto"
|
||||
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
|
||||
@change="[schema.default_value = $event.target.value || undefined, save()]"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
:selected="!schema.default_value"
|
||||
>{{ t('none') }}</option>
|
||||
<option
|
||||
v-for="opt in (schema.options || [])"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
:selected="schema.default_value === opt"
|
||||
>{{ opt }}</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>{{ t('default_value') }}</label>
|
||||
</div>
|
||||
<li
|
||||
v-if="formType === 'checkbox'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
:checked="schema.default_value === true"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@change="[schema.default_value = $event.target.checked || undefined, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('checked') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li @click.stop>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
:checked="schema.required !== false"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@change="[schema.required = $event.target.checked, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('required') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="['radio', 'select'].includes(formType) && schema.options"
|
||||
ref="options"
|
||||
class="pl-2 pr-1 pb-1.5 space-y-1.5"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in schema.options"
|
||||
:key="index"
|
||||
class="flex space-x-1.5 items-center"
|
||||
>
|
||||
<span class="text-sm w-3.5 select-none">{{ index + 1 }}.</span>
|
||||
<input
|
||||
:value="option"
|
||||
class="w-full input input-primary input-xs text-sm bg-transparent"
|
||||
type="text"
|
||||
dir="auto"
|
||||
:placeholder="`${t('option')} ${index + 1}`"
|
||||
@blur="[schema.options.splice(index, 1, $event.target.value), save()]"
|
||||
@keydown.enter="$event.target.value ? onOptionEnter(index, $event.target.value) : null"
|
||||
>
|
||||
<button
|
||||
class="text-sm w-3.5"
|
||||
tabindex="-1"
|
||||
@click="[schema.options.splice(index, 1), save()]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="text-center text-sm w-full pb-1"
|
||||
@click="addOptionAndFocus((schema.options || []).length)"
|
||||
>
|
||||
+ {{ t('add_option') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldType from './field_type'
|
||||
import { IconSettings } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'DynamicVariable',
|
||||
components: {
|
||||
FieldType,
|
||||
IconSettings
|
||||
},
|
||||
inject: ['t', 'save', 'backgroundColor'],
|
||||
provide () {
|
||||
return {
|
||||
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
|
||||
}
|
||||
},
|
||||
props: {
|
||||
path: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
groupKey: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
schema: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isArray: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
displayName () {
|
||||
if (this.groupKey) {
|
||||
const prefix = this.groupKey + (this.path.startsWith(this.groupKey + '[].') ? '[].' : '.')
|
||||
|
||||
return this.path.slice(prefix.length)
|
||||
} else {
|
||||
return this.path
|
||||
}
|
||||
},
|
||||
dropdownBgColor () {
|
||||
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
|
||||
},
|
||||
schemaTypeToFormType () {
|
||||
return { string: 'text', number: 'number', boolean: 'checkbox', date: 'date' }
|
||||
},
|
||||
formType () {
|
||||
return this.schema.form_type || this.schemaTypeToFormType[this.schema.type] || 'text'
|
||||
},
|
||||
variableTypes () {
|
||||
return ['text', 'number', 'date', 'checkbox', 'radio', 'select']
|
||||
},
|
||||
formTypeToSchemaType () {
|
||||
return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' }
|
||||
},
|
||||
numberFormats () {
|
||||
return [
|
||||
'none',
|
||||
'usd',
|
||||
'eur',
|
||||
'gbp',
|
||||
'comma',
|
||||
'dot',
|
||||
'space'
|
||||
]
|
||||
},
|
||||
dateFormats () {
|
||||
const formats = [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, YYYY',
|
||||
'MMMM D, YYYY',
|
||||
'D MMM YYYY',
|
||||
'D MMMM YYYY'
|
||||
]
|
||||
|
||||
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
|
||||
formats.push('YYYY년 MM월 DD일')
|
||||
}
|
||||
|
||||
if (this.schema.format && !formats.includes(this.schema.format)) {
|
||||
formats.unshift(this.schema.format)
|
||||
}
|
||||
|
||||
return formats
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onTypeChange (newType) {
|
||||
this.schema.type = this.formTypeToSchemaType[newType] || 'string'
|
||||
this.schema.form_type = newType
|
||||
|
||||
if (['radio', 'select'].includes(newType)) {
|
||||
if (!this.schema.options || !this.schema.options.length) {
|
||||
this.schema.options = ['', '']
|
||||
}
|
||||
} else {
|
||||
delete this.schema.options
|
||||
delete this.schema.default_value
|
||||
delete this.schema.format
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
onOptionEnter (index, value) {
|
||||
this.schema.options.splice(index, 1, value)
|
||||
this.schema.options.splice(index + 1, 0, '')
|
||||
|
||||
this.save()
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.options.querySelectorAll('input')[index + 1]?.focus()
|
||||
})
|
||||
},
|
||||
addOptionAndFocus (index) {
|
||||
if (!this.schema.options) {
|
||||
this.schema.options = []
|
||||
}
|
||||
|
||||
this.schema.options.splice(index, 0, '')
|
||||
this.save()
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.options.querySelectorAll('input')[index]?.focus()
|
||||
})
|
||||
},
|
||||
formatNumber (number, format) {
|
||||
if (format === 'comma') {
|
||||
return new Intl.NumberFormat('en-US').format(number)
|
||||
} else if (format === 'usd') {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
|
||||
} else if (format === 'gbp') {
|
||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
|
||||
} else if (format === 'eur') {
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
|
||||
} else if (format === 'dot') {
|
||||
return new Intl.NumberFormat('de-DE').format(number)
|
||||
} else if (format === 'space') {
|
||||
return new Intl.NumberFormat('fr-FR').format(number)
|
||||
} else {
|
||||
return number
|
||||
}
|
||||
},
|
||||
formatDate (date, format) {
|
||||
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
|
||||
const dayFormats = { D: 'numeric', DD: '2-digit' }
|
||||
const yearFormats = { YYYY: 'numeric', YY: '2-digit' }
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], {
|
||||
day: dayFormats[format.match(/D+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)]
|
||||
}).formatToParts(date)
|
||||
|
||||
return format
|
||||
.replace(/D+/, parts.find((p) => p.type === 'day').value)
|
||||
.replace(/M+/, parts.find((p) => p.type === 'month').value)
|
||||
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
|
||||
},
|
||||
closeDropdown () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="!schemaEntries.length"
|
||||
class="text-center py-4 px-2"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{{ t('no_variables') }}
|
||||
</p>
|
||||
<p class="text-sm mt-1">
|
||||
{{ t('no_variables_description') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<template
|
||||
v-for="([key, node], index) in schemaEntries"
|
||||
:key="key"
|
||||
>
|
||||
<div v-if="isGroup(node)">
|
||||
<hr
|
||||
v-if="index > 0"
|
||||
class="border-base-300"
|
||||
>
|
||||
<label class="peer flex items-center py-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden peer"
|
||||
checked
|
||||
>
|
||||
<IconChevronDown
|
||||
class="hidden peer-checked:block"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<IconChevronRight
|
||||
class="block peer-checked:hidden"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span class="ml-1">{{ key }}</span>
|
||||
<span
|
||||
v-if="node.type === 'array'"
|
||||
class="text-xs bg-base-200 rounded px-1 ml-1"
|
||||
>{{ t('list') }}</span>
|
||||
</label>
|
||||
<div class="hidden peer-has-[:checked]:block pl-3.5">
|
||||
<template
|
||||
v-for="[varNode, varPath] in nestedVariables(node, key)"
|
||||
:key="varPath"
|
||||
>
|
||||
<hr class="border-base-300">
|
||||
<DynamicVariable
|
||||
:path="varPath"
|
||||
:group-key="key"
|
||||
:editable="editable"
|
||||
:schema="varNode"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<hr
|
||||
v-if="index > 0"
|
||||
class="border-base-300"
|
||||
>
|
||||
<DynamicVariable
|
||||
:path="key"
|
||||
:editable="editable"
|
||||
:schema="node.type === 'array' && node.items ? node.items : node"
|
||||
:is-array="node.type === 'array'"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DynamicVariable from './dynamic_variable'
|
||||
import { IconChevronDown, IconChevronRight } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'DynamicVariables',
|
||||
components: {
|
||||
DynamicVariable,
|
||||
IconChevronDown,
|
||||
IconChevronRight
|
||||
},
|
||||
inject: ['t', 'template', 'save', 'backgroundColor'],
|
||||
props: {
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
schemaEntries () {
|
||||
return Object.entries(this.template.variables_schema || {}).filter(([, node]) => !node.disabled)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isGroup (node) {
|
||||
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.properties)
|
||||
},
|
||||
nestedVariables (node, groupKey) {
|
||||
const properties = node.type === 'array' ? node.items?.properties : node.properties
|
||||
|
||||
if (!properties) return []
|
||||
|
||||
const prefix = node.type === 'array' ? `${groupKey}[]` : groupKey
|
||||
|
||||
return this.collectLeafVariables(properties, prefix)
|
||||
},
|
||||
collectLeafVariables (properties, prefix) {
|
||||
return Object.entries(properties).reduce((result, [key, node]) => {
|
||||
if (node.disabled) return result
|
||||
|
||||
const path = `${prefix}.${key}`
|
||||
|
||||
if (node.type === 'object' && node.properties) {
|
||||
result.push(...this.collectLeafVariables(node.properties, path))
|
||||
} else if (node.type === 'array' && node.items?.properties) {
|
||||
result.push(...this.collectLeafVariables(node.items.properties, `${path}[]`))
|
||||
} else {
|
||||
result.push([node, path])
|
||||
}
|
||||
|
||||
return result
|
||||
}, [])
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,559 @@
|
||||
const KEYWORDS = ['if', 'else', 'for', 'end']
|
||||
const TYPE_PRIORITY = { string: 3, number: 2, boolean: 1 }
|
||||
const AND_OR_REGEXP = /\s+(AND|OR)\s+/i
|
||||
const COMPARISON_OPERATORS_REGEXP = />=|<=|!=|==|>|<|=/
|
||||
|
||||
function buildTokens (elem, acc = []) {
|
||||
if (elem.nodeType === Node.TEXT_NODE) {
|
||||
if (elem.textContent) {
|
||||
const text = elem.textContent
|
||||
const re = /[[\]]/g
|
||||
let match
|
||||
let found = false
|
||||
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
found = true
|
||||
|
||||
acc.push({
|
||||
elem,
|
||||
value: match[0],
|
||||
textLength: text.length,
|
||||
index: match.index
|
||||
})
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
acc.push({ elem, value: '', textLength: 0, index: 0 })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const child of elem.childNodes) {
|
||||
buildTokens(child, acc)
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
function tokensPair (cur, nxt) {
|
||||
if (cur.elem === nxt.elem) {
|
||||
return cur.elem.textContent.slice(cur.index + 1, nxt.index).trim() === ''
|
||||
} else {
|
||||
return cur.elem.textContent.slice(cur.index + 1).trim() === '' &&
|
||||
nxt.elem.textContent.slice(0, nxt.index).trim() === ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildTags (tokens) {
|
||||
const normalized = []
|
||||
|
||||
for (let i = 0; i < tokens.length - 1; i++) {
|
||||
const cur = tokens[i]
|
||||
const nxt = tokens[i + 1]
|
||||
|
||||
if (cur.value === '[' && nxt.value === '[' && tokensPair(cur, nxt)) {
|
||||
normalized.push(['open', cur])
|
||||
} else if (cur.value === ']' && nxt.value === ']' && tokensPair(cur, nxt)) {
|
||||
normalized.push(['close', nxt])
|
||||
}
|
||||
}
|
||||
|
||||
const tags = []
|
||||
|
||||
for (let i = 0; i < normalized.length - 1; i++) {
|
||||
const [curOp, openToken] = normalized[i]
|
||||
const [nxtOp, closeToken] = normalized[i + 1]
|
||||
|
||||
if (curOp === 'open' && nxtOp === 'close') {
|
||||
tags.push({ openToken, closeToken, value: '' })
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
function findTextNodesInBranch (elements, toElem, acc) {
|
||||
if (!elements || elements.length === 0) return acc
|
||||
|
||||
for (const elem of elements) {
|
||||
if (elem.nodeType === Node.TEXT_NODE) {
|
||||
acc.push(elem)
|
||||
} else {
|
||||
findTextNodesInBranch(Array.from(elem.childNodes), toElem, acc)
|
||||
}
|
||||
|
||||
if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
function findTextNodesBetween (fromElem, toElem, acc = []) {
|
||||
if (fromElem === toElem) return [fromElem]
|
||||
|
||||
let currentElement = fromElem
|
||||
|
||||
while (true) {
|
||||
const parent = currentElement.parentNode
|
||||
|
||||
if (!parent) return acc
|
||||
|
||||
const children = Array.from(parent.childNodes)
|
||||
const startIndex = children.indexOf(currentElement)
|
||||
|
||||
if (startIndex === -1) return acc
|
||||
|
||||
const elementsInBranch = children.slice(startIndex)
|
||||
|
||||
findTextNodesInBranch(elementsInBranch, toElem, acc)
|
||||
|
||||
if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc
|
||||
|
||||
let p = elementsInBranch[0].parentNode
|
||||
|
||||
while (p && !p.nextSibling) {
|
||||
p = p.parentNode
|
||||
}
|
||||
|
||||
if (!p || !p.nextSibling) return acc
|
||||
|
||||
currentElement = p.nextSibling
|
||||
}
|
||||
}
|
||||
|
||||
function mapTagValues (tags) {
|
||||
for (const tag of tags) {
|
||||
const textNodes = findTextNodesBetween(tag.openToken.elem, tag.closeToken.elem)
|
||||
|
||||
for (const elem of textNodes) {
|
||||
let part
|
||||
|
||||
if (tag.openToken.elem === elem && tag.closeToken.elem === elem) {
|
||||
part = elem.textContent.slice(tag.openToken.index, tag.closeToken.index + 1)
|
||||
} else if (tag.openToken.elem === elem) {
|
||||
part = elem.textContent.slice(tag.openToken.index)
|
||||
} else if (tag.closeToken.elem === elem) {
|
||||
part = elem.textContent.slice(0, tag.closeToken.index + 1)
|
||||
} else {
|
||||
part = elem.textContent
|
||||
}
|
||||
|
||||
tag.value += part
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
function parseTagTypeName (tagString) {
|
||||
const val = tagString.replace(/[[\]]/g, '').trim()
|
||||
const parts = val.split(':').map((s) => s.trim())
|
||||
|
||||
if (parts.length === 2 && KEYWORDS.includes(parts[0])) {
|
||||
return [parts[0], parts[1]]
|
||||
} else if (KEYWORDS.includes(val)) {
|
||||
return [val, null]
|
||||
} else {
|
||||
return ['var', val]
|
||||
}
|
||||
}
|
||||
|
||||
function isSimpleVariable (str) {
|
||||
const s = str.trim()
|
||||
|
||||
return !AND_OR_REGEXP.test(s) &&
|
||||
!COMPARISON_OPERATORS_REGEXP.test(s) &&
|
||||
!s.includes('(') &&
|
||||
!s.includes('!') &&
|
||||
!s.includes('&&') &&
|
||||
!s.includes('||') &&
|
||||
!s.startsWith('"') &&
|
||||
!s.startsWith("'") &&
|
||||
!/^-?\d/.test(s) &&
|
||||
!/^(true|false)$/i.test(s)
|
||||
}
|
||||
|
||||
function tokenizeCondition (str) {
|
||||
const tokens = []
|
||||
let pos = 0
|
||||
|
||||
str = str.trim()
|
||||
|
||||
while (pos < str.length) {
|
||||
const rest = str.slice(pos)
|
||||
let m
|
||||
|
||||
if ((m = rest.match(/^\s+/))) {
|
||||
pos += m[0].length
|
||||
} else if ((m = rest.match(/^(>=|<=|!=|==|>|<|=)/))) {
|
||||
tokens.push({ type: 'operator', value: m[1] })
|
||||
pos += m[1].length
|
||||
} else if (rest[0] === '!') {
|
||||
tokens.push({ type: 'not', value: '!' })
|
||||
pos += 1
|
||||
} else if (rest[0] === '(') {
|
||||
tokens.push({ type: 'lparen', value: '(' })
|
||||
pos += 1
|
||||
} else if (rest[0] === ')') {
|
||||
tokens.push({ type: 'rparen', value: ')' })
|
||||
pos += 1
|
||||
} else if (rest.startsWith('&&')) {
|
||||
tokens.push({ type: 'and', value: 'AND' })
|
||||
pos += 2
|
||||
} else if ((m = rest.match(/^AND\b/i))) {
|
||||
tokens.push({ type: 'and', value: 'AND' })
|
||||
pos += 3
|
||||
} else if (rest.startsWith('||')) {
|
||||
tokens.push({ type: 'or', value: 'OR' })
|
||||
pos += 2
|
||||
} else if ((m = rest.match(/^OR\b/i))) {
|
||||
tokens.push({ type: 'or', value: 'OR' })
|
||||
pos += 2
|
||||
} else if ((m = rest.match(/^"([^"]*)"/) || rest.match(/^'([^']*)'/))) {
|
||||
tokens.push({ type: 'string', value: m[1] })
|
||||
pos += m[0].length
|
||||
} else if ((m = rest.match(/^(-?\d+\.?\d*)/))) {
|
||||
tokens.push({ type: 'number', value: m[1].includes('.') ? parseFloat(m[1]) : parseInt(m[1], 10) })
|
||||
pos += m[1].length
|
||||
} else if ((m = rest.match(/^(true|false)\b/i))) {
|
||||
tokens.push({ type: 'boolean', value: m[1].toLowerCase() === 'true' })
|
||||
pos += m[1].length
|
||||
} else if ((m = rest.match(/^([\p{L}_][\p{L}\p{N}_.]*)/u))) {
|
||||
tokens.push({ type: 'variable', value: m[1] })
|
||||
pos += m[1].length
|
||||
} else {
|
||||
pos += 1
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function parseOrExpr (tokens, pos) {
|
||||
let left, right
|
||||
|
||||
;[left, pos] = parseAndExpr(tokens, pos)
|
||||
|
||||
while (pos < tokens.length && tokens[pos].type === 'or') {
|
||||
pos += 1
|
||||
;[right, pos] = parseAndExpr(tokens, pos)
|
||||
left = { type: 'or', left, right }
|
||||
}
|
||||
|
||||
return [left, pos]
|
||||
}
|
||||
|
||||
function parseAndExpr (tokens, pos) {
|
||||
let left, right
|
||||
|
||||
;[left, pos] = parsePrimary(tokens, pos)
|
||||
|
||||
while (pos < tokens.length && tokens[pos].type === 'and') {
|
||||
pos += 1
|
||||
;[right, pos] = parsePrimary(tokens, pos)
|
||||
left = { type: 'and', left, right }
|
||||
}
|
||||
|
||||
return [left, pos]
|
||||
}
|
||||
|
||||
function parsePrimary (tokens, pos) {
|
||||
if (pos >= tokens.length) return [null, pos]
|
||||
|
||||
if (tokens[pos].type === 'not') {
|
||||
const [child, p] = parsePrimary(tokens, pos + 1)
|
||||
|
||||
return [{ type: 'not', child }, p]
|
||||
}
|
||||
|
||||
if (tokens[pos].type === 'lparen') {
|
||||
const [node, p] = parseOrExpr(tokens, pos + 1)
|
||||
|
||||
return [node, p < tokens.length && tokens[p].type === 'rparen' ? p + 1 : p]
|
||||
}
|
||||
|
||||
return parseComparisonOrPresence(tokens, pos)
|
||||
}
|
||||
|
||||
function parseComparisonOrPresence (tokens, pos) {
|
||||
if (pos >= tokens.length || tokens[pos].type !== 'variable') return [null, pos]
|
||||
|
||||
const variableName = tokens[pos].value
|
||||
|
||||
pos += 1
|
||||
|
||||
if (pos < tokens.length && tokens[pos].type === 'operator') {
|
||||
let operator = tokens[pos].value
|
||||
|
||||
if (operator === '=') operator = '=='
|
||||
|
||||
pos += 1
|
||||
|
||||
if (pos < tokens.length && ['string', 'number', 'variable', 'boolean'].includes(tokens[pos].type)) {
|
||||
const valueToken = tokens[pos]
|
||||
|
||||
return [{
|
||||
type: 'comparison',
|
||||
variableName,
|
||||
operator,
|
||||
value: valueToken.value,
|
||||
valueIsVariable: valueToken.type === 'variable'
|
||||
}, pos + 1]
|
||||
}
|
||||
}
|
||||
|
||||
return [{ type: 'presence', variableName }, pos]
|
||||
}
|
||||
|
||||
function parseCondition (conditionString) {
|
||||
const stripped = conditionString.trim()
|
||||
|
||||
if (stripped.startsWith('!') && isSimpleVariable(stripped.slice(1))) {
|
||||
return { type: 'not', child: { type: 'presence', variableName: stripped.slice(1) } }
|
||||
}
|
||||
|
||||
if (isSimpleVariable(stripped)) {
|
||||
return { type: 'presence', variableName: stripped }
|
||||
}
|
||||
|
||||
const tokens = tokenizeCondition(stripped)
|
||||
const [ast] = parseOrExpr(tokens, 0)
|
||||
|
||||
return ast
|
||||
}
|
||||
|
||||
function extractConditionVariables (node, acc = []) {
|
||||
if (!node) return acc
|
||||
|
||||
switch (node.type) {
|
||||
case 'or':
|
||||
case 'and':
|
||||
extractConditionVariables(node.left, acc)
|
||||
extractConditionVariables(node.right, acc)
|
||||
break
|
||||
case 'not':
|
||||
extractConditionVariables(node.child, acc)
|
||||
break
|
||||
case 'comparison':
|
||||
acc.push({
|
||||
name: node.variableName,
|
||||
type: node.valueIsVariable ? null : (typeof node.value === 'boolean' ? 'boolean' : (typeof node.value === 'number' ? 'number' : 'string'))
|
||||
})
|
||||
|
||||
if (node.valueIsVariable) {
|
||||
acc.push({ name: node.value, type: null })
|
||||
}
|
||||
|
||||
break
|
||||
case 'presence':
|
||||
acc.push({ name: node.variableName, type: 'boolean' })
|
||||
break
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
function singularize (word) {
|
||||
if (word.endsWith('ies')) return word.slice(0, -3) + 'y'
|
||||
if (word.endsWith('ches') || word.endsWith('shes')) return word.slice(0, -2)
|
||||
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2)
|
||||
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
|
||||
|
||||
return word
|
||||
}
|
||||
|
||||
function buildOperators (tags) {
|
||||
const operators = []
|
||||
const stack = [{ children: operators, operator: null }]
|
||||
|
||||
for (const tag of tags) {
|
||||
const [type, variableName] = parseTagTypeName(tag.value)
|
||||
|
||||
switch (type) {
|
||||
case 'for':
|
||||
case 'if': {
|
||||
const operator = { type, variableName, tag, children: [] }
|
||||
|
||||
if (type === 'if') {
|
||||
try {
|
||||
operator.condition = parseCondition(variableName)
|
||||
} catch (e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
stack[stack.length - 1].children.push(operator)
|
||||
stack.push({ children: operator.children, operator })
|
||||
break
|
||||
}
|
||||
case 'else': {
|
||||
const current = stack[stack.length - 1]
|
||||
|
||||
if (current.operator && current.operator.type === 'if') {
|
||||
current.operator.elseTag = tag
|
||||
current.operator.elseChildren = []
|
||||
current.children = current.operator.elseChildren
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'end': {
|
||||
const popped = stack.pop()
|
||||
|
||||
if (popped.operator) {
|
||||
popped.operator.endTag = tag
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'var':
|
||||
stack[stack.length - 1].children.push({ type, variableName, tag })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return operators
|
||||
}
|
||||
|
||||
function assignNestedSchema (propertiesHash, parentProperties, keyString, value) {
|
||||
const keys = keyString.split('.')
|
||||
const lastKey = keys.pop()
|
||||
|
||||
let currentLevel = null
|
||||
|
||||
if (keys.length > 0 && parentProperties[keys[0]]) {
|
||||
currentLevel = keys.reduce((current, key) => {
|
||||
if (!current[key]) current[key] = { type: 'object', properties: {} }
|
||||
|
||||
return current[key].properties
|
||||
}, parentProperties)
|
||||
}
|
||||
|
||||
if (!currentLevel) {
|
||||
currentLevel = keys.reduce((current, key) => {
|
||||
if (!current[key]) current[key] = { type: 'object', properties: {} }
|
||||
|
||||
return current[key].properties
|
||||
}, propertiesHash)
|
||||
}
|
||||
|
||||
currentLevel[lastKey] = value
|
||||
}
|
||||
|
||||
function assignNestedSchemaWithPriority (propertiesHash, parentProperties, keyString, newType) {
|
||||
const keys = keyString.split('.')
|
||||
const lastKey = keys.pop()
|
||||
|
||||
let currentLevel = null
|
||||
|
||||
if (keys.length > 0 && parentProperties[keys[0]]) {
|
||||
currentLevel = keys.reduce((current, key) => {
|
||||
if (!current[key]) current[key] = { type: 'object', properties: {} }
|
||||
|
||||
return current[key].properties
|
||||
}, parentProperties)
|
||||
}
|
||||
|
||||
if (!currentLevel) {
|
||||
currentLevel = keys.reduce((current, key) => {
|
||||
if (!current[key]) current[key] = { type: 'object', properties: {} }
|
||||
|
||||
return current[key].properties
|
||||
}, propertiesHash)
|
||||
}
|
||||
|
||||
const existing = currentLevel[lastKey]
|
||||
|
||||
if (existing && (TYPE_PRIORITY[newType] || 0) <= (TYPE_PRIORITY[existing.type] || 0)) return
|
||||
|
||||
currentLevel[lastKey] = { type: newType }
|
||||
}
|
||||
|
||||
function processConditionVariables (condition, propertiesHash, parentProperties) {
|
||||
const variables = extractConditionVariables(condition)
|
||||
|
||||
for (const varInfo of variables) {
|
||||
assignNestedSchemaWithPriority(propertiesHash, parentProperties, varInfo.name, varInfo.type || 'boolean')
|
||||
}
|
||||
}
|
||||
|
||||
function processOperators (operators, propertiesHash = {}, parentProperties = {}) {
|
||||
if (!operators || operators.length === 0) return propertiesHash
|
||||
|
||||
for (const op of operators) {
|
||||
switch (op.type) {
|
||||
case 'var': {
|
||||
if (!op.variableName.includes('.') && parentProperties[op.variableName]) {
|
||||
const item = parentProperties[op.variableName]
|
||||
|
||||
if (item && item.type === 'object' && item.properties && Object.keys(item.properties).length === 0) {
|
||||
delete item.properties
|
||||
item.type = 'string'
|
||||
}
|
||||
} else {
|
||||
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'string' })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'if':
|
||||
if (op.condition) {
|
||||
processConditionVariables(op.condition, propertiesHash, parentProperties)
|
||||
}
|
||||
|
||||
processOperators(op.children, propertiesHash, parentProperties)
|
||||
processOperators(op.elseChildren, propertiesHash, parentProperties)
|
||||
break
|
||||
case 'for': {
|
||||
const parts = op.variableName.split('.')
|
||||
const singularKey = singularize(parts[parts.length - 1])
|
||||
|
||||
let itemProperties = parentProperties[singularKey]?.items
|
||||
itemProperties = itemProperties || propertiesHash[parts[0]]?.items
|
||||
itemProperties = itemProperties || { type: 'object', properties: {} }
|
||||
|
||||
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'array', items: itemProperties })
|
||||
processOperators(op.children, propertiesHash, { ...parentProperties, [singularKey]: itemProperties })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return propertiesHash
|
||||
}
|
||||
|
||||
function mergeSchemaProperties (target, source) {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (!target[key]) {
|
||||
target[key] = source[key]
|
||||
} else if (target[key].type === 'object' && source[key].type === 'object') {
|
||||
if (!target[key].properties) target[key].properties = {}
|
||||
if (source[key].properties) {
|
||||
mergeSchemaProperties(target[key].properties, source[key].properties)
|
||||
}
|
||||
} else if (target[key].type === 'array' && source[key].type === 'array') {
|
||||
if (source[key].items && source[key].items.properties) {
|
||||
if (!target[key].items) {
|
||||
target[key].items = source[key].items
|
||||
} else if (target[key].items.properties) {
|
||||
mergeSchemaProperties(target[key].items.properties, source[key].items.properties)
|
||||
}
|
||||
} else if (source[key].items && !target[key].items) {
|
||||
target[key].items = source[key].items
|
||||
}
|
||||
} else if ((TYPE_PRIORITY[source[key].type] || 0) > (TYPE_PRIORITY[target[key].type] || 0)) {
|
||||
target[key] = source[key]
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
function buildVariablesSchema (dom) {
|
||||
const tokens = buildTokens(dom)
|
||||
const tags = mapTagValues(buildTags(tokens))
|
||||
const operators = buildOperators(tags)
|
||||
|
||||
return processOperators(operators)
|
||||
}
|
||||
|
||||
export { buildVariablesSchema, mergeSchemaProperties, buildOperators, buildTokens, buildTags, mapTagValues }
|
||||
@ -1,21 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class GenerateAttachmentPreviewJob
|
||||
include Sidekiq::Job
|
||||
|
||||
InvalidFormat = Class.new(StandardError)
|
||||
|
||||
sidekiq_options queue: :images
|
||||
|
||||
def perform(params = {})
|
||||
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
|
||||
|
||||
if attachment.content_type == Templates::ProcessDocument::PDF_CONTENT_TYPE
|
||||
Templates::ProcessDocument.generate_pdf_preview_images(attachment, attachment.download)
|
||||
elsif attachment.image?
|
||||
Templates::ProcessDocument.generate_preview_image(attachment, attachment.download)
|
||||
else
|
||||
raise InvalidFormat, attachment.id
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: dynamic_documents
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# body :text not null
|
||||
# head :text
|
||||
# sha1 :text not null
|
||||
# uuid :uuid not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# template_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_dynamic_documents_on_template_id (template_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (template_id => templates.id)
|
||||
#
|
||||
class DynamicDocument < ApplicationRecord
|
||||
belongs_to :template
|
||||
|
||||
has_many_attached :attachments
|
||||
|
||||
has_many :versions, class_name: 'DynamicDocumentVersion', dependent: :destroy
|
||||
|
||||
before_validation :set_sha1
|
||||
|
||||
def set_sha1
|
||||
self.sha1 = Digest::SHA1.hexdigest(body)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: dynamic_document_versions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# areas :text not null
|
||||
# sha1 :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# dynamic_document_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_on_dynamic_document_id_sha1_3503adf557 (dynamic_document_id,sha1) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (dynamic_document_id => dynamic_documents.id)
|
||||
#
|
||||
class DynamicDocumentVersion < ApplicationRecord
|
||||
belongs_to :dynamic_document
|
||||
|
||||
has_one_attached :document
|
||||
|
||||
attribute :areas, :string, default: -> { [] }
|
||||
|
||||
serialize :areas, coder: JSON
|
||||
end
|
||||
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateDynamicDocuments < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :dynamic_documents do |t|
|
||||
t.uuid :uuid, null: false
|
||||
t.references :template, null: false, foreign_key: true, index: true
|
||||
t.text :body, null: false
|
||||
t.text :head
|
||||
t.text :sha1, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateDynamicDocumentVersions < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :dynamic_document_versions do |t|
|
||||
t.references :dynamic_document, null: false, foreign_key: true, index: false
|
||||
t.string :sha1, null: false
|
||||
t.text :areas, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :dynamic_document_versions, %i[dynamic_document_id sha1], unique: true
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,18 @@
|
||||
const path = require('path')
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
path.resolve(__dirname, 'app/javascript/template_builder/dynamic_area.vue'),
|
||||
path.resolve(__dirname, 'app/javascript/template_builder/dynamic_section.vue')
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'base-100': '#faf7f5',
|
||||
'base-200': '#efeae6',
|
||||
'base-300': '#e7e2df',
|
||||
'base-content': '#291334'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue