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