Merge from docusealco/wip

pull/572/head 2.3.0
Alex Turchyn 2 weeks ago committed by GitHub
commit b3e72f0726
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -47,7 +47,7 @@ FROM ruby:3.4.2-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/app/openssl_legacy.cnf
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
@ -65,36 +65,35 @@ legacy = legacy_sect\n\
activate = 1\n\
\n\
[legacy_sect]\n\
activate = 1' >> /app/openssl_legacy.cnf
activate = 1' >> /etc/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime
COPY ./bin ./bin
COPY ./app ./app
COPY ./config ./config
COPY ./db/migrate ./db/migrate
COPY ./log ./log
COPY ./lib ./lib
COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version
COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app
COPY --chown=docuseal:docuseal ./config ./config
COPY --chown=docuseal:docuseal ./db/migrate ./db/migrate
COPY --chown=docuseal:docuseal ./log ./log
COPY --chown=docuseal:docuseal ./lib ./lib
COPY --chown=docuseal:docuseal ./public ./public
COPY --chown=docuseal:docuseal ./tmp ./tmp
COPY --chown=docuseal:docuseal LICENSE README.md Rakefile config.ru .version ./
COPY --chown=docuseal:docuseal .version ./public/version
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --from=download /model.onnx /app/tmp/model.onnx
COPY --from=webpack /app/public/packs ./public/packs
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile -j 1 --gemfile app/ lib/
RUN chown -R docuseal:docuseal /app
RUN ln -s /fonts /app/public/fonts && \
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache
WORKDIR /data/docuseal
ENV HOME=/home/docuseal

@ -11,11 +11,14 @@ class TemplatesDetectFieldsController < ApplicationController
sse = SSE.new(response.stream)
documents = @template.schema_documents.preload(:blob)
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
page_number = params[:page].present? ? params[:page].to_i : nil
documents.each do |document|
io = StringIO.new(document.download)
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)|
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: })
end
end

@ -1244,17 +1244,31 @@ export default {
} else if (['equal', 'contains'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
if (option) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return [this.values[condition.field_uuid] ?? defaultValue].flat().includes(condition.value)
}
} else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
if (option) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return false
}
} else {
return true
}

@ -8,7 +8,7 @@
@touchstart="startTouchDrag"
>
<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="activeBorderClasses"
/>
@ -24,7 +24,7 @@
:style="{ left: (cellW / area.w * 100) + '%' }"
>
<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"
style="left: -4px"
@mousedown.stop="startResizeCell"
@ -32,7 +32,7 @@
</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"
style="top: -25px; height: 25px"
@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]'"
:submitters="template.submitters"
@update:model-value="save"
@click="selectedAreaRef.value = area"
@click="selectedAreasRef.value = [area]"
/>
<FieldType
v-model="field.type"
@ -57,7 +57,7 @@
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="selectedAreaRef.value = area"
@click="selectedAreasRef.value = [area]"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
@ -146,7 +146,7 @@
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true"
@scroll-to="[selectedAreaRef.value = $event, $emit('scroll-to', $event)]"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
</ul>
</span>
@ -266,7 +266,7 @@
ref="defaultValueSelect"
class="bg-transparent outline-none focus:outline-none w-full"
@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"
>
<option
@ -293,7 +293,7 @@
: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'))"
@blur="onDefaultValueBlur"
@focus="selectedAreaRef.value = area"
@focus="selectedAreasRef.value = [area]"
@paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span>
@ -319,6 +319,7 @@
<span
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="{ 'z-30': isInMultiSelection }"
@mousedown.stop="startResize"
@touchstart="startTouchResize"
/>
@ -398,7 +399,7 @@ export default {
FieldSubmitter,
IconX
},
inject: ['template', 'selectedAreaRef', 'save', 't', 'isInlineSize'],
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef'],
props: {
area: {
type: Object,
@ -463,6 +464,11 @@ export default {
type: Object,
required: false,
default: null
},
isSelectMode: {
type: Boolean,
required: false,
default: false
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'],
@ -646,7 +652,10 @@ export default {
]
},
isSelected () {
return this.selectedAreaRef.value === this.area
return this.selectedAreasRef.value.includes(this.area)
},
isInMultiSelection () {
return this.selectedAreasRef.value.length >= 2 && this.isSelected
},
positionStyle () {
const { x, y, w, h } = this.area
@ -683,10 +692,10 @@ export default {
buildAreaOptionValue (area) {
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 () {
if (!this.editable) {
if (!this.editable || this.isCmdKeyRef.value) {
return
}
@ -770,7 +779,7 @@ export default {
}
},
onNameFocus (e) {
this.selectedAreaRef.value = this.area
this.selectedAreasRef.value = [this.area]
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
@ -906,6 +915,15 @@ export default {
if (e.target.id === 'mask') {
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
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) {
@ -931,7 +949,7 @@ export default {
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 }
@ -980,13 +998,23 @@ export default {
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) {
this.isDragged = true
}
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 }
@ -1055,7 +1083,9 @@ export default {
this.$emit('stop-drag')
},
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('mouseup', this.stopResize)
@ -1071,7 +1101,9 @@ export default {
this.save()
},
startTouchResize (e) {
this.selectedAreaRef.value = this.area
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
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.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 () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)

@ -373,9 +373,16 @@
:draw-field-type="drawFieldType"
:editable="editable"
:base-url="baseUrl"
:with-fields-detection="withFieldsDetection"
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
@drop-field="onDropfield"
@remove-area="removeArea"
@paste-field="pasteField"
@copy-field="copyField"
@copy-selected-areas="copySelectedAreas"
@delete-selected-areas="deleteSelectedAreas"
@align-selected-areas="alignSelectedAreas"
@autodetect-fields="detectFieldsForPage"
/>
<DocumentControls
v-if="isBreakpointLg && editable"
@ -518,6 +525,43 @@
@select="startFieldDraw($event)"
/>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-y-4 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-4 opacity-0"
>
<div
v-if="isDetectingPageFields || detectingFieldsAddedCount !== null"
class="sticky bottom-0 z-50"
>
<div class="absolute left-0 right-0 h-0 overflow-visible bottom-16 z-50 flex justify-center">
<div
class="rounded-full bg-base-content h-12 flex items-center justify-center space-x-1.5 uppercase font-semibold text-white text-sm cursor-default"
style="min-width: 180px"
>
<template v-if="detectingFieldsAddedCount !== null">
<span>{{ (detectingFieldsAddedCount === 1 ? t('field_added') : t('fields_added')).replace('{count}', detectingFieldsAddedCount) }}</span>
</template>
<template v-else>
<IconInnerShadowTop
v-if="!detectingAnalyzingProgress"
width="20"
class="animate-spin"
/>
<span v-if="detectingAnalyzingProgress">
{{ Math.round(detectingAnalyzingProgress * 100) }}% {{ t('analyzing_') }}
</span>
<span v-else>
{{ t('processing_') }}
</span>
</template>
</div>
</div>
</div>
</Transition>
<div
id="docuseal_modal_container"
class="modal-container"
@ -590,8 +634,10 @@ export default {
withConditions: this.withConditions,
isInlineSize: this.isInlineSize,
defaultDrawFieldType: this.defaultDrawFieldType,
selectedAreaRef: computed(() => this.selectedAreaRef),
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef)
selectedAreasRef: computed(() => this.selectedAreasRef),
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef),
isSelectModeRef: computed(() => this.isSelectModeRef),
isCmdKeyRef: computed(() => this.isCmdKeyRef)
}
},
props: {
@ -848,11 +894,13 @@ export default {
isDownloading: false,
isLoadingBlankPage: false,
isSaving: false,
isDetectingPageFields: false,
detectingAnalyzingProgress: null,
detectingFieldsAddedCount: null,
selectedSubmitter: null,
showDrawField: false,
pendingFieldAttachmentUuids: [],
drawField: null,
copiedArea: null,
drawFieldType: null,
drawOption: null,
dragField: null,
@ -861,8 +909,10 @@ export default {
},
computed: {
submitterDefaultNames: FieldSubmitter.computed.names,
selectedAreaRef: () => ref(),
isSelectModeRef: () => ref(false),
isCmdKeyRef: () => ref(false),
fieldsDragFieldRef: () => ref(),
selectedAreasRef: () => ref([]),
language () {
return this.locale.split('-')[0].toLowerCase()
},
@ -876,6 +926,14 @@ export default {
isInlineSize () {
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 () {
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
@ -917,7 +975,7 @@ export default {
})
},
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 () {
return this.template.schema.map((item) => {
@ -1004,6 +1062,81 @@ export default {
},
methods: {
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 () {
this.isDownloading = true
@ -1388,49 +1521,92 @@ export default {
}
},
onKeyUp (e) {
this.isCmdKeyRef.value = false
if (e.code === 'Escape') {
this.selectedAreasRef.value = []
this.clearDrawField()
this.selectedAreaRef.value = null
}
if (this.editable && ['Backspace', 'Delete'].includes(e.key) && this.selectedAreaRef.value && document.activeElement === document.body) {
this.removeArea(this.selectedAreaRef.value)
this.selectedAreaRef.value = null
if (this.editable && ['Backspace', 'Delete'].includes(e.key) && document.activeElement === document.body) {
if (this.selectedAreasRef.value.length > 1) {
this.deleteSelectedAreas()
} else if (this.selectedAreasRef.value.length) {
this.removeArea(this.lastSelectedArea)
}
}
},
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.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()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
event.stopImmediatePropagation()
event.preventDefault()
this.selectedAreasRef.value = []
this.undo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) {
if (this.selectedAreasRef.value.length > 1) {
event.preventDefault()
this.copiedArea = this.selectedAreaRef?.value
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.copiedArea && document.activeElement === document.body) {
this.copySelectedAreas()
} else if (this.selectedAreasRef.value.length) {
event.preventDefault()
this.copyField()
}
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData() && document.activeElement === document.body) {
event.preventDefault()
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()
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) {
if (!this.editable) {
return
}
const area = this.selectedAreaRef.value
const area = this.lastSelectedArea
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const page = documentRef.pageRefs[area.page].$refs.image
const rect = page.getBoundingClientRect()
@ -1463,7 +1639,7 @@ export default {
this.save()
}, 700)
},
removeArea (area) {
removeArea (area, save = true) {
const field = this.template.fields.find((f) => f.areas?.includes(area))
field.areas.splice(field.areas.indexOf(area), 1)
@ -1474,7 +1650,11 @@ export default {
this.removeFieldConditions(field)
}
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
if (save) {
this.save()
}
},
removeFieldConditions (field) {
this.template.fields.forEach((f) => {
@ -1497,45 +1677,242 @@ export default {
}
})
},
pasteField () {
const field = this.template.fields.find((f) => f.areas?.includes(this.copiedArea))
const currentArea = this.selectedAreaRef?.value || this.copiedArea
copyField () {
const area = this.lastSelectedArea
if (!area) return
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
const field = this.template.fields.find((f) => f.areas?.includes(area))
if (!field) return
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)
}
},
copySelectedAreas () {
const items = []
if (['radio', 'multiple'].includes(field.type)) {
this.copiedArea.option_uuid ||= field.options[0].uuid
area.option_uuid = v4()
const areas = this.selectedAreasRef.value
const lastOption = field.options[field.options.length - 1]
const minX = Math.min(...areas.map(a => a.x))
const minY = Math.min(...areas.map(a => a.y))
if (!field.areas.find((a) => lastOption.uuid === a.option_uuid)) {
area.option_uuid = lastOption.uuid
} else {
field.options.push({ uuid: area.option_uuid })
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.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')
if (!clipboard) return
const data = JSON.parse(clipboard)
if (Date.now() - data.timestamp >= 3600000) {
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 (!isSameTemplate) {
delete field.conditions
delete field.preferences?.formula
}
const defaultAttachmentUuid = this.template.schema[0]?.attachment_uuid
if (field && (this.lowestSelectedArea || targetPosition)) {
const attachmentUuid = targetPosition?.attachment_uuid ||
(this.template.documents.find((d) => d.uuid === this.lowestSelectedArea.attachment_uuid) ? this.lowestSelectedArea.attachment_uuid : null) ||
defaultAttachmentUuid
const newArea = {
...JSON.parse(JSON.stringify(area)),
attachment_uuid: attachmentUuid,
page: targetPosition?.page ?? (attachmentUuid === this.lowestSelectedArea.attachment_uuid ? this.lowestSelectedArea.page : 0),
x: targetPosition ? (targetPosition.x - area.w / 2) : Math.min(...this.selectedAreasRef.value.map((area) => area.x)),
y: targetPosition ? (targetPosition.y - area.h / 2) : (this.lowestSelectedArea.y + this.lowestSelectedArea.h * 1.3)
}
field.areas.push(area)
} else {
const newField = {
...JSON.parse(JSON.stringify(field)),
uuid: v4(),
areas: [area]
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.selectedAreasRef.value = [newArea]
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 = []
const fieldUuidIndex = {}
const fieldOptionsMap = {}
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 = fieldUuidIndex[field.uuid] || {
...field,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
areas: []
}
fieldUuidIndex[field.uuid] = newField
newField.areas.push(newArea)
newAreas.push(newArea)
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid
if (!fieldOptionsMap[field.uuid]) {
fieldOptionsMap[field.uuid] = {}
newField.options = field.options.map((opt) => {
const newUuid = v4()
this.selectedAreaRef.value = area
fieldOptionsMap[field.uuid][opt.uuid] = newUuid
return { ...opt, uuid: newUuid }
})
}
newArea.option_uuid = fieldOptionsMap[field.uuid][oldOptionUuid] || newField.options[0].uuid
}
})
Object.values(fieldUuidIndex).forEach((field) => {
this.insertField(field)
})
this.selectedAreasRef.value = [...newAreas]
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 () {
@ -1589,8 +1966,8 @@ export default {
const previousArea = this.drawField.areas?.[this.drawField.areas.length - 1]
if (this.selectedField?.type === this.drawField.type) {
area.w = this.selectedAreaRef.value.w
area.h = this.selectedAreaRef.value.h
area.w = this.lastSelectedArea.w
area.h = this.lastSelectedArea.h
} else if (previousArea) {
area.w = previousArea.w
area.h = previousArea.h
@ -1621,7 +1998,7 @@ export default {
this.drawField = null
this.drawOption = null
this.selectedAreaRef.value = area
this.selectedAreasRef.value = [area]
this.save()
} else {
@ -1658,8 +2035,8 @@ export default {
if (this.drawFieldType && (area.w === 0 || area.h === 0)) {
if (this.selectedField?.type === this.drawFieldType) {
area.w = this.selectedAreaRef.value.w
area.h = this.selectedAreaRef.value.h
area.w = this.lastSelectedArea.w
area.h = this.lastSelectedArea.h
} else {
this.setDefaultAreaSize(area, this.drawFieldType)
}
@ -1671,7 +2048,7 @@ export default {
if (area.w && (type !== 'checkbox' || this.drawFieldType || !isTooSmall)) {
this.addField(type, area)
this.selectedAreaRef.value = area
this.selectedAreasRef.value = [area]
}
}
},
@ -1751,7 +2128,11 @@ export default {
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) {
this.insertField(field)
@ -1764,7 +2145,7 @@ export default {
if (field.type === 'heading') {
this.$nextTick(() => {
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
@ -1780,7 +2161,7 @@ export default {
let baseArea
if (this.selectedField?.type === fieldType) {
baseArea = this.selectedAreaRef.value
baseArea = this.lastSelectedArea
} else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1]
} else {
@ -1957,6 +2338,7 @@ export default {
},
onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data
// eslint-disable-next-line camelcase
const { google_drive_file_id, ...cleanedReplaceSchemaItem } = replaceSchemaItem
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
@ -2104,7 +2486,7 @@ export default {
documentRef.scrollToArea(area)
this.selectedAreaRef.value = area
this.selectedAreasRef.value = [area]
},
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
@ -2116,6 +2498,194 @@ export default {
}
})
},
detectFieldsForPage ({ page, attachmentUuid }) {
this.isDetectingPageFields = true
this.detectingAnalyzingProgress = null
this.detectingFieldsAddedCount = null
let totalFieldsAdded = 0
const hadFieldsBeforeDetection = this.template.fields.length > 0
const calculateIoU = (area1, area2) => {
const x1 = Math.max(area1.x, area2.x)
const y1 = Math.max(area1.y, area2.y)
const x2 = Math.min(area1.x + area1.w, area2.x + area2.w)
const y2 = Math.min(area1.y + area1.h, area2.y + area2.h)
const intersectionArea = Math.max(0, x2 - x1) * Math.max(0, y2 - y1)
const area1Size = area1.w * area1.h
const area2Size = area2.w * area2.h
const unionArea = area1Size + area2Size - intersectionArea
return unionArea > 0 ? intersectionArea / unionArea : 0
}
const hasOverlappingField = (newArea) => {
const pageAreas = this.fieldAreasIndex[newArea.attachment_uuid]?.[newArea.page] || []
return pageAreas.some(({ area: existingArea }) => {
return calculateIoU(existingArea, newArea) >= 0.1
})
}
const filterNonOverlappingFields = (detectedFields) => {
return detectedFields.filter((field) => {
return (field.areas || []).every((area) => !hasOverlappingField(area))
})
}
this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attachment_uuid: attachmentUuid, page })
}).then(async (response) => {
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
const fields = []
while (true) {
const { value, done } = await reader.read()
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop()
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.replace(/^data: /, '')
const data = JSON.parse(jsonStr)
if (data.error) {
const errorFields = filterNonOverlappingFields(data.fields || fields)
if (errorFields.length) {
errorFields.forEach((f) => {
if (!f.submitter_uuid) {
f.submitter_uuid = this.template.submitters[0].uuid
}
this.insertField(f)
})
totalFieldsAdded += errorFields.length
this.save()
} else if (!(data.fields || fields).length) {
alert(data.error)
}
break
} else if (data.analyzing) {
this.detectingAnalyzingProgress = data.progress
} else if (data.completed) {
if (data.submitters) {
if (!hadFieldsBeforeDetection) {
this.template.submitters = data.submitters
this.selectedSubmitter = this.template.submitters[0]
const finalFields = data.fields || fields
finalFields.forEach((f) => {
if (!f.submitter_uuid) {
f.submitter_uuid = this.template.submitters[0].uuid
}
})
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f))
totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) {
this.save()
}
} else {
const existingSubmitters = this.template.submitters
const submitterUuidMap = {}
data.submitters.forEach((newSubmitter) => {
const existingMatch = existingSubmitters.find(
(s) => s.name.toLowerCase() === newSubmitter.name.toLowerCase()
)
if (existingMatch) {
submitterUuidMap[newSubmitter.uuid] = existingMatch.uuid
} else {
submitterUuidMap[newSubmitter.uuid] = newSubmitter.uuid
if (!existingSubmitters.find((s) => s.uuid === newSubmitter.uuid)) {
this.template.submitters.push(newSubmitter)
}
}
})
const finalFields = data.fields || fields
finalFields.forEach((f) => {
if (f.submitter_uuid && submitterUuidMap[f.submitter_uuid]) {
f.submitter_uuid = submitterUuidMap[f.submitter_uuid]
} else if (!f.submitter_uuid) {
f.submitter_uuid = this.template.submitters[0].uuid
}
})
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f))
totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) {
this.save()
}
}
} else {
const finalFields = data.fields || fields
finalFields.forEach((f) => {
if (!f.submitter_uuid) {
f.submitter_uuid = this.template.submitters[0].uuid
}
})
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f))
totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) {
this.save()
}
}
break
} else if (data.fields) {
data.fields.forEach((f) => {
if (!f.submitter_uuid) {
f.submitter_uuid = this.template.submitters[0].uuid
}
})
fields.push(...data.fields)
}
}
}
if (done) break
}
}).catch(error => {
console.error('Error in streaming message: ', error)
}).finally(() => {
this.isDetectingPageFields = false
this.detectingAnalyzingProgress = null
this.detectingFieldsAddedCount = totalFieldsAdded
setTimeout(() => {
this.detectingFieldsAddedCount = null
}, 1000)
})
},
save ({ force } = { force: false }) {
this.pendingFieldAttachmentUuids = []

@ -168,9 +168,19 @@ export default {
buildDefaultName: {
type: Function,
required: true
},
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
excludeFieldUuids: {
type: Array,
required: false,
default: () => []
}
},
emits: ['close'],
emits: ['close', 'click-save'],
data () {
return {
conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}]
@ -183,14 +193,14 @@ export default {
fields () {
if (this.item.submitter_uuid) {
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)
}
return acc
}, [])
} 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
}
if (this.withClickSaveEvent) {
this.$emit('click-save')
} else {
this.save()
}
this.$emit('close')
}
}

@ -0,0 +1,546 @@
<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'],
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'].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'].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>

@ -22,8 +22,16 @@
:selected-submitter="selectedSubmitter"
:total-pages="sortedPreviewImages.length"
:image="image"
:attachment-uuid="document.uuid"
:with-fields-detection="withFieldsDetection"
@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 })"
@copy-selected-areas="$emit('copy-selected-areas')"
@delete-selected-areas="$emit('delete-selected-areas')"
@align-selected-areas="$emit('align-selected-areas', $event)"
@autodetect-fields="$emit('autodetect-fields', $event)"
@scroll-to="scrollToArea"
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
/>
@ -116,9 +124,14 @@ export default {
type: Boolean,
required: false,
default: false
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
}
},
emits: ['draw', 'drop-field', 'remove-area'],
emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields'],
data () {
return {
pageRefs: []

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

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

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

@ -185,7 +185,18 @@ const en = {
start_tour: 'Start Tour',
or_add_from: 'Or add from',
sync: 'Sync',
syncing: 'Syncing...'
syncing: 'Syncing...',
copy: 'Copy',
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',
field_added: '{count} Field Added',
fields_added: '{count} Fields Added'
}
const es = {
@ -375,7 +386,18 @@ const es = {
start_tour: 'Iniciar guía',
or_add_from: 'O agregar desde',
sync: 'Sincronizar',
syncing: 'Sincronizando...'
syncing: 'Sincronizando...',
copy: 'Copiar',
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',
field_added: '{count} Campo Añadido',
fields_added: '{count} Campos Añadidos'
}
const it = {
@ -565,7 +587,18 @@ const it = {
start_tour: 'Inizia il tour',
or_add_from: 'O aggiungi da',
sync: 'Sincronizza',
syncing: 'Sincronizzazione...'
syncing: 'Sincronizzazione...',
copy: 'Copia',
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',
field_added: '{count} Campo Aggiunto',
fields_added: '{count} Campi Aggiunti'
}
const pt = {
@ -755,7 +788,18 @@ const pt = {
start_tour: 'Iniciar tour',
or_add_from: 'Ou adicionar de',
sync: 'Sincronizar',
syncing: 'Sincronizando...'
syncing: 'Sincronizando...',
copy: 'Copiar',
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',
field_added: '{count} Campo Adicionado',
fields_added: '{count} Campos Adicionados'
}
const fr = {
@ -945,7 +989,18 @@ const fr = {
start_tour: 'Démarrer',
or_add_from: 'Ou ajouter depuis',
sync: 'Synchroniser',
syncing: 'Synchronisation...'
syncing: 'Synchronisation...',
copy: 'Copier',
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',
field_added: '{count} Champ Ajouté',
fields_added: '{count} Champs Ajoutés'
}
const de = {
@ -1135,7 +1190,18 @@ const de = {
start_tour: 'Tour starten',
or_add_from: 'Oder hinzufügen aus',
sync: 'Synchronisieren',
syncing: 'Synchronisiere...'
syncing: 'Synchronisiere...',
copy: 'Kopieren',
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',
field_added: '{count} Feld Hinzugefügt',
fields_added: '{count} Felder Hinzugefügt'
}
const nl = {
@ -1325,7 +1391,18 @@ const nl = {
start_tour: 'Rondleiding starten',
or_add_from: 'Of toevoegen van',
sync: 'Synchroniseren',
syncing: 'Synchroniseren...'
syncing: 'Synchroniseren...',
copy: 'Kopiëren',
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',
field_added: '{count} Veld Toegevoegd',
fields_added: '{count} Velden Toegevoegd'
}
export { en, es, it, pt, fr, de, nl }

@ -1,7 +1,7 @@
<template>
<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="{ 'cursor-crosshair': allowDraw && editable, 'touch-none': !!drawField }"
:class="{ 'cursor-crosshair': allowDraw && editable && !isSelectMode, 'touch-none': !!drawField }"
style="container-type: size"
:style="{ aspectRatio: `${width} / ${height}`}"
>
@ -17,7 +17,20 @@
<div
class="top-0 bottom-0 left-0 right-0 absolute"
@pointerdown="onStartDraw"
@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
v-for="(item, i) in areas"
:key="i"
@ -34,10 +47,12 @@
:default-field="defaultFieldsIndex[item.field.name]"
:default-submitters="defaultSubmitters"
:max-page="totalPages - 1"
:is-select-mode="isSelectMode"
@start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null"
@remove="$emit('remove-area', item.area)"
@scroll-to="$emit('scroll-to', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
/>
<FieldArea
v-if="newArea"
@ -47,15 +62,45 @@
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || dragFieldPlaceholder?.type || defaultFieldType }"
:area="newArea"
/>
<div
v-if="selectionRect"
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="selectionRectStyle"
/>
<ContextMenu
v-if="contextMenu"
:context-menu="contextMenu"
:field="contextMenu.field"
:editable="editable"
:with-fields-detection="withFieldsDetection"
@copy="handleCopy"
@delete="handleDelete"
@paste="handlePaste"
@autodetect-fields="handleAutodetectFields"
@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
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value"
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value || selectionRect"
id="mask"
ref="mask"
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"
@pointerdown="onStartDraw"
@contextmenu="openContextMenu"
@dragover.prevent="onDragover"
@dragenter="onDragenter"
@dragleave="newArea = null"
@ -67,13 +112,17 @@
<script>
import FieldArea from './area'
import ContextMenu from './context_menu'
import SelectionBox from './selection_box'
export default {
name: 'TemplatePage',
components: {
FieldArea
FieldArea,
ContextMenu,
SelectionBox
},
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize'],
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef'],
props: {
image: {
type: Object,
@ -155,18 +204,69 @@ export default {
number: {
type: Number,
required: true
},
attachmentUuid: {
type: String,
required: false,
default: ''
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
}
},
emits: ['draw', 'drop-field', 'remove-area', 'scroll-to'],
emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields'],
data () {
return {
areaRefs: [],
showMask: false,
resizeDirection: null,
newArea: null
newArea: null,
contextMenu: null,
selectionRect: null,
selectionContextMenu: null
}
},
computed: {
isSelectMode () {
return this.isSelectModeRef.value && !this.drawFieldType && this.editable && !this.drawField
},
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 () {
return this.defaultFields.reduce((acc, field) => {
acc[field.name] = field
@ -201,6 +301,16 @@ export default {
},
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 () {
@ -211,6 +321,114 @@ 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
}
},
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 () {
this.contextMenu = null
this.newArea = null
this.showMask = false
},
handleCopy () {
if (this.contextMenu.area) {
this.selectedAreasRef.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()
},
handleAutodetectFields () {
this.$emit('autodetect-fields', {
page: this.number,
attachmentUuid: this.attachmentUuid
})
this.closeContextMenu()
},
setAreaRefs (el) {
if (el) {
this.areaRefs.push(el)
@ -243,6 +461,20 @@ export default {
})
},
onStartDraw (e) {
if (e.button === 2) {
return
}
if (this.selectedAreasRef.value.length >= 2) {
this.selectedAreasRef.value = []
}
if (this.isSelectMode) {
this.startSelectionRect(e)
return
}
if (!this.allowDraw) {
return
}
@ -268,7 +500,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) {
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) {
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
@ -294,7 +588,20 @@ export default {
}
},
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 = {
x: this.newArea.x,
y: this.newArea.y,
@ -317,6 +624,14 @@ export default {
this.showMask = false
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>

@ -78,7 +78,8 @@ class Submission < ApplicationRecord
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
scope :pending, lambda {
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
where(expire_at: nil).or(where(expire_at: Time.current..))
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
scope :completed, lambda {

@ -135,7 +135,7 @@
<a href="mailto:<%= Docuseal::SUPPORT_EMAIL %>" target="_blank" class="w-full block mt-4 underline text-center">
<%= Docuseal::SUPPORT_EMAIL %>
</a>
<% if Docuseal.version.present? %>
<% if Docuseal.version.present? && !Docuseal.multitenant? && can?(:manage, EncryptedConfig) %>
<a href="https://github.com/docusealco/docuseal/releases" target="_blank" class="badge badge-outline text-xs block mx-auto mt-4">
v<%= Docuseal.version %>
</a>

@ -30,14 +30,14 @@
<div class="grid <%= 'md:grid-cols-2 gap-1' if submitters.size == 1 %>">
<submitters-autocomplete data-field="email">
<linked-input data-target-id="<%= "detailed_email_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: (local_assigns[:require_email_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:email) : "#{t('email')} (#{t('optional')})"), value: item['email'].presence || ((params[:selfsign] && index.zero?) || item['is_requester'] ? current_user.email : ''), id: "detailed_email_#{item['uuid']}", required: local_assigns[:require_email_2fa] == true %>
<%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: (local_assigns[:require_email_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:email) : "#{t('email')} (#{t('optional')})"), value: item['email'].presence || ((params[:selfsign] && index.zero?) || item['is_requester'] ? current_user.email : ''), id: "detailed_email_#{item['uuid']}", required: local_assigns[:require_email_2fa] == true && index.zero? %>
</linked-input>
</submitters-autocomplete>
<% has_phone_field = true %>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true %>
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true && index.zero? %>
</linked-input>
</submitters-autocomplete>
</custom-validation>
@ -99,6 +99,7 @@
<% if has_phone_field %>
<%= render 'send_sms', f: %>
<% end %>
<%= render 'extra_fields', f: %>
</div>
<div class="form-control">
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %>

@ -47,8 +47,9 @@
</dynamic-list>
<% end %>
<div>
<%= render 'send_email', f:, template: %>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_email', f:, template: %>
<%= render 'extra_fields', f: %>
</div>
<div class="form-control">
<%= content_for(:submit_button) || capture do %>

@ -58,7 +58,8 @@
</dynamic-list>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_sms', f: %>
<%= render 'send_sms', f:, checked: true %>
<%= render 'extra_phone_fields', f: %>
</div>
<div class="form-control">
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %>

@ -133,7 +133,7 @@
<% bg_class = bg_classes[submitter_index % bg_classes.size] %>
<div class="absolute overflow-visible" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%;">
<div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>">
<%= svg_icon(SubmissionsController::FIELD_ICONS[field['type']], class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
<%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
</div>
</div>
<% end %>

@ -29,6 +29,10 @@ en: &en
pro: Pro
thanks: Thanks
private: Private
require_email_2fa: Require email 2FA
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: When checked, each signer must verify their email with a one-time code before accessing the document.
require_phone_2fa: Require phone 2FA
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: When checked, each signer must verify their phone with a one-time code before accessing the document.
select: Select
enabled: Enabled
disabled: Disabled
@ -1025,6 +1029,10 @@ es: &es
stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada.
re_connect_stripe: Volver a conectar Stripe
private: Privado
require_email_2fa: Requerir 2FA por correo electrónico
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Cuando está marcado, cada firmante debe verificar su correo electrónico con un código de un solo uso antes de acceder al documento.
require_phone_2fa: Requerir 2FA por teléfono
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Cuando está marcado, cada firmante debe verificar su teléfono con un código de un solo uso antes de acceder al documento.
resend_pending: Reenviar pendiente
ensure_unique_recipients: Asegurar destinatarios únicos
require_phone_2fa_to_open: Requiere 2FA por teléfono para abrir
@ -2003,6 +2011,10 @@ it: &it
stripe_account_has_been_connected: L'account Stripe è stato collegato.
re_connect_stripe: Ricollega Stripe
private: Privato
require_email_2fa: Richiedi 2FA email
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Quando selezionato, ogni firmatario deve verificare la propria email con un codice monouso prima di accedere al documento.
require_phone_2fa: Richiedi 2FA telefono
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Quando selezionato, ogni firmatario deve verificare il proprio numero di telefono con un codice monouso prima di accedere al documento.
resend_pending: Reinvia in sospeso
ensure_unique_recipients: Assicurarsi destinatari unici
require_phone_2fa_to_open: Richiedi l'autenticazione a due fattori tramite telefono per aprire
@ -2982,6 +2994,10 @@ fr: &fr
stripe_account_has_been_connected: Le compte Stripe a été connecté.
re_connect_stripe: Reconnecter Stripe
private: Privé
require_email_2fa: Exiger la 2FA par email
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Lorsque coché, chaque signataire doit vérifier son email avec un code à usage unique avant d'accéder au document.
require_phone_2fa: Exiger la 2FA par téléphone
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Lorsque coché, chaque signataire doit vérifier son numéro de téléphone avec un code à usage unique avant d'accéder au document.
resend_pending: Renvoyer en attente
ensure_unique_recipients: Garantir des destinataires uniques
require_phone_2fa_to_open: Exiger la 2FA par téléphone pour ouvrir
@ -3957,6 +3973,10 @@ pt: &pt
stripe_account_has_been_connected: Conta Stripe foi conectada.
re_connect_stripe: Reconectar Stripe
private: Privado
require_email_2fa: Exigir 2FA por email
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Quando marcado, cada signatário deve verificar seu email com um código de uso único antes de acessar o documento.
require_phone_2fa: Exigir 2FA por telefone
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Quando marcado, cada signatário deve verificar seu telefone com um código de uso único antes de acessar o documento.
resend_pending: Re-enviar pendente
ensure_unique_recipients: Garantir destinatários únicos
require_phone_2fa_to_open: Necessário autenticação de dois fatores via telefone para abrir
@ -4921,6 +4941,10 @@ de: &de
pro: Pro
thanks: Danke
private: Privat
require_email_2fa: E-Mail 2FA erforderlich
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Wenn aktiviert, muss jeder Unterzeichner seine E-Mail mit einem einmaligen Code verifizieren, bevor er auf das Dokument zugreifen kann.
require_phone_2fa: Telefon 2FA erforderlich
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Wenn aktiviert, muss jeder Unterzeichner sein Telefon mit einem einmaligen Code verifizieren, bevor er auf das Dokument zugreifen kann.
select: Auswählen
enabled: Aktiviert
disabled: Deaktiviert
@ -6287,6 +6311,10 @@ nl: &nl
pro: Pro
thanks: Bedankt
private: Privé
require_email_2fa: E-mail 2FA vereist
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Wanneer aangevinkt, moet elke ondertekenaar zijn e-mail verifiëren met een eenmalige code voordat hij toegang krijgt tot het document.
require_phone_2fa: Telefoon 2FA vereist
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Wanneer aangevinkt, moet elke ondertekenaar zijn telefoon verifiëren met een eenmalige code voordat hij toegang krijgt tot het document.
select: Selecteer
enabled: Ingeschakeld
disabled: Uitgeschakeld

@ -13,9 +13,9 @@ services:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/docuseal
postgres:
image: postgres:15
image: postgres:18
volumes:
- './pg_data:/var/lib/postgresql/data'
- './pg_data:/var/lib/postgresql/18/docker'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -34,6 +34,6 @@ services:
- 443:443
- 443:443/udp
volumes:
- .:/data
- ./caddy:/data/caddy
environment:
- HOST=${HOST}

@ -39,6 +39,7 @@ module Docuseal
CERTS = JSON.parse(ENV.fetch('CERTS', '{}'))
TIMESERVER_URL = ENV.fetch('TIMESERVER_URL', nil)
VERSION_FILE_PATH = Rails.root.join('.version')
VERSION_FILE2_PATH = Rails.public_path.join('version')
DEFAULT_URL_OPTIONS = {
host: HOST,
@ -48,7 +49,12 @@ module Docuseal
module_function
def version
@version ||= VERSION_FILE_PATH.read.strip if VERSION_FILE_PATH.exist?
@version ||=
if VERSION_FILE_PATH.exist?
VERSION_FILE_PATH.read.strip
elsif VERSION_FILE2_PATH.exist?
VERSION_FILE2_PATH.each_line.first.to_s.strip
end
end
def multitenant?

@ -318,7 +318,7 @@ module Submissions
timezone = submitter.account.timezone
timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone
if with_signature_id_reason
if with_signature_id_reason || field.dig('preferences', 'reasons').present?
"#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \

@ -318,21 +318,38 @@ module Submitters
end
def check_field_condition(condition, submitter_values, fields_uuid_index)
value = submitter_values[condition['field_uuid']]
case condition['action']
when 'empty', 'unchecked'
submitter_values[condition['field_uuid']].blank?
value.blank?
when 'not_empty', 'checked'
submitter_values[condition['field_uuid']].present?
value.present?
when 'equal', 'contains'
field = fields_uuid_index[condition['field_uuid']]
return true unless field
values = Array.wrap(value)
return values.include?(condition['value']) unless field['options']
option = field['options'].find { |o| o['uuid'] == condition['value'] }
values = Array.wrap(submitter_values[condition['field_uuid']])
return false unless option
values.include?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}")
when 'not_equal', 'does_not_contain'
field = fields_uuid_index[condition['field_uuid']]
return true unless field
return false unless field['options']
option = field['options'].find { |o| o['uuid'] == condition['value'] }
values = Array.wrap(submitter_values[condition['field_uuid']])
return false unless option
values = Array.wrap(value)
values.exclude?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}")
else

@ -60,21 +60,23 @@ module Templates
# rubocop:disable Metrics, Style
def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields, nms: 0.1,
nmm: 0.5, split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, &)
nmm: 0.5, split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, page_number: nil, &)
fields, head_node =
if attachment&.image?
process_image_attachment(io, attachment:, confidence:, nms:, nmm:, split_page:, inference:,
temperature:, aspect_ratio:, padding:, &)
temperature:, aspect_ratio:, padding:, page_number:, &)
else
process_pdf_attachment(io, attachment:, confidence:, nms:, nmm:, split_page:, inference:,
temperature:, aspect_ratio:, regexp_type:, padding:, &)
temperature:, aspect_ratio:, regexp_type:, padding:, page_number:, &)
end
[fields, head_node]
end
def process_image_attachment(io, attachment:, confidence:, nms:, nmm:, temperature:, inference:,
split_page: false, aspect_ratio: false, padding: nil)
split_page: false, aspect_ratio: false, padding: nil, page_number: nil)
return [[], nil] if page_number && page_number != 0
image = Vips::Image.new_from_buffer(io.read, '')
fields = inference.call(image, confidence:, nms:, nmm:, split_page:,
@ -105,14 +107,19 @@ module Templates
end
def process_pdf_attachment(io, attachment:, confidence:, nms:, nmm:, temperature:, inference:,
split_page: false, aspect_ratio: false, padding: nil, regexp_type: false)
split_page: false, aspect_ratio: false, padding: nil, regexp_type: false,
page_number: nil)
doc = Pdfium::Document.open_bytes(io.read)
head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid)
tail_node = head_node
fields = doc.page_count.times.flat_map do |page_number|
page = doc.get_page(page_number)
page_range = page_number ? [page_number] : (0...doc.page_count)
fields = page_range.flat_map do |current_page_number|
next [] if current_page_number >= doc.page_count
page = doc.get_page(current_page_number)
size_key = page.width > page.height ? :width : :height
size = padding ? inference.resolution * 1.5 : inference.resolution
@ -149,13 +156,13 @@ module Templates
areas: [{
x: field.x, y: field.y,
w: field.w, h: field.h,
page: page_number,
page: current_page_number,
attachment_uuid: attachment&.uuid
}]
}
end
yield [attachment&.uuid, page_number, fields] if block_given?
yield [attachment&.uuid, current_page_number, fields] if block_given?
fields
ensure

Loading…
Cancel
Save