add more options to context menu

master^2
Alex Turchyn 5 days ago committed by Pete Matsyburka
parent 2fc5e9cfb3
commit 12c767b7d3

@ -134,6 +134,7 @@
@focusout="maybeBlurSettings" @focusout="maybeBlurSettings"
> >
<FieldSettings <FieldSettings
v-if="isMobile"
:field="field" :field="field"
:default-field="defaultField" :default-field="defaultField"
:editable="editable" :editable="editable"
@ -150,6 +151,12 @@
@save="save" @save="save"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]" @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> </ul>
</span> </span>
</div> </div>
@ -468,6 +475,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
isMobile: {
type: Boolean,
required: false,
default: false
},
isSelectMode: { isSelectMode: {
type: Boolean, type: Boolean,
required: false, required: false,

@ -374,6 +374,7 @@
:draw-field-type="drawFieldType" :draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField" :draw-custom-field="drawCustomField"
:editable="editable" :editable="editable"
:is-mobile="isMobile"
:base-url="baseUrl" :base-url="baseUrl"
:with-fields-detection="withFieldsDetection" :with-fields-detection="withFieldsDetection"
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', drawCustomField = null, showDrawField = false]" @draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', drawCustomField = null, showDrawField = false]"
@ -382,9 +383,9 @@
@paste-field="pasteField" @paste-field="pasteField"
@copy-field="copyField" @copy-field="copyField"
@add-custom-field="addCustomField" @add-custom-field="addCustomField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@copy-selected-areas="copySelectedAreas" @copy-selected-areas="copySelectedAreas"
@delete-selected-areas="deleteSelectedAreas" @delete-selected-areas="deleteSelectedAreas"
@align-selected-areas="alignSelectedAreas"
@autodetect-fields="detectFieldsForPage" @autodetect-fields="detectFieldsForPage"
/> />
<DocumentControls <DocumentControls
@ -1176,27 +1177,6 @@ export default {
this.debouncedSave() this.debouncedSave()
}, },
alignSelectedAreas (direction) {
const areas = this.selectedAreasRef.value
let targetValue
if (direction === 'left') {
targetValue = Math.min(...areas.map(a => a.x))
areas.forEach((area) => { area.x = targetValue })
} else if (direction === 'right') {
targetValue = Math.max(...areas.map(a => a.x + a.w))
areas.forEach((area) => { area.x = targetValue - area.w })
} else if (direction === 'top') {
targetValue = Math.min(...areas.map(a => a.y))
areas.forEach((area) => { area.y = targetValue })
} else if (direction === 'bottom') {
targetValue = Math.max(...areas.map(a => a.y + a.h))
areas.forEach((area) => { area.y = targetValue - area.h })
}
this.save()
},
download () { download () {
this.isDownloading = true this.isDownloading = true

@ -1,546 +0,0 @@
<template>
<div>
<div
v-if="!isShowFormulaModal && !isShowFontModal && !isShowConditionsModal && !isShowDescriptionModal"
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-base-300 min-w-[170px] cursor-default"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<label
v-if="showRequired"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isRequired"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleRequired($event.target.checked)"
@click.stop
>
<span>{{ t('required') }}</span>
</label>
<label
v-if="showReadOnly"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isReadOnly"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleReadOnly($event.target.checked)"
@click.stop
>
<span>{{ t('read_only') }}</span>
</label>
<hr
v-if="(showRequired || showReadOnly) && (showFont || showDescription || showCondition || showFormula)"
class="my-1 border-base-300"
>
<button
v-if="showFont && !isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showDescription"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openDescriptionModal"
>
<IconInfoCircle class="w-4 h-4" />
<span>{{ t('description') }}</span>
</button>
<button
v-if="showCondition && !isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<button
v-if="showFormula"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFormulaModal"
>
<IconMathFunction class="w-4 h-4" />
<span>{{ t('formula') }}</span>
</button>
<hr
v-if="((showFont && !isMultiSelection) || showDescription || (showCondition && !isMultiSelection) || showFormula) && (showCopy || showDelete || showPaste)"
class="my-1 border-base-300"
>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'left')"
>
<IconLayoutAlignLeft class="w-4 h-4" />
<span>{{ t('align_left') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'right')"
>
<IconLayoutAlignRight class="w-4 h-4" />
<span>{{ t('align_right') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'top')"
>
<IconLayoutAlignTop class="w-4 h-4" />
<span>{{ t('align_top') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'bottom')"
>
<IconLayoutAlignBottom class="w-4 h-4" />
<span>{{ t('align_bottom') }}</span>
</button>
<hr
v-if="isMultiSelection && (showFont || showCondition)"
class="my-1 border-base-300"
>
<button
v-if="showFont && isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition && isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr
v-if="isMultiSelection"
class="my-1 border-base-300"
>
<button
v-if="showCopy"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
<IconCopy class="w-4 h-4" />
<span>{{ t('copy') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
v-if="showDelete"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Del</span>
</button>
<button
v-if="showPaste"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
:class="!hasClipboardData ? 'opacity-50 cursor-not-allowed' : 'hover:bg-base-100'"
:disabled="!hasClipboardData"
@click.stop="!hasClipboardData ? null : $emit('paste')"
>
<span class="flex items-center space-x-2">
<IconClipboard class="w-4 h-4" />
<span>{{ t('paste') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
</button>
<button
v-if="showSelectFields"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
@click.stop="handleToggleSelectMode"
>
<span class="flex items-center space-x-2">
<IconClick
v-if="!isSelectModeRef.value"
class="w-4 h-4"
/>
<IconNewSection
v-else
class="w-4 h-4"
/>
<span>{{ isSelectModeRef.value ? t('draw_fields') : t('select_fields') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Tab</span>
</button>
<hr
v-if="showAutodetectFields"
class="my-1 border-base-300"
>
<button
v-if="showAutodetectFields"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('autodetect-fields')"
>
<IconSparkles class="w-4 h-4" />
<span>{{ t('autodetect_fields') }}</span>
</button>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="multiSelectField || field"
:area="contextMenu.area"
:editable="editable"
:build-default-name="buildDefaultName"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectFontModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="multiSelectField || field"
:build-default-name="buildDefaultName"
:exclude-field-uuids="isMultiSelection ? selectedFields.map(f => f.uuid) : []"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectConditionsModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
</div>
</template>
<script>
import { IconCopy, IconClipboard, IconTrashX, IconTypography, IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconClick, IconNewSection, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom, IconSparkles } from '@tabler/icons-vue'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import Field from './field'
import FieldType from './field_type.vue'
export default {
name: 'ContextMenu',
components: {
IconCopy,
IconClipboard,
IconTrashX,
IconTypography,
IconInfoCircle,
IconRouteAltLeft,
IconMathFunction,
IconClick,
IconNewSection,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconLayoutAlignTop,
IconLayoutAlignBottom,
IconSparkles,
FormulaModal,
FontModal,
ConditionsModal,
DescriptionModal
},
inject: ['t', 'save', 'selectedAreasRef', 'isSelectModeRef', 'getFieldTypeIndex'],
props: {
contextMenu: {
type: Object,
default: null,
required: true
},
field: {
type: Object,
default: null
},
editable: {
type: Boolean,
default: true
},
isMultiSelection: {
type: Boolean,
default: false
},
selectedAreas: {
type: Array,
default: () => []
},
template: {
type: Object,
default: null
},
withFieldsDetection: {
type: Boolean,
default: false
}
},
emits: ['copy', 'paste', 'delete', 'close', 'align', 'autodetect-fields'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
multiSelectField: null
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
selectedFields () {
if (!this.isMultiSelection) return []
return this.selectedAreasRef.value.map((area) => {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
hasClipboardData () {
try {
const clipboard = localStorage.getItem('docuseal_clipboard')
if (clipboard) {
const data = JSON.parse(clipboard)
return Date.now() - data.timestamp < 3600000
}
return false
} catch {
return false
}
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showCopy () {
return !!this.contextMenu.area || this.isMultiSelection
},
showPaste () {
return !this.contextMenu.area && !this.isMultiSelection
},
showDelete () {
return !!this.contextMenu.area || this.isMultiSelection
},
showFont () {
if (this.isMultiSelection) return true
if (!this.field) return false
return ['text', 'number', 'date', 'select', 'heading', 'cells'].includes(this.field.type)
},
showDescription () {
if (!this.field) return false
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
},
showCondition () {
if (this.isMultiSelection) return true
if (!this.field) return false
return !['stamp', 'heading'].includes(this.field.type)
},
showFormula () {
if (!this.field) return false
return this.field.type === 'number'
},
showRequired () {
if (!this.field) return false
return !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
},
showReadOnly () {
if (!this.field) return false
return ['text', 'number', 'radio', 'multiple', 'select'].includes(this.field.type)
},
isRequired () {
return this.field?.required || false
},
isReadOnly () {
return this.field?.readonly || false
},
showSelectFields () {
return !this.contextMenu.area && !this.isMultiSelection
},
showAutodetectFields () {
return this.withFieldsDetection && this.editable && !this.contextMenu.area && !this.isMultiSelection
}
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => {
this.checkMenuPosition()
})
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
if (rect.bottom > window.innerHeight) {
this.contextMenu.y = this.contextMenu.y - rect.height
}
if (rect.right > window.innerWidth) {
this.contextMenu.x = this.contextMenu.x - rect.width
}
}
},
handleToggleRequired (value) {
if (this.field) {
this.field.required = value
this.save()
}
},
handleToggleReadOnly (value) {
if (this.field) {
this.field.readonly = value
this.save()
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData) {
event.preventDefault()
event.stopPropagation()
this.$emit('paste')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
openFontModal () {
if (this.isMultiSelection) {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
preferences: {}
}
const preferencesStrings = this.selectedFields.map((f) => JSON.stringify(f.preferences || {}))
if (preferencesStrings.every((s) => s === preferencesStrings[0])) {
this.multiSelectField.preferences = JSON.parse(preferencesStrings[0])
}
}
this.isShowFontModal = true
},
openDescriptionModal () {
this.isShowDescriptionModal = true
},
openConditionModal () {
if (this.isMultiSelection) {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
conditions: []
}
const conditionStrings = this.selectedFields.map((f) => JSON.stringify(f.conditions || []))
if (conditionStrings.every((s) => s === conditionStrings[0])) {
this.multiSelectField.conditions = JSON.parse(conditionStrings[0])
}
}
this.isShowConditionsModal = true
},
openFormulaModal () {
this.isShowFormulaModal = true
},
closeModal () {
this.isShowFormulaModal = false
this.isShowFontModal = false
this.isShowConditionsModal = false
this.isShowDescriptionModal = false
this.multiSelectField = null
this.$emit('close')
},
handleSaveMultiSelectFontModal () {
this.selectedFields.forEach((field) => {
field.preferences = { ...field.preferences, ...this.multiSelectField.preferences }
})
this.save()
this.closeModal()
},
handleSaveMultiSelectConditionsModal () {
this.selectedFields.forEach((field) => {
field.conditions = JSON.parse(JSON.stringify(this.multiSelectField.conditions))
})
this.save()
this.closeModal()
},
handleToggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
this.$emit('close')
}
}
}
</script>

@ -13,6 +13,7 @@
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
:with-prefillable="withPrefillable" :with-prefillable="withPrefillable"
:is-drag="isDrag" :is-drag="isDrag"
:is-mobile="isMobile"
:with-field-placeholder="withFieldPlaceholder" :with-field-placeholder="withFieldPlaceholder"
:default-fields="defaultFields" :default-fields="defaultFields"
:drag-field-placeholder="dragFieldPlaceholder" :drag-field-placeholder="dragFieldPlaceholder"
@ -30,9 +31,9 @@
@copy-field="$emit('copy-field', $event)" @copy-field="$emit('copy-field', $event)"
@paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })" @paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })"
@add-custom-field="$emit('add-custom-field', $event)" @add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
@copy-selected-areas="$emit('copy-selected-areas')" @copy-selected-areas="$emit('copy-selected-areas')"
@delete-selected-areas="$emit('delete-selected-areas')" @delete-selected-areas="$emit('delete-selected-areas')"
@align-selected-areas="$emit('align-selected-areas', $event)"
@autodetect-fields="$emit('autodetect-fields', $event)" @autodetect-fields="$emit('autodetect-fields', $event)"
@scroll-to="scrollToArea" @scroll-to="scrollToArea"
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })" @draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
@ -98,6 +99,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
isMobile: {
type: Boolean,
required: false,
default: false
},
allowDraw: { allowDraw: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -138,7 +144,7 @@ export default {
default: false default: false
} }
}, },
emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields', 'add-custom-field'], emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'autodetect-fields', 'add-custom-field', 'set-draw'],
data () { data () {
return { return {
pageRefs: [] pageRefs: []

@ -0,0 +1,999 @@
<template>
<div>
<div
v-if="!isShowFormulaModal && !isShowFontModal && !isShowConditionsModal && !isShowDescriptionModal && !isShowCustomValidationModal && !isShowLengthValidationModal && !isShowNumberRangeModal && !isShowPriceModal && !isShowPaymentLinkModal"
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-base-300 cursor-default"
style="min-width: 170px"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<label
v-if="showRequired"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isRequired"
type="checkbox"
class="toggle toggle-xs"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.required))"
@change="handleToggleRequired($event.target.checked)"
@click.stop
>
<span>{{ t('required') }}</span>
</label>
<label
v-if="showReadOnly"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isReadOnly"
type="checkbox"
class="toggle toggle-xs"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.readonly))"
@change="handleToggleReadOnly($event.target.checked)"
@click.stop
>
<span>{{ t('read_only') }}</span>
</label>
<label
v-if="showPrefillable"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="field.prefillable"
type="checkbox"
class="toggle toggle-xs"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.prefillable))"
@change="handleTogglePrefillable($event.target.checked)"
@click.stop
>
<span>{{ t('prefillable') }}</span>
</label>
<label
v-if="showSetSigningDate"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="field.readonly && field.default_value === '{{date}}'"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleSetSigningDate($event.target.checked)"
@click.stop
>
<span>{{ t('set_signing_date') }}</span>
</label>
<label
v-if="showWithLogo"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="field.preferences?.with_logo !== false"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleWithLogo($event.target.checked)"
@click.stop
>
<span>{{ t('with_logo') }}</span>
</label>
<label
v-if="showSignatureId"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="field.preferences?.with_signature_id"
type="checkbox"
class="toggle toggle-xs"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.required))"
@change="handleToggleSignatureId($event.target.checked)"
@click.stop
>
<span>{{ t('signature_id') }}</span>
</label>
<label
v-if="showChecked"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="field.default_value"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleChecked($event.target.checked)"
@click.stop
>
<span>{{ t('checked') }}</span>
</label>
<ContextSubmenu
v-if="showVerificationMethod"
:icon="IconId"
:label="t('method')"
:options="methodOptions"
:model-value="currentVerificationMethod"
@select="handleSelectVerificationMethod"
/>
<hr
v-if="showRequired || showReadOnly || showPrefillable || showSetSigningDate || showWithLogo || showSignatureId"
class="my-1 border-base-300"
>
<ContextSubmenu
v-if="showFormatSubmenu"
:icon="IconAdjustmentsHorizontal"
:label="t('format')"
:options="formatOptions"
:model-value="currentFormat"
@select="handleSelectFormat"
/>
<ContextSubmenu
v-if="showValidationSubmenu && field.type !== 'number'"
:icon="IconInputCheck"
:label="t('validation')"
:options="validationMenuItems.map(k => ({ value: k, label: t(k) }))"
:model-value="currentValidationKey"
@select="handleSelectValidation"
/>
<button
v-if="field.type === 'number'"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openNumberRangeModal"
>
<IconInputCheck class="w-4 h-4" />
<span>{{ t('validation') }}</span>
</button>
<ContextSubmenu
v-if="showPaymentSettings"
:icon="IconCash"
:label="t('currency')"
:options="currencyOptions"
:model-value="currentCurrency"
@select="handleSelectCurrency"
/>
<ContextSubmenu
v-if="showPaymentSettings"
:icon="IconCoins"
:label="t('price')"
:options="priceTypeOptions"
:model-value="currentPriceType"
@select="handleSelectPriceType"
/>
<button
v-if="showFont"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showDescription"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openDescriptionModal"
>
<IconInfoCircle class="w-4 h-4" />
<span>{{ t('description') }}</span>
</button>
<button
v-if="showCondition"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="openConditionModal"
>
<span class="flex items-center space-x-2">
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</span>
<span
v-if="field.conditions?.length"
class="bg-base-200 rounded px-1 leading-3"
style="font-size: 9px;"
>{{ field.conditions.length }}</span>
</button>
<button
v-if="showFormula"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openFormulaModal"
>
<IconMathFunction class="w-4 h-4" />
<span>{{ t('formula') }}</span>
</button>
<hr
v-if="(showFont || showDescription || showCondition || showFormula || showPaymentSettings)"
class="my-1 border-base-300"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
<IconCopy class="w-4 h-4" />
<span>{{ t('copy') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Del</span>
</button>
<ContextSubmenu
v-if="showMoreSubmenu"
:icon="IconDots"
:label="t('more')"
>
<button
v-if="showDrawNewArea"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click="handleMoreSelect('draw_new_area')"
>
<IconNewSection class="w-4 h-4" />
<span class="whitespace-nowrap">{{ t('draw_new_area') }}</span>
</button>
<button
v-if="showCopyToAllPages"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click="handleMoreSelect('copy_to_all_pages')"
>
<IconCopy class="w-4 h-4" />
<span class="whitespace-nowrap">{{ t('copy_to_all_pages') }}</span>
</button>
<button
v-if="showSaveAsCustom"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click="handleMoreSelect('save_as_custom')"
>
<IconForms class="w-4 h-4" />
<span class="whitespace-nowrap">{{ t('save_as_custom_field') }}</span>
</button>
</ContextSubmenu>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:area="contextMenu.area"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="field"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
<ContextModal
v-if="isShowCustomValidationModal"
:title="`${t('custom_validation')} - ${modalFieldName}`"
:modal-container-el="modalContainerEl"
@close="closeValidationModal"
@save="saveCustomValidation"
>
<div class="space-y-1 mb-1">
<div>
<label
dir="auto"
class="label text-sm"
for="custom_validation_pattern"
>
{{ t('regexp_validation') }}
</label>
<input
id="custom_validation_pattern"
v-model="customValidationPattern"
dir="auto"
type="text"
class="base-input !text-base w-full"
:placeholder="t('regexp_validation')"
>
</div>
<div>
<label
dir="auto"
class="label text-sm"
for="custom_validation_message"
>
{{ t('error_message') }}
</label>
<input
id="custom_validation_message"
v-model="customValidationMessage"
dir="auto"
:placeholder="t('error_message')"
class="base-input !text-base w-full"
>
</div>
</div>
</ContextModal>
<ContextModal
v-if="isShowLengthValidationModal"
:title="`${t('length_validation')} - ${modalFieldName}`"
:modal-container-el="modalContainerEl"
@close="closeValidationModal"
@save="saveLengthValidation"
>
<div class="flex space-x-3">
<div class="flex-1">
<label
dir="auto"
class="label text-sm"
for="length_validation_min"
>
{{ t('min') }}
</label>
<input
id="length_validation_min"
v-model="lengthValidationMin"
dir="auto"
type="number"
min="0"
class="base-input !text-base w-full"
:placeholder="t('min')"
>
</div>
<div class="flex-1">
<label
dir="auto"
class="label text-sm"
for="length_validation_max"
>
{{ t('max') }}
</label>
<input
id="length_validation_max"
v-model="lengthValidationMax"
dir="auto"
type="number"
min="1"
class="base-input !text-base w-full"
:placeholder="t('max')"
>
</div>
</div>
</ContextModal>
<ContextModal
v-if="isShowNumberRangeModal"
:title="`${t('number_range')} - ${modalFieldName}`"
:modal-container-el="modalContainerEl"
@close="closeValidationModal"
@save="saveNumberRange"
>
<div class="flex space-x-3">
<div class="flex-1">
<label
dir="auto"
class="label text-sm"
for="number_range_min"
>
{{ t('min') }}
</label>
<input
id="number_range_min"
v-model="numberRangeMin"
dir="auto"
type="number"
class="base-input !text-base w-full"
:placeholder="t('min')"
>
</div>
<div class="flex-1">
<label
dir="auto"
class="label text-sm"
for="number_range_max"
>
{{ t('max') }}
</label>
<input
id="number_range_max"
v-model="numberRangeMax"
dir="auto"
type="number"
class="base-input !text-base w-full"
:placeholder="t('max')"
>
</div>
</div>
</ContextModal>
<ContextModal
v-if="isShowPriceModal"
:title="`${t('price')} - ${modalFieldName}`"
:modal-container-el="modalContainerEl"
@close="closeValidationModal"
@save="savePrice"
>
<div>
<input
id="price_value"
v-model="priceValue"
dir="auto"
type="number"
class="base-input !text-base w-full"
:placeholder="t('price')"
>
</div>
</ContextModal>
<ContextModal
v-if="isShowPaymentLinkModal"
:title="`${t('payment_link')} - ${modalFieldName}`"
:modal-container-el="modalContainerEl"
@close="closeValidationModal"
@save="savePaymentLink"
>
<div>
<input
id="payment_link_value"
v-model="paymentLinkValue"
dir="auto"
type="text"
class="base-input !text-base w-full"
placeholder="plink_XXXXX"
>
</div>
</ContextModal>
</div>
</template>
<script>
import { IconCopy, IconTrashX, IconTypography, IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconAdjustmentsHorizontal, IconInputCheck, IconDots, IconNewSection, IconForms, IconId, IconCash, IconCoins } from '@tabler/icons-vue'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import Field from './field'
import FieldType from './field_type.vue'
import FieldSettings from './field_settings.vue'
import ContextSubmenu from './field_context_submenu.vue'
import ContextModal from './field_context_modal.vue'
export default {
name: 'FieldContextMenu',
components: {
IconCopy,
IconTrashX,
IconTypography,
IconInfoCircle,
IconRouteAltLeft,
IconMathFunction,
IconInputCheck,
IconNewSection,
IconForms,
FormulaModal,
FontModal,
ConditionsModal,
DescriptionModal,
ContextSubmenu,
ContextModal
},
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies'],
props: {
contextMenu: {
type: Object,
required: true
},
field: {
type: Object,
required: true
},
editable: {
type: Boolean,
default: true
},
withPrefillable: {
type: Boolean,
default: false
},
withSignatureId: {
type: Boolean,
default: null
},
withRequired: {
type: Boolean,
default: true
},
withCondition: {
type: Boolean,
default: true
},
withCopyToAllPages: {
type: Boolean,
default: true
},
defaultField: {
type: Object,
required: false,
default: null
}
},
emits: ['copy', 'delete', 'close', 'set-draw', 'add-custom-field', 'scroll-to', 'save'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
isShowCustomValidationModal: false,
isShowLengthValidationModal: false,
isShowNumberRangeModal: false,
isShowPriceModal: false,
isShowPaymentLinkModal: false,
customValidationPattern: '',
customValidationMessage: '',
lengthValidationMin: '',
lengthValidationMax: '',
numberRangeMin: '',
numberRangeMax: '',
priceValue: '',
paymentLinkValue: ''
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
validationOptions: FieldSettings.computed.validations,
dateFormats: FieldSettings.computed.dateFormats,
numberFormats: FieldSettings.computed.numberFormats,
prefillableFieldTypes: FieldSettings.computed.prefillableFieldTypes,
verificationMethods: FieldSettings.computed.verificationMethods,
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
modalFieldName () {
return (this.defaultField ? (this.defaultField.title || this.field.title || this.field.name) : this.field.name) || this.buildDefaultName(this.field)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showFont () {
return ['text', 'number', 'date', 'select', 'heading', 'cells'].includes(this.field.type)
},
showDescription () {
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
},
showCondition () {
return this.withCondition && !['stamp', 'heading'].includes(this.field.type)
},
showFormula () {
return this.field.type === 'number' || this.field.type === 'payment'
},
showRequired () {
return this.withRequired && !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
},
showReadOnly () {
return ['text', 'number', 'radio', 'multiple', 'select'].includes(this.field.type)
},
isRequired () {
return this.field.required || false
},
isReadOnly () {
return this.field.readonly || false
},
showFormatSubmenu () {
return ['date', 'number', 'signature'].includes(this.field.type)
},
showValidationSubmenu () {
return ['text', 'cells', 'number'].includes(this.field.type)
},
showPrefillable () {
return this.withPrefillable && this.prefillableFieldTypes.includes(this.field.type)
},
showSetSigningDate () {
return this.field.type === 'date'
},
showWithLogo () {
return this.field.type === 'stamp'
},
showSignatureId () {
return [true, false].includes(this.withSignatureId) && this.field.type === 'signature'
},
showChecked () {
return this.field.type === 'checkbox'
},
showVerificationMethod () {
return this.field.type === 'verification'
},
lengthValidation () {
return this.parseLengthPattern(this.field.validation?.pattern)
},
currentValidationKey () {
if (!this.field.validation?.pattern) return 'none'
if (this.lengthValidation) return 'length'
const matchedKey = Object.keys(this.validationOptions).find(
key => this.validationOptions[key] !== 'length' && key === this.field.validation.pattern
)
if (matchedKey) return this.validationOptions[matchedKey]
return 'custom'
},
validationMenuItems () {
return ['none', ...Object.values(this.validationOptions), 'custom']
},
signatureFormats () {
return ['any', ...FieldSettings.computed.signatureFormats.call(this)]
},
currentDateFormat () {
return this.field.preferences?.format || 'MM/DD/YYYY'
},
currentNumberFormat () {
return this.field.preferences?.format || 'none'
},
currentSignatureFormat () {
return this.field.preferences?.format || 'any'
},
currentVerificationMethod () {
return this.field.preferences?.method || 'qes'
},
methodOptions () {
return this.verificationMethods.map(m => ({ value: m.toLowerCase(), label: m }))
},
formatOptions () {
switch (this.field.type) {
case 'date': return this.dateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) }))
case 'number': return this.numberFormats.map(f => ({ value: f, label: this.formatNumber(123456789.567, f) }))
case 'signature': return this.signatureFormats.map(f => ({ value: f, label: this.t(f) }))
default: return []
}
},
currentFormat () {
switch (this.field.type) {
case 'date': return this.currentDateFormat
case 'number': return this.currentNumberFormat
case 'signature': return this.currentSignatureFormat
default: return null
}
},
showPaymentSettings () {
return this.field.type === 'payment'
},
defaultCurrencies () {
return ['USD', 'EUR', 'GBP', 'CAD', 'AUD']
},
currenciesList () {
return this.currencies?.length ? this.currencies : this.defaultCurrencies
},
currencyOptions () {
return this.currenciesList.map(c => ({ value: c, label: c }))
},
currentCurrency () {
return this.field.preferences?.currency || 'USD'
},
priceTypeOptions () {
return [
{ value: 'one_off', label: this.t('fixed') },
{ value: 'formula', label: this.t('formula') },
{ value: 'payment_link', label: this.t('payment_link') }
]
},
currentPriceType () {
if (this.field.preferences?.formula) {
return 'formula'
}
if ('payment_link_id' in (this.field.preferences || {})) {
return 'payment_link'
}
if (this.field.preferences?.price) {
return 'one_off'
}
return ''
},
showDrawNewArea () {
return !this.field.areas?.length || !['radio', 'multiple'].includes(this.field.type)
},
showCopyToAllPages () {
return this.withCopyToAllPages && this.field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(this.field.type)
},
showSaveAsCustom () {
return this.withCustomFields
},
showMoreSubmenu () {
return this.showDrawNewArea || this.showCopyToAllPages || this.showSaveAsCustom
}
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => {
this.checkMenuPosition()
})
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
IconAdjustmentsHorizontal,
IconInputCheck,
IconDots,
IconId,
IconCash,
IconCoins,
buildDefaultName: Field.methods.buildDefaultName,
formatNumber: FieldSettings.methods.formatNumber,
formatDate: FieldSettings.methods.formatDate,
parseLengthPattern: FieldSettings.methods.parseLengthPattern,
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.contextMenu.y = this.contextMenu.y - overflow - 4
}
}
},
handleToggleRequired (value) {
this.field.required = value
this.$emit('save')
},
handleToggleReadOnly (value) {
this.field.readonly = value
this.$emit('save')
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
openFontModal () {
this.isShowFontModal = true
},
openDescriptionModal () {
this.isShowDescriptionModal = true
},
openConditionModal () {
this.isShowConditionsModal = true
},
openFormulaModal () {
this.isShowFormulaModal = true
},
closeModal () {
this.isShowFormulaModal = false
this.isShowFontModal = false
this.isShowConditionsModal = false
this.isShowDescriptionModal = false
this.$emit('close')
},
handleSelectValidation (key) {
if (key === 'none') {
delete this.field.validation
} else if (key === 'custom') {
this.customValidationPattern = this.field.validation?.pattern || ''
this.customValidationMessage = this.field.validation?.message || ''
this.isShowCustomValidationModal = true
return
} else if (key === 'length') {
const existingLength = this.lengthValidation
this.lengthValidationMin = existingLength?.min || ''
this.lengthValidationMax = existingLength?.max || ''
this.isShowLengthValidationModal = true
return
} else {
this.field.validation ||= {}
this.field.validation.pattern = Object.keys(this.validationOptions).find(k => this.validationOptions[k] === key)
delete this.field.validation.message
}
this.$emit('save')
this.$emit('close')
},
handleSelectFormat (format) {
this.field.preferences ||= {}
this.field.preferences.format = format
this.$emit('save')
this.$emit('close')
},
handleTogglePrefillable (value) {
this.field.prefillable = value
this.$emit('save')
},
handleToggleSetSigningDate (value) {
this.field.default_value = value ? '{{date}}' : null
this.field.readonly = value
this.$emit('save')
},
handleToggleWithLogo (value) {
this.field.preferences ||= {}
this.field.preferences.with_logo = value
this.$emit('save')
},
handleToggleSignatureId (value) {
this.field.preferences ||= {}
this.field.preferences.with_signature_id = value
this.$emit('save')
},
handleToggleChecked (value) {
this.field.default_value = value
this.field.readonly = value
this.$emit('save')
},
handleSelectVerificationMethod (method) {
this.field.preferences ||= {}
this.field.preferences.method = method
this.$emit('save')
this.$emit('close')
},
openNumberRangeModal () {
this.numberRangeMin = this.field.validation?.min || ''
this.numberRangeMax = this.field.validation?.max || ''
this.isShowNumberRangeModal = true
},
saveCustomValidation () {
this.field.validation = {
pattern: this.customValidationPattern,
message: this.customValidationMessage
}
this.isShowCustomValidationModal = false
this.$emit('save')
this.$emit('close')
},
saveLengthValidation () {
const min = this.lengthValidationMin || '0'
const max = this.lengthValidationMax || ''
this.field.validation = { pattern: `.{${min},${max}}` }
this.isShowLengthValidationModal = false
this.$emit('save')
this.$emit('close')
},
saveNumberRange () {
this.field.validation ||= {}
if (this.numberRangeMin) {
this.field.validation.min = this.numberRangeMin
} else {
delete this.field.validation.min
}
if (this.numberRangeMax) {
this.field.validation.max = this.numberRangeMax
} else {
delete this.field.validation.max
}
this.isShowNumberRangeModal = false
this.$emit('save')
this.$emit('close')
},
closeValidationModal () {
this.isShowCustomValidationModal = false
this.isShowLengthValidationModal = false
this.isShowNumberRangeModal = false
this.isShowPriceModal = false
this.isShowPaymentLinkModal = false
},
handleSelectCurrency (currency) {
this.field.preferences ||= {}
this.field.preferences.currency = currency
this.$emit('save')
this.$emit('close')
},
handleSelectPriceType (type) {
this.field.preferences ||= {}
if (type === 'one_off') {
this.priceValue = this.field.preferences.price || ''
this.isShowPriceModal = true
} else if (type === 'payment_link') {
this.paymentLinkValue = this.field.preferences.payment_link_id || ''
this.isShowPaymentLinkModal = true
} else if (type === 'formula') {
this.openFormulaModal()
}
},
savePrice () {
this.field.preferences ||= {}
this.field.preferences.price = this.priceValue
delete this.field.preferences.payment_link_id
delete this.field.preferences.formula
this.isShowPriceModal = false
this.$emit('save')
this.$emit('close')
},
savePaymentLink () {
this.field.preferences ||= {}
this.field.preferences.payment_link_id = this.paymentLinkValue
delete this.field.preferences.price
delete this.field.preferences.formula
this.isShowPaymentLinkModal = false
this.$emit('save')
this.$emit('close')
},
handleMoreSelect (value) {
if (value === 'draw_new_area') {
this.$emit('set-draw', { field: this.field })
this.$emit('close')
} else if (value === 'save_as_custom') {
this.$emit('add-custom-field', this.field)
this.$emit('close')
} else if (value === 'copy_to_all_pages') {
this.copyToAllPages(this.field)
this.$emit('close')
}
},
copyToAllPages: FieldSettings.methods.copyToAllPages
}
}
</script>

@ -0,0 +1,49 @@
<template>
<Teleport :to="modalContainerEl">
<div class="modal modal-open items-start !animate-none overflow-y-auto">
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ title }}
</span>
<a
href="#"
class="text-xl modal-close-button"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<form @submit.prevent="$emit('save')">
<slot />
<button
class="base-button w-full mt-4 modal-save-button"
type="submit"
>
{{ t('save') }}
</button>
</form>
</div>
</div>
</Teleport>
</template>
<script>
export default {
name: 'ContextModal',
inject: ['t'],
props: {
title: {
type: String,
required: true
},
modalContainerEl: {
type: Element,
required: true
}
},
emits: ['close', 'save']
}
</script>

@ -0,0 +1,147 @@
<template>
<div
class="relative"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="isOpen ? close() : open()"
>
<span class="flex items-center space-x-2">
<component
:is="icon || 'span'"
class="w-4 h-4"
/>
<span>{{ label }}</span>
</span>
<IconChevronRight class="w-4 h-4" />
</button>
<div
v-if="isOpen"
ref="submenu"
class="absolute p-1 z-50 left-full bg-white shadow-lg rounded-lg border border-base-300 cursor-default"
style="min-width: 170px"
:style="submenuStyle"
:class="menuClass"
@click.stop
>
<slot>
<button
v-for="option in options"
:key="option.value"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between space-x-2 text-sm cursor-pointer"
@click="handleSelect(option.value)"
>
<span class="whitespace-nowrap">{{ option.label }}</span>
<IconCheck
v-if="modelValue === option.value"
class="w-4 h-4"
/>
</button>
</slot>
</div>
</div>
</template>
<script>
import { IconChevronRight, IconCheck } from '@tabler/icons-vue'
export default {
name: 'ContextSubmenu',
components: {
IconChevronRight,
IconCheck
},
props: {
icon: {
type: [Function],
required: false,
default: null
},
label: {
type: String,
required: true
},
options: {
type: Array,
default: () => []
},
modelValue: {
type: [String, Number],
default: null
},
menuClass: {
type: String,
default: ''
}
},
emits: ['select', 'update:modelValue'],
data () {
return {
isOpen: false,
topOffset: 0
}
},
computed: {
submenuStyle () {
return {
top: this.topOffset + 'px'
}
}
},
beforeUnmount () {
this.clearTimeout()
},
methods: {
handleMouseEnter () {
clearTimeout(this.closeTimeout)
this.openTimeout = setTimeout(() => this.open(), 200)
},
handleMouseLeave () {
clearTimeout(this.openTimeout)
this.closeTimeout = setTimeout(() => this.close(), 200)
},
open () {
this.clearTimeout()
this.isOpen = true
this.topOffset = 0
this.$nextTick(() => setTimeout(() => this.adjustPosition(), 0))
},
clearTimeout () {
if (this.openTimeout) {
clearTimeout(this.openTimeout)
}
if (this.closeTimeout) {
clearTimeout(this.closeTimeout)
}
},
close () {
this.clearTimeout()
this.isOpen = false
},
handleSelect (value) {
this.$emit('select', value)
this.$emit('update:modelValue', value)
},
adjustPosition () {
if (!this.$refs.submenu) return
const rect = this.$refs.submenu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.topOffset = -overflow - 4
} else {
this.topOffset = 0
}
}
}
}
</script>

@ -10,7 +10,7 @@
@change="[field.preferences ||= {}, field.preferences.method = $event.target.value, $emit('save')]" @change="[field.preferences ||= {}, field.preferences.method = $event.target.value, $emit('save')]"
> >
<option <option
v-for="method in ['QeS', 'AeS']" v-for="method in verificationMethods"
:key="method" :key="method"
:value="method.toLowerCase()" :value="method.toLowerCase()"
:selected="method.toLowerCase() === field.preferences?.method || (method === 'QeS' && !field.preferences?.method)" :selected="method.toLowerCase() === field.preferences?.method || (method === 'QeS' && !field.preferences?.method)"
@ -322,7 +322,7 @@
{{ t('any') }} {{ t('any') }}
</option> </option>
<option <option
v-for="type in ['drawn', 'typed', 'drawn_or_typed', 'drawn_or_upload', 'upload']" v-for="type in signatureFormats"
:key="type" :key="type"
:value="type" :value="type"
:selected="field.preferences?.format === type" :selected="field.preferences?.format === type"
@ -426,7 +426,7 @@
</label> </label>
</li> </li>
<li <li
v-if="withPrefillable && ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone'].includes(field['type'])" v-if="withPrefillable && prefillableFieldTypes.includes(field['type'])"
@click.stop @click.stop
> >
<label class="cursor-pointer py-1.5"> <label class="cursor-pointer py-1.5">
@ -692,7 +692,7 @@ export default {
}, },
lengthValidation () { lengthValidation () {
if (this.field.validation?.pattern && this.selectedValidation !== 'custom') { if (this.field.validation?.pattern && this.selectedValidation !== 'custom') {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups return this.parseLengthPattern(this.field.validation.pattern)
} else { } else {
return null return null
} }
@ -709,6 +709,15 @@ export default {
'^[a-zA-Z]+$': 'letters_only' '^[a-zA-Z]+$': 'letters_only'
} }
}, },
signatureFormats () {
return ['drawn', 'typed', 'drawn_or_typed', 'drawn_or_upload', 'upload']
},
verificationMethods () {
return ['QeS', 'AeS']
},
prefillableFieldTypes () {
return ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone']
},
sortedAreas () { sortedAreas () {
return (this.field.areas || []).sort((a, b) => { return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid] return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
@ -772,6 +781,9 @@ export default {
return number return number
} }
}, },
parseLengthPattern (pattern) {
return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null
},
formatDate (date, format) { formatDate (date, format) {
const monthFormats = { const monthFormats = {
M: 'numeric', M: 'numeric',

@ -212,7 +212,10 @@ export default {
} else { } else {
this.field.preferences.formula = normalizedFormula this.field.preferences.formula = normalizedFormula
if (this.field.type !== 'payment') { if (this.field.type === 'payment') {
delete this.field.preferences.price
delete this.field.preferences.payment_link_id
} else {
this.field.readonly = !!normalizedFormula this.field.readonly = !!normalizedFormula
} }

@ -1,4 +1,5 @@
const en = { const en = {
fixed: 'Fixed',
default: 'Default', default: 'Default',
save_as_custom_field: 'Save as Custom Field', save_as_custom_field: 'Save as Custom Field',
kba: 'KBA', kba: 'KBA',
@ -31,6 +32,9 @@ const en = {
field_not_found: 'Field not found', field_not_found: 'Field not found',
clear: 'Clear', clear: 'Clear',
align: 'Align', align: 'Align',
resize: 'Resize',
width: 'Width',
height: 'Height',
add_all_required_fields_to_continue: 'Add all required fields to continue', add_all_required_fields_to_continue: 'Add all required fields to continue',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Uploaded PDF contains form fields. Keep or remove them?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Uploaded PDF contains form fields. Keep or remove them?',
keep: 'Keep', keep: 'Keep',
@ -93,6 +97,7 @@ const en = {
page: 'Page', page: 'Page',
draw_new_area: 'Draw New Area', draw_new_area: 'Draw New Area',
copy_to_all_pages: 'Copy to All Pages', copy_to_all_pages: 'Copy to All Pages',
more: 'More',
add_option: 'Add option', add_option: 'Add option',
option: 'Option', option: 'Option',
options: 'Options', options: 'Options',
@ -173,6 +178,9 @@ const en = {
numbers_only: 'Numbers only', numbers_only: 'Numbers only',
letters_only: 'Letters only', letters_only: 'Letters only',
regexp_validation: 'Regexp validation', regexp_validation: 'Regexp validation',
custom_validation: 'Custom Validation',
length_validation: 'Length Validation',
number_range: 'Number Range',
enter_pdf_password: 'Enter PDF password', enter_pdf_password: 'Enter PDF password',
wrong_password: 'Wrong password', wrong_password: 'Wrong password',
currency: 'Currency', currency: 'Currency',
@ -202,6 +210,7 @@ const en = {
} }
const es = { const es = {
fixed: 'Fijo',
default: 'Predeterminado', default: 'Predeterminado',
save_as_custom_field: 'Guardar como personalizado', save_as_custom_field: 'Guardar como personalizado',
kba: 'KBA', kba: 'KBA',
@ -235,6 +244,9 @@ const es = {
clear: 'Borrar', clear: 'Borrar',
type_value: 'Escriba valor', type_value: 'Escriba valor',
align: 'Alinear', align: 'Alinear',
resize: 'Redimensionar',
width: 'Ancho',
height: 'Alto',
add_all_required_fields_to_continue: 'Agregar todos los campos requeridos para continuar', add_all_required_fields_to_continue: 'Agregar todos los campos requeridos para continuar',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'El PDF cargado tiene campos. ¿Mantenerlos o eliminarlos?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'El PDF cargado tiene campos. ¿Mantenerlos o eliminarlos?',
keep: 'Mantener', keep: 'Mantener',
@ -292,6 +304,7 @@ const es = {
page: 'Página', page: 'Página',
draw_new_area: 'Dibujar nueva área', draw_new_area: 'Dibujar nueva área',
copy_to_all_pages: 'Copiar a todas las páginas', copy_to_all_pages: 'Copiar a todas las páginas',
more: 'Más',
add_option: 'Agregar opción', add_option: 'Agregar opción',
option: 'Opción', option: 'Opción',
options: 'Opciones', options: 'Opciones',
@ -376,6 +389,9 @@ const es = {
numbers_only: 'Solo números', numbers_only: 'Solo números',
letters_only: 'Solo letras', letters_only: 'Solo letras',
regexp_validation: 'Validación de expresión regular', regexp_validation: 'Validación de expresión regular',
custom_validation: 'Validación Personalizada',
length_validation: 'Validación de Longitud',
number_range: 'Rango de Números',
enter_pdf_password: 'Ingrese la contraseña del PDF', enter_pdf_password: 'Ingrese la contraseña del PDF',
wrong_password: 'Contraseña incorrecta', wrong_password: 'Contraseña incorrecta',
currency: 'Moneda', currency: 'Moneda',
@ -405,6 +421,7 @@ const es = {
} }
const it = { const it = {
fixed: 'Fisso',
default: 'Predefinito', default: 'Predefinito',
save_as_custom_field: 'Salva come personalizzato', save_as_custom_field: 'Salva come personalizzato',
kba: 'KBA', kba: 'KBA',
@ -437,6 +454,9 @@ const it = {
field_not_found: 'Campo non trovato', field_not_found: 'Campo non trovato',
clear: 'Cancella', clear: 'Cancella',
align: 'Allinea', align: 'Allinea',
resize: 'Ridimensiona',
width: 'Larghezza',
height: 'Altezza',
add_all_required_fields_to_continue: 'Aggiungi tutti i campi obbligatori per continuare', add_all_required_fields_to_continue: 'Aggiungi tutti i campi obbligatori per continuare',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Il PDF caricato contiene campi del modulo. Mantenerli o rimuoverli?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Il PDF caricato contiene campi del modulo. Mantenerli o rimuoverli?',
keep: 'Mantieni', keep: 'Mantieni',
@ -499,6 +519,7 @@ const it = {
page: 'Pagina', page: 'Pagina',
draw_new_area: 'Disegna nuova area', draw_new_area: 'Disegna nuova area',
copy_to_all_pages: 'Copia in tutte le pagine', copy_to_all_pages: 'Copia in tutte le pagine',
more: 'Altro',
add_option: 'Aggiungi opzione', add_option: 'Aggiungi opzione',
option: 'Opzione', option: 'Opzione',
options: 'Opzioni', options: 'Opzioni',
@ -579,6 +600,9 @@ const it = {
numbers_only: 'Solo numeri', numbers_only: 'Solo numeri',
letters_only: 'Solo lettere', letters_only: 'Solo lettere',
regexp_validation: 'Validazione regexp', regexp_validation: 'Validazione regexp',
custom_validation: 'Validazione Personalizzata',
length_validation: 'Validazione Lunghezza',
number_range: 'Intervallo Numerico',
enter_pdf_password: 'Inserisci password PDF', enter_pdf_password: 'Inserisci password PDF',
wrong_password: 'Password errata', wrong_password: 'Password errata',
currency: 'Valuta', currency: 'Valuta',
@ -608,6 +632,7 @@ const it = {
} }
const pt = { const pt = {
fixed: 'Fixo',
default: 'Padrão', default: 'Padrão',
save_as_custom_field: 'Salvar como personalizado', save_as_custom_field: 'Salvar como personalizado',
kba: 'KBA', kba: 'KBA',
@ -644,6 +669,9 @@ const pt = {
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?',
keep: 'Manter', keep: 'Manter',
align: 'Alinhar', align: 'Alinhar',
resize: 'Redimensionar',
width: 'Largura',
height: 'Altura',
left: 'Esquerda', left: 'Esquerda',
heading: 'Cabeçalho', heading: 'Cabeçalho',
validation: 'Validação', validation: 'Validação',
@ -698,6 +726,7 @@ const pt = {
page: 'Página', page: 'Página',
draw_new_area: 'Desenhar nova área', draw_new_area: 'Desenhar nova área',
copy_to_all_pages: 'Copiar para todas as páginas', copy_to_all_pages: 'Copiar para todas as páginas',
more: 'Mais',
add_option: 'Adicionar opção', add_option: 'Adicionar opção',
option: 'Opção', option: 'Opção',
options: 'Opções', options: 'Opções',
@ -782,6 +811,9 @@ const pt = {
numbers_only: 'Somente números', numbers_only: 'Somente números',
letters_only: 'Somente letras', letters_only: 'Somente letras',
regexp_validation: 'Validação de expressão regular', regexp_validation: 'Validação de expressão regular',
custom_validation: 'Validação Personalizada',
length_validation: 'Validação de Comprimento',
number_range: 'Intervalo de Números',
enter_pdf_password: 'Digite a senha do PDF', enter_pdf_password: 'Digite a senha do PDF',
wrong_password: 'Senha incorreta', wrong_password: 'Senha incorreta',
currency: 'Moeda', currency: 'Moeda',
@ -811,6 +843,7 @@ const pt = {
} }
const fr = { const fr = {
fixed: 'Fixe',
default: 'Par défaut', default: 'Par défaut',
save_as_custom_field: 'Enregistrer comme personnalisé', save_as_custom_field: 'Enregistrer comme personnalisé',
kba: 'KBA', kba: 'KBA',
@ -843,6 +876,9 @@ const fr = {
field_not_found: 'Champ introuvable', field_not_found: 'Champ introuvable',
clear: 'Effacer', clear: 'Effacer',
align: 'Aligner', align: 'Aligner',
resize: 'Redimensionner',
width: 'Largeur',
height: 'Hauteur',
add_all_required_fields_to_continue: 'Ajoutez tous les champs obligatoires pour continuer', add_all_required_fields_to_continue: 'Ajoutez tous les champs obligatoires pour continuer',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Le PDF téléversé contient des champs de formulaire. Les conserver ou les supprimer ?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Le PDF téléversé contient des champs de formulaire. Les conserver ou les supprimer ?',
keep: 'Conserver', keep: 'Conserver',
@ -905,6 +941,7 @@ const fr = {
page: 'Page', page: 'Page',
draw_new_area: 'Dessiner une zone', draw_new_area: 'Dessiner une zone',
copy_to_all_pages: 'Copier sur toutes les pages', copy_to_all_pages: 'Copier sur toutes les pages',
more: 'Plus',
add_option: 'Ajouter une option', add_option: 'Ajouter une option',
option: 'Option', option: 'Option',
options: 'Options', options: 'Options',
@ -985,6 +1022,9 @@ const fr = {
numbers_only: 'Chiffres uniquement', numbers_only: 'Chiffres uniquement',
letters_only: 'Lettres uniquement', letters_only: 'Lettres uniquement',
regexp_validation: 'Validation par expression régulière', regexp_validation: 'Validation par expression régulière',
custom_validation: 'Validation Personnalisée',
length_validation: 'Validation de Longueur',
number_range: 'Plage de Nombres',
enter_pdf_password: 'Saisir le mot de passe du PDF', enter_pdf_password: 'Saisir le mot de passe du PDF',
wrong_password: 'Mot de passe incorrect', wrong_password: 'Mot de passe incorrect',
currency: 'Devise', currency: 'Devise',
@ -1014,6 +1054,7 @@ const fr = {
} }
const de = { const de = {
fixed: 'Fest',
default: 'Standard', default: 'Standard',
save_as_custom_field: 'Als benutzerdefiniert speichern', save_as_custom_field: 'Als benutzerdefiniert speichern',
kba: 'KBA', kba: 'KBA',
@ -1046,6 +1087,9 @@ const de = {
field_not_found: 'Feld nicht gefunden', field_not_found: 'Feld nicht gefunden',
clear: 'Leeren', clear: 'Leeren',
align: 'Ausrichten', align: 'Ausrichten',
resize: 'Größe ändern',
width: 'Breite',
height: 'Höhe',
add_all_required_fields_to_continue: 'Fügen Sie alle erforderlichen Felder hinzu, um fortzufahren', add_all_required_fields_to_continue: 'Fügen Sie alle erforderlichen Felder hinzu, um fortzufahren',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Das hochgeladene PDF enthält Formularfelder. Beibehalten oder entfernen?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Das hochgeladene PDF enthält Formularfelder. Beibehalten oder entfernen?',
keep: 'Beibehalten', keep: 'Beibehalten',
@ -1108,6 +1152,7 @@ const de = {
page: 'Seite', page: 'Seite',
draw_new_area: 'Bereich zeichnen', draw_new_area: 'Bereich zeichnen',
copy_to_all_pages: 'Auf alle Seiten kopieren', copy_to_all_pages: 'Auf alle Seiten kopieren',
more: 'Mehr',
add_option: 'Option hinzufügen', add_option: 'Option hinzufügen',
option: 'Option', option: 'Option',
options: 'Optionen', options: 'Optionen',
@ -1188,6 +1233,9 @@ const de = {
numbers_only: 'Nur Zahlen', numbers_only: 'Nur Zahlen',
letters_only: 'Nur Buchstaben', letters_only: 'Nur Buchstaben',
regexp_validation: 'RegExp-Validierung', regexp_validation: 'RegExp-Validierung',
custom_validation: 'Benutzerdefinierte Validierung',
length_validation: 'Längenvalidierung',
number_range: 'Zahlenbereich',
enter_pdf_password: 'PDF-Passwort eingeben', enter_pdf_password: 'PDF-Passwort eingeben',
wrong_password: 'Falsches Passwort', wrong_password: 'Falsches Passwort',
currency: 'Währung', currency: 'Währung',
@ -1217,6 +1265,7 @@ const de = {
} }
const nl = { const nl = {
fixed: 'Vast',
default: 'Standaard', default: 'Standaard',
save_as_custom_field: 'Opslaan als aangepast', save_as_custom_field: 'Opslaan als aangepast',
kba: 'KBA', kba: 'KBA',
@ -1249,6 +1298,9 @@ const nl = {
field_not_found: 'Veld niet gevonden', field_not_found: 'Veld niet gevonden',
clear: 'Wissen', clear: 'Wissen',
align: 'Uitlijnen', align: 'Uitlijnen',
resize: 'Formaat wijzigen',
width: 'Breedte',
height: 'Hoogte',
add_all_required_fields_to_continue: 'Voeg alle vereiste velden toe om door te gaan', add_all_required_fields_to_continue: 'Voeg alle vereiste velden toe om door te gaan',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Geüploade PDF bevat formuliervelden. Behouden of verwijderen?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Geüploade PDF bevat formuliervelden. Behouden of verwijderen?',
keep: 'Behouden', keep: 'Behouden',
@ -1311,6 +1363,7 @@ const nl = {
page: 'Pagina', page: 'Pagina',
draw_new_area: 'Nieuw gebied tekenen', draw_new_area: 'Nieuw gebied tekenen',
copy_to_all_pages: 'Kopieer naar alle pag.', copy_to_all_pages: 'Kopieer naar alle pag.',
more: 'Meer',
add_option: 'Optie toevoegen', add_option: 'Optie toevoegen',
option: 'Optie', option: 'Optie',
options: 'Opties', options: 'Opties',
@ -1391,6 +1444,9 @@ const nl = {
numbers_only: 'Alleen cijfers', numbers_only: 'Alleen cijfers',
letters_only: 'Alleen letters', letters_only: 'Alleen letters',
regexp_validation: 'Regex validatie', regexp_validation: 'Regex validatie',
custom_validation: 'Aangepaste Validatie',
length_validation: 'Lengte Validatie',
number_range: 'Getalbereik',
enter_pdf_password: 'Voer PDF-wachtwoord in', enter_pdf_password: 'Voer PDF-wachtwoord in',
wrong_password: 'Onjuist wachtwoord', wrong_password: 'Onjuist wachtwoord',
currency: 'Valuta', currency: 'Valuta',

@ -48,6 +48,7 @@
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:max-page="totalPages - 1" :max-page="totalPages - 1"
:is-select-mode="isSelectMode" :is-select-mode="isSelectMode"
:is-mobile="isMobile"
@start-resize="resizeDirection = $event" @start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null" @stop-resize="resizeDirection = null"
@remove="$emit('remove-area', item.area)" @remove="$emit('remove-area', item.area)"
@ -74,30 +75,40 @@
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20" class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="selectionRectStyle" :style="selectionRectStyle"
/> />
<ContextMenu <FieldContextMenu
v-if="contextMenu" v-if="contextMenu && contextMenu.field"
:context-menu="contextMenu" :context-menu="contextMenu"
:field="contextMenu.field" :field="contextMenu.field"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:editable="editable" :editable="editable"
:with-fields-detection="withFieldsDetection" :default-field="defaultFieldsIndex[contextMenu.field.name]"
@copy="handleCopy" @copy="handleCopy"
@delete="handleDelete" @delete="handleDelete"
@paste="handlePaste"
@autodetect-fields="handleAutodetectFields"
@close="closeContextMenu" @close="closeContextMenu"
@set-draw="$emit('set-draw', $event)"
@scroll-to="$emit('scroll-to', $event)"
@save="save"
@add-custom-field="$emit('add-custom-field', $event)"
/> />
<ContextMenu <SelectionContextMenu
v-if="selectionContextMenu" v-if="selectionContextMenu"
:context-menu="selectionContextMenu" :context-menu="selectionContextMenu"
:editable="editable" :editable="editable"
:is-multi-selection="true"
:selected-areas="selectedAreasRef.value"
:template="template" :template="template"
@copy="handleSelectionCopy" @copy="handleSelectionCopy"
@delete="handleSelectionDelete" @delete="handleSelectionDelete"
@align="handleSelectionAlign"
@close="closeSelectionContextMenu" @close="closeSelectionContextMenu"
/> />
<PageContextMenu
v-if="contextMenu && !contextMenu.field"
:context-menu="contextMenu"
:editable="editable"
:with-fields-detection="withFieldsDetection"
@paste="handlePaste"
@autodetect-fields="handleAutodetectFields"
@close="closeContextMenu"
/>
</div> </div>
<div <div
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value || customDragFieldRef?.value || selectionRect" v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value || customDragFieldRef?.value || selectionRect"
@ -119,17 +130,21 @@
<script> <script>
import FieldArea from './area' import FieldArea from './area'
import ContextMenu from './context_menu' import FieldContextMenu from './field_context_menu'
import SelectionContextMenu from './selection_context_menu'
import PageContextMenu from './page_context_menu'
import SelectionBox from './selection_box' import SelectionBox from './selection_box'
export default { export default {
name: 'TemplatePage', name: 'TemplatePage',
components: { components: {
FieldArea, FieldArea,
ContextMenu, FieldContextMenu,
SelectionContextMenu,
PageContextMenu,
SelectionBox SelectionBox
}, },
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'customDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef'], inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'customDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef', 'save'],
props: { props: {
image: { image: {
type: Object, type: Object,
@ -140,6 +155,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
isMobile: {
type: Boolean,
required: false,
default: false
},
withSignatureId: { withSignatureId: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -228,7 +248,7 @@ export default {
default: false default: false
} }
}, },
emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields', 'add-custom-field'], emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'autodetect-fields', 'add-custom-field', 'set-draw'],
data () { data () {
return { return {
areaRefs: [], areaRefs: [],
@ -305,11 +325,6 @@ export default {
return 'text' return 'text'
} }
}, },
isMobile () {
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent)
},
resizeDirectionClasses () { resizeDirectionClasses () {
return { return {
nwse: 'cursor-nwse-resize', nwse: 'cursor-nwse-resize',
@ -419,10 +434,6 @@ export default {
this.$emit('delete-selected-areas') this.$emit('delete-selected-areas')
this.closeSelectionContextMenu() this.closeSelectionContextMenu()
}, },
handleSelectionAlign (direction) {
this.$emit('align-selected-areas', direction)
this.closeSelectionContextMenu()
},
closeContextMenu () { closeContextMenu () {
this.contextMenu = null this.contextMenu = null
this.newAreas = [] this.newAreas = []

@ -0,0 +1,159 @@
<template>
<div
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-base-300 cursor-default"
style="min-width: 170px"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
:class="!hasClipboardData ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
:disabled="!hasClipboardData"
@click.stop="!hasClipboardData ? null : $emit('paste')"
>
<span class="flex items-center space-x-2">
<IconClipboard class="w-4 h-4" />
<span>{{ t('paste') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="handleToggleSelectMode"
>
<span class="flex items-center space-x-2">
<IconClick
v-if="!isSelectModeRef.value"
class="w-4 h-4"
/>
<IconNewSection
v-else
class="w-4 h-4"
/>
<span>{{ isSelectModeRef.value ? t('draw_fields') : t('select_fields') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Tab</span>
</button>
<hr
v-if="showAutodetectFields"
class="my-1 border-base-300"
>
<button
v-if="showAutodetectFields"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('autodetect-fields')"
>
<IconSparkles class="w-4 h-4" />
<span>{{ t('autodetect_fields') }}</span>
</button>
</div>
</template>
<script>
import { IconClipboard, IconClick, IconNewSection, IconSparkles } from '@tabler/icons-vue'
export default {
name: 'PageContextMenu',
components: {
IconClipboard,
IconClick,
IconNewSection,
IconSparkles
},
inject: ['t', 'isSelectModeRef'],
props: {
contextMenu: {
type: Object,
default: null,
required: true
},
editable: {
type: Boolean,
default: true
},
withFieldsDetection: {
type: Boolean,
default: false
}
},
emits: ['paste', 'close', 'autodetect-fields'],
computed: {
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
hasClipboardData () {
try {
const clipboard = localStorage.getItem('docuseal_clipboard')
if (clipboard) {
const data = JSON.parse(clipboard)
return Date.now() - data.timestamp < 3600000
}
return false
} catch {
return false
}
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showAutodetectFields () {
return this.withFieldsDetection && this.editable
}
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => {
this.checkMenuPosition()
})
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.contextMenu.y = this.contextMenu.y - overflow - 4
}
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData) {
event.preventDefault()
event.stopPropagation()
this.$emit('paste')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
handleToggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
this.$emit('close')
}
}
}
</script>

@ -0,0 +1,350 @@
<template>
<div>
<div
v-if="!isShowFontModal && !isShowConditionsModal"
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-base-300 cursor-default"
style="min-width: 170px"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<ContextSubmenu
:icon="IconLayoutAlignMiddle"
:label="t('align')"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('left')"
>
<IconLayoutAlignLeft class="w-4 h-4" />
<span>{{ t('align_left') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('right')"
>
<IconLayoutAlignRight class="w-4 h-4" />
<span>{{ t('align_right') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('top')"
>
<IconLayoutAlignTop class="w-4 h-4" />
<span>{{ t('align_top') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('bottom')"
>
<IconLayoutAlignBottom class="w-4 h-4" />
<span>{{ t('align_bottom') }}</span>
</button>
</ContextSubmenu>
<ContextSubmenu
:icon="IconAspectRatio"
:label="t('resize')"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="resizeSelectedAreas('width')"
>
<IconArrowsHorizontal class="w-4 h-4" />
<span>{{ t('width') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="resizeSelectedAreas('height')"
>
<IconArrowsVertical class="w-4 h-4" />
<span>{{ t('height') }}</span>
</button>
</ContextSubmenu>
<hr
v-if="showFont || showCondition"
class="my-1 border-base-300"
>
<button
v-if="showFont"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr class="my-1 border-base-300">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
<IconCopy class="w-4 h-4" />
<span>{{ t('copy') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Del</span>
</button>
</div>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="multiSelectField"
:area="contextMenu.area"
:editable="editable"
:build-default-name="buildDefaultName"
:with-click-save-event="true"
@click-save="handleSaveMultiSelectFontModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="multiSelectField"
:build-default-name="buildDefaultName"
:exclude-field-uuids="selectedFields.map(f => f.uuid)"
:with-click-save-event="true"
@click-save="handleSaveMultiSelectConditionsModal"
@close="closeModal"
/>
</Teleport>
</div>
</template>
<script>
import { IconCopy, IconTrashX, IconTypography, IconRouteAltLeft, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom, IconLayoutAlignMiddle, IconAspectRatio, IconArrowsHorizontal, IconArrowsVertical } from '@tabler/icons-vue'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import ContextSubmenu from './field_context_submenu'
import Field from './field'
import FieldType from './field_type'
export default {
name: 'SelectionContextMenu',
components: {
IconCopy,
IconTrashX,
IconTypography,
IconRouteAltLeft,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconLayoutAlignTop,
IconLayoutAlignBottom,
FontModal,
IconArrowsHorizontal,
IconArrowsVertical,
ConditionsModal,
ContextSubmenu
},
inject: ['t', 'save', 'selectedAreasRef', 'getFieldTypeIndex'],
props: {
contextMenu: {
type: Object,
required: true
},
editable: {
type: Boolean,
default: true
},
template: {
type: Object,
required: true
},
withCondition: {
type: Boolean,
default: true
}
},
emits: ['copy', 'delete', 'close'],
data () {
return {
isShowFontModal: false,
isShowConditionsModal: false,
multiSelectField: null
}
},
computed: {
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
selectedFields () {
return this.selectedAreasRef.value.map((area) => {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showFont () {
return true
},
showCondition () {
return this.withCondition
},
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => this.checkMenuPosition())
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
IconLayoutAlignMiddle,
IconAspectRatio,
buildDefaultName: Field.methods.buildDefaultName,
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.contextMenu.y = this.contextMenu.y - overflow - 4
}
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
openFontModal () {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
preferences: {}
}
const preferencesStrings = this.selectedFields.map((f) => JSON.stringify(f.preferences || {}))
if (preferencesStrings.every((s) => s === preferencesStrings[0])) {
this.multiSelectField.preferences = JSON.parse(preferencesStrings[0])
}
this.isShowFontModal = true
},
openConditionModal () {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
conditions: []
}
const conditionStrings = this.selectedFields.map((f) => JSON.stringify(f.conditions || []))
if (conditionStrings.every((s) => s === conditionStrings[0])) {
this.multiSelectField.conditions = JSON.parse(conditionStrings[0])
}
this.isShowConditionsModal = true
},
closeModal () {
this.isShowFontModal = false
this.isShowConditionsModal = false
this.multiSelectField = null
this.$emit('close')
},
alignSelectedAreas (direction) {
const areas = this.selectedAreasRef.value
let targetValue
if (direction === 'left') {
targetValue = Math.min(...areas.map(a => a.x))
areas.forEach((area) => { area.x = targetValue })
} else if (direction === 'right') {
targetValue = Math.max(...areas.map(a => a.x + a.w))
areas.forEach((area) => { area.x = targetValue - area.w })
} else if (direction === 'top') {
targetValue = Math.min(...areas.map(a => a.y))
areas.forEach((area) => { area.y = targetValue })
} else if (direction === 'bottom') {
targetValue = Math.max(...areas.map(a => a.y + a.h))
areas.forEach((area) => { area.y = targetValue - area.h })
}
this.save()
this.$emit('close')
},
resizeSelectedAreas (dimension) {
const areas = this.selectedAreasRef.value
const values = areas.map(a => dimension === 'width' ? a.w : a.h).sort((a, b) => a - b)
const medianValue = values[Math.floor(values.length / 2)]
if (dimension === 'width') {
areas.forEach((area) => { area.w = medianValue })
} else if (dimension === 'height') {
areas.forEach((area) => {
const diff = medianValue - area.h
area.y = area.y - diff
area.h = medianValue
})
}
this.save()
this.$emit('close')
},
handleSaveMultiSelectFontModal () {
this.selectedFields.forEach((field) => {
field.preferences = { ...field.preferences, ...this.multiSelectField.preferences }
})
this.save()
this.closeModal()
},
handleSaveMultiSelectConditionsModal () {
this.selectedFields.forEach((field) => {
field.conditions = JSON.parse(JSON.stringify(this.multiSelectField.conditions))
})
this.save()
this.closeModal()
}
}
}
</script>
Loading…
Cancel
Save