pull/150/merge
Zee 2 years ago committed by GitHub
commit e94e8d14b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -6,9 +6,12 @@ module Api
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
attachment = Submitters.create_attachment!(submitter, params)
record = if params[:template_slug].present?
Template.find_by!(slug: params[:template_slug])
else
Submitter.find_by!(slug: params[:submitter_slug])
end
attachment = Submitters.create_attachment!(record, params)
render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type])
end

@ -54,7 +54,7 @@ module Api
def template_params
params.require(:template).permit(
:name,
:name, values: {},
schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,

@ -23,5 +23,62 @@ module Api
)
}
end
def del_image
template = Template.find(params[:template_id])
document = template.documents.find(params[:document_id])
img_attachment_id = params[:attachment_id]
page_number = document.preview_images.find_index { |pic| pic.id == img_attachment_id }
if page_number
Templates::ProcessDocument.delete_picture(template, document, img_attachment_id, page_number)
template.fields.each do |field|
field['areas'] = (field['areas'] || []).reject do |area|
area['attachment_uuid'] == document[:uuid] && area['page'] == page_number
end
end
template.fields = (template.fields || []).reject do |field|
field['areas'].empty?
end
template.save
updated_images = updated_preview_images(document)
new_metadata = document.metadata
render json: { success: true, message: 'image deleted successfully', updated_preview_images: updated_images, updated_metadata: new_metadata }
else
page_number = "No image found for deletion"
render json: { success: false, message: "Error: #{page_number}" }, status: :unprocessable_entity
end
end
def add_new_image
template = Template.find(params[:template_id])
raw_document = params[:document]
document = template.documents.find_by(id: raw_document[:id])
begin
Templates::ProcessDocument.upload_new_blank_image(template, document)
updated_images = updated_preview_images(document)
new_metadata = document.metadata
render json: { success: true, message: 'New blank image added successfully', updated_preview_images: updated_images, updated_metadata: new_metadata }
rescue StandardError => e
render json: { success: false, message: "Error adding new blank image: #{e.message}" }, status: :unprocessable_entity
end
end
def updated_preview_images(document)
updated_images = document.preview_images.map do |image|
{
"id": image.id,
"name": image.name,
"uuid": image.uuid,
"record_type": image.record_type,
"record_id": image.record_id,
"blob_id": image.blob_id,
"filename": image.filename.as_json,
"metadata": image.metadata,
"url": image.url,
"created_at": image.created_at
}
end
end
end
end

@ -9,7 +9,7 @@ class StartFormController < ApplicationController
before_action :load_template
def show
@submitter = @template.submissions.new.submitters.new(uuid: @template.submitters.first['uuid'])
@submitter = @template.submitters.second.nil? ? @template.submissions.new.submitters.new(uuid: @template.submitters.first['uuid']) : @template.submissions.new.submitters.new(uuid: @template.submitters.second['uuid'])
end
def update

@ -20,7 +20,7 @@ class SubmissionsPreviewController < ApplicationController
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
associations: [:blob, { preview_secured_images_attachments: :blob }]
).call
end

@ -83,6 +83,7 @@ window.customElements.define('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
templateAttachments: reactive(JSON.parse(this.dataset.templateAttachmentsIndex)),
backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false',

@ -75,6 +75,10 @@ button[disabled] .enabled {
@apply select base-input w-full font-normal;
}
.bg-redact {
background: black;
}
.tooltip-bottom-end:before {
transform: translateX(-95%);
top: var(--tooltip-offset);
@ -105,7 +109,7 @@ button[disabled] .enabled {
font: inherit;
}
.autocomplete > div {
.autocomplete>div {
@apply px-2 py-1 font-normal text-sm;
}
@ -113,8 +117,8 @@ button[disabled] .enabled {
background: #eee;
}
.autocomplete > div:hover:not(.group),
.autocomplete > div.selected {
.autocomplete>div:hover:not(.group),
.autocomplete>div.selected {
@apply bg-base-300;
cursor: pointer;
}
@ -125,3 +129,44 @@ button[disabled] .enabled {
outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2);
}
.my-display {
display: none;
}
#loader {
background: rgba(0, 7, 14, 0.7);
text-align: center;
position: absolute;
top: 0em;
left: 0em;
padding: 20% 0px;
height: 100vh;
width: 100vw;
text-align: -webkit-center;
}
.loader-animation {
width: 50px;
height: 50px;
border: 4px solid #fff;
border-top: 4px solid transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.text-load {
color: #fff;
font-size: large;
}

@ -10,6 +10,9 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter),
templateValues: JSON.parse(this.dataset.templateValues),
templateAttachments: reactive(JSON.parse(this.dataset.templateAttachmentsIndex)),
authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true',
goToLast: this.dataset.goToLast === 'true',

@ -1,5 +1,101 @@
<template>
<div
v-if="field.type === 'redact'"
class="flex absolute"
:style="{ ...computedStyle, backgroundColor: 'black' }"
:class="{ 'cursor-default ': !submittable, 'border ': submittable, 'z-0 ': isActive && submittable, 'bg-opacity-100 ': (isActive || isValueSet) && submittable }"
>
<div
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable"
class="absolute top-0 bottom-0 right-0 left-0 items-center justify-center h-full w-full"
>
<span
v-if="field"
class="flex justify-center items-center h-full opacity-50"
>
<component
:is="fieldIcons[field.type]"
width="100%"
height="100%"
class="max-h-10 text-base-content text-white"
/>
</span>
</div>
</div>
<!-- show myText prefill with stored value -->
<div
v-else-if="field.type === 'my_text'"
class="flex absolute"
:style="{ ...computedStyle, backgroundColor: 'transparent' }"
:class="{ 'cursor-default ': !submittable, 'z-0 ': isActive && submittable, 'bg-opacity-100 ': (isActive || isValueSet) && submittable }"
>
<span
style="--tw-bg-opacity: 1; --tw-border-opacity: 0.2; font-size: 1.4rem"
class="!text-2xl w-full h-full"
v-text="showLocalText"
/>
</div>
<!-- show myDate prefill with stored value -->
<div
v-else-if="field.type === 'my_date'"
class="flex absolute"
:style="{ ...computedStyle, backgroundColor: 'transparent' }"
:class="{ 'cursor-default ': !submittable, 'z-0 ': isActive && submittable, 'bg-opacity-100 ': (isActive || isValueSet) && submittable }"
>
<span
style="--tw-bg-opacity: 1; --tw-border-opacity: 0.2; font-size: 1.4rem"
class="flex items-center px-0.5 w-full h-full"
>
{{ getFormattedDate }}
</span>
</div>
<!-- show mySignature and myInitial prefill with stored value -->
<div
v-else-if="['my_signature', 'my_initials'].includes(field.type)"
class="flex absolute"
:style="computedStyle"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-70': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-30': (isActive || isValueSet) && submittable }"
>
<img
v-if="field.type === 'my_signature' && mySignatureUrl"
class="mx-auto"
:src="mySignatureUrl.url"
>
<img
v-else-if="field.type === 'my_initials' && myInitialsUrl"
class="mx-auto"
:src="myInitialsUrl.url"
>
<img
v-else
class="mx-auto"
>
</div>
<!-- show my_check prefill -->
<div
v-else-if="field.type === 'my_check'"
class="flex absolute items-center h-full w-full justify-center"
:style="{ ...computedStyle, backgroundColor: 'transparent' }"
:class="{'cursor-default ': !submittable}"
>
<span
style="--tw-bg-opacity: 1; --tw-border-opacity: 0.2; font-size: 1.4rem"
class="w-full h-full"
>
<component
:is="fieldIcons[field.type]"
width="100%"
height="100%"
class="h-full"
/>
</span>
</div>
<div
v-else
class="flex absolute lg:text-base"
:style="computedStyle"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
@ -158,7 +254,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconBarrierBlock, IconCreditCard } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@ -166,6 +262,7 @@ export default {
IconPaperclip,
IconCheck
},
inject: ['templateAttachments'],
props: {
field: {
type: Object,
@ -209,12 +306,20 @@ export default {
area: {
type: Object,
required: true
},
templateValues: {
type: Object,
required: false,
default () {
return {}
}
}
},
emits: ['update:model-value'],
data () {
return {
textOverflowChars: 0
textOverflowChars: 0,
showLocalText: ''
}
},
computed: {
@ -232,6 +337,12 @@ export default {
radio: 'Radio',
multiple: 'Multiple Select',
phone: 'Phone',
redact: 'Redact',
my_text: 'Text',
my_signature: 'My Signature',
my_initials: 'My Initials',
my_date: 'Date',
my_check: 'Check',
payment: 'Payment'
}
},
@ -249,6 +360,8 @@ export default {
cells: IconColumns3,
multiple: IconChecks,
phone: IconPhoneCheck,
redact: IconBarrierBlock,
my_check: IconCheck,
payment: IconCreditCard
}
},
@ -273,6 +386,27 @@ export default {
return null
}
},
myAttachmentsIndex () {
return this.templateAttachments.reduce((acc, a) => {
acc[a.uuid] = a
return acc
}, {})
},
mySignatureUrl () {
if (this.field.type === 'my_signature') {
return this.myAttachmentsIndex[this.templateValues[this.field.uuid]]
} else {
return null
}
},
myInitialsUrl () {
if (this.field.type === 'my_initials') {
return this.myAttachmentsIndex[this.templateValues[this.field.uuid]]
} else {
return null
}
},
locale () {
return Intl.DateTimeFormat().resolvedOptions()?.locale
},
@ -286,6 +420,13 @@ export default {
return ''
}
},
getFormattedDate () {
if (this.field.type === 'my_date' && this.templateValues[this.field.uuid]) {
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.templateValues[this.field.uuid]))
} else {
return ''
}
},
attachments () {
if (this.field.type === 'file') {
return (this.modelValue || []).map((uuid) => this.attachmentsIndex[uuid])
@ -314,6 +455,15 @@ export default {
}
},
mounted () {
if (this.field.type === 'my_text') {
const fieldUuid = this.field.uuid
if (this.templateValues && this.templateValues[fieldUuid]) {
this.showLocalText = this.templateValues[fieldUuid]
} else {
this.showLocalText = ''
}
}
if (this.field.type === 'text' && this.$refs.textContainer) {
this.$nextTick(() => {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0

@ -26,6 +26,7 @@
:with-label="withLabel"
:is-value-set="step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex"
:template-values="templateValues"
@click="$emit('focus-step', step)"
/>
</Teleport>
@ -67,6 +68,13 @@ export default {
type: Array,
required: false,
default: () => []
},
templateValues: {
type: Object,
required: false,
default () {
return {}
}
}
},
emits: ['focus-step'],

@ -6,6 +6,7 @@
:attachments-index="attachmentsIndex"
:with-label="!isAnonymousChecboxes"
:current-step="currentStepFields"
:template-values="templateValues"
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
/>
<button
@ -65,6 +66,9 @@
@focus="$refs.areas.scrollIntoField(currentField)"
/>
</div>
<div v-if="['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(currentField.type)">
<!-- do nothing on this side just chill for now -->
</div>
<DateStep
v-else-if="currentField.type === 'date'"
:key="currentField.uuid"
@ -266,6 +270,15 @@
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
<RedactStep
v-else-if="currentField.type === 'redact'"
ref="currentStep"
v-model="values[currentField.uuid]"
:field="currentField"
:submitter-slug="submitterSlug"
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
<PaymentStep
v-else-if="currentField.type === 'payment'"
ref="currentStep"
@ -347,6 +360,7 @@ import InitialsStep from './initials_step'
import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import RedactStep from './redact_step.vue'
import PaymentStep from './payment_step'
import TextStep from './text_step'
import DateStep from './date_step'
@ -368,6 +382,7 @@ export default {
IconArrowsDiagonal,
TextStep,
PhoneStep,
RedactStep,
PaymentStep,
IconArrowsDiagonalMinimize2,
FormCompleted
@ -375,7 +390,8 @@ export default {
provide () {
return {
baseUrl: this.baseUrl,
t: this.t
t: this.t,
templateAttachments: this.templateAttachments
}
},
props: {
@ -383,6 +399,15 @@ export default {
type: Object,
required: true
},
templateValues: {
type: Object,
required: true
},
templateAttachments: {
type: Array,
required: false,
default: () => []
},
canSendEmail: {
type: Boolean,
required: false,
@ -708,7 +733,12 @@ export default {
stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid]
if (['redact', 'my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(fields[0]?.type)) {
fields[0].required = 'false'
return false
} else {
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid]
}
})
const formData = new FormData(this.$refs.form)

@ -23,6 +23,7 @@ const en = {
please_check_the_box_to_continue: 'Please check the box to continue',
open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number',
redact: 'redact',
use_international_format: 'Use internatioanl format: +1xxx',
six_digits_code: '6-digit code',
change_phone_number: 'Change phone number',
@ -68,6 +69,7 @@ const es = {
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar',
open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono',
redact: 'redact',
use_international_format: 'Usar formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Cambiar número de teléfono',
@ -113,6 +115,7 @@ const it = {
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare',
open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono',
redact: 'redact',
use_international_format: 'Usa formato internazionale: +1xxx',
six_digits_code: 'Codice a 6 cifre',
change_phone_number: 'Cambia numero di telefono',
@ -158,6 +161,7 @@ const de = {
please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren',
open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer überprüfen',
redact: 'redact',
use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern',
@ -203,6 +207,7 @@ const fr = {
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer',
open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone',
redact: 'redact',
use_international_format: 'Utiliser le format international : +1xxx',
six_digits_code: 'Code à 6 chiffres',
change_phone_number: 'Changer le numéro de téléphone',
@ -248,6 +253,7 @@ const pl = {
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować',
open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu',
redact: 'redact',
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
six_digits_code: '6-cyfrowy kod',
change_phone_number: 'Zmień numer telefonu',
@ -293,6 +299,7 @@ const uk = {
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити',
open_source_documents_software: 'відкритий програмний засіб для документів',
verified_phone_number: 'Підтвердіть номер телефону',
redact: 'redact',
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
six_digits_code: '6-значний код',
change_phone_number: 'Змінити номер телефону',
@ -338,6 +345,7 @@ const cs = {
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování',
open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo',
redact: 'redact',
use_international_format: 'Použijte mezinárodní formát: +1xxx',
six_digits_code: '6-místný kód',
change_phone_number: 'Změnit telefonní číslo',
@ -383,6 +391,7 @@ const pt = {
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar',
open_source_documents_software: 'software de documentos de código aberto',
verified_phone_number: 'Verificar Número de Telefone',
redact: 'redact',
use_international_format: 'Use formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Alterar número de telefone',

@ -0,0 +1,5 @@
<template>
<div>
<p class="text-center">This portion is redacted</p>
</div>
</template>

@ -9,7 +9,8 @@
<div
v-if="isSelected || isDraw"
class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none"
:class="borderColors[submitterIndex]"
:class="submitterIndex != 0 ? borderColors[submitterIndex] : ''"
:style="{ backgroundColor: submitterIndex == 0 ? bgColors[submitterIndex] : '' }"
/>
<div
v-if="field.type === 'cells' && (isSelected || isDraw)"
@ -19,8 +20,8 @@
v-for="(cellW, index) in cells"
:key="index"
class="absolute top-0 bottom-0 border-r"
:class="borderColors[submitterIndex]"
:style="{ left: (cellW / area.w * 100) + '%' }"
:class="submitterIndex != 0 ? borderColors[submitterIndex] : ''"
:style="{ left: (cellW / area.w * 100) + '%', backgroundColor: submitterIndex == 0 ? borderColors[submitterIndex] : '' }"
>
<span
v-if="index === 0 && editable"
@ -41,6 +42,8 @@
<FieldSubmitter
v-model="field.submitter_uuid"
class="border-r"
:me-fields="['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(field.type)"
:hide-select-me="true"
:compact="true"
:editable="editable && !defaultField"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px]'"
@ -52,6 +55,7 @@
v-model="field.type"
:button-width="27"
:editable="editable && !defaultField"
:me-active="['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(field.type)"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@ -68,7 +72,7 @@
@blur="onNameBlur"
>{{ optionIndexText }} {{ field.name || defaultName }}</span>
<div
v-if="isNameFocus && !['checkbox', 'phone'].includes(field.type)"
v-if="isNameFocus && !['checkbox', 'phone', 'redact', 'my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(field.type)"
class="flex items-center ml-1.5"
>
<input
@ -89,14 +93,110 @@
v-else-if="editable"
class="pr-1"
title="Remove"
@click.prevent="$emit('remove')"
@click.prevent="removeField"
>
<IconX width="14" />
</button>
</div>
<!-- adding redacting box -->
<div
v-if="field.type === 'redact'"
class="opacity-100 flex items-center justify-center h-full w-full bg-redact"
>
<span
v-if="field"
class="flex justify-center items-center space-x-1 h-full"
>
<component
:is="fieldIcons[field.type]"
width="100%"
height="100%"
class="max-h-10 text-white"
/>
</span>
</div>
<!-- adding editable textarea for prefills -->
<div
v-else-if="field.type === 'my_text'"
class="flex items-center justify-center h-full w-full"
style="background-color: transparent;"
>
<textarea
:id="field.uuid"
ref="textarea"
:value="myLocalText"
style="border-width: 2px; --tw-bg-opacity: 1; --tw-border-opacity: 0.2; background-color: transparent;"
class="!text-2xl w-full h-full"
:placeholder="`type here`"
:name="`values[${field.uuid}]`"
@input="makeMyText"
/>
</div>
<!-- adding my_date prefills -->
<div
v-else-if="field.type === 'my_date'"
class="flex items-center justify-center h-full w-full"
style="border-width: 2px; --tw-bg-opacity: 1; --tw-border-opacity: 0.2; background-color: transparent;"
>
<span
:id="field.uuid"
ref="my_date"
>
{{ getFormattedDate }}
</span>
</div>
<!-- adding my_signature and my_initials for prefills -->
<div
v-else-if="['my_signature', 'my_initials'].includes(field.type)"
class="flex items-center justify-center h-full w-full"
style="background-color: white;"
>
<img
v-if="field.type === 'my_signature' && mySignatureUrl"
:id="field.uuid"
:src="mySignatureUrl.url"
class="d-flex justify-center w-full h-full"
style="border-width: 2px; --tw-bg-opacity: 1; --tw-border-opacity: 0.2; background-color: transparent;"
>
<img
v-else-if="field.type === 'my_initials' && myInitialsUrl"
:id="field.uuid"
:src="myInitialsUrl.url"
class="d-flex justify-center w-full h-full"
style="border-width: 2px; --tw-bg-opacity: 1; --tw-border-opacity: 0.2; background-color: transparent;"
>
<img
v-else
:id="field.uuid"
class="d-flex justify-center w-full h-full"
style="border-width: 2px; --tw-bg-opacity: 1; --tw-border-opacity: 0.2; background-color: transparent;"
>
</div>
<!-- show my_check prefill -->
<div
v-else-if="field.type === 'my_check'"
class="flex items-center h-full w-full justify-center"
style="border-width: 2px; --tw-bg-opacity: 1; --tw-border-opacity: 0.2; background-color: transparent;"
:class="{'cursor-default ': !submittable}"
>
<span
style="border-width: 2px; --tw-bg-opacity: 1; --tw-border-opacity: 0.2; font-size: 1.4rem"
class="w-full h-full"
>
<component
:is="fieldIcons[field.type]"
width="100%"
height="100%"
class="h-full"
/>
</span>
</div>
<div
v-else
class="flex items-center h-full w-full"
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
:class="[submitterIndex != 0 ? bgColors[submitterIndex] : '', field?.default_value ? '' : 'justify-center']"
:style="{backgroundColor: submitterIndex == 0 ? bgColors[submitterIndex] : ''}"
>
<span
v-if="field"
@ -123,6 +223,7 @@
</span>
</div>
<div
v-if="!['my_text', 'my_signature', 'my_initials', 'my_date'].includes(field.type)"
ref="touchTarget"
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
/>
@ -133,23 +234,82 @@
@touchstart="startTouchResize"
/>
</div>
<div
@pointerdown.stop
@touchstart="startTouchDrag"
>
<div
v-if="showMySignature"
>
<MySignature
:key="field.uuid"
v-model="setSignatureValue"
:my-signature-style="mySignatureStyle"
:is-direct-upload="isDirectUpload"
:field="field"
:previous-value="previousSignatureValue"
:template="template"
:attachments-index="attachmentsIndex"
@attached="handleMySignatureAttachment"
@hide="showMySignature = false"
@start="$refs.areas.scrollIntoField(field)"
/>
</div>
<div
v-if="showMyInitials"
>
<MyInitials
:key="field.uuid"
v-model="setInitialsValue"
:my-signature-style="mySignatureStyle"
:is-direct-upload="isDirectUpload"
:field="field"
:previous-value="previousInitialsValue"
:template="template"
:attachments-index="attachmentsIndex"
@attached="handleMyInitialsAttachment"
@hide="showMyInitials = false"
@start="$refs.areas.scrollIntoField(field)"
/>
</div>
<div
v-if="showMyDate"
class="absolute"
style="z-index: 50;"
:style="{ ...mySignatureStyle }"
>
<MyDate
:key="field.uuid"
v-model="setMyDateValue"
:my-signature-style="mySignatureStyle"
:field="field"
/>
</div>
</div>
</template>
<script>
import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import { IconX } from '@tabler/icons-vue'
import { IconX, IconWriting } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import MySignature from './my_signature'
import MyInitials from './my_initials'
import MyDate from './my_date'
export default {
name: 'FieldArea',
components: {
FieldType,
FieldSubmitter,
IconX
IconX,
IconWriting,
MySignature,
MyInitials,
MyDate
},
inject: ['template', 'selectedAreaRef', 'save'],
inject: ['template', 'selectedAreaRef', 'save', 'templateAttachments', 'isDirectUpload'],
props: {
area: {
type: Object,
@ -176,20 +336,52 @@ export default {
default: null
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove'],
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'update:myField'],
data () {
return {
isResize: false,
isDragged: false,
isNameFocus: false,
myLocalText: '',
textOverflowChars: 0,
dragFrom: { x: 0, y: 0 }
dragFrom: { x: 0, y: 0 },
showMySignature: false,
showMyInitials: false,
showMyDate: false,
myLocalSignatureValue: '',
myLocalInitialsValue: '',
myLocalDateValue: ''
}
},
computed: {
defaultName: Field.computed.defaultName,
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
setSignatureValue: {
get () {
return this.myLocalSignatureValue
},
set (value) {
this.makeMySignature(value)
}
},
setInitialsValue: {
get () {
return this.myLocalInitialsValue
},
set (value) {
this.makeMyInitials(value)
}
},
setMyDateValue: {
get () {
return this.myLocalDateValue
},
set (value) {
this.myLocalDateValue = value
this.makeMyDate(value)
}
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
@ -197,6 +389,54 @@ export default {
return ''
}
},
attachmentsIndex () {
return this.templateAttachments.reduce((acc, a) => {
acc[a.uuid] = a
return acc
}, {})
},
previousSignatureValue () {
const mySignatureField = (this.field.type === 'my_signature' && !!this.template.values[this.field.uuid])
return this.template.values[mySignatureField?.uuid]
},
previousInitialsValue () {
const initialsField = (this.field.type === 'my_initials' && !!this.template.values[this.field.uuid])
return this.template.values[initialsField?.uuid]
},
mySignatureStyle () {
const { x, y, w, h } = this.area
return {
top: (y * 100) + 7 + '%',
left: (x * 100) + '%',
width: w * 100 + '%',
height: h * 100 + '%'
}
},
mySignatureUrl () {
if (this.field.type === 'my_signature') {
return this.attachmentsIndex[this.myLocalSignatureValue]
} else {
return null
}
},
myInitialsUrl () {
if (this.field.type === 'my_initials') {
return this.attachmentsIndex[this.myLocalInitialsValue]
} else {
return null
}
},
getFormattedDate () {
if (this.field.type === 'my_date' && this.myLocalDateValue) {
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.myLocalDateValue))
} else {
return ''
}
},
cells () {
const cells = []
@ -218,30 +458,32 @@ export default {
},
borderColors () {
return [
'border-red-500/80',
'border-sky-500/80',
'border-emerald-500/80',
'border-yellow-300/80',
'border-purple-600/80',
'border-pink-500/80',
'border-cyan-500/80',
'border-orange-500/80',
'border-lime-500/80',
'border-indigo-500/80'
'rgb(205 205 205 / 0.5)',
'border-red-500/50',
'border-sky-500/50',
'border-emerald-500/50',
'border-yellow-300/50',
'border-purple-600/50',
'border-pink-500/50',
'border-cyan-500/50',
'border-orange-500/50',
'border-lime-500/50',
'border-indigo-500/50'
]
},
bgColors () {
return [
'bg-red-100/80',
'bg-sky-100/80',
'bg-emerald-100/80',
'bg-yellow-100/80',
'bg-purple-100/80',
'bg-pink-100/80',
'bg-cyan-100/80',
'bg-orange-100/80',
'bg-lime-100/80',
'bg-indigo-100/80'
'transparent',
'bg-red-100/50',
'bg-sky-100/50',
'bg-emerald-100/50',
'bg-yellow-100/50',
'bg-purple-100/50',
'bg-pink-100/50',
'bg-cyan-100/50',
'bg-orange-100/50',
'bg-lime-100/50',
'bg-indigo-100/50'
]
},
isSelected () {
@ -266,13 +508,148 @@ export default {
}
},
mounted () {
if (['my_signature', 'my_initials', 'my_text', 'my_date'].includes(this.field.type)) {
const fieldUuid = this.field.uuid
let myValue = ''
if (this.template.values && this.template.values[fieldUuid]) {
myValue = this.template.values[fieldUuid]
}
switch (this.field.type) {
case 'my_signature':
this.myLocalSignatureValue = myValue
break
case 'my_initials':
this.myLocalInitialsValue = myValue
break
case 'my_text':
this.myLocalText = myValue
break
case 'my_date':
this.myLocalDateValue = myValue
break
default:
break
}
}
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value)) {
this.$nextTick(() => {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
})
}
},
updated () {
if (['my_signature', 'my_initials', 'my_text', 'my_date'].includes(this.field.type)) {
const fieldUuid = this.field.uuid
let myValue = ''
if (this.template.values && this.template.values[fieldUuid]) {
myValue = this.template.values[fieldUuid]
}
switch (this.field.type) {
case 'my_signature':
this.myLocalSignatureValue = myValue
break
case 'my_initials':
this.myLocalInitialsValue = myValue
break
case 'my_text':
this.myLocalText = myValue
break
case 'my_date':
this.myLocalDateValue = myValue
break
default:
break
}
}
},
methods: {
makeMyText (e) {
this.myLocalText = e.target.value ? e.target.value : this.myLocalText
this.saveFieldValue(
{ [this.field.uuid]: e.target.value }
)
},
makeMySignature (value) {
if (value !== null) {
this.myLocalSignatureValue = value
this.saveFieldValue({ [this.field.uuid]: value })
} else {
this.saveFieldValue({ [this.field.uuid]: '' })
}
},
makeMyInitials (value) {
if (value !== null) {
this.myLocalInitialsValue = value
this.saveFieldValue({ [this.field.uuid]: value })
} else {
this.saveFieldValue({ [this.field.uuid]: '' })
}
},
makeMyDate (value) {
this.saveFieldValue(
{ [this.field.uuid]: value }
)
this.save()
},
saveFieldValue (event) {
this.$emit('update:myField', event)
},
handleMyInitialsAttachment (attachment) {
this.templateAttachments.push(attachment)
this.makeMyInitials(attachment.uuid)
this.save()
},
handleMySignatureAttachment (attachment) {
this.templateAttachments.push(attachment)
this.makeMySignature(attachment.uuid)
this.save()
},
removeField () {
const templateValue = this.template.values[this.field.uuid]
switch (this.field.type) {
case 'my_signature':
this.showMySignature = false
if (this.myLocalSignatureValue === templateValue) {
this.myLocalSignatureValue = ''
}
console.log('switch signature portion')
break
case 'my_initials':
this.showMyInitials = false
if (this.myLocalInitialsValue === templateValue) {
this.myLocalInitialsValue = ''
}
console.log('switch initials portion')
break
case 'my_date':
this.showMyDate = false
if (this.myLocalDateValue === templateValue) {
this.myLocalDateValue = ''
}
console.log('switch my_date portion')
break
case 'my_text':
if (this.myLocalText === templateValue) {
this.myLocalText = ''
}
break
default:
console.log('switch default portion')
}
this.$emit('remove')
},
onNameFocus (e) {
this.selectedAreaRef.value = this.area
@ -360,6 +737,13 @@ export default {
}
},
startDrag (e) {
if (this.field.type === 'my_signature') {
this.handleMySignatureClick()
} else if (this.field.type === 'my_initials') {
this.handleMyInitialClick()
} else if (this.field.type === 'my_date') {
this.handleMyDateClick()
}
this.selectedAreaRef.value = this.area
if (!this.editable) {
@ -470,6 +854,15 @@ export default {
this.$emit('stop-resize')
this.save()
},
handleMySignatureClick () {
this.showMySignature = !this.showMySignature
},
handleMyInitialClick () {
this.showMyInitials = !this.showMyInitials
},
handleMyDateClick () {
this.showMyDate = !this.showMyDate
}
}
}

@ -102,12 +102,16 @@
:editable="editable"
:template="template"
:is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)"
:is-loading="isLoading"
:is-deleting="isDeleting"
@scroll-to="scrollIntoDocument"
@add-blank-page="addBlankPage"
@remove="onDocumentRemove"
@replace="onDocumentReplace"
@up="moveDocument(item, -1)"
@down="moveDocument(item, 1)"
@change="save"
@remove-image="removeImage"
/>
<div
class="sticky bottom-0 py-2"
@ -152,6 +156,7 @@
@draw="onDraw"
@drop-field="onDropfield"
@remove-area="removeArea"
@update:my-field="updateMyValues"
/>
<DocumentControls
v-if="isBreakpointLg && editable"
@ -273,6 +278,7 @@ import Contenteditable from './contenteditable'
import DocumentPreview from './preview'
import DocumentControls from './controls'
import FieldType from './field_type'
import { t } from './i18n'
import { IconUsersPlus, IconDeviceFloppy, IconWritingSign, IconInnerShadowTop, IconPlus, IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed } from 'vue'
@ -301,11 +307,15 @@ export default {
return {
template: this.template,
save: this.save,
templateAttachments: this.templateAttachments,
isDirectUpload: this.isDirectUpload,
baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
withPayment: this.withPayment,
selectedAreaRef: computed(() => this.selectedAreaRef)
selectedAreaRef: computed(() => this.selectedAreaRef),
baseUrl: this.baseUrl,
t: this.t
}
},
props: {
@ -313,6 +323,11 @@ export default {
type: Object,
required: true
},
templateAttachments: {
type: Array,
required: false,
default: () => []
},
isDirectUpload: {
type: Boolean,
required: false,
@ -408,6 +423,9 @@ export default {
isSaving: false,
selectedSubmitter: null,
drawField: null,
dragFieldType: null,
isLoading: false,
isDeleting: false,
drawOption: null,
dragField: null
}
@ -438,6 +456,13 @@ export default {
return this.template.schema.map((item) => {
return this.template.documents.find(doc => doc.uuid === item.attachment_uuid)
})
},
myAttachmentsIndex () {
return this.templateAttachments.reduce((acc, a) => {
acc[a.uuid] = a
return acc
}, {})
}
},
created () {
@ -479,6 +504,12 @@ export default {
this.documentRefs = []
},
methods: {
t,
updateMyValues (values) {
const existingValues = this.template.values || {}
const updatedValues = { ...existingValues, ...values }
this.template.values = updatedValues
},
startFieldDraw (type) {
const field = {
name: '',
@ -488,7 +519,9 @@ export default {
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['redact', 'my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(type)) {
field.required = false
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
}
@ -542,10 +575,9 @@ export default {
this.documentRefs.push(el)
}
},
scrollIntoDocument (item) {
const ref = this.documentRefs.find((e) => e.document.uuid === item.attachment_uuid)
ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
scrollIntoDocument (item, page) {
const documentRef = this.documentRefs.find((e) => e.document?.uuid === item.attachment_uuid)
documentRef.scrollIntoDocument(page)
},
onKeyUp (e) {
if (e.code === 'Escape') {
@ -575,6 +607,17 @@ export default {
},
removeArea (area) {
const field = this.template.fields.find((f) => f.areas?.includes(area))
if (['my_text', 'my_signature', 'my_initials', 'my_date'].includes(field.type)) {
const valuesArray = Object.values(this.template.values)
const valueIndex = valuesArray.findIndex((value) => value === this.template.values[field.uuid])
valuesArray.splice(valueIndex, 1)
const valueKey = Object.keys(this.template.values)[valueIndex]
if (['my_signature', 'my_initials'].includes(field.type)) {
const myAttachmentsIndex = this.myAttachmentsIndex[this.template.values[field.uuid]]
this.templateAttachments.splice(this.templateAttachments.indexOf(myAttachmentsIndex), 1)
}
delete this.template.values[valueKey]
}
field.areas.splice(field.areas.indexOf(area), 1)
@ -620,6 +663,30 @@ export default {
this.selectedAreaRef.value = area
this.save()
} else if (this.selectedSubmitter.name === 'Me') {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
const type = (pageMask.clientWidth * area.w) < 35 ? 'my_check' : 'my_text'
if (area.w) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
type,
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
if (['redact', 'my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(field.type)) {
field.required = false
}
this.template.fields.push(field)
this.selectedAreaRef.value = area
this.save()
}
} else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
@ -653,7 +720,9 @@ export default {
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
if (['redact', 'my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(field.type)) {
field.required = false
}
this.template.fields.push(field)
this.selectedAreaRef.value = area
@ -671,6 +740,9 @@ export default {
...this.dragField
}
if (['redact', 'my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(field.type)) {
field.required = false
}
if (['select', 'multiple', 'radio'].includes(field.type)) {
field.options = [{ value: '', uuid: v4() }]
}
@ -697,7 +769,7 @@ export default {
} else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1]
} else {
if (['checkbox'].includes(field.type)) {
if (['checkbox', 'my_check'].includes(field.type)) {
baseArea = {
w: area.maskW / 30 / area.maskW,
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
@ -707,12 +779,12 @@ export default {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
}
} else if (field.type === 'signature') {
} else if (['signature', 'my_signature'].includes(field.type)) {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
}
} else if (field.type === 'initials') {
} else if (['initials', 'my_initials'].includes(field.type)) {
baseArea = {
w: area.maskW / 10 / area.maskW,
h: area.maskW / 35 / area.maskW
@ -763,7 +835,7 @@ export default {
this.save()
},
onDocumentRemove (item) {
if (window.confirm('Are you sure?')) {
if (window.confirm('Are you sure you want to delete the document?')) {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
}
@ -856,11 +928,99 @@ export default {
name: this.template.name,
schema: this.template.schema,
submitters: this.template.submitters,
fields: this.template.fields
fields: this.template.fields,
values: this.template.values
}
}),
headers: { 'Content-Type': 'application/json' }
})
},
removeImage (item, imageId) {
const document = this.template.documents.find((e) => e.uuid === item.attachment_uuid)
if (Array.isArray(document.preview_images)) {
const indexToRemove = document.preview_images.findIndex((previewImage) => previewImage.id === imageId)
// console.log(indexToRemove)
if (indexToRemove !== -1) {
const confirmed = window.confirm('Are you sure you want to delete this image?')
if (confirmed) {
this.isDeleting = true
const documentId = document.id
const apiUrl = `/api/templates/${this.template.id}/documents/${documentId}/del_image`
fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
template: this.template.id,
attachment_id: imageId,
document_id: documentId
})
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
}
return response.json()
})
.then((data) => {
console.log('Success:', data)
const pageNumber = document.preview_images.findIndex(pic => pic.id === imageId)
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === document.uuid && area.page === pageNumber) {
field.areas.splice(field.areas.indexOf(area), 1)
}
})
})
document.preview_images = data.updated_preview_images
document.metadata = data.updated_metadata
})
.catch((error) => {
console.error('Error:', error)
})
.finally(() => {
this.isDeleting = false
})
}
}
}
},
addBlankPage (item) {
const documentRef = this.documentRefs.find((e) => e.document.uuid === item.attachment_uuid)
const confirmed = window.confirm('Are you sure you want to create new image?')
if (confirmed) {
this.isLoading = true
const documentId = documentRef.document.id
const apiUrl = `/api/templates/${this.template.id}/documents/${documentId}/add_new_image`
fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
template_id: this.template.id,
document: documentRef.document
})
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
}
return response.json()
})
.then((data) => {
console.log('Success: ---', data)
documentRef.document.preview_images = data.updated_preview_images
documentRef.document.metadata = data.updated_metadata
})
.catch((error) => {
console.error('Error: ---', error)
})
.finally(() => {
this.isLoading = false
})
}
}
}
}

@ -13,7 +13,7 @@
@focus="$emit('focus', $event)"
@blur="onBlur"
>
{{ value }}
{{ editable ? value : value+' (Fill Out Now)' }}
</span>
<span
v-if="withRequired"

@ -0,0 +1,49 @@
function cropCanvasAndExportToPNG (canvas) {
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
let topmost = height
let bottommost = 0
let leftmost = width
let rightmost = 0
const imageData = ctx.getImageData(0, 0, width, height)
const pixels = imageData.data
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = (y * width + x) * 4
const alpha = pixels[pixelIndex + 3]
if (alpha !== 0) {
topmost = Math.min(topmost, y)
bottommost = Math.max(bottommost, y)
leftmost = Math.min(leftmost, x)
rightmost = Math.max(rightmost, x)
}
}
}
const croppedWidth = rightmost - leftmost + 1
const croppedHeight = bottommost - topmost + 1
const croppedCanvas = document.createElement('canvas')
croppedCanvas.width = croppedWidth
croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d')
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => {
croppedCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create a PNG blob.'))
}
}, 'image/png')
})
}
export { cropCanvasAndExportToPNG }

@ -15,6 +15,7 @@
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)"
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })"
@update:my-field="$emit('update:myField', $event)"
/>
</div>
</template>
@ -66,7 +67,7 @@ export default {
default: false
}
},
emits: ['draw', 'drop-field', 'remove-area'],
emits: ['draw', 'drop-field', 'remove-area', 'update:myField'],
data () {
return {
pageRefs: []
@ -109,6 +110,10 @@ export default {
scrollToArea (area) {
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
},
scrollIntoDocument (page) {
const ref = this.pageRefs.find((e) => e.image?.uuid === page.uuid)
ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
},
setPageRefs (el) {
if (el) {
this.pageRefs.push(el)

@ -16,6 +16,7 @@
v-model="field.type"
:editable="editable && !defaultField"
:button-width="20"
:me-active="meActive"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="scrollToFirstArea"
/>
@ -31,7 +32,7 @@
/>
</div>
<div
v-if="isNameFocus"
v-if="isNameFocus && !['redact', 'my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(field.type)"
class="flex items-center relative"
>
<template v-if="field.type != 'phone'">
@ -196,7 +197,7 @@
Draw New Area
</a>
</li>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'my_text', 'redact', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@ -323,6 +324,11 @@ export default {
type: Boolean,
required: false,
default: true
},
meActive: {
type: Boolean,
required: false,
default: true
}
},
emits: ['set-draw', 'remove', 'scroll-to'],

@ -29,7 +29,7 @@
/>
</label>
<ul
v-if="editable"
v-if="editable && !meFields"
tabindex="0"
class="rounded-md min-w-max mb-2"
:class="menuClasses"
@ -40,6 +40,7 @@
:key="submitter.uuid"
>
<a
v-if="!hideSelectMe || submitter.name!=='Me'"
href="#"
class="flex px-2 group justify-between items-center"
:class="{ 'active': submitter === selectedSubmitter }"
@ -48,14 +49,15 @@
<span class="py-1 flex items-center">
<span
class="rounded-full w-3 h-3 ml-1 mr-3"
:class="colors[index]"
:class="submitter.name !== 'Me'? colors[index] : ''"
:style="{backgroundColor: submitter.name === 'Me'? colors[index] : ''}"
/>
<span>
{{ submitter.name }}
</span>
</span>
<button
v-if="submitters.length > 1 && editable"
v-if="submitters?.length > 1 && editable && submitter.name !== 'Me'"
class="px-2"
@click.stop="remove(submitter)"
>
@ -63,7 +65,7 @@
</button>
</a>
</li>
<li v-if="submitters.length < 10 && editable">
<li v-if="submitters?.length < 11 && editable">
<a
href="#"
class="flex px-2"
@ -74,7 +76,7 @@
:stroke-width="1.6"
/>
<span class="py-1">
Add {{ names[submitters.length] }}
Add {{ names[submitters?.length] }}
</span>
</a>
</li>
@ -94,7 +96,8 @@
>
<button
class="mx-1 w-3 h-3 rounded-full"
:class="colors[submitters.indexOf(selectedSubmitter)]"
:class="selectedSubmitter.name !== 'Me'? colors[submitters.indexOf(selectedSubmitter)] : ''"
:style="{backgroundColor: selectedSubmitter.name === 'Me'? colors[submitters.indexOf(selectedSubmitter)] : ''}"
/>
</label>
<label
@ -105,27 +108,29 @@
<div class="flex items-center space-x-2">
<span
class="w-3 h-3 rounded-full"
:class="colors[submitters.indexOf(selectedSubmitter)]"
:class="selectedSubmitter.name !== 'Me' ? colors[submitters.indexOf(selectedSubmitter)] : ''"
:style="{ backgroundColor: selectedSubmitter.name === 'Me' ? colors[submitters.indexOf(selectedSubmitter)] : '' }"
/>
<Contenteditable
v-model="selectedSubmitter.name"
class="cursor-text"
:icon-inline="true"
:editable="editable"
:editable="selectedSubmitter.name==='Me'? false : editable"
:select-on-edit-click="true"
:icon-width="18"
@update:model-value="$emit('name-change', selectedSubmitter)"
/>
</div>
<span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 flex justify-center items-center rounded">
<IconPlus
<IconChevronDown
width="18"
height="18"
/>
</span>
</label>
<ul
v-if="editable || !compact"
v-if="(editable && !meFields) || !compact"
tabindex="0"
:class="menuClasses"
@click="closeDropdown"
@ -135,6 +140,7 @@
:key="submitter.uuid"
>
<a
v-if="!hideSelectMe || submitter.name!=='Me'"
href="#"
class="flex px-2 group justify-between items-center"
:class="{ 'active': submitter === selectedSubmitter }"
@ -143,14 +149,15 @@
<span class="py-1 flex items-center">
<span
class="rounded-full w-3 h-3 ml-1 mr-3"
:class="colors[index]"
:class="submitter.name !== 'Me'? colors[index] : ''"
:style="{backgroundColor: submitter.name === 'Me'? colors[index] : ''}"
/>
<span>
{{ submitter.name }}
</span>
</span>
<button
v-if="!compact && submitters.length > 1 && editable"
v-if="!compact && submitters?.length > 1 && editable && submitter.name !== 'Me'"
class="hidden group-hover:block px-2"
@click.stop="remove(submitter)"
>
@ -158,7 +165,7 @@
</button>
</a>
</li>
<li v-if="submitters.length < 10 && editable">
<li v-if="submitters?.length < 11 && editable">
<a
href="#"
class="flex px-2"
@ -169,7 +176,7 @@
:stroke-width="1.6"
/>
<span class="py-1">
Add {{ names[submitters.length] }}
Add {{ names[submitters?.length] }}
</span>
</a>
</li>
@ -178,7 +185,7 @@
</template>
<script>
import { IconUserPlus, IconTrashX, IconPlus, IconChevronUp } from '@tabler/icons-vue'
import { IconUserPlus, IconTrashX, IconChevronDown, IconChevronUp } from '@tabler/icons-vue'
import Contenteditable from './contenteditable'
import { v4 } from 'uuid'
@ -187,15 +194,30 @@ export default {
components: {
IconUserPlus,
Contenteditable,
IconPlus,
IconTrashX,
IconChevronUp
IconChevronUp,
IconChevronDown
},
props: {
showNewFields: {
type: Boolean,
required: false,
default: false
},
submitters: {
type: Array,
required: true
},
meFields: {
type: Boolean,
required: false,
default: false
},
hideSelectMe: {
type: Boolean,
required: false,
default: false
},
editable: {
type: Boolean,
required: false,
@ -221,10 +243,11 @@ export default {
default: 'dropdown-content menu p-2 shadow bg-base-100 rounded-box w-full z-10'
}
},
emits: ['update:model-value', 'remove', 'new-submitter', 'name-change'],
emits: ['update:model-value', 'remove', 'new-submitter', 'name-change', 'add-prefills'],
computed: {
colors () {
return [
'gray',
'bg-red-500',
'bg-sky-500',
'bg-emerald-500',
@ -239,6 +262,7 @@ export default {
},
names () {
return [
'Me',
'First Party',
'Second Party',
'Third Party',
@ -258,15 +282,19 @@ export default {
methods: {
selectSubmitter (submitter) {
this.$emit('update:model-value', submitter.uuid)
this.$emit('add-prefills', submitter.name)
},
remove (submitter) {
if (window.confirm('Are you sure?')) {
this.$emit('remove', submitter)
}
if (this.submitters?.length === 1) {
this.$emit('add-prefills', this.submitters[0].name)
}
},
addSubmitter () {
const newSubmitter = {
name: this.names[this.submitters.length],
name: this.names[this.submitters?.length],
uuid: v4()
}
@ -274,6 +302,7 @@ export default {
this.$emit('update:model-value', newSubmitter.uuid)
this.$emit('new-submitter', newSubmitter)
this.$emit('add-prefills', newSubmitter.name)
},
closeDropdown () {
document.activeElement.blur()

@ -15,7 +15,7 @@
</label>
</slot>
<ul
v-if="editable"
v-if="editable && !meActive"
tabindex="0"
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3"
:class="menuClasses"
@ -25,7 +25,7 @@
v-for="(icon, type) in fieldIcons"
:key="type"
>
<li v-if="withPhone || withPayment || !['phone', 'payment'].includes(type)">
<li v-if="withPhone || withPayment && !['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(type)">
<a
href="#"
class="text-sm py-1 px-2"
@ -46,8 +46,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconBarrierBlock, IconLetterCaseUpper, IconTextResize, IconCheck, IconCreditCard } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
inject: ['withPhone', 'withPayment'],
@ -75,6 +74,11 @@ export default {
type: Number,
required: false,
default: 18
},
meActive: {
type: Boolean,
required: false,
default: true
}
},
emits: ['update:model-value'],
@ -93,6 +97,12 @@ export default {
radio: 'Radio',
cells: 'Cells',
phone: 'Phone',
redact: 'Redact',
my_text: 'Text',
my_signature: 'My Signature',
my_initials: 'My Initials',
my_date: 'Date',
my_check: 'check',
payment: 'Payment'
}
},
@ -108,6 +118,13 @@ export default {
checkbox: IconCheckbox,
radio: IconCircleDot,
multiple: IconChecks,
radio: IconCircleDot,
redact: IconBarrierBlock,
my_text: IconTextResize,
my_signature: IconWritingSign,
my_initials: IconLetterCaseUpper,
my_date: IconCalendarEvent,
my_check: IconCheck,
cells: IconColumns3,
phone: IconPhoneCheck,
payment: IconCreditCard

@ -6,10 +6,12 @@
:class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters"
:editable="editable && !defaultSubmitters.length"
:show-new-fields="showNewFields"
@new-submitter="save"
@remove="removeSubmitter"
@name-change="save"
@update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))"
@add-prefills="toggleNewFields"
/>
</div>
<div
@ -26,6 +28,7 @@
:editable="editable && !dragField"
:default-field="defaultFields.find((f) => f.name === field.name)"
:draggable="editable"
:me-active="selectedSubmitter.name === 'Me'"
@dragstart="dragField = field"
@dragend="dragField = null"
@remove="removeField"
@ -63,49 +66,85 @@
</template>
</div>
<div
v-if="editable"
v-if="editable && !showNewFields"
class="grid grid-cols-3 gap-1 pb-2"
>
<template
v-for="(icon, type) in fieldIcons"
:key="type"
>
<button
v-if="(withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')"
@click="addField(type)"
<div
v-if="!['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(type)"
>
<div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0">
<IconDrag class=" my-auto" />
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
<button
v-if="(withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')"
@click="addField(type)"
>
<div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0">
<IconDrag class=" my-auto" />
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</button>
<div
v-else-if="type == 'phone'"
class="tooltip tooltip-bottom-end flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
>
<div class="w-0 absolute left-0">
<IconLock
width="18"
height="18"
stroke-width="1.5"
/>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</a>
</div>
</button>
</div>
</template>
</div>
<div
v-else-if="editable && showNewFields"
class="grid grid-cols-3 gap-1 pb-2"
>
<template
v-for="(icon, type) in fieldIcons"
:key="type"
>
<div
v-else-if="type == 'phone'"
class="tooltip tooltip-bottom-end flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
v-if="['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(type)"
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
<button
draggable="true"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')"
@click="addField(type)"
>
<div class="w-0 absolute left-0">
<IconLock
width="18"
height="18"
stroke-width="1.5"
/>
<div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0">
<IconDrag class=" my-auto" />
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
@ -113,7 +152,7 @@
{{ fieldNames[type] }}
</span>
</div>
</a>
</button>
</div>
</template>
</div>
@ -190,7 +229,8 @@ export default {
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
data () {
return {
dragField: null
dragField: null,
showNewFields: true
}
},
computed: {
@ -205,7 +245,21 @@ export default {
})
}
},
mounted () {
if (this.selectedSubmitter.name === 'Me') {
this.showNewFields = true
} else {
this.showNewFields = false
}
},
methods: {
toggleNewFields (sName) {
if (sName === 'Me') {
this.showNewFields = true
} else {
this.showNewFields = false
}
},
onDragstart (field) {
this.$emit('set-drag', field)
},
@ -256,7 +310,9 @@ export default {
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['redact', 'my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(type)) {
field.required = false
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
}

@ -0,0 +1,404 @@
const en = {
submit_form: 'Submit Form',
type_here: 'Type here',
optional: 'optional',
select_your_option: 'Select your option',
complete_hightlighted_checkboxes_and_click: 'Complete hightlighted checkboxes and click',
submit: 'submit',
next: 'next',
click_to_upload: 'Click to upload',
or_drag_and_drop_files: 'or drag and drop files',
send_copy_via_email: 'Send copy via email',
download: 'Download',
signature: 'Signature',
initials: 'Initials',
clear: 'Clear',
redraw: 'Redraw',
draw_initials: 'Draw initials',
type_signature_here: 'Type signature here',
type_initial_here: 'Type initials here',
form_has_been_completed: 'Form has been completed!',
create_a_free_account: 'Create a Free Account',
signed_with: 'Signed with',
please_check_the_box_to_continue: 'Please check the box to continue',
open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number',
redact: 'redact',
use_international_format: 'Use internatioanl format: +1xxx',
six_digits_code: '6-digit code',
change_phone_number: 'Change phone number',
sending: 'Sending...',
resend_code: 'Re-send code',
verification_code_has_been_resent: 'Verification code has been re-sent via SMS',
please_fill_all_required_fields: 'Please fill all required fields',
set_today: 'Set Today',
toggle_multiline_text: 'Toggle Multiline Text',
draw_signature: 'Draw signature',
type_initial: 'Type initials',
draw: 'Draw',
type: 'Type',
type_text: 'Type text',
date: 'Date',
email_has_been_sent: 'Email has been sent'
}
const es = {
submit_form: 'Enviar Formulario',
type_here: 'Escribe aquí',
optional: 'opcional',
select_your_option: 'Selecciona tu opción',
complete_hightlighted_checkboxes_and_click: 'Completa las casillas resaltadas y haz clic',
submit: 'enviar',
next: 'siguiente',
click_to_upload: 'Haz clic para cargar',
or_drag_and_drop_files: 'o arrastra y suelta archivos',
send_copy_via_email: 'Enviar copia por correo electrónico',
download: 'Descargar',
signature: 'Firma',
initials: 'Iniciales',
clear: 'Borrar',
redraw: 'Redibujar',
draw_initials: 'Dibujar iniciales',
type_signature_here: 'Escribe la firma aquí',
type_initial_here: 'Escribe las iniciales aquí',
form_has_been_completed: '¡El formulario ha sido completado!',
create_a_free_account: 'Crear una Cuenta Gratuita',
signed_with: 'Firmado con',
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar',
open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono',
redact: 'redact',
use_international_format: 'Usar formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Cambiar número de teléfono',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS',
please_fill_all_required_fields: 'Por favor, complete todos los campos obligatorios',
set_today: 'Establecer Hoy',
date: 'Fecha',
toggle_multiline_text: 'Alternar Texto Multilínea',
draw_signature: 'Dibujar firma',
type_initial: 'Escribir iniciales',
draw: 'Dibujar',
type: 'Escribir',
type_text: 'Escribir texto',
email_has_been_sent: 'El correo electrónico ha sido enviado'
}
const it = {
submit_form: 'Invia Modulo',
type_here: 'Digita qui',
optional: 'opzionale',
select_your_option: 'Seleziona la tua opzione',
complete_hightlighted_checkboxes_and_click: 'Completa le caselle evidenziate e fai clic',
submit: 'invia',
next: 'avanti',
click_to_upload: 'Clicca per caricare',
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
send_copy_via_email: 'Invia copia via email',
download: 'Scarica',
signature: 'Firma',
initials: 'Iniziali',
clear: 'Cancella',
redraw: 'Ridisegna',
draw_initials: 'Disegna iniziali',
type_signature_here: 'Scrivi la firma qui',
type_initial_here: 'Scrivi le iniziali qui',
form_has_been_completed: 'Il modulo è stato completato!',
create_a_free_account: 'Crea un Account Gratuito',
signed_with: 'Firmato con',
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare',
open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono',
redact: 'redact',
use_international_format: 'Usa formato internazionale: +1xxx',
six_digits_code: 'Codice a 6 cifre',
change_phone_number: 'Cambia numero di telefono',
sending: 'Invio in corso...',
resend_code: 'Rinvia codice',
verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS',
please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori',
set_today: 'Imposta Oggi',
date: 'Data',
draw_signature: 'Disegna firma',
type_initial: 'Inserisci iniziali',
draw: 'Disegna',
type: 'Inserisci',
type_text: 'Inserisci testo',
toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata"
}
const de = {
submit_form: 'Formular absenden',
type_here: 'Hier eingeben',
optional: 'optional',
select_your_option: 'Wähle deine Option',
complete_hightlighted_checkboxes_and_click: 'Markierte Kontrollkästchen ausfüllen und klicken',
submit: 'absenden',
next: 'weiter',
click_to_upload: 'Klicken zum Hochladen',
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
send_copy_via_email: 'Kopie per E-Mail senden',
download: 'Herunterladen',
signature: 'Unterschrift',
initials: 'Initialen',
clear: 'Löschen',
redraw: 'Neu zeichnen',
draw_initials: 'Initialen zeichnen',
type_signature_here: 'Unterschrift hier eingeben',
type_initial_here: 'Initialen hier eingeben',
form_has_been_completed: 'Formular wurde ausgefüllt!',
create_a_free_account: 'Kostenloses Konto erstellen',
signed_with: 'Unterschrieben mit',
please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren',
open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer überprüfen',
redact: 'redact',
use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern',
sending: 'Senden...',
resend_code: 'Code erneut senden',
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
set_today: 'Heute einstellen',
date: 'Datum',
draw_signature: 'Unterschrift zeichnen',
type_initial: 'Initialen eingeben',
draw: 'Zeichnen',
type: 'Eingeben',
type_text: 'Text eingeben',
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
email_has_been_sent: 'Die E-Mail wurde gesendet'
}
const fr = {
submit_form: 'Envoyer le Formulaire',
type_here: 'Tapez ici',
optional: 'facultatif',
select_your_option: 'Sélectionnez votre option',
complete_hightlighted_checkboxes_and_click: 'Complétez les cases à cocher en surbrillance et cliquez',
submit: 'envoyer',
next: 'suivant',
click_to_upload: 'Cliquez pour télécharger',
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
send_copy_via_email: 'Envoyer une copie par e-mail',
download: 'Télécharger',
signature: 'Signature',
initials: 'Initiales',
clear: 'Effacer',
redraw: 'Redessiner',
draw_initials: 'Dessiner les initiales',
type_signature_here: 'Tapez la signature ici',
type_initial_here: 'Tapez les initiales ici',
form_has_been_completed: 'Le formulaire a été complété !',
create_a_free_account: 'Créer un Compte Gratuit',
signed_with: 'Signé avec',
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer',
open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone',
redact: 'redact',
use_international_format: 'Utiliser le format international : +1xxx',
six_digits_code: 'Code à 6 chiffres',
change_phone_number: 'Changer le numéro de téléphone',
sending: 'Envoi en cours...',
resend_code: 'Renvoyer le code',
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
set_today: "Définir Aujourd'hui",
date: 'Date',
draw_signature: 'Dessiner une signature',
type_initial: 'Saisir les initiales',
draw: 'Dessiner',
type: 'Saisir',
type_text: 'Saisir du texte',
toggle_multiline_text: 'Basculer le Texte Multiligne',
email_has_been_sent: "L'email a été envoyé"
}
const pl = {
submit_form: 'Wyślij Formularz',
type_here: 'Wpisz tutaj',
optional: 'opcjonalny',
select_your_option: 'Wybierz swoją opcję',
complete_hightlighted_checkboxes_and_click: 'Wypełnij zaznaczone pola wyboru i kliknij',
submit: 'wyślij',
next: 'dalej',
click_to_upload: 'Kliknij, aby przesłać',
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
send_copy_via_email: 'Wyślij kopię drogą mailową',
download: 'Pobierz',
signature: 'Podpis',
initials: 'Inicjały',
clear: 'Wyczyść',
redraw: 'Przerysuj',
draw_initials: 'Narysuj inicjały',
type_signature_here: 'Wpisz podpis tutaj',
type_initial_here: 'Wpisz inicjały tutaj',
form_has_been_completed: 'Formularz został wypełniony!',
create_a_free_account: 'Utwórz darmowe konto',
signed_with: 'Podpisane za pomocą',
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować',
open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu',
redact: 'redact',
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
six_digits_code: '6-cyfrowy kod',
change_phone_number: 'Zmień numer telefonu',
sending: 'Wysyłanie...',
resend_code: 'Ponownie wyślij kod',
verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany',
please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola',
set_today: 'Ustaw Dziś',
date: 'Data',
draw_signature: 'Rysuj podpis',
type_initial: 'Wprowadź inicjały',
draw: 'Rysuj',
type: 'Wprowadź',
type_text: 'Wprowadź tekst',
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany'
}
const uk = {
submit_form: 'Надіслати Форму',
type_here: 'Введіть тут',
optional: 'необов’язково',
select_your_option: 'Виберіть варіант',
complete_hightlighted_checkboxes_and_click: 'Заповніть позначені прапорці та натисніть',
submit: 'надіслати',
next: 'далі',
click_to_upload: 'Клацніть, щоб завантажити',
or_drag_and_drop_files: 'або перетягніть файли сюди',
send_copy_via_email: 'Надіслати копію електронною поштою',
download: 'Завантажити',
signature: 'Підпис',
initials: 'Ініціали',
clear: 'Очистити',
redraw: 'Перемалювати',
draw_initials: 'Намалювати ініціали',
type_signature_here: 'Введіть підпис тут',
type_initial_here: 'Введіть ініціали тут',
form_has_been_completed: 'Форму заповнено!',
create_a_free_account: 'Створити безкоштовний обліковий запис',
signed_with: 'Підписано за допомогою',
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити',
open_source_documents_software: 'відкритий програмний засіб для документів',
verified_phone_number: 'Підтвердіть номер телефону',
redact: 'redact',
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
six_digits_code: '6-значний код',
change_phone_number: 'Змінити номер телефону',
sending: 'Надсилаю...',
resend_code: 'Повторно відправити код',
verification_code_has_been_resent: 'Код підтвердження був повторно надісланий',
please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля",
set_today: 'Задати Сьогодні',
date: 'Дата',
draw_signature: 'Намалюйте підпис',
type_initial: 'Введіть ініціали',
draw: 'Підпис',
type: 'Текст',
type_text: 'Введіть текст',
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений'
}
const cs = {
submit_form: 'Odeslat formulář',
type_here: 'Zadejte zde',
optional: 'volitelné',
select_your_option: 'Vyberte svou volbu',
complete_hightlighted_checkboxes_and_click: 'Označte zvýrazněné zaškrtávací políčka a klikněte na',
submit: 'odeslat',
next: 'další',
click_to_upload: 'Klikněte pro nahrání',
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
send_copy_via_email: 'Odeslat kopii e-mailem',
download: 'Stáhnout',
signature: 'Podpis',
initials: 'Iniciály',
clear: 'Smazat',
redraw: 'Překreslit',
draw_initials: 'Nakreslit iniciály',
type_signature_here: 'Sem zadejte podpis',
type_initial_here: 'Sem zadejte iniciály',
form_has_been_completed: 'Formulář byl dokončen!',
create_a_free_account: 'Vytvořit bezplatný účet',
signed_with: 'Podepsáno pomocí',
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování',
open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo',
redact: 'redact',
use_international_format: 'Použijte mezinárodní formát: +1xxx',
six_digits_code: '6-místný kód',
change_phone_number: 'Změnit telefonní číslo',
sending: 'Odesílání...',
resend_code: 'Znovu odeslat kód',
verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán',
please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky',
set_today: 'Nastavit Dnes',
date: 'Datum',
draw_signature: 'Nakreslit podpis',
type_initial: 'Zadat iniciály',
draw: 'Kreslit',
type: 'Zadat',
type_text: 'Zadat text',
toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán'
}
const pt = {
submit_form: 'Enviar Formulário',
type_here: 'Digite aqui',
optional: 'opcional',
select_your_option: 'Selecione sua opção',
complete_hightlighted_checkboxes_and_click: 'Complete as caixas de seleção destacadas e clique',
submit: 'enviar',
next: 'próximo',
click_to_upload: 'Clique para fazer o upload',
or_drag_and_drop_files: 'ou arraste e solte arquivos',
send_copy_via_email: 'Enviar cópia por e-mail',
download: 'Baixar',
signature: 'Assinatura',
initials: 'Iniciais',
clear: 'Limpar',
redraw: 'Redesenhar',
draw_initials: 'Desenhar iniciais',
type_signature_here: 'Digite a assinatura aqui',
type_initial_here: 'Digite as iniciais aqui',
form_has_been_completed: 'O formulário foi concluído!',
create_a_free_account: 'Criar uma Conta Gratuita',
signed_with: 'Assinado com',
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar',
open_source_documents_software: 'software de documentos de código aberto',
verified_phone_number: 'Verificar Número de Telefone',
redact: 'redact',
use_international_format: 'Use formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Alterar número de telefone',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS',
please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios',
set_today: 'Definir Hoje',
date: 'Data',
draw_signature: 'Desenhar assinatura',
type_initial: 'Inserir iniciais',
draw: 'Desenhar',
type: 'Inserir',
type_text: 'Inserir texto',
toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt }
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
const t = (key) => i18n[browserLanguage]?.[key] || i18n.en[key] || key
export default i18n
export { t }

@ -0,0 +1,74 @@
<template>
<div>
<div
class="flex justify-between items-center w-full mb-2"
>
<label
:for="field.uuid"
class="label text-2xl"
>{{ field.name || t('date') }}
</label>
<button
class="btn btn-outline btn-sm !normal-case font-normal"
@click.prevent="setCurrentDate"
>
<IconCalendarCheck :width="16" />
{{ t('set_today') }}
</button>
</div>
<div class="text-center">
<input
ref="input"
v-model="value"
class="base-input !text-2xl text-center w-full"
:required="field.required"
type="date"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
</div>
</div>
</template>
<script>
import { IconCalendarCheck } from '@tabler/icons-vue'
export default {
name: 'MyDate',
components: {
IconCalendarCheck
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
computed: {
value: {
set (value) {
this.$emit('update:model-value', value)
},
get () {
return this.modelValue
}
}
},
methods: {
setCurrentDate () {
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
}
}
</script>

@ -0,0 +1,298 @@
<template>
<div
class="absolute"
style="z-index: 50;"
:style="{ ...mySignatureStyle }"
>
<div
style="min-height: 250px; min-width: 250px;"
>
<div class="flex justify-between items-center w-full mb-2">
<label
class="label text-2xl"
>{{ field.name || t('initials') }}</label>
<div class="space-x-2 flex">
<span
v-if="isDrawInitials"
class="tooltip"
:data-tip="t('type_initials')"
>
<a
id="type_text_button"
href="#"
class="btn btn-outline font-medium btn-sm"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
</a>
</span>
<span
v-else
class="tooltip"
:data-tip="t('draw_initials')"
>
<a
id="type_text_button"
href="#"
class="btn btn-outline font-medium btn-sm"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
</a>
</span>
<a
v-if="modelValue || computedPreviousValue"
href="#"
class="tooltip btn font-medium btn-outline btn-sm"
:data-tip="'redraw'"
@click.prevent="remove"
>
<IconReload :width="16" />
</a>
<a
v-else
href="#"
class="tooltip btn font-medium btn-outline btn-sm"
:data-tip="'clear'"
@click.prevent="clear"
>
<IconReload :width="16" />
</a>
<div
class="tooltip btn btn-outline btn-sm font-medium"
:data-tip="'close'"
@click="$emit('hide')"
>
<IconTrashX :width="16" />
</div>
</div>
</div>
<input
:value="modelValue || computedPreviousValue"
type="hidden"
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72 w-full"
>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded-2xl max-h-72 w-full"
/>
<input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
id="initials_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6 text-center"
:required="field.required && !isInitialsStarted"
:placeholder="`${t('type_initial_here')}...`"
type="text"
@focus="$emit('focus')"
@input="updateWrittenInitials"
>
<button
class="btn btn-outline w-full mt-2"
@click="submit"
>
<span> Submit </span>
</button>
</div>
</div>
</template>
<script>
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconTextSize, IconSignature, IconTrashX } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad'
export default {
name: 'MyInitials',
components: {
IconReload,
IconTextSize,
IconSignature,
IconTrashX
},
inject: ['baseUrl', 't'],
props: {
field: {
type: Object,
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
default: ''
},
template: {
type: Object,
required: true
},
mySignatureStyle: {
type: Object,
required: true
}
},
emits: ['attached', 'update:model-value', 'start', 'hide', 'focus'],
data () {
return {
isInitialsStarted: !!this.previousValue,
isUsePreviousValue: true,
isDrawInitials: false
}
},
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5
}
this.$refs.textInput?.focus()
})
if (this.isDirectUpload) {
import('@rails/activestorage')
}
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
this.pad.addEventListener('beginStroke', () => {
this.isInitialsStarted = true
this.$emit('start')
})
}
},
methods: {
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isInitialsStarted = false
},
clear () {
this.pad.clear()
this.isInitialsStarted = false
if (this.$refs.textInput) {
this.$refs.textInput.value = ''
}
},
updateWrittenInitials (e) {
this.isInitialsStarted = true
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const fontFamily = 'Arial'
const fontSize = '44px'
const fontStyle = 'italic'
const fontWeight = ''
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
context.textAlign = 'center'
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
},
toggleTextInput () {
this.remove()
this.clear()
this.isDrawInitials = !this.isDrawInitials
if (!this.isDrawInitials) {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.$emit('start')
})
}
},
async submit () {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
return Promise.resolve({})
}
return new Promise((resolve) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'my_initials.png', { type: 'image/png' })
if (this.isDirectUpload) {
const { DirectUpload } = await import('@rails/activestorage')
new DirectUpload(
file,
'/direct_uploads'
).create((_error, data) => {
fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
template_slug: this.template.slug,
blob_signed_id: data.signed_id,
name: 'attachments'
}),
headers: { 'Content-Type': 'application/json' }
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
return resolve(attachment)
})
})
} else {
const formData = new FormData()
formData.append('file', file)
formData.append('template_slug', this.template.slug)
formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return resolve(attachment)
})
}
})
})
}
}
}
</script>

@ -0,0 +1,371 @@
<template>
<div
class="absolute"
style="z-index: 50;"
:style="{ ...mySignatureStyle }"
>
<div class="flex justify-between items-center w-full mb-2">
<label
class="label text-2xl"
>{{ field.name || t('signature') }}</label>
<div class="space-x-2 flex">
<span
v-if="isTextSignature"
class="tooltip"
:data-tip="t('draw_signature')"
>
<a
id="type_text_button"
href="#"
class="btn btn-outline btn-sm font-medium"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
</a>
</span>
<span
v-else
class="tooltip"
:data-tip="t('type_text')"
>
<a
id="type_text_button"
href="#"
class="btn btn-outline btn-sm font-medium"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
</a>
</span>
<span
class="tooltip"
data-tip="Take photo"
>
<label
class="btn btn-outline btn-sm font-medium"
>
<IconCamera :width="16" />
<input
type="file"
hidden
accept="image/*"
@change="drawImage"
>
</label>
</span>
<a
v-if="modelValue || computedPreviousValue"
href="#"
class="tooltip btn btn-outline btn-sm font-medium"
:data-tip="'redraw'"
@click.prevent="remove"
>
<IconReload :width="16" />
</a>
<a
v-else
href="#"
class="tooltip btn btn-outline btn-sm font-medium"
:data-tip="'clear'"
@click.prevent="clear"
>
<IconReload :width="16" />
</a>
<div
class="tooltip btn btn-outline btn-sm font-medium"
:data-tip="'close'"
@click="$emit('hide')"
>
<IconTrashX :width="16" />
</div>
</div>
</div>
<input
:value="modelValue || computedPreviousValue"
type="hidden"
>
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue]?.url"
class="mx-auto bg-white border border-base-300 rounded max-h-72 w-full"
>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full"
/>
<input
v-if="isTextSignature"
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
:placeholder="`${t('type_signature_here')}...`"
type="text"
@input="updateWrittenSignature"
>
<button
class="btn btn-outline w-full mt-2"
@click="submit"
>
<span> Submit </span>
</button>
</div>
</template>
<script>
import { IconReload, IconCamera, IconSignature, IconTextSize, IconTrashX } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad'
let isFontLoaded = false
export default {
name: 'MySignature',
components: {
IconReload,
IconCamera,
IconTextSize,
IconSignature,
IconTrashX
},
inject: ['baseUrl', 't'],
props: {
field: {
type: Object,
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
default: ''
},
template: {
type: Object,
required: true
},
mySignatureStyle: {
type: Object,
required: true
}
},
emits: ['attached', 'update:model-value', 'start', 'hide'],
data () {
return {
isSignatureStarted: !!this.previousValue,
isUsePreviousValue: true,
isTextSignature: false
}
},
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas?.parentNode?.clientWidth
this.$refs.canvas.height = this.$refs.canvas?.parentNode?.clientWidth / 3
}
})
if (this.isDirectUpload) {
import('@rails/activestorage')
}
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
this.pad.addEventListener('beginStroke', () => {
this.isSignatureStarted = true
this.$emit('start')
})
}
},
methods: {
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isSignatureStarted = false
},
loadFont () {
if (!isFontLoaded) {
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript.otf) format("opentype")`)
font.load().then((loadedFont) => {
document.fonts.add(loadedFont)
isFontLoaded = true
}).catch((error) => {
console.error('Font loading failed:', error)
})
}
},
clear () {
this.pad.clear()
this.isSignatureStarted = false
if (this.$refs.textInput) {
this.$refs.textInput.value = ''
}
},
updateWrittenSignature (e) {
this.isSignatureStarted = true
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const fontFamily = 'Dancing Script'
const fontSize = '38px'
const fontStyle = 'italic'
const fontWeight = ''
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
context.textAlign = 'center'
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
},
toggleTextInput () {
this.remove()
this.isTextSignature = !this.isTextSignature
if (this.isTextSignature) {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.loadFont()
this.$emit('start')
})
}
},
drawImage (event) {
this.remove()
this.isSignatureStarted = true
const file = event.target.files[0]
if (file && file.type.match('image.*')) {
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.src = event.target.result
img.onload = () => {
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const aspectRatio = img.width / img.height
let targetWidth = canvas.width
let targetHeight = canvas.height
if (canvas.width / canvas.height > aspectRatio) {
targetWidth = canvas.height * aspectRatio
} else {
targetHeight = canvas.width / aspectRatio
}
if (targetHeight > targetWidth) {
const scale = targetHeight / targetWidth
targetWidth = targetWidth * scale
targetHeight = targetHeight * scale
}
const x = (canvas.width - targetWidth) / 2
const y = (canvas.height - targetHeight) / 2
context.clearRect(0, 0, canvas.width, canvas.height)
context.drawImage(img, x, y, targetWidth, targetHeight)
this.$emit('start')
}
}
reader.readAsDataURL(file)
}
},
async submit () {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
return Promise.resolve({})
}
return new Promise((resolve) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'my_signature.png', { type: 'image/png' })
if (this.isDirectUpload) {
const { DirectUpload } = await import('@rails/activestorage')
new DirectUpload(
file,
'/direct_uploads'
).create((_error, data) => {
fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
template_slug: this.template.slug,
blob_signed_id: data.signed_id,
name: 'attachments'
}),
headers: { 'Content-Type': 'application/json' }
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
return resolve(attachment)
})
})
} else {
const formData = new FormData()
formData.append('file', file)
formData.append('template_slug', this.template.slug)
formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return resolve(attachment)
})
}
})
})
}
}
}
</script>

@ -29,12 +29,14 @@
@start-drag="isMove = true"
@stop-drag="isMove = false"
@remove="$emit('remove-area', item.area)"
@update:my-field="$emit('update:myField', $event)"
/>
<FieldArea
v-if="newArea"
:is-draw="true"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || 'text' }"
:area="newArea"
@update:my-field="$emit('update:myField', $event)"
/>
</div>
<div
@ -99,7 +101,7 @@ export default {
required: true
}
},
emits: ['draw', 'drop-field', 'remove-area'],
emits: ['draw', 'drop-field', 'remove-area', 'update:myField'],
data () {
return {
areaRefs: [],

@ -1,6 +1,6 @@
<template>
<div>
<div class="relative">
<div v-for="(previewImage, index) in sortedPreviewImages" :key="previewImage.id" class="relative">
<img
:src="previewImage.url"
:width="previewImage.metadata.width"
@ -10,13 +10,24 @@
>
<div
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1"
@click="$emit('scroll-to', item)"
@click="$emit('scroll-to', item, previewImage)"
>
<div
v-if="editable"
v-if="editable && index==0"
class="flex justify-between w-full"
>
<div style="width: 26px" />
<div
v-if="sortedPreviewImages.length != 1"
class="flex flex-col justify-between opacity-0 group-hover:opacity-100"
>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('remove-image', item, previewImage.id)"
>
&times;
</button>
</div>
<div class="">
<ReplaceButton
v-if="withReplaceButton"
@ -31,28 +42,19 @@
<div
class="flex flex-col justify-between opacity-0 group-hover:opacity-100"
>
<div>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('remove', item)"
>
&times;
</button>
</div>
<div
v-if="withArrows"
class="flex flex-col space-y-1"
>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-red-500 hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('up', item)"
>
&uarr;
</button>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-red-500 hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
@ -61,8 +63,58 @@
</div>
</div>
</div>
<div
v-else
class="flex justify-between w-full"
>
<div
class="flex flex-col justify-between opacity-0 group-hover:opacity-100"
>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
:class="{ disabled: isDeleting }"
:disabled="isDeleting"
@click.stop="$emit('remove-image', item, previewImage.id)"
>
<IconInnerShadowTop
v-if="isDeleting"
width="22"
class="animate-spin"
/>
&times;
</button>
</div>
</div>
</div>
</div>
<button
class="btn btn-outline w-full mt-2"
:class="{ disabled: isLoading }"
:disabled="isLoading"
@click="$emit('add-blank-page', item)"
>
<IconInnerShadowTop
v-if="isLoading"
width="22"
class="animate-spin"
/>
<span v-if="isLoading"> Adding blank page </span>
<span v-else>Add blank page</span>
</button>
<label
class="btn btn-outline w-full mt-2"
@click.stop="$emit('remove', item)"
>
<span class="flex items-center">
<IconFileOff
width="22"
/>
&nbsp;
del document </span>
</label>
<div class="flex pb-2 pt-1.5">
<Contenteditable
:model-value="item.name"
@ -80,12 +132,15 @@
import Contenteditable from './contenteditable'
import Upload from './upload'
import ReplaceButton from './replace'
import { IconInnerShadowTop, IconFileOff } from '@tabler/icons-vue'
export default {
name: 'DocumentPreview',
components: {
Contenteditable,
ReplaceButton
ReplaceButton,
IconInnerShadowTop,
IconFileOff
},
props: {
item: {
@ -124,12 +179,22 @@ export default {
type: Boolean,
required: false,
default: true
},
isLoading: {
type: Boolean,
required: true,
default: false
},
isDeleting: {
type: Boolean,
required: true,
default: false
}
},
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'remove-image', 'add-blank-page'],
computed: {
previewImage () {
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))[0]
sortedPreviewImages () {
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))
}
},
mounted () {

@ -13,6 +13,7 @@
# slug :string not null
# source :text not null
# submitters :text not null
# values :text
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
@ -33,7 +34,7 @@
# fk_rails_... (folder_id => template_folders.id)
#
class Template < ApplicationRecord
DEFAULT_SUBMITTER_NAME = 'First Party'
DEFAULT_SUBMITTER_NAME = 'Me'
belongs_to :author, class_name: 'User'
belongs_to :account
@ -50,6 +51,7 @@ class Template < ApplicationRecord
serialize :fields, JSON
serialize :schema, JSON
serialize :submitters, JSON
serialize :values, JSON
has_many_attached :documents
@ -61,6 +63,15 @@ class Template < ApplicationRecord
scope :active, -> { where(deleted_at: nil) }
scope :archived, -> { where.not(deleted_at: nil) }
after_save :create_secure_images
def create_secure_images
documents.each do |doc|
document_data = doc.blob.download
Templates::ProcessDocument.generate_pdf_secured_preview_images(self, doc, document_data)
end
end
private
def maybe_set_default_folder

@ -34,7 +34,7 @@
"submitters": [
{
"name": "John Doe",
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Party' %>",
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'Me' %>",
"email": "<%= current_user.email.sub('@', '+test@') %>",
"values": {
"Form Text Field Name": "Default Value"

@ -10,6 +10,9 @@
</div>
<div class="grid <%= 'md:grid-cols-2' if template.submitters.size > 1 %> gap-4">
<% template.submitters.each_with_index do |item, index| %>
<% if item["name"] == 'Me' %>
<% next %>
<% end %>
<submitter-item class="form-control">
<% if template.submitters.size > 1 %>
<label class="label pt-0 pb-1 text-xs">

@ -19,6 +19,9 @@
</div>
<div class="grid md:grid-cols-2 gap-4">
<% template.submitters.each_with_index do |item, index| %>
<% if item["name"] == 'Me' %>
<% next %>
<% end %>
<submitter-item class="form-control">
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>

@ -10,6 +10,9 @@
</div>
<div class="grid <%= template.submitters.size > 1 ? 'md:grid-cols-2 gap-4' : 'gap-1' %>">
<% template.submitters.each_with_index do |item, index| %>
<% if item["name"] == 'Me' %>
<% next %>
<% end %>
<submitter-item class="grid <%= template.submitters.size > 1 ? 'gap-4' : 'md:grid-cols-2 gap-1' %>">
<div class="form-control">
<% if template.submitters.size > 1 %>

@ -1,5 +1,5 @@
<field-value class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% if field['type'].in?(['signature', 'image', 'initials']) %>
<% if field['type'].in?(['signature', 'image', 'initials', 'my_signature', 'my_initials']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'].in?(['file', 'payment']) %>
<autosize-field></autosize-field>
@ -11,7 +11,7 @@
</a>
<% end %>
</div>
<% elsif field['type'] == 'checkbox' %>
<% elsif field['type'].in?(%w[checkbox my_check]) %>
<div class="w-full flex items-center justify-center">
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div>
@ -31,13 +31,18 @@
<% end %>
<% end %>
</div>
<% elsif field['type'] == 'date' %>
<% elsif ['data', 'my_date'].include?(field['type']) %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5">
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %>
</div>
<% elsif field['type'] == 'redact' %>
<div class="flex absolute" style="width: 100%; height: 100%;" :style="{ backgroundColor: 'black' }"></div>
<% elsif field['type'] == 'my_text' %>
<autosize-field></autosize-field>
<div style="letter-spacing: 1.1px; padding-bottom: 1.1px; font-size: 1.4rem;" class="flex items-center px-0.5 whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div>
<% else %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5 whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div>
<div style="letter-spacing: 1.1px; padding-bottom: 1.1px;" class="flex items-center px-0.5 whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div>
<% end %>
</field-value>

@ -61,7 +61,17 @@
<%= render 'submissions/annotation', annot: %>
<% end %>
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']] %>
<% if ['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].include?(field['type']) %>
<% value = @submission.template.values[field['uuid']] %>
<% value = 'my_check' if ['my_check'].include?(field['type']) %>
<% else %>
<% value = values[field['uuid']] %>
<% end %>
<% if ['my_signature', 'my_initials'].include?(field['type']) %>
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.template, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% else %>
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% end %>
<% next if value.blank? %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submission.template.account.locale %>
<% end %>
@ -142,7 +152,7 @@
<%= field['name'].presence || "#{field['type'].titleize} Field #{submitter_field_counters[field['type']]}" %>
</div>
<div>
<% if field['type'].in?(%w[signature initials]) %>
<% if field['type'].in?(%w[signature initials my_signature my_initials]) %>
<div class="w-full bg-base-300 py-1">
<img class="object-contain mx-auto" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
</div>

@ -1,4 +1,9 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| ['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].include?(f['type']) || f['submitter_uuid'] == submitter.uuid }.to_json %>
<% configs = Submitters::FormConfigs.call(submitter) %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>
<% completed_button_params = submitter.submission.template.account.account_configs.find_by(key: AccountConfig::FORM_COMPLETED_BUTTON_KEY)&.value || {} %>
<% templateValues = submitter.submission.template.values %>
<% template_attachments = ActiveStorage::Attachment.where(record: submitter.submission.template, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% template_attachments_index = template_attachments.values.select { |e| e.record_id == submitter.submission.template.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<submission-form data-template-attachments-index="<%= template_attachments_index %>" data-template-values="<%= templateValues.to_json %>" data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>

@ -9,10 +9,11 @@
<%= render 'banner' %>
<% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %>
<% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last.metadata %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% document.preview_secured_images.load %>
<% preview_images_index = document.preview_secured_images.loaded? ? document.preview_secured_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_secured_images.last.metadata %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_secured_images.loaded? ? preview_images_index.size : document.preview_secured_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.uuid, "#{index}.jpg")) %>
<div class="relative my-4 shadow-md">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>">

@ -6,6 +6,7 @@
<p>Alternatively, you can review and download your copy using:</p>
<p>
<%= link_to @submitter.template.name, submissions_preview_url(@submitter.submission.slug) %>
<%# link_to @submitter.template.name, submitter_download_index_path(@submitter.submission.slug), download: true %>
</p>
<p>
Thanks,<br><%= @current_account.name %>

@ -36,15 +36,17 @@
<% end %>
<% end %>
<% if !template.deleted_at? && can?(:update, template) %>
<%= link_to edit_template_path(template), class: 'btn btn-outline btn-sm' do %>
<%= link_to edit_template_path(template), class: 'btn btn-outline btn-sm', id: 'edit-template-link', data: {template: edit_template_path(@template)} do %>
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('pencil', class: 'w-6 h-6') %>
<span>Edit</span>
</span>
<% end %>
<% end %>
<% if template.deleted_at? && can?(:create, template) %>
<%= button_to button_title(title: 'Restore', disabled_with: 'Restoring', icon: svg_icon('rotate', class: 'w-6 h-6')), template_restore_index_path(template), class: 'btn btn-outline btn-sm' %>
<% end %>
</div>
</div>
</div>

@ -1 +1,3 @@
<template-builder class="grid" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-template="<%= @template_data %>"></template-builder>
<% attachments_index = ActiveStorage::Attachment.where(record: @template, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% template_attachments_index = attachments_index.values.select { |e| e.record_id == @template.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<template-builder class="grid" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-template="<%= @template_data %>" data-template-attachments-index="<%= template_attachments_index %>"></template-builder>

@ -100,3 +100,27 @@
</div>
</div>
<% end %>
<div class="my-display" id="loader">
<div class="loader-animation"></div>
<div class="text-load">Loading...</div>
</div>
<script>
function showLoading () {
document.getElementById('loader').style.display = 'block'
}
const editTemplateLink = document.getElementById('edit-template-link')
if (editTemplateLink) {
editTemplateLink.addEventListener('click', function (event) {
event.preventDefault()
showLoading()
const template = this.getAttribute('data-template')
setTimeout(function () {
window.location.href = template
document.getElementById('loader').style.display = 'none'
}, 3000)
})
}
</script>

@ -4,6 +4,7 @@ ActiveSupport.on_load(:active_storage_attachment) do
attribute :uuid, :string, default: -> { SecureRandom.uuid }
has_many_attached :preview_images
has_many_attached :preview_secured_images
end
ActiveStorage::LogSubscriber.detach_from(:active_storage) if Rails.env.production?

@ -44,7 +44,10 @@ Rails.application.routes.draw do
resources :templates, only: %i[update show index destroy] do
resources :clone, only: %i[create], controller: 'templates_clone'
resources :submissions, only: %i[index create]
resources :documents, only: %i[create], controller: 'templates_documents'
resources :documents, only: %i[create], controller: 'templates_documents'do
post 'add_new_image', on: :member
post 'del_image', on: :member
end
end
end

@ -0,0 +1,5 @@
class AddValuesToTemplates < ActiveRecord::Migration[7.0]
def change
add_column :templates, :values, :text
end
end

@ -77,7 +77,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_22_212612) do
t.string "event_name", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["submitter_id", "event_name"], name: "index_document_generation_events_on_submitter_id_and_event_name", unique: true, where: "((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))"
t.index ["submitter_id", "event_name"], name: "index_document_generation_events_on_submitter_id_and_event_name", unique: true, where: "((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))"
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end
@ -192,6 +192,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_22_212612) do
t.text "source", null: false
t.bigint "folder_id", null: false
t.string "application_key"
t.text "values"
t.index ["account_id"], name: "index_templates_on_account_id"
t.index ["author_id"], name: "index_templates_on_author_id"
t.index ["folder_id"], name: "index_templates_on_folder_id"

@ -35,8 +35,9 @@ module Submissions
pdfs_index = build_pdfs_index(submitter)
submitter.submission.template_fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid
unless ['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].include?(field['type'])
next if field['submitter_uuid'] != submitter.uuid
end
field.fetch('areas', []).each do |area|
pdf = pdfs_index[area['attachment_uuid']]
@ -59,9 +60,10 @@ module Submissions
layouter = HexaPDF::Layout::TextLayouter.new(valign: :center, font: pdf.fonts.add(FONT_NAME), font_size:)
value = submitter.values[field['uuid']]
next if Array.wrap(value).compact_blank.blank?
if !field['type'].in?(%w[redact my_check])
value = submitter.values[field['uuid']] || template.values[field['uuid']]
next if Array.wrap(value).compact_blank.blank?
end
canvas = page.canvas(type: :overlay)
canvas.font(FONT_NAME, size: font_size)
@ -77,6 +79,25 @@ module Submissions
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
canvas.image(
io,
at: [
(area['x'] * width) + (area['w'] * width / 2) - ((image.width * scale) / 2),
height - (area['y'] * height) - (image.height * scale / 2) - (area['h'] * height / 2)
],
width: image.width * scale,
height: image.height * scale
)
when 'my_signature', 'my_initials'
attachment = ActiveStorage::Attachment.where(record: template, name: :attachments).preload(:blob).index_by(&:uuid).values.find{ |a| a.uuid == value }
image = Vips::Image.new_from_buffer(attachment.download, '').autorot
scale = [(area['w'] * width) / image.width,
(area['h'] * height) / image.height].min
io = StringIO.new(image.resize([scale * 4, 1].min).write_to_buffer('.png'))
canvas.image(
io,
at: [
@ -133,7 +154,8 @@ module Submissions
layouter.fit(items, area['w'] * width, height_diff.positive? ? box_height : area['h'] * height)
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN)
when ->(type) { type == 'checkbox' || (type.in?(%w[multiple radio]) && area['option_uuid'].present?) }
when ->(type) { type == 'checkbox' || type == 'my_check' || (type.in?(%w[multiple radio]) && area['option_uuid'].present?) }
value=true if field['type'] == 'my_check'
if field['type'].in?(%w[multiple radio])
option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] }
@ -164,8 +186,35 @@ module Submissions
.draw(canvas, ((area['x'] * width) + (cell_width * index)),
height - (area['y'] * height))
end
when 'redact'
x = area['x'] * width
y = height - (area['y'] * height) - (area['h'] * height)
w = area['w'] * width
h = area['h'] * height
canvas.fill_color(0, 0, 0) # Set fill color to black
canvas.rectangle(x, y, w, h)
canvas.fill
when 'my_text'
x = area['x'] * width
y = height - (area['y'] * height) - (area['h'] * height)
w = area['w'] * width * 1.01
h = area['h'] * height
font_size_my_text = (font_size / 0.75).to_i
layouter_my_text = HexaPDF::Layout::TextLayouter.new(valign: :top, font: pdf.fonts.add(FONT_NAME), font_size: font_size_my_text)
value_my_text = submitter.values[field['uuid']] || template.values[field['uuid']]
text_my_text = HexaPDF::Layout::TextFragment.create(Array.wrap(value_my_text).join(', '), font: pdf.fonts.add(FONT_NAME), font_size: font_size_my_text)
lines_my_text = layouter_my_text.fit([text_my_text], w, h).lines
box_height_my_text = lines_my_text.sum(&:height)
height_diff_my_text = [0, box_height_my_text - h].max
layouter_my_text.fit([text_my_text], w, height_diff_my_text.positive? ? box_height_my_text : h)
.draw(canvas, x + TEXT_LEFT_MARGIN, y - height_diff_my_text + 17)
else
if field['type'] == 'date'
if field['type'].in?(%w[date my_date])
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale)
end
@ -304,7 +353,7 @@ module Submissions
page.box.height = attachment.metadata['height'] * scale
page.canvas.image(
StringIO.new(attachment.preview_images.first.download),
StringIO.new(attachment.preview_secured_images.first.download),
at: [0, 0],
width: page.box.width,
height: page.box.height

@ -28,7 +28,7 @@ module Submitters
end
end
def create_attachment!(submitter, params)
def create_attachment!(record, params)
blob =
if (file = params[:file])
ActiveStorage::Blob.create_and_upload!(io: file.open,
@ -41,7 +41,7 @@ module Submitters
ActiveStorage::Attachment.create!(
blob:,
name: params[:name],
record: submitter
record: record
)
end

@ -5,6 +5,7 @@ module Templates
DPI = 200
FORMAT = '.jpg'
ATTACHMENT_NAME = 'preview_images'
SECURED_ATTACHMENT_NAME = 'preview_secured_images'
PDF_CONTENT_TYPE = 'application/pdf'
Q = 35
@ -50,6 +51,7 @@ module Templates
attachment.save!
(0..[number_of_pages - 1, MAX_NUMBER_OF_PAGES_PROCESSED].min).each do |page_number|
page = Vips::Image.new_from_buffer(data, '', dpi: DPI, page: page_number)
page = page.resize(MAX_WIDTH / page.width.to_f)
@ -101,5 +103,122 @@ module Templates
io
end
def generate_pdf_secured_preview_images(template, attachment, data)
ActiveStorage::Attachment.where(name: SECURED_ATTACHMENT_NAME, record: attachment).destroy_all
number_of_pages = PDF::Reader.new(StringIO.new(data)).pages.size - 1
(0..number_of_pages).each do |page_number|
pdf = Vips::Image.new_from_buffer(data, '', dpi: DPI, page: page_number)
pdf = pdf.resize(MAX_WIDTH / pdf.width.to_f)
redacted_boxes = template.fields.select { |field| field['type'] == 'redact' && field['areas'][0]['page'] == page_number }
if !redacted_boxes.empty?
redacted_boxes.each do |box|
x = (box['areas'][0]['x'] * pdf.width).to_i
y = (box['areas'][0]['y'] * pdf.height).to_i
w = (box['areas'][0]['w'] * pdf.width).to_i
h = (box['areas'][0]['h'] * pdf.height).to_i
black_rect = Vips::Image.black(w, h)
pdf = pdf.insert(black_rect, x, y)
end
end
io = StringIO.new(pdf.write_to_buffer(FORMAT, Q: Q))
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
io: io, filename: "#{page_number}#{FORMAT}",
metadata: { analyzed: true, identified: true, width: pdf.width, height: pdf.height }
),
name: SECURED_ATTACHMENT_NAME,
record: attachment
)
end
end
def delete_picture(template, document, image_attachment_id, page_number)
image_attachment = ActiveStorage::Attachment.find_by(id: image_attachment_id)
return unless image_attachment
file_path =
if document.service.name == :disk
ActiveStorage::Blob.service.path_for(document.key)
end
temp_dir = "#{Rails.root}/tmp/"
FileUtils.mkdir_p(temp_dir)
temp_file_path = "#{temp_dir}#{SecureRandom.uuid}.pdf"
File.open(temp_file_path, 'wb') do |file|
document.download { |chunk| file.write(chunk) }
end
pdf = HexaPDF::Document.open(temp_file_path)
pdf.pages.delete_at(page_number)
pdf.write(temp_file_path)
document.reload
document.metadata[:pdf]['number_of_pages'] -= 1
temp_doc = document.metadata
new_attachment = document.attachments.update!(
name: document.name,
uuid: document.uuid,
blob: ActiveStorage::Blob.create_and_upload!(
io: File.open(temp_file_path),
filename: document.blob.filename,
content_type: document.blob.content_type,
metadata: temp_doc
)
)
document.blob.purge
image_attachment.purge
document.reload
File.delete(temp_file_path)
remaining_images = document.preview_images
remaining_images.each_with_index do |image, index|
new_filename = "#{index}.jpg"
image.blob.update!(filename: new_filename)
end
rescue StandardError => e
Rails.logger.error("Error uploading new blank image: #{e.message}")
ensure
File.delete(temp_file_path) if File.exist?(temp_file_path)
end
def upload_new_blank_image(template, document)
file_path =
if document.service.name == :disk
ActiveStorage::Blob.service.path_for(document.key)
end
temp_dir = "#{Rails.root}/tmp/"
FileUtils.mkdir_p(temp_dir)
temp_file_path = "#{temp_dir}#{SecureRandom.uuid}.pdf"
File.open(temp_file_path, 'wb') do |file|
document.download { |chunk| file.write(chunk) }
end
pdf = HexaPDF::Document.open(temp_file_path)
pdf.pages.add
pdf.write(temp_file_path)
document.reload
document.metadata[:pdf]['number_of_pages'] += 1
temp_doc = document.metadata
new_attachment = document.attachments.update!(
name: document.name,
uuid: document.uuid,
blob: ActiveStorage::Blob.create_and_upload!(
io: File.open(temp_file_path),
filename: document.blob.filename,
content_type: document.blob.content_type,
metadata: temp_doc
)
)
document.blob.purge
document.reload
# to update pdf images in storage blob
self.generate_pdf_preview_images(document, document.blob.download)
File.delete(temp_file_path)
rescue StandardError => e
Rails.logger.error("Error uploading new blank image: #{e.message}")
ensure
File.delete(temp_file_path) if File.exist?(temp_file_path)
end
end
end

@ -24,7 +24,7 @@ FactoryBot.define do
template.schema = [{ attachment_uuid: attachment.uuid, name: 'sample-document' }]
template.submitters = [
{
'name' => 'First Party',
'name' => 'Me',
'uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c'
}
]

@ -11,6 +11,7 @@ module.exports = {
secondary: '#ef9fbc',
accent: '#eeaf3a',
neutral: '#291334',
black: '#000000',
'base-100': '#faf7f5',
'base-200': '#efeae6',
'base-300': '#e7e2df',

Loading…
Cancel
Save