diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 13af471a..2eb1d0a8 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -376,6 +376,8 @@ @draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]" @drop-field="onDropfield" @remove-area="removeArea" + @paste-field="pasteField" + @copy-field="copyField" /> f.areas?.includes(this.copiedArea)) - const currentArea = this.selectedAreaRef?.value || this.copiedArea + copyField () { + const area = this.selectedAreaRef.value - if (field && currentArea) { - const area = { - ...JSON.parse(JSON.stringify(this.copiedArea)), - attachment_uuid: currentArea.attachment_uuid, - page: currentArea.page, - x: currentArea.x, - y: currentArea.y + currentArea.h * 1.3 - } + if (!area) return - if (['radio', 'multiple'].includes(field.type)) { - this.copiedArea.option_uuid ||= field.options[0].uuid - area.option_uuid = v4() + const field = this.template.fields.find((f) => f.areas?.includes(area)) - const lastOption = field.options[field.options.length - 1] + if (!field) return - if (!field.areas.find((a) => lastOption.uuid === a.option_uuid)) { - area.option_uuid = lastOption.uuid - } else { - field.options.push({ uuid: area.option_uuid }) - } + const clipboardData = { + field: JSON.parse(JSON.stringify(field)), + area: JSON.parse(JSON.stringify(area)), + templateId: this.template.id, + timestamp: Date.now() + } + + delete clipboardData.field.areas + delete clipboardData.field.uuid + delete clipboardData.field.submitter_uuid + + try { + localStorage.setItem('docuseal_clipboard', JSON.stringify(clipboardData)) + } catch (e) { + console.error('Failed to save clipboard:', e) + } + }, + pasteField (targetPosition = null) { + let field = null + let area = null + let isSameTemplate = false + + const clipboard = localStorage.getItem('docuseal_clipboard') + + if (clipboard) { + const data = JSON.parse(clipboard) - field.areas.push(area) + if (Date.now() - data.timestamp < 3600000) { + field = data.field + area = data.area + isSameTemplate = data.templateId === this.template.id } else { - const newField = { - ...JSON.parse(JSON.stringify(field)), - uuid: v4(), - areas: [area] - } + localStorage.removeItem('docuseal_clipboard') + } + } + + if (!field || !area) return + + if (!isSameTemplate) { + delete field.conditions + delete field.preferences?.formula + } + + const currentArea = this.selectedAreaRef.value + const defaultAttachmentUuid = this.template.schema[0]?.attachment_uuid - this.insertField(newField) + if (field && currentArea) { + const attachmentUuid = targetPosition?.attachment_uuid || + (this.template.documents.find((d) => d.uuid === currentArea.attachment_uuid) ? currentArea.attachment_uuid : null) || + defaultAttachmentUuid + + const newArea = { + ...JSON.parse(JSON.stringify(area)), + attachment_uuid: attachmentUuid, + page: targetPosition?.page ?? (attachmentUuid === currentArea.attachment_uuid ? currentArea.page : 0), + x: targetPosition ? (targetPosition.x - area.w / 2) : currentArea.x, + y: targetPosition ? (targetPosition.y - area.h / 2) : (currentArea.y + currentArea.h * 1.3) } - this.selectedAreaRef.value = area + const newField = { + ...JSON.parse(JSON.stringify(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) + + this.selectedAreaRef.value = newArea this.save() } }, + 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 + } + }, pushUndo () { const stringData = JSON.stringify(this.template) diff --git a/app/javascript/template_builder/context_menu.vue b/app/javascript/template_builder/context_menu.vue new file mode 100644 index 00000000..5323b44a --- /dev/null +++ b/app/javascript/template_builder/context_menu.vue @@ -0,0 +1,357 @@ + + + diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index e512fb2c..8cd267da 100644 --- a/app/javascript/template_builder/document.vue +++ b/app/javascript/template_builder/document.vue @@ -22,8 +22,10 @@ :selected-submitter="selectedSubmitter" :total-pages="sortedPreviewImages.length" :image="image" - @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)" + @copy-field="$emit('copy-field', $event)" + @paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })" @scroll-to="scrollToArea" @draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })" /> @@ -118,7 +120,7 @@ export default { default: false } }, - emits: ['draw', 'drop-field', 'remove-area'], + emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field'], data () { return { pageRefs: [] diff --git a/app/javascript/template_builder/i18n.js b/app/javascript/template_builder/i18n.js index 5cbc6a19..7f4bfade 100644 --- a/app/javascript/template_builder/i18n.js +++ b/app/javascript/template_builder/i18n.js @@ -185,7 +185,9 @@ const en = { start_tour: 'Start Tour', or_add_from: 'Or add from', sync: 'Sync', - syncing: 'Syncing...' + syncing: 'Syncing...', + copy: 'Copy', + paste: 'Paste' } const es = { @@ -375,7 +377,9 @@ const es = { start_tour: 'Iniciar guía', or_add_from: 'O agregar desde', sync: 'Sincronizar', - syncing: 'Sincronizando...' + syncing: 'Sincronizando...', + copy: 'Copiar', + paste: 'Pegar' } const it = { @@ -565,7 +569,9 @@ const it = { start_tour: 'Inizia il tour', or_add_from: 'O aggiungi da', sync: 'Sincronizza', - syncing: 'Sincronizzazione...' + syncing: 'Sincronizzazione...', + copy: 'Copia', + paste: 'Incolla' } const pt = { @@ -755,7 +761,9 @@ const pt = { start_tour: 'Iniciar tour', or_add_from: 'Ou adicionar de', sync: 'Sincronizar', - syncing: 'Sincronizando...' + syncing: 'Sincronizando...', + copy: 'Copiar', + paste: 'Colar' } const fr = { @@ -945,7 +953,9 @@ const fr = { start_tour: 'Démarrer', or_add_from: 'Ou ajouter depuis', sync: 'Synchroniser', - syncing: 'Synchronisation...' + syncing: 'Synchronisation...', + copy: 'Copier', + paste: 'Coller' } const de = { @@ -1135,7 +1145,9 @@ const de = { start_tour: 'Tour starten', or_add_from: 'Oder hinzufügen aus', sync: 'Synchronisieren', - syncing: 'Synchronisiere...' + syncing: 'Synchronisiere...', + copy: 'Kopieren', + paste: 'Einfügen' } const nl = { @@ -1325,7 +1337,9 @@ const nl = { start_tour: 'Rondleiding starten', or_add_from: 'Of toevoegen van', sync: 'Synchroniseren', - syncing: 'Synchroniseren...' + syncing: 'Synchroniseren...', + copy: 'Kopiëren', + paste: 'Plakken' } export { en, es, it, pt, fr, de, nl } diff --git a/app/javascript/template_builder/page.vue b/app/javascript/template_builder/page.vue index 75ddf023..da3f7614 100644 --- a/app/javascript/template_builder/page.vue +++ b/app/javascript/template_builder/page.vue @@ -17,6 +17,7 @@
+
import FieldArea from './area' +import ContextMenu from './context_menu' export default { name: 'TemplatePage', components: { - FieldArea + FieldArea, + ContextMenu }, - inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize'], + inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize', 'selectedAreaRef'], props: { image: { type: Object, @@ -157,13 +172,14 @@ export default { required: true } }, - emits: ['draw', 'drop-field', 'remove-area', 'scroll-to'], + emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to'], data () { return { areaRefs: [], showMask: false, resizeDirection: null, - newArea: null + newArea: null, + contextMenu: null } }, computed: { @@ -211,6 +227,81 @@ export default { this.image.metadata.width = e.target.naturalWidth this.image.metadata.height = e.target.naturalHeight }, + openContextMenu (event) { + if (!this.editable) { + return + } + + event.preventDefault() + event.stopPropagation() + + const rect = this.$refs.image.getBoundingClientRect() + + this.newArea = null + this.showMask = false + + this.contextMenu = { + x: event.clientX, + y: event.clientY, + relativeX: (event.clientX - rect.left) / rect.width, + relativeY: (event.clientY - rect.top) / rect.height + } + }, + openAreaContextMenu (event, area, field) { + if (!this.editable) { + return + } + + event.preventDefault() + event.stopPropagation() + + const rect = this.$refs.image.getBoundingClientRect() + + this.newArea = null + this.showMask = false + + this.contextMenu = { + x: event.clientX, + y: event.clientY, + relativeX: (event.clientX - rect.left) / rect.width, + relativeY: (event.clientY - rect.top) / rect.height, + area, + field + } + }, + closeContextMenu () { + this.contextMenu = null + this.newArea = null + this.showMask = false + }, + handleCopy () { + if (this.contextMenu.area) { + this.selectedAreaRef.value = this.contextMenu.area + + this.$emit('copy-field') + } + + this.closeContextMenu() + }, + handleDelete () { + if (this.contextMenu.area) { + this.$emit('remove-area', this.contextMenu.area) + } + + this.closeContextMenu() + }, + handlePaste () { + this.newArea = null + this.showMask = false + + this.$emit('paste-field', { + page: this.number, + x: this.contextMenu.relativeX, + y: this.contextMenu.relativeY + }) + + this.closeContextMenu() + }, setAreaRefs (el) { if (el) { this.areaRefs.push(el) @@ -243,6 +334,10 @@ export default { }) }, onStartDraw (e) { + if (e.button === 2) { + return + } + if (!this.allowDraw) { return }