add select fields mode

pull/572/head
Pete Matsyburka 2 months ago
parent bc8eb33f05
commit 43c41e2557

@ -8,7 +8,7 @@
@touchstart="startTouchDrag" @touchstart="startTouchDrag"
> >
<div <div
v-if="isSelected || isDraw" v-if="isSelected || isDraw || isInMultiSelection"
class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none" class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none"
:class="activeBorderClasses" :class="activeBorderClasses"
/> />
@ -24,7 +24,7 @@
:style="{ left: (cellW / area.w * 100) + '%' }" :style="{ left: (cellW / area.w * 100) + '%' }"
> >
<span <span
v-if="index === 0 && editable" v-if="index === 0 && editable && !isInMultiSelection"
class="h-2.5 w-2.5 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-ew-resize z-10" class="h-2.5 w-2.5 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-ew-resize z-10"
style="left: -4px" style="left: -4px"
@mousedown.stop="startResizeCell" @mousedown.stop="startResizeCell"
@ -32,7 +32,7 @@
</div> </div>
</div> </div>
<div <div
v-if="field?.type && (isSelected || isNameFocus)" v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls" class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px" style="top: -25px; height: 25px"
@mousedown.stop @mousedown.stop
@ -48,7 +48,7 @@
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'" :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" :submitters="template.submitters"
@update:model-value="save" @update:model-value="save"
@click="selectedAreaRef.value = area" @click="selectedAreasRef.value = [area]"
/> />
<FieldType <FieldType
v-model="field.type" v-model="field.type"
@ -57,7 +57,7 @@
:button-classes="'px-1'" :button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'" :menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]" @update:model-value="[maybeUpdateOptions(), save()]"
@click="selectedAreaRef.value = area" @click="selectedAreasRef.value = [area]"
/> />
<span <span
v-if="field.type !== 'checkbox' || field.name" v-if="field.type !== 'checkbox' || field.name"
@ -146,7 +146,7 @@
@click-font="isShowFontModal = true" @click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true" @click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true" @click-condition="isShowConditionsModal = true"
@scroll-to="[selectedAreaRef.value = $event, $emit('scroll-to', $event)]" @scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/> />
</ul> </ul>
</span> </span>
@ -266,7 +266,7 @@
ref="defaultValueSelect" ref="defaultValueSelect"
class="bg-transparent outline-none focus:outline-none w-full" class="bg-transparent outline-none focus:outline-none w-full"
@change="[field.default_value = $event.target.value, field.readonly = !!field.default_value?.length, save()]" @change="[field.default_value = $event.target.value, field.readonly = !!field.default_value?.length, save()]"
@focus="selectedAreaRef.value = area" @focus="selectedAreasRef.value = [area]"
@keydown.enter="onDefaultValueEnter" @keydown.enter="onDefaultValueEnter"
> >
<option <option
@ -293,7 +293,7 @@
:class="{ 'cursor-text': isValueInput }" :class="{ 'cursor-text': isValueInput }"
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))" :placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))"
@blur="onDefaultValueBlur" @blur="onDefaultValueBlur"
@focus="selectedAreaRef.value = area" @focus="selectedAreasRef.value = [area]"
@paste.prevent="onPaste" @paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter" @keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span> >{{ field.default_value }}</span>
@ -319,6 +319,7 @@
<span <span
v-if="field?.type && editable" v-if="field?.type && editable"
class="h-4 w-4 lg:h-2.5 lg:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize" class="h-4 w-4 lg:h-2.5 lg:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
:class="{ 'z-30': isInMultiSelection }"
@mousedown.stop="startResize" @mousedown.stop="startResize"
@touchstart="startTouchResize" @touchstart="startTouchResize"
/> />
@ -398,7 +399,7 @@ export default {
FieldSubmitter, FieldSubmitter,
IconX IconX
}, },
inject: ['template', 'selectedAreaRef', 'save', 't', 'isInlineSize'], inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef'],
props: { props: {
area: { area: {
type: Object, type: Object,
@ -463,6 +464,11 @@ export default {
type: Object, type: Object,
required: false, required: false,
default: null default: null
},
isSelectMode: {
type: Boolean,
required: false,
default: false
} }
}, },
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'], emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'],
@ -646,7 +652,10 @@ export default {
] ]
}, },
isSelected () { isSelected () {
return this.selectedAreaRef.value === this.area return this.selectedAreasRef.value.includes(this.area)
},
isInMultiSelection () {
return this.selectedAreasRef.value.length >= 2 && this.isSelected
}, },
positionStyle () { positionStyle () {
const { x, y, w, h } = this.area const { x, y, w, h } = this.area
@ -683,10 +692,10 @@ export default {
buildAreaOptionValue (area) { buildAreaOptionValue (area) {
const option = this.optionsUuidIndex[area.option_uuid] const option = this.optionsUuidIndex[area.option_uuid]
return option.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}` return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
}, },
maybeToggleDefaultValue () { maybeToggleDefaultValue () {
if (!this.editable) { if (!this.editable || this.isCmdKeyRef.value) {
return return
} }
@ -770,7 +779,7 @@ export default {
} }
}, },
onNameFocus (e) { onNameFocus (e) {
this.selectedAreaRef.value = this.area this.selectedAreasRef.value = [this.area]
this.isNameFocus = true this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px' this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
@ -906,6 +915,15 @@ export default {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
this.area.w = e.offsetX / e.target.clientWidth - this.area.x this.area.w = e.offsetX / e.target.clientWidth - this.area.x
this.area.h = e.offsetY / e.target.clientHeight - this.area.y this.area.h = e.offsetY / e.target.clientHeight - this.area.y
if (this.isInMultiSelection) {
this.selectedAreasRef.value.forEach((area) => {
if (area !== this.area) {
area.w = this.area.w
area.h = this.area.h
}
})
}
} }
}, },
drag (e) { drag (e) {
@ -931,7 +949,7 @@ export default {
const rect = e.target.getBoundingClientRect() const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area this.selectedAreasRef.value = [this.area]
this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY } this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY }
@ -980,13 +998,23 @@ export default {
e.preventDefault() e.preventDefault()
if (e.metaKey || e.ctrlKey) {
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value.push(this.area)
} else {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
}
return
}
if (this.editable) { if (this.editable) {
this.isDragged = true this.isDragged = true
} }
const rect = e.target.getBoundingClientRect() const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area this.selectedAreasRef.value = [this.area]
this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY } this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY }
@ -1055,7 +1083,9 @@ export default {
this.$emit('stop-drag') this.$emit('stop-drag')
}, },
startResize () { startResize () {
this.selectedAreaRef.value = this.area if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
this.$el.getRootNode().addEventListener('mousemove', this.resize) this.$el.getRootNode().addEventListener('mousemove', this.resize)
this.$el.getRootNode().addEventListener('mouseup', this.stopResize) this.$el.getRootNode().addEventListener('mouseup', this.stopResize)
@ -1071,7 +1101,9 @@ export default {
this.save() this.save()
}, },
startTouchResize (e) { startTouchResize (e) {
this.selectedAreaRef.value = this.area if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
this.$refs?.name?.blur() this.$refs?.name?.blur()
@ -1088,6 +1120,15 @@ export default {
this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x
this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y
if (this.isInMultiSelection) {
this.selectedAreasRef.value.forEach((area) => {
if (area !== this.area) {
area.w = this.area.w
area.h = this.area.h
}
})
}
}, },
stopTouchResize () { stopTouchResize () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize) this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)

@ -378,6 +378,9 @@
@remove-area="removeArea" @remove-area="removeArea"
@paste-field="pasteField" @paste-field="pasteField"
@copy-field="copyField" @copy-field="copyField"
@copy-selected-areas="copySelectedAreas"
@delete-selected-areas="deleteSelectedAreas"
@align-selected-areas="alignSelectedAreas"
/> />
<DocumentControls <DocumentControls
v-if="isBreakpointLg && editable" v-if="isBreakpointLg && editable"
@ -592,8 +595,10 @@ export default {
withConditions: this.withConditions, withConditions: this.withConditions,
isInlineSize: this.isInlineSize, isInlineSize: this.isInlineSize,
defaultDrawFieldType: this.defaultDrawFieldType, defaultDrawFieldType: this.defaultDrawFieldType,
selectedAreaRef: computed(() => this.selectedAreaRef), selectedAreasRef: computed(() => this.selectedAreasRef),
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef) fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef),
isSelectModeRef: computed(() => this.isSelectModeRef),
isCmdKeyRef: computed(() => this.isCmdKeyRef)
} }
}, },
props: { props: {
@ -862,8 +867,10 @@ export default {
}, },
computed: { computed: {
submitterDefaultNames: FieldSubmitter.computed.names, submitterDefaultNames: FieldSubmitter.computed.names,
selectedAreaRef: () => ref(), isSelectModeRef: () => ref(false),
isCmdKeyRef: () => ref(false),
fieldsDragFieldRef: () => ref(), fieldsDragFieldRef: () => ref(),
selectedAreasRef: () => ref([]),
language () { language () {
return this.locale.split('-')[0].toLowerCase() return this.locale.split('-')[0].toLowerCase()
}, },
@ -877,6 +884,14 @@ export default {
isInlineSize () { isInlineSize () {
return CSS.supports('container-type: size') return CSS.supports('container-type: size')
}, },
lowestSelectedArea () {
return this.selectedAreasRef.value.reduce((acc, area) => {
return area.y + area.h < acc.y + acc.h ? acc : area
}, this.selectedAreasRef.value[0])
},
lastSelectedArea () {
return this.selectedAreasRef.value[this.selectedAreasRef.value.length - 1]
},
isMobile () { isMobile () {
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent) const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
@ -918,7 +933,7 @@ export default {
}) })
}, },
selectedField () { selectedField () {
return this.template.fields.find((f) => f.areas?.includes(this.selectedAreaRef.value)) return this.template.fields.find((f) => f.areas?.includes(this.lastSelectedArea))
}, },
sortedDocuments () { sortedDocuments () {
return this.template.schema.map((item) => { return this.template.schema.map((item) => {
@ -1005,6 +1020,81 @@ export default {
}, },
methods: { methods: {
toRaw, toRaw,
toggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
if (!this.isSelectModeRef.value && this.selectedAreasRef.value.length > 1) {
this.selectedAreasRef.value = []
}
},
deleteSelectedAreas () {
[...this.selectedAreasRef.value].forEach((area) => {
this.removeArea(area, false)
})
this.save()
},
moveSelectedAreas (dx, dy) {
let clampedDx = dx
let clampedDy = dy
const rectIndex = {}
this.selectedAreasRef.value.map((area) => {
const key = `${area.attachment_uuid}-${area.page}`
let rect = rectIndex[key]
if (!rect) {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const page = documentRef.pageRefs[area.page].$refs.image
rect = page.getBoundingClientRect()
rectIndex[key] = rect
}
const normalizedDx = dx / rect.width
const normalizedDy = dy / rect.height
const maxDxLeft = -area.x
const maxDxRight = 1 - area.w - area.x
const maxDyTop = -area.y
const maxDyBottom = 1 - area.h - area.y
if (normalizedDx < maxDxLeft) clampedDx = Math.max(clampedDx, maxDxLeft * rect.width)
if (normalizedDx > maxDxRight) clampedDx = Math.min(clampedDx, maxDxRight * rect.width)
if (normalizedDy < maxDyTop) clampedDy = Math.max(clampedDy, maxDyTop * rect.height)
if (normalizedDy > maxDyBottom) clampedDy = Math.min(clampedDy, maxDyBottom * rect.height)
return [area, rect]
}).forEach(([area, rect]) => {
area.x += clampedDx / rect.width
area.y += clampedDy / rect.height
})
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
@ -1389,49 +1479,92 @@ export default {
} }
}, },
onKeyUp (e) { onKeyUp (e) {
this.isCmdKeyRef.value = false
if (e.code === 'Escape') { if (e.code === 'Escape') {
this.selectedAreasRef.value = []
this.clearDrawField() this.clearDrawField()
this.selectedAreaRef.value = null
} }
if (this.editable && ['Backspace', 'Delete'].includes(e.key) && this.selectedAreaRef.value && document.activeElement === document.body) { if (this.editable && ['Backspace', 'Delete'].includes(e.key) && document.activeElement === document.body) {
this.removeArea(this.selectedAreaRef.value) if (this.selectedAreasRef.value.length > 1) {
this.deleteSelectedAreas()
this.selectedAreaRef.value = null } else if (this.selectedAreasRef.value.length) {
this.removeArea(this.lastSelectedArea)
}
} }
}, },
onKeyDown (event) { onKeyDown (event) {
if ((event.metaKey && event.shiftKey && event.key === 'z') || (event.ctrlKey && event.key === 'Z')) { if (event.key === 'Tab' && document.activeElement === document.body) {
event.stopImmediatePropagation() event.stopImmediatePropagation()
event.preventDefault() event.preventDefault()
this.toggleSelectMode()
} else if ((event.metaKey && event.shiftKey && event.key === 'z') || (event.ctrlKey && event.key === 'Z')) {
event.stopImmediatePropagation()
event.preventDefault()
this.selectedAreasRef.value = []
this.redo() this.redo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') { } else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
event.stopImmediatePropagation() event.stopImmediatePropagation()
event.preventDefault() event.preventDefault()
this.selectedAreasRef.value = []
this.undo() this.undo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) { } else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) {
if (this.selectedAreasRef.value.length > 1) {
event.preventDefault()
this.copySelectedAreas()
} else if (this.selectedAreasRef.value.length) {
event.preventDefault() event.preventDefault()
this.copyField() this.copyField()
}
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData() && document.activeElement === document.body) { } else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData() && document.activeElement === document.body) {
event.preventDefault() event.preventDefault()
this.pasteField() this.pasteField()
} else if (this.selectedAreaRef.value && ['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown'].includes(event.key) && document.activeElement === document.body) { } else if (['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown'].includes(event.key) && document.activeElement === document.body) {
if (this.selectedAreasRef.value.length > 1) {
event.preventDefault()
this.handleSelectedAreasArrows(event)
} else if (this.selectedAreasRef.value.length) {
event.preventDefault() event.preventDefault()
this.handleAreaArrows(event) this.handleAreaArrows(event)
} }
} else if (event.metaKey || event.ctrlKey) {
this.isCmdKeyRef.value = true
}
},
handleSelectedAreasArrows (event) {
if (!this.editable) {
return
}
const diff = (event.shiftKey ? 5.0 : 1.0)
let dx = 0
let dy = 0
if (event.key === 'ArrowRight') {
dx = diff
} else if (event.key === 'ArrowLeft') {
dx = -diff
} else if (event.key === 'ArrowUp') {
dy = -diff
} else if (event.key === 'ArrowDown') {
dy = diff
}
this.moveSelectedAreas(dx, dy)
}, },
handleAreaArrows (event) { handleAreaArrows (event) {
if (!this.editable) { if (!this.editable) {
return return
} }
const area = this.selectedAreaRef.value const area = this.lastSelectedArea
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid) const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const page = documentRef.pageRefs[area.page].$refs.image const page = documentRef.pageRefs[area.page].$refs.image
const rect = page.getBoundingClientRect() const rect = page.getBoundingClientRect()
@ -1464,7 +1597,7 @@ export default {
this.save() this.save()
}, 700) }, 700)
}, },
removeArea (area) { removeArea (area, save = true) {
const field = this.template.fields.find((f) => f.areas?.includes(area)) const field = this.template.fields.find((f) => f.areas?.includes(area))
field.areas.splice(field.areas.indexOf(area), 1) field.areas.splice(field.areas.indexOf(area), 1)
@ -1475,7 +1608,11 @@ export default {
this.removeFieldConditions(field) this.removeFieldConditions(field)
} }
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
if (save) {
this.save() this.save()
}
}, },
removeFieldConditions (field) { removeFieldConditions (field) {
this.template.fields.forEach((f) => { this.template.fields.forEach((f) => {
@ -1499,7 +1636,7 @@ export default {
}) })
}, },
copyField () { copyField () {
const area = this.selectedAreaRef.value const area = this.lastSelectedArea
if (!area) return if (!area) return
@ -1524,25 +1661,68 @@ export default {
console.error('Failed to save clipboard:', e) console.error('Failed to save clipboard:', e)
} }
}, },
pasteField (targetPosition = null) { copySelectedAreas () {
let field = null const items = []
let area = null
let isSameTemplate = false const areas = this.selectedAreasRef.value
const minX = Math.min(...areas.map(a => a.x))
const minY = Math.min(...areas.map(a => a.y))
areas.forEach((area) => {
const field = this.template.fields.find((f) => f.areas?.includes(area))
if (!field) return
const fieldCopy = JSON.parse(JSON.stringify(field))
const areaCopy = JSON.parse(JSON.stringify(area))
delete fieldCopy.areas
delete fieldCopy.uuid
delete fieldCopy.submitter_uuid
areaCopy.relativeX = area.x - minX
areaCopy.relativeY = area.y - minY
items.push({ field: fieldCopy, area: areaCopy })
})
const clipboardData = {
items,
templateId: this.template.id,
timestamp: Date.now(),
isGroup: true
}
try {
localStorage.setItem('docuseal_clipboard', JSON.stringify(clipboardData))
} catch (e) {
console.error('Failed to save clipboard:', e)
}
},
pasteField (targetPosition = null) {
const clipboard = localStorage.getItem('docuseal_clipboard') const clipboard = localStorage.getItem('docuseal_clipboard')
if (clipboard) { if (!clipboard) return
const data = JSON.parse(clipboard) const data = JSON.parse(clipboard)
if (Date.now() - data.timestamp < 3600000) { if (Date.now() - data.timestamp >= 3600000) {
field = data.field
area = data.area
isSameTemplate = data.templateId === this.template.id
} else {
localStorage.removeItem('docuseal_clipboard') localStorage.removeItem('docuseal_clipboard')
return
} }
if (data.isGroup && data.items?.length) {
this.pasteFieldGroup(data, targetPosition)
return
} }
const field = data.field
const area = data.area
const isSameTemplate = data.templateId === this.template.id
if (!field || !area) return if (!field || !area) return
if (!isSameTemplate) { if (!isSameTemplate) {
@ -1550,20 +1730,19 @@ export default {
delete field.preferences?.formula delete field.preferences?.formula
} }
const currentArea = this.selectedAreaRef.value
const defaultAttachmentUuid = this.template.schema[0]?.attachment_uuid const defaultAttachmentUuid = this.template.schema[0]?.attachment_uuid
if (field && (currentArea || targetPosition)) { if (field && (this.lowestSelectedArea || targetPosition)) {
const attachmentUuid = targetPosition?.attachment_uuid || const attachmentUuid = targetPosition?.attachment_uuid ||
(this.template.documents.find((d) => d.uuid === currentArea.attachment_uuid) ? currentArea.attachment_uuid : null) || (this.template.documents.find((d) => d.uuid === this.lowestSelectedArea.attachment_uuid) ? this.lowestSelectedArea.attachment_uuid : null) ||
defaultAttachmentUuid defaultAttachmentUuid
const newArea = { const newArea = {
...JSON.parse(JSON.stringify(area)), ...JSON.parse(JSON.stringify(area)),
attachment_uuid: attachmentUuid, attachment_uuid: attachmentUuid,
page: targetPosition?.page ?? (attachmentUuid === currentArea.attachment_uuid ? currentArea.page : 0), page: targetPosition?.page ?? (attachmentUuid === this.lowestSelectedArea.attachment_uuid ? this.lowestSelectedArea.page : 0),
x: targetPosition ? (targetPosition.x - area.w / 2) : currentArea.x, x: targetPosition ? (targetPosition.x - area.w / 2) : Math.min(...this.selectedAreasRef.value.map((area) => area.x)),
y: targetPosition ? (targetPosition.y - area.h / 2) : (currentArea.y + currentArea.h * 1.3) y: targetPosition ? (targetPosition.y - area.h / 2) : (this.lowestSelectedArea.y + this.lowestSelectedArea.h * 1.3)
} }
const newField = { const newField = {
@ -1588,11 +1767,85 @@ export default {
this.insertField(newField) this.insertField(newField)
this.selectedAreaRef.value = newArea this.selectedAreasRef.value = [newArea]
this.save() this.save()
} }
}, },
pasteFieldGroup (data, targetPosition) {
const isSameTemplate = data.templateId === this.template.id
const defaultAttachmentUuid = this.template.schema[0]?.attachment_uuid
const attachmentUuid = targetPosition?.attachment_uuid ||
(this.lowestSelectedArea && this.template.documents.find((d) => d.uuid === this.lowestSelectedArea.attachment_uuid) ? this.lowestSelectedArea.attachment_uuid : null) ||
defaultAttachmentUuid
const page = targetPosition?.page ?? (this.lowestSelectedArea && attachmentUuid === this.lowestSelectedArea.attachment_uuid ? this.lowestSelectedArea.page : 0)
let baseX, baseY
if (targetPosition) {
baseX = targetPosition.x
baseY = targetPosition.y
} else if (this.lowestSelectedArea) {
baseX = Math.min(...this.selectedAreasRef.value.map((area) => area.x))
baseY = this.lowestSelectedArea.y + this.lowestSelectedArea.h * 1.3
} else {
baseX = 0.1
baseY = 0.1
}
const newAreas = []
data.items.forEach((item) => {
const field = JSON.parse(JSON.stringify(item.field))
const area = JSON.parse(JSON.stringify(item.area))
if (!isSameTemplate) {
delete field.conditions
delete field.preferences?.formula
}
const newArea = {
...area,
attachment_uuid: attachmentUuid,
page,
x: baseX + (area.relativeX || 0),
y: baseY + (area.relativeY || 0)
}
delete newArea.relativeX
delete newArea.relativeY
const newField = {
...field,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
areas: [newArea]
}
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid
const optionsMap = {}
newField.options = field.options.map((opt) => {
const newUuid = v4()
optionsMap[opt.uuid] = newUuid
return { ...opt, uuid: newUuid }
})
newArea.option_uuid = optionsMap[oldOptionUuid] || newField.options[0].uuid
}
this.insertField(newField)
newAreas.push(newArea)
})
this.selectedAreasRef.value = [...newAreas]
this.save()
},
hasClipboardData () { hasClipboardData () {
try { try {
const clipboard = localStorage.getItem('docuseal_clipboard') const clipboard = localStorage.getItem('docuseal_clipboard')
@ -1659,8 +1912,8 @@ export default {
const previousArea = this.drawField.areas?.[this.drawField.areas.length - 1] const previousArea = this.drawField.areas?.[this.drawField.areas.length - 1]
if (this.selectedField?.type === this.drawField.type) { if (this.selectedField?.type === this.drawField.type) {
area.w = this.selectedAreaRef.value.w area.w = this.lastSelectedArea.w
area.h = this.selectedAreaRef.value.h area.h = this.lastSelectedArea.h
} else if (previousArea) { } else if (previousArea) {
area.w = previousArea.w area.w = previousArea.w
area.h = previousArea.h area.h = previousArea.h
@ -1691,7 +1944,7 @@ export default {
this.drawField = null this.drawField = null
this.drawOption = null this.drawOption = null
this.selectedAreaRef.value = area this.selectedAreasRef.value = [area]
this.save() this.save()
} else { } else {
@ -1728,8 +1981,8 @@ export default {
if (this.drawFieldType && (area.w === 0 || area.h === 0)) { if (this.drawFieldType && (area.w === 0 || area.h === 0)) {
if (this.selectedField?.type === this.drawFieldType) { if (this.selectedField?.type === this.drawFieldType) {
area.w = this.selectedAreaRef.value.w area.w = this.lastSelectedArea.w
area.h = this.selectedAreaRef.value.h area.h = this.lastSelectedArea.h
} else { } else {
this.setDefaultAreaSize(area, this.drawFieldType) this.setDefaultAreaSize(area, this.drawFieldType)
} }
@ -1741,7 +1994,7 @@ export default {
if (area.w && (type !== 'checkbox' || this.drawFieldType || !isTooSmall)) { if (area.w && (type !== 'checkbox' || this.drawFieldType || !isTooSmall)) {
this.addField(type, area) this.addField(type, area)
this.selectedAreaRef.value = area this.selectedAreasRef.value = [area]
} }
} }
}, },
@ -1821,7 +2074,11 @@ export default {
field.areas.push(fieldArea) field.areas.push(fieldArea)
this.selectedAreaRef.value = fieldArea if (this.selectedAreasRef.value.length < 2) {
this.selectedAreasRef.value = [fieldArea]
} else {
this.selectedAreasRef.value.push(fieldArea)
}
if (this.template.fields.indexOf(field) === -1) { if (this.template.fields.indexOf(field) === -1) {
this.insertField(field) this.insertField(field)
@ -1834,7 +2091,7 @@ export default {
if (field.type === 'heading') { if (field.type === 'heading') {
this.$nextTick(() => { this.$nextTick(() => {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid) const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const areaRef = documentRef.pageRefs[area.page].areaRefs.find((ref) => ref.area === this.selectedAreaRef.value) const areaRef = documentRef.pageRefs[area.page].areaRefs.find((ref) => ref.area === fieldArea)
areaRef.isHeadingSelected = true areaRef.isHeadingSelected = true
@ -1850,7 +2107,7 @@ export default {
let baseArea let baseArea
if (this.selectedField?.type === fieldType) { if (this.selectedField?.type === fieldType) {
baseArea = this.selectedAreaRef.value baseArea = this.lastSelectedArea
} else if (previousField?.areas?.length) { } else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1] baseArea = previousField.areas[previousField.areas.length - 1]
} else { } else {
@ -2175,7 +2432,7 @@ export default {
documentRef.scrollToArea(area) documentRef.scrollToArea(area)
this.selectedAreaRef.value = area this.selectedAreasRef.value = [area]
}, },
baseFetch (path, options = {}) { baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, { return fetch(this.baseUrl + path, {

@ -168,9 +168,19 @@ export default {
buildDefaultName: { buildDefaultName: {
type: Function, type: Function,
required: true required: true
},
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
excludeFieldUuids: {
type: Array,
required: false,
default: () => []
} }
}, },
emits: ['close'], emits: ['close', 'click-save'],
data () { data () {
return { return {
conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}] conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}]
@ -183,14 +193,14 @@ export default {
fields () { fields () {
if (this.item.submitter_uuid) { if (this.item.submitter_uuid) {
return this.template.fields.reduce((acc, f) => { return this.template.fields.reduce((acc, f) => {
if (f !== this.item && !this.excludeTypes.includes(f.type) && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) { if (f !== this.item && !this.excludeTypes.includes(f.type) && !this.excludeFieldUuids.includes(f.uuid) && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
acc.push(f) acc.push(f)
} }
return acc return acc
}, []) }, [])
} else { } else {
return this.template.fields return this.template.fields.filter((f) => !this.excludeFieldUuids.includes(f.uuid))
} }
} }
}, },
@ -234,7 +244,12 @@ export default {
delete this.item.conditions delete this.item.conditions
} }
if (this.withClickSaveEvent) {
this.$emit('click-save')
} else {
this.save() this.save()
}
this.$emit('close') this.$emit('close')
} }
} }

@ -41,7 +41,7 @@
class="my-1 border-base-300" class="my-1 border-base-300"
> >
<button <button
v-if="showFont" 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" class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal" @click.stop="openFontModal"
> >
@ -57,7 +57,7 @@
<span>{{ t('description') }}</span> <span>{{ t('description') }}</span>
</button> </button>
<button <button
v-if="showCondition" 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" class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal" @click.stop="openConditionModal"
> >
@ -73,7 +73,63 @@
<span>{{ t('formula') }}</span> <span>{{ t('formula') }}</span>
</button> </button>
<hr <hr
v-if="(showFont || showDescription || showCondition || showFormula) && (showCopy || showDelete || showPaste)" 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" class="my-1 border-base-300"
> >
<button <button
@ -111,6 +167,24 @@
</span> </span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span> <span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
</button> </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>
</div> </div>
<Teleport <Teleport
v-if="isShowFormulaModal" v-if="isShowFormulaModal"
@ -128,10 +202,12 @@
to="#docuseal_modal_container" to="#docuseal_modal_container"
> >
<FontModal <FontModal
:field="field" :field="multiSelectField || field"
:area="contextMenu.area" :area="contextMenu.area"
:editable="editable" :editable="editable"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectFontModal"
@close="closeModal" @close="closeModal"
/> />
</Teleport> </Teleport>
@ -140,8 +216,11 @@
to="#docuseal_modal_container" to="#docuseal_modal_container"
> >
<ConditionsModal <ConditionsModal
:item="field" :item="multiSelectField || field"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
:exclude-field-uuids="isMultiSelection ? selectedFields.map(f => f.uuid) : []"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectConditionsModal"
@close="closeModal" @close="closeModal"
/> />
</Teleport> </Teleport>
@ -160,7 +239,7 @@
</template> </template>
<script> <script>
import { IconCopy, IconClipboard, IconTrashX, IconTypography, IconInfoCircle, IconRouteAltLeft, IconMathFunction } from '@tabler/icons-vue' import { IconCopy, IconClipboard, IconTrashX, IconTypography, IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconClick, IconNewSection, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom } from '@tabler/icons-vue'
import FormulaModal from './formula_modal' import FormulaModal from './formula_modal'
import FontModal from './font_modal' import FontModal from './font_modal'
import ConditionsModal from './conditions_modal' import ConditionsModal from './conditions_modal'
@ -178,12 +257,18 @@ export default {
IconInfoCircle, IconInfoCircle,
IconRouteAltLeft, IconRouteAltLeft,
IconMathFunction, IconMathFunction,
IconClick,
IconNewSection,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconLayoutAlignTop,
IconLayoutAlignBottom,
FormulaModal, FormulaModal,
FontModal, FontModal,
ConditionsModal, ConditionsModal,
DescriptionModal DescriptionModal
}, },
inject: ['t', 'save'], inject: ['t', 'save', 'selectedAreasRef', 'isSelectModeRef'],
props: { props: {
contextMenu: { contextMenu: {
type: Object, type: Object,
@ -197,20 +282,40 @@ export default {
editable: { editable: {
type: Boolean, type: Boolean,
default: true default: true
},
isMultiSelection: {
type: Boolean,
default: false
},
selectedAreas: {
type: Array,
default: () => []
},
template: {
type: Object,
default: null
} }
}, },
emits: ['copy', 'paste', 'delete', 'close'], emits: ['copy', 'paste', 'delete', 'close', 'align'],
data () { data () {
return { return {
isShowFormulaModal: false, isShowFormulaModal: false,
isShowFontModal: false, isShowFontModal: false,
isShowConditionsModal: false, isShowConditionsModal: false,
isShowDescriptionModal: false isShowDescriptionModal: false,
multiSelectField: null
} }
}, },
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels, fieldLabels: FieldType.computed.fieldLabels,
selectedFields () {
if (!this.isMultiSelection) return []
return this.selectedAreasRef.value.map((area) => {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
isMac () { isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac') return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
}, },
@ -236,36 +341,44 @@ export default {
} }
}, },
showCopy () { showCopy () {
return !!this.contextMenu.area return !!this.contextMenu.area || this.isMultiSelection
}, },
showPaste () { showPaste () {
return !this.contextMenu.area return !this.contextMenu.area && !this.isMultiSelection
}, },
showDelete () { showDelete () {
return !!this.contextMenu.area return !!this.contextMenu.area || this.isMultiSelection
}, },
showFont () { showFont () {
if (this.isMultiSelection) return true
if (!this.field) return false if (!this.field) return false
return ['text', 'number', 'date', 'select', 'heading'].includes(this.field.type) return ['text', 'number', 'date', 'select', 'heading'].includes(this.field.type)
}, },
showDescription () { showDescription () {
if (!this.field) return false if (!this.field) return false
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type) return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
}, },
showCondition () { showCondition () {
if (this.isMultiSelection) return true
if (!this.field) return false if (!this.field) return false
return !['stamp', 'heading'].includes(this.field.type) return !['stamp', 'heading'].includes(this.field.type)
}, },
showFormula () { showFormula () {
if (!this.field) return false if (!this.field) return false
return this.field.type === 'number' return this.field.type === 'number'
}, },
showRequired () { showRequired () {
if (!this.field) return false if (!this.field) return false
return !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type) return !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
}, },
showReadOnly () { showReadOnly () {
if (!this.field) return false if (!this.field) return false
return ['text', 'number'].includes(this.field.type) return ['text', 'number'].includes(this.field.type)
}, },
isRequired () { isRequired () {
@ -273,6 +386,9 @@ export default {
}, },
isReadOnly () { isReadOnly () {
return this.field?.readonly || false return this.field?.readonly || false
},
showSelectFields () {
return !this.contextMenu.area && !this.isMultiSelection
} }
}, },
mounted () { mounted () {
@ -333,12 +449,38 @@ export default {
} }
}, },
openFontModal () { 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 this.isShowFontModal = true
}, },
openDescriptionModal () { openDescriptionModal () {
this.isShowDescriptionModal = true this.isShowDescriptionModal = true
}, },
openConditionModal () { 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 this.isShowConditionsModal = true
}, },
openFormulaModal () { openFormulaModal () {
@ -349,6 +491,30 @@ export default {
this.isShowFontModal = false this.isShowFontModal = false
this.isShowConditionsModal = false this.isShowConditionsModal = false
this.isShowDescriptionModal = 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') this.$emit('close')
} }

@ -22,10 +22,14 @@
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:total-pages="sortedPreviewImages.length" :total-pages="sortedPreviewImages.length"
:image="image" :image="image"
:attachment-uuid="document.uuid"
@drop-field="$emit('drop-field', { ...$event, attachment_uuid: document.uuid })" @drop-field="$emit('drop-field', { ...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)" @remove-area="$emit('remove-area', $event)"
@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 })"
@copy-selected-areas="$emit('copy-selected-areas')"
@delete-selected-areas="$emit('delete-selected-areas')"
@align-selected-areas="$emit('align-selected-areas', $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 })"
/> />
@ -120,7 +124,7 @@ export default {
default: false default: false
} }
}, },
emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field'], emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas'],
data () { data () {
return { return {
pageRefs: [] pageRefs: []

@ -330,7 +330,7 @@ export default {
IconMathFunction, IconMathFunction,
FieldType FieldType
}, },
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't', 'locale'], inject: ['template', 'save', 'backgroundColor', 'selectedAreasRef', 't', 'locale'],
props: { props: {
field: { field: {
type: Object, type: Object,
@ -451,7 +451,7 @@ export default {
const area = this.field.areas.find((a) => a.option_uuid === option.uuid) const area = this.field.areas.find((a) => a.option_uuid === option.uuid)
if (area) { if (area) {
this.selectedAreaRef.value = area this.selectedAreasRef.value = [area]
} }
}, },
scrollToFirstArea () { scrollToFirstArea () {

@ -283,7 +283,7 @@ export default {
IconDrag, IconDrag,
IconLock IconLock
}, },
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'], inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch', 'selectedAreasRef'],
props: { props: {
fields: { fields: {
type: Array, type: Array,
@ -610,6 +610,10 @@ export default {
}) })
}) })
field.areas?.forEach((area) => {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
})
if (save) { if (save) {
this.save() this.save()
} }

@ -212,12 +212,17 @@ export default {
required: false, required: false,
default: true default: true
}, },
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
buildDefaultName: { buildDefaultName: {
type: Function, type: Function,
required: true required: true
} }
}, },
emits: ['close'], emits: ['close', 'click-save'],
data () { data () {
return { return {
preferences: {} preferences: {}
@ -322,7 +327,11 @@ export default {
Object.assign(this.field.preferences, this.preferences) Object.assign(this.field.preferences, this.preferences)
if (this.withClickSaveEvent) {
this.$emit('click-save')
} else {
this.save() this.save()
}
this.$emit('close') this.$emit('close')
} }

@ -187,7 +187,14 @@ const en = {
sync: 'Sync', sync: 'Sync',
syncing: 'Syncing...', syncing: 'Syncing...',
copy: 'Copy', copy: 'Copy',
paste: 'Paste' paste: 'Paste',
select_fields: 'Select Fields',
draw_fields: 'Draw Fields',
align_left: 'Align Left',
align_right: 'Align Right',
align_top: 'Align Top',
align_bottom: 'Align Bottom',
fields_selected: '{count} Fields Selected'
} }
const es = { const es = {
@ -379,7 +386,14 @@ const es = {
sync: 'Sincronizar', sync: 'Sincronizar',
syncing: 'Sincronizando...', syncing: 'Sincronizando...',
copy: 'Copiar', copy: 'Copiar',
paste: 'Pegar' paste: 'Pegar',
select_fields: 'Seleccionar Campos',
draw_fields: 'Dibujar Campos',
align_left: 'Alinear a la izquierda',
align_right: 'Alinear a la derecha',
align_top: 'Alinear arriba',
align_bottom: 'Alinear abajo',
fields_selected: '{count} Campos Seleccionados'
} }
const it = { const it = {
@ -571,7 +585,14 @@ const it = {
sync: 'Sincronizza', sync: 'Sincronizza',
syncing: 'Sincronizzazione...', syncing: 'Sincronizzazione...',
copy: 'Copia', copy: 'Copia',
paste: 'Incolla' paste: 'Incolla',
select_fields: 'Seleziona Campi',
draw_fields: 'Disegna Campi',
align_left: 'Allinea a sinistra',
align_right: 'Allinea a destra',
align_top: 'Allinea in alto',
align_bottom: 'Allinea in basso',
fields_selected: '{count} Campi Selezionati'
} }
const pt = { const pt = {
@ -763,7 +784,14 @@ const pt = {
sync: 'Sincronizar', sync: 'Sincronizar',
syncing: 'Sincronizando...', syncing: 'Sincronizando...',
copy: 'Copiar', copy: 'Copiar',
paste: 'Colar' paste: 'Colar',
select_fields: 'Selecionar Campos',
draw_fields: 'Desenhar Campos',
align_left: 'Alinhar à esquerda',
align_right: 'Alinhar à direita',
align_top: 'Alinhar ao topo',
align_bottom: 'Alinhar à parte inferior',
fields_selected: '{count} Campos Selecionados'
} }
const fr = { const fr = {
@ -955,7 +983,14 @@ const fr = {
sync: 'Synchroniser', sync: 'Synchroniser',
syncing: 'Synchronisation...', syncing: 'Synchronisation...',
copy: 'Copier', copy: 'Copier',
paste: 'Coller' paste: 'Coller',
select_fields: 'Sélectionner Champs',
draw_fields: 'Dessiner Champs',
align_left: 'Aligner à gauche',
align_right: 'Aligner à droite',
align_top: 'Aligner en haut',
align_bottom: 'Aligner en bas',
fields_selected: '{count} Champs Sélectionnés'
} }
const de = { const de = {
@ -1147,7 +1182,14 @@ const de = {
sync: 'Synchronisieren', sync: 'Synchronisieren',
syncing: 'Synchronisiere...', syncing: 'Synchronisiere...',
copy: 'Kopieren', copy: 'Kopieren',
paste: 'Einfügen' paste: 'Einfügen',
select_fields: 'Felder Auswählen',
draw_fields: 'Felder Zeichnen',
align_left: 'Links ausrichten',
align_right: 'Rechts ausrichten',
align_top: 'Oben ausrichten',
align_bottom: 'Unten ausrichten',
fields_selected: '{count} Felder Ausgewählt'
} }
const nl = { const nl = {
@ -1339,7 +1381,14 @@ const nl = {
sync: 'Synchroniseren', sync: 'Synchroniseren',
syncing: 'Synchroniseren...', syncing: 'Synchroniseren...',
copy: 'Kopiëren', copy: 'Kopiëren',
paste: 'Plakken' paste: 'Plakken',
select_fields: 'Velden Selecteren',
draw_fields: 'Velden Tekenen',
align_left: 'Links uitlijnen',
align_right: 'Rechts uitlijnen',
align_top: 'Boven uitlijnen',
align_bottom: 'Onder uitlijnen',
fields_selected: '{count} Velden Geselecteerd'
} }
export { en, es, it, pt, fr, de, nl } export { en, es, it, pt, fr, de, nl }

@ -1,7 +1,7 @@
<template> <template>
<div <div
class="relative select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute" class="relative select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': allowDraw && editable, 'touch-none': !!drawField }" :class="{ 'cursor-crosshair': allowDraw && editable && !isSelectMode, 'touch-none': !!drawField }"
style="container-type: size" style="container-type: size"
:style="{ aspectRatio: `${width} / ${height}`}" :style="{ aspectRatio: `${width} / ${height}`}"
> >
@ -19,6 +19,18 @@
@pointerdown="onStartDraw" @pointerdown="onStartDraw"
@contextmenu="openContextMenu" @contextmenu="openContextMenu"
> >
<SelectionBox
v-if="showSelectionBox"
:selection-box="selectionBox"
:page-width="width"
:page-height="height"
:is-resizing="!!resizeDirection"
:is-drawing="!!drawFieldType"
:is-drag="isDrag"
@move="onSelectionBoxMove"
@contextmenu="openSelectionContextMenu"
@close-context-menu="closeSelectionContextMenu"
/>
<FieldArea <FieldArea
v-for="(item, i) in areas" v-for="(item, i) in areas"
:key="i" :key="i"
@ -35,6 +47,7 @@
:default-field="defaultFieldsIndex[item.field.name]" :default-field="defaultFieldsIndex[item.field.name]"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:max-page="totalPages - 1" :max-page="totalPages - 1"
:is-select-mode="isSelectMode"
@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)"
@ -49,6 +62,11 @@
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || dragFieldPlaceholder?.type || defaultFieldType }" :field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || dragFieldPlaceholder?.type || defaultFieldType }"
:area="newArea" :area="newArea"
/> />
<div
v-if="selectionRect"
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="selectionRectStyle"
/>
<ContextMenu <ContextMenu
v-if="contextMenu" v-if="contextMenu"
:context-menu="contextMenu" :context-menu="contextMenu"
@ -59,13 +77,25 @@
@paste="handlePaste" @paste="handlePaste"
@close="closeContextMenu" @close="closeContextMenu"
/> />
<ContextMenu
v-if="selectionContextMenu"
:context-menu="selectionContextMenu"
:editable="editable"
:is-multi-selection="true"
:selected-areas="selectedAreasRef.value"
:template="template"
@copy="handleSelectionCopy"
@delete="handleSelectionDelete"
@align="handleSelectionAlign"
@close="closeSelectionContextMenu"
/>
</div> </div>
<div <div
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value" v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value || selectionRect"
id="mask" id="mask"
ref="mask" ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute" class="top-0 bottom-0 left-0 right-0 absolute"
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }" :class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField && !isSelectMode, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove" @pointermove="onPointermove"
@pointerdown="onStartDraw" @pointerdown="onStartDraw"
@contextmenu="openContextMenu" @contextmenu="openContextMenu"
@ -81,14 +111,16 @@
<script> <script>
import FieldArea from './area' import FieldArea from './area'
import ContextMenu from './context_menu' import ContextMenu from './context_menu'
import SelectionBox from './selection_box'
export default { export default {
name: 'TemplatePage', name: 'TemplatePage',
components: { components: {
FieldArea, FieldArea,
ContextMenu ContextMenu,
SelectionBox
}, },
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize', 'selectedAreaRef'], inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef'],
props: { props: {
image: { image: {
type: Object, type: Object,
@ -170,19 +202,64 @@ export default {
number: { number: {
type: Number, type: Number,
required: true required: true
},
attachmentUuid: {
type: String,
required: false,
default: ''
} }
}, },
emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to'], emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas'],
data () { data () {
return { return {
areaRefs: [], areaRefs: [],
showMask: false, showMask: false,
resizeDirection: null, resizeDirection: null,
newArea: null, newArea: null,
contextMenu: null contextMenu: null,
selectionRect: null,
selectionContextMenu: null
} }
}, },
computed: { computed: {
isSelectMode () {
return this.isSelectModeRef.value && !this.drawFieldType && this.editable
},
pageSelectedAreas () {
if (!this.selectedAreasRef.value) return []
return this.selectedAreasRef.value.filter((a) =>
a.attachment_uuid === this.attachmentUuid && a.page === this.number
)
},
showSelectionBox () {
return this.pageSelectedAreas.length >= 2 && this.editable
},
minSelectionBoxHeight () {
const ys = this.pageSelectedAreas.map((a) => a.y)
return Math.max(...ys) - Math.min(...ys)
},
minSelectionBoxWidth () {
const xs = this.pageSelectedAreas.map((a) => a.x)
return Math.max(...xs) - Math.min(...xs)
},
selectionBox () {
if (!this.pageSelectedAreas.length) return null
const minX = Math.min(...this.pageSelectedAreas.map((a) => a.x))
const minY = Math.min(...this.pageSelectedAreas.map((a) => a.y))
const maxX = Math.max(...this.pageSelectedAreas.map((a) => a.x + a.w))
const maxY = Math.max(...this.pageSelectedAreas.map((a) => a.y + a.h))
return {
x: minX,
y: minY,
w: Math.max(maxX - minX, this.minSelectionBoxWidth),
h: Math.max(maxY - minY, this.minSelectionBoxHeight)
}
},
defaultFieldsIndex () { defaultFieldsIndex () {
return this.defaultFields.reduce((acc, field) => { return this.defaultFields.reduce((acc, field) => {
acc[field.name] = field acc[field.name] = field
@ -217,6 +294,16 @@ export default {
}, },
height () { height () {
return this.image.metadata.height return this.image.metadata.height
},
selectionRectStyle () {
if (!this.selectionRect) return {}
return {
left: this.selectionRect.x * 100 + '%',
top: this.selectionRect.y * 100 + '%',
width: this.selectionRect.w * 100 + '%',
height: this.selectionRect.h * 100 + '%'
}
} }
}, },
beforeUpdate () { beforeUpdate () {
@ -269,6 +356,31 @@ export default {
field field
} }
}, },
openSelectionContextMenu (event) {
const rect = this.$el.getBoundingClientRect()
this.selectionContextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height
}
},
closeSelectionContextMenu () {
this.selectionContextMenu = null
},
handleSelectionCopy () {
this.$emit('copy-selected-areas')
this.closeSelectionContextMenu()
},
handleSelectionDelete () {
this.$emit('delete-selected-areas')
this.closeSelectionContextMenu()
},
handleSelectionAlign (direction) {
this.$emit('align-selected-areas', direction)
this.closeSelectionContextMenu()
},
closeContextMenu () { closeContextMenu () {
this.contextMenu = null this.contextMenu = null
this.newArea = null this.newArea = null
@ -276,7 +388,7 @@ export default {
}, },
handleCopy () { handleCopy () {
if (this.contextMenu.area) { if (this.contextMenu.area) {
this.selectedAreaRef.value = this.contextMenu.area this.selectedAreasRef.value = [this.contextMenu.area]
this.$emit('copy-field') this.$emit('copy-field')
} }
@ -338,6 +450,16 @@ export default {
return return
} }
if (this.selectedAreasRef.value.length >= 2) {
this.selectedAreasRef.value = []
}
if (this.isSelectMode) {
this.startSelectionRect(e)
return
}
if (!this.allowDraw) { if (!this.allowDraw) {
return return
} }
@ -363,7 +485,69 @@ export default {
} }
}) })
}, },
startSelectionRect (e) {
this.selectedAreasRef.value = []
this.showMask = true
this.$nextTick(() => {
const x = e.offsetX / this.$refs.mask.clientWidth
const y = e.offsetY / this.$refs.mask.clientHeight
this.selectionRect = {
initialX: x,
initialY: y,
x,
y,
w: 0,
h: 0
}
})
},
onSelectionBoxMove (dx, dy) {
let clampedDx = dx
let clampedDy = dy
this.pageSelectedAreas.forEach((area) => {
const maxDxLeft = -area.x
const maxDxRight = 1 - area.w - area.x
const maxDyTop = -area.y
const maxDyBottom = 1 - area.h - area.y
if (dx < maxDxLeft) clampedDx = Math.max(clampedDx, maxDxLeft)
if (dx > maxDxRight) clampedDx = Math.min(clampedDx, maxDxRight)
if (dy < maxDyTop) clampedDy = Math.max(clampedDy, maxDyTop)
if (dy > maxDyBottom) clampedDy = Math.min(clampedDy, maxDyBottom)
})
this.pageSelectedAreas.forEach((area) => {
area.x += clampedDx
area.y += clampedDy
})
},
onPointermove (e) { onPointermove (e) {
if (this.selectionRect) {
const dx = e.offsetX / this.$refs.mask.clientWidth - this.selectionRect.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.selectionRect.initialY
if (dx > 0) {
this.selectionRect.x = this.selectionRect.initialX
} else {
this.selectionRect.x = e.offsetX / this.$refs.mask.clientWidth
}
if (dy > 0) {
this.selectionRect.y = this.selectionRect.initialY
} else {
this.selectionRect.y = e.offsetY / this.$refs.mask.clientHeight
}
this.selectionRect.w = Math.abs(dx)
this.selectionRect.h = Math.abs(dy)
return
}
if (this.newArea) { if (this.newArea) {
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
@ -389,7 +573,20 @@ export default {
} }
}, },
onPointerup (e) { onPointerup (e) {
if (this.newArea) { if (this.selectionRect) {
const selRect = this.selectionRect
const areasToSelect = this.areas || []
areasToSelect.forEach((item) => {
const area = item.area
if (this.rectsOverlap(selRect, area)) {
this.selectedAreasRef.value.push(area)
}
})
this.selectionRect = null
} else if (this.newArea) {
const area = { const area = {
x: this.newArea.x, x: this.newArea.x,
y: this.newArea.y, y: this.newArea.y,
@ -412,6 +609,14 @@ export default {
this.showMask = false this.showMask = false
this.newArea = null this.newArea = null
},
rectsOverlap (r1, r2) {
return !(
r1.x + r1.w < r2.x ||
r2.x + r2.w < r1.x ||
r1.y + r1.h < r2.y ||
r2.y + r2.h < r1.y
)
} }
} }
} }

@ -0,0 +1,100 @@
<template>
<div
class="absolute outline-dashed outline-gray-400 cursor-move"
:class="[isResizing || isCmdKeyRef.value ? 'z-0' : 'z-20', { 'pointer-events-none': isDrawing || fieldsDragFieldRef.value || isDrag }]"
:style="positionStyle"
@pointerdown.stop.prevent="onPointerDown"
@contextmenu.stop.prevent="openContextMenu"
/>
</template>
<script>
export default {
name: 'SelectionBox',
inject: ['save', 'selectedAreasRef', 'isCmdKeyRef', 'fieldsDragFieldRef'],
props: {
selectionBox: {
type: Object,
required: true
},
pageWidth: {
type: Number,
required: true
},
pageHeight: {
type: Number,
required: true
},
isResizing: {
type: Boolean,
default: false
},
isDrag: {
type: Boolean,
default: false
},
isDrawing: {
type: Boolean,
default: false
}
},
emits: ['move', 'contextmenu', 'close-context-menu'],
data () {
return {
isDragging: false,
dragStart: { x: 0, y: 0 }
}
},
computed: {
positionStyle () {
const { x, y, w, h } = this.selectionBox
return {
top: y * 100 + '%',
left: x * 100 + '%',
width: w * 100 + '%',
height: h * 100 + '%'
}
}
},
methods: {
openContextMenu (e) {
this.$emit('contextmenu', e)
},
onPointerDown (e) {
this.$emit('close-context-menu')
this.startDrag(e)
},
startDrag (e) {
this.isDragging = true
this.dragStart = { x: e.clientX, y: e.clientY }
document.addEventListener('pointermove', this.onDrag)
document.addEventListener('pointerup', this.stopDrag)
},
onDrag (e) {
if (!this.isDragging) return
const parent = this.$el.parentElement
const rect = parent.getBoundingClientRect()
const dx = (e.clientX - this.dragStart.x) / rect.width
const dy = (e.clientY - this.dragStart.y) / rect.height
this.$emit('move', dx, dy)
this.dragStart = { x: e.clientX, y: e.clientY }
},
stopDrag () {
this.isDragging = false
document.removeEventListener('pointermove', this.onDrag)
document.removeEventListener('pointerup', this.stopDrag)
this.save()
}
}
}
</script>
Loading…
Cancel
Save