diff --git a/Gemfile.lock b/Gemfile.lock index 5dc77e9f3..a1472da2d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -497,7 +497,7 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger - rubyzip (3.2.2) + rubyzip (3.7.0) securerandom (0.4.1) semantic_range (3.1.1) shakapacker (9.7.0) diff --git a/README.md b/README.md index 64a917ade..e2f6e8b92 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d - Files storage on disk or AWS S3, Google Storage, Azure Cloud - Automatic PDF eSignature - PDF signature verification -- Users management +- User management - Mobile-optimized - 7 UI languages with signing available in 14 languages - API and Webhooks for integrations diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index e55cbe03d..a00f1d543 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -98,7 +98,7 @@ module Api render json: build_create_json(submissions) rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError, - DownloadUtils::UnableToDownload => e + Submitters::SubmitValues::ValidationError, DownloadUtils::UnableToDownload => e Rollbar.warning(e) if defined?(Rollbar) render json: { error: e.message }, status: :unprocessable_content diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb index 555dc92dd..313f84fc1 100644 --- a/app/controllers/api/submitters_controller.rb +++ b/app/controllers/api/submitters_controller.rb @@ -78,7 +78,8 @@ module Api render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true, with_events: false, params:) - rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e + rescue Submitters::NormalizeValues::BaseError, Submitters::SubmitValues::ValidationError, + DownloadUtils::UnableToDownload => e Rollbar.warning(e) if defined?(Rollbar) render json: { error: e.message }, status: :unprocessable_content diff --git a/app/controllers/submit_form_email_2fas_controller.rb b/app/controllers/submit_form_email_2fas_controller.rb index 511daaf65..6f95e7544 100644 --- a/app/controllers/submit_form_email_2fas_controller.rb +++ b/app/controllers/submit_form_email_2fas_controller.rb @@ -10,12 +10,10 @@ class SubmitFormEmail2fasController < ApplicationController def create RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) - RateLimit.call("verify-2fa-code-1h-#{@submitter.id}", limit: 10, ttl: 1.hour, enabled: true) - RateLimit.call("verify-2fa-code-1d-#{@submitter.id}", limit: 20, ttl: 1.day, enabled: true) - value = [@submitter.email.downcase.strip, @submitter.slug].join(':') + value = [@submitter.email.downcase.squish, @submitter.slug].join(':') - if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value) + if OtpChallenges.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value, purpose: 'form_email_2fa') SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email }) cookies.encrypted[:email_2fa_slug] = diff --git a/app/controllers/submit_form_metadata_controller.rb b/app/controllers/submit_form_metadata_controller.rb index 55c95c830..32faba108 100644 --- a/app/controllers/submit_form_metadata_controller.rb +++ b/app/controllers/submit_form_metadata_controller.rb @@ -17,12 +17,12 @@ class SubmitFormMetadataController < ApplicationController submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } end - ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call + ActiveRecord::Associations::Preloader.new(records: documents, associations: :blob).call text_runs = documents.to_h do |document| [ document.uuid, - DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs + DocumentMetadatas.find_or_create_for_document(document, account_id: submission.account_id).text_runs ] end diff --git a/app/controllers/submit_form_prefill_attachments_controller.rb b/app/controllers/submit_form_prefill_attachments_controller.rb index d56dc2a45..dbd720c6a 100644 --- a/app/controllers/submit_form_prefill_attachments_controller.rb +++ b/app/controllers/submit_form_prefill_attachments_controller.rb @@ -4,19 +4,19 @@ class SubmitFormPrefillAttachmentsController < ActionController::API include ActiveStorage::SetCurrent def create - submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) - unless can_upload?(submitter) + unless can_upload?(@submitter) return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content end uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment) - raise ActiveRecord::RecordNotFound if slug != submitter.slug + raise ActiveRecord::RecordNotFound if slug != @submitter.slug source_attachment = ActiveStorage::Attachment.find_by!(uuid:) - attachment = submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id) + attachment = @submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id) render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) end diff --git a/app/controllers/template_documents_controller.rb b/app/controllers/template_documents_controller.rb index fafa6c141..6aa95da02 100644 --- a/app/controllers/template_documents_controller.rb +++ b/app/controllers/template_documents_controller.rb @@ -6,7 +6,17 @@ class TemplateDocumentsController < ApplicationController FILES_TTL = 5.minutes def index - render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) } + documents = @template.schema.map do |item| + if item['dynamic'] + dynamic_document = @template.schema_dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] } + + DynamicDocuments::EnsureVersionGenerated.call(dynamic_document).document_attachment + else + @template.schema_documents.find { |e| e.uuid == item['attachment_uuid'] } + end + end + + render json: documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) } end def create diff --git a/app/javascript/submission_form/i18n.js b/app/javascript/submission_form/i18n.js index 1da0bd06c..e349e56ce 100644 --- a/app/javascript/submission_form/i18n.js +++ b/app/javascript/submission_form/i18n.js @@ -13,6 +13,7 @@ const en = { must_be_valid_email: 'Must be a valid email address', must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)', must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)', + must_be_valid_iban: 'Must be a valid IBAN', must_contain_numbers_only: 'Must contain numbers only', must_contain_letters_only: 'Must contain letters only', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.', @@ -132,6 +133,7 @@ const es = { must_be_valid_email: 'Debe ser una dirección de correo electrónico válida', must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)', must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)', + must_be_valid_iban: 'Debe ser un IBAN válido', must_contain_numbers_only: 'Debe contener solo números', must_contain_letters_only: 'Debe contener solo letras', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.', @@ -251,6 +253,7 @@ const it = { must_be_valid_email: 'Deve essere un indirizzo email valido', must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)', must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)', + must_be_valid_iban: 'Deve essere un IBAN valido', must_contain_numbers_only: 'Deve contenere solo numeri', must_contain_letters_only: 'Deve contenere solo lettere', complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.", @@ -370,6 +373,7 @@ const de = { must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein', must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)', must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)', + must_be_valid_iban: 'Muss eine gültige IBAN sein', must_contain_numbers_only: 'Darf nur Zahlen enthalten', must_contain_letters_only: 'Darf nur Buchstaben enthalten', complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.', @@ -489,6 +493,7 @@ const fr = { must_be_valid_email: 'Doit être une adresse e-mail valide', must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)', must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)', + must_be_valid_iban: 'Doit être un IBAN valide', must_contain_numbers_only: 'Doit contenir uniquement des chiffres', must_contain_letters_only: 'Doit contenir uniquement des lettres', complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.", @@ -608,6 +613,7 @@ const pl = { must_be_valid_email: 'Musi być prawidłowym adresem e-mail', must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)', must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)', + must_be_valid_iban: 'Musi być prawidłowym numerem IBAN', must_contain_numbers_only: 'Może zawierać tylko cyfry', must_contain_letters_only: 'Może zawierać tylko litery', complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.', @@ -727,6 +733,7 @@ const uk = { must_be_valid_email: 'Має бути дійсною адресою електронної пошти', must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)', must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)', + must_be_valid_iban: 'Має бути дійсним IBAN', must_contain_numbers_only: 'Має містити лише цифри', must_contain_letters_only: 'Має містити лише літери', complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.", @@ -846,6 +853,7 @@ const cs = { must_be_valid_email: 'Musí být platná e-mailová adresa', must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)', must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)', + must_be_valid_iban: 'Musí být platný IBAN', must_contain_numbers_only: 'Musí obsahovat pouze čísla', must_contain_letters_only: 'Musí obsahovat pouze písmena', complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.', @@ -965,6 +973,7 @@ const pt = { must_be_valid_email: 'Deve ser um endereço de e-mail válido', must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)', must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)', + must_be_valid_iban: 'Deve ser um IBAN válido', must_contain_numbers_only: 'Deve conter apenas números', must_contain_letters_only: 'Deve conter apenas letras', complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.', @@ -1084,6 +1093,7 @@ const he = { must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית', must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)', must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)', + must_be_valid_iban: 'חייב להיות IBAN חוקי', must_contain_numbers_only: 'חייב להכיל מספרים בלבד', must_contain_letters_only: 'חייב להכיל אותיות בלבד', complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.', @@ -1203,6 +1213,7 @@ const nl = { must_be_valid_email: 'Moet een geldig e-mailadres zijn', must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)', must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)', + must_be_valid_iban: 'Moet een geldig IBAN zijn', must_contain_numbers_only: 'Mag alleen cijfers bevatten', must_contain_letters_only: 'Mag alleen letters bevatten', complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.', @@ -1322,6 +1333,7 @@ const ar = { must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا', must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)', must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)', + must_be_valid_iban: 'يجب أن يكون IBAN صالحًا', must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط', must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط', complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.', @@ -1441,6 +1453,7 @@ const ko = { must_be_valid_email: '유효한 이메일 주소여야 합니다', must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)', must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)', + must_be_valid_iban: '유효한 IBAN이어야 합니다', must_contain_numbers_only: '숫자만 포함해야 합니다', must_contain_letters_only: '문자만 포함해야 합니다', complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.', @@ -1560,6 +1573,7 @@ const ja = { must_be_valid_email: '有効なメールアドレスである必要があります', must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)', must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)', + must_be_valid_iban: '有効なIBANである必要があります', must_contain_numbers_only: '数字のみを含む必要があります', must_contain_letters_only: '文字のみを含む必要があります', complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。', diff --git a/app/javascript/submission_form/kba_step.vue b/app/javascript/submission_form/kba_step.vue index bb2ac672e..27026d558 100644 --- a/app/javascript/submission_form/kba_step.vue +++ b/app/javascript/submission_form/kba_step.vue @@ -483,7 +483,7 @@ export default { this.clearCountdown() try { - const payload = { ...this.form, submitter_slug: this.submitterSlug } + const payload = { ...this.form } if (payload.dob) { payload.dob = payload.dob.replace(/-/g, '') @@ -497,7 +497,7 @@ export default { payload.phone = payload.phone.replace(/^\+1/, '') } - const resp = await fetch(this.baseUrl + '/api/kba', { + const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba`, { method: 'POST', body: JSON.stringify(payload), ...this.fetchOptions, @@ -548,13 +548,12 @@ export default { }, {}) try { - const resp = await fetch(this.baseUrl + `/api/kba/${this.field.uuid}`, { + const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba/${this.field.uuid}`, { method: 'PUT', body: JSON.stringify({ token: this.token, answers: formattedAnswers, - reference: this.reference, - submitter_slug: this.submitterSlug + reference: this.reference }), ...this.fetchOptions, headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers } diff --git a/app/javascript/submission_form/payment_step.vue b/app/javascript/submission_form/payment_step.vue index 44d516bdc..8ba8e7437 100644 --- a/app/javascript/submission_form/payment_step.vue +++ b/app/javascript/submission_form/payment_step.vue @@ -156,7 +156,7 @@ export default { return this.provider === 'paypal' }, paymentEndpoint () { - return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments' + return `/s/${this.submitterSlug}/${this.isPaypal ? 'paypal_payments' : 'stripe_payments'}` }, sessionId () { return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token')) @@ -255,9 +255,6 @@ export default { if (this.sessionId) { return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, { method: 'PUT', - body: JSON.stringify({ - submitter_slug: this.submitterSlug - }), ...this.fetchOptions, headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers } }).then(async (resp) => { @@ -294,7 +291,6 @@ export default { fetch(this.baseUrl + this.paymentEndpoint, { method: 'POST', body: JSON.stringify({ - submitter_slug: this.submitterSlug, field_uuid: this.field.uuid, check_status: checkStatus, referer: this.normalizedPaymentUrl() diff --git a/app/javascript/submission_form/phone_step.vue b/app/javascript/submission_form/phone_step.vue index c3e90fb3a..fae2d5bfb 100644 --- a/app/javascript/submission_form/phone_step.vue +++ b/app/javascript/submission_form/phone_step.vue @@ -285,7 +285,7 @@ export default { }, 1000) }, sendVerificationCode () { - return fetch(this.baseUrl + '/api/send_phone_verification_code', { + const options = { method: 'POST', body: JSON.stringify({ submitter_slug: this.submitterSlug, @@ -293,6 +293,14 @@ export default { phone: this.fullInternationalPhoneValue }), headers: { 'Content-Type': 'application/json' } + } + + return fetch(this.baseUrl + `/s/${this.submitterSlug}/send_phone_verification_code`, options).then((resp) => { + if (resp.status === 404) { + return fetch(this.baseUrl + '/api/send_phone_verification_code', options) + } else { + return resp + } }).then(async (resp) => { if ([422, 429].includes(resp.status)) { const data = await resp.json() diff --git a/app/javascript/submission_form/text_step.vue b/app/javascript/submission_form/text_step.vue index c8695c268..dbb9e3275 100644 --- a/app/javascript/submission_form/text_step.vue +++ b/app/javascript/submission_form/text_step.vue @@ -34,6 +34,7 @@ char.charCodeAt(0) - 55) + + return !iban || digits.split('').reduce((remainder, digit) => (remainder * 10 + parseInt(digit)) % 97, 0) === 1 + }, resizeTextarea () { const textarea = this.$refs.textarea diff --git a/app/javascript/submission_form/verification_step.vue b/app/javascript/submission_form/verification_step.vue index 20c5c9608..f6af173b8 100644 --- a/app/javascript/submission_form/verification_step.vue +++ b/app/javascript/submission_form/verification_step.vue @@ -162,10 +162,9 @@ export default { }, methods: { start () { - return fetch(this.baseUrl + `/api/identity_verification/${this.field.uuid}`, { + return fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification/${this.field.uuid}`, { method: 'PUT', body: JSON.stringify({ - submitter_slug: this.submitterSlug, redirect_url: document.location.href }), ...this.fetchOptions, @@ -206,11 +205,8 @@ export default { }) }, async submit () { - const resp = await fetch(this.baseUrl + '/api/identity_verification', { + const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification`, { method: 'POST', - body: JSON.stringify({ - submitter_slug: this.submitterSlug - }), ...this.fetchOptions, headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers } }) diff --git a/app/javascript/template_builder/area_title.vue b/app/javascript/template_builder/area_title.vue index 9b039b39f..dde2091e7 100644 --- a/app/javascript/template_builder/area_title.vue +++ b/app/javascript/template_builder/area_title.vue @@ -272,7 +272,7 @@ export default { FieldSubmitter, IconX }, - inject: ['t', 'isMobile', 'nativePlatform'], + inject: ['t', 'nativePlatform'], props: { template: { type: Object, diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 1ee213493..26a62bcd5 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -906,7 +906,7 @@ @@ -1007,6 +1007,9 @@ export default { return { template: this.template, save: this.save, + pushUndo: this.pushUndo, + undo: this.undo, + redo: this.redo, t: this.t, assignDropAreaSize: this.assignDropAreaSize, currencies: this.currencies, @@ -1640,18 +1643,20 @@ export default { this.selectedSubmitter = this.template.submitters[0] }, mounted () { - this.undoStack = [JSON.stringify(this.template)] + this.undoStack = [] this.redoStack = [] + this.undoStack.push(this.buildUndoData()) + this.$nextTick(() => { - this.onWindowResize() + this.resizeObserver = new ResizeObserver(this.onResize) + this.resizeObserver.observe(this.$el.getRootNode().querySelector('div[data-v-app]')) }) document.addEventListener('keyup', this.onKeyUp) document.addEventListener('template-builder:apply-revision', this.onApplyRevisionEvent) window.addEventListener('keydown', this.onKeyDown) - window.addEventListener('resize', this.onWindowResize) window.addEventListener('dragleave', this.onWindowDragLeave) this.$nextTick(() => { @@ -1675,7 +1680,7 @@ export default { document.removeEventListener('template-builder:apply-revision', this.onApplyRevisionEvent) window.removeEventListener('keydown', this.onKeyDown) - window.removeEventListener('resize', this.onWindowResize) + this.resizeObserver.disconnect() window.removeEventListener('dragleave', this.onWindowDragLeave) }, beforeUpdate () { @@ -2287,6 +2292,8 @@ export default { const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic) + this.pushUndo() + dynamicDocumentRefs.forEach((ref) => ref.update()) this.rebuildVariablesSchema({ disable: false }) @@ -2414,42 +2421,85 @@ export default { this.drawOption = null }, undo () { + this.selectedAreasRef.value = [] + if (this.undoStack.length > 1) { this.undoStack.pop() - const stringData = this.undoStack[this.undoStack.length - 1] - const currentStringData = JSON.stringify(this.template) + const undoData = this.undoStack[this.undoStack.length - 1] + const currentUndoData = this.buildUndoData() - if (stringData && stringData !== currentStringData) { - this.redoStack.push(currentStringData) + if (!this.isSameUndoData(undoData, currentUndoData)) { + this.redoStack.push(currentUndoData) - Object.assign(this.template, JSON.parse(stringData)) - - this.save() + this.applyUndoData(undoData, currentUndoData) } } }, redo () { - const stringData = this.redoStack.pop() - this.lastRedoData = stringData - const currentStringData = JSON.stringify(this.template) + this.selectedAreasRef.value = [] - if (stringData && stringData !== currentStringData) { - if (this.undoStack[this.undoStack.length - 1] !== currentStringData) { - this.undoStack.push(currentStringData) + const undoData = this.redoStack.pop() + const currentUndoData = this.buildUndoData() + + if (undoData && !this.isSameUndoData(undoData, currentUndoData)) { + if (!this.isSameUndoData(this.undoStack[this.undoStack.length - 1], currentUndoData)) { + this.undoStack.push(currentUndoData) } - Object.assign(this.template, JSON.parse(stringData)) + this.undoStack.push(undoData) - this.save() + this.applyUndoData(undoData, currentUndoData) } }, - onWindowResize (e) { + buildUndoData () { + const template = JSON.stringify(toRaw(this.template)) + const lastUndoData = this.undoStack[this.undoStack.length - 1] + + return { template: template === lastUndoData?.template ? lastUndoData.template : template, sectionDocs: this.buildSectionDocs() } + }, + buildSectionDocs () { + return new Map(this.documentRefs.flatMap((ref) => ref.isDynamic ? ref.getSectionDocs() : [])) + }, + isSameUndoData (undoData, currentUndoData) { + return undoData.template === currentUndoData.template && this.isSameSectionDocs(undoData, currentUndoData) + }, + isSameSectionDocs (undoData, currentUndoData) { + return [...currentUndoData.sectionDocs].every(([sectionKey, doc]) => undoData.sectionDocs.get(sectionKey)?.eq(doc)) + }, + applyUndoData (undoData, currentUndoData) { + const isTemplateChanged = undoData.template !== currentUndoData.template + + if (isTemplateChanged) { + Object.assign(this.template, JSON.parse(undoData.template)) + } + + this.documentRefs.forEach((ref) => { + if (ref.isDynamic) ref.restoreSectionDocs(undoData.sectionDocs) + }) + + this.buildSectionDocs().forEach((doc, sectionKey) => undoData.sectionDocs.set(sectionKey, doc)) + + if (isTemplateChanged) { + this.save() + + this.$nextTick(this.syncUndoTemplate) + } + }, + syncUndoTemplate () { + const lastUndoData = this.undoStack[this.undoStack.length - 1] + const template = JSON.stringify(toRaw(this.template)) + + if (template !== lastUndoData.template) lastUndoData.template = template + }, + onResize () { const breakpointLg = 1024 const breakpointMd = 768 const width = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth - this.isBreakpointLg = width < breakpointLg - this.isBreakpointMd = width < breakpointMd + if (width) { + this.isBreakpointLg = width < breakpointLg + this.isBreakpointMd = width < breakpointMd + } }, onPagesWheel (event) { const isFastScroll = Math.abs(event.deltaY) > 1 @@ -2462,23 +2512,33 @@ export default { event.preventDefault() - const oldZoom = this.zoomLevel - const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006)) - - if (nextZoom === oldZoom) return - + this.setZoomLevel(this.zoomLevel - event.deltaY * 0.006, event.clientX, event.clientY) + }, + resetZoomLevel () { const rect = this.$refs.pagesContainer.getBoundingClientRect() - const cursorX = event.clientX - rect.left - const cursorY = event.clientY - rect.top - const ratio = nextZoom / oldZoom - const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX - const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY + + this.setZoomLevel(1, rect.left + rect.width / 2, (Math.max(rect.top, 0) + Math.min(rect.bottom, window.innerHeight)) / 2) + }, + setZoomLevel (zoomLevel, clientX, clientY) { + const nextZoom = Math.max(1, Math.min(3, zoomLevel)) + + if (nextZoom === this.zoomLevel) return + + const container = this.$refs.pagesContainer + const rect = this.$refs.documents.getBoundingClientRect() this.zoomLevel = nextZoom this.$nextTick(() => { - this.$refs.pagesContainer.scrollLeft = nextScrollLeft - this.$refs.pagesContainer.scrollTop = nextScrollTop + this.documentRefs.filter((ref) => ref.isDynamic).forEach((ref) => ref.updateContainerWidth()) + + this.$nextTick(() => { + const nextRect = this.$refs.documents.getBoundingClientRect() + const scrollContainer = container.scrollHeight > container.clientHeight ? container : document.scrollingElement + + container.scrollLeft += nextRect.left - clientX + (clientX - rect.left) * nextRect.width / rect.width + scrollContainer.scrollTop += nextRect.top - clientY + (clientY - rect.top) * nextRect.height / rect.height + }) }) }, setDocumentRefs (el) { @@ -2597,15 +2657,11 @@ export default { event.stopImmediatePropagation() event.preventDefault() - this.selectedAreasRef.value = [] - this.redo() } else if ((event.ctrlKey || event.metaKey) && event.key === 'z') { event.stopImmediatePropagation() event.preventDefault() - this.selectedAreasRef.value = [] - this.undo() } else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) { if (this.selectedAreasRef.value.length > 1) { @@ -2980,16 +3036,29 @@ export default { return false } }, - pushUndo () { - const stringData = JSON.stringify(this.template) + pushUndo ({ amend = false } = {}) { + const undoData = this.buildUndoData() + const lastUndoData = this.undoStack[this.undoStack.length - 1] + const isTemplateChanged = undoData.template !== lastUndoData.template + const isSectionDocsChanged = !this.isSameSectionDocs(lastUndoData, undoData) - if (this.undoStack[this.undoStack.length - 1] !== stringData) { - this.undoStack.push(stringData) + if (this.isUndoBatch || (amend && (!isSectionDocsChanged || (!this.redoStack.length && !isTemplateChanged)))) { + this.undoStack[this.undoStack.length - 1] = undoData + } else if (isTemplateChanged || isSectionDocsChanged) { + this.undoStack.push(undoData) - if (this.lastRedoData !== stringData) { - this.redoStack = [] - } + this.redoStack = [] + } else { + return } + + this.isUndoBatch = true + + this.$nextTick(() => { + this.isUndoBatch = false + + this.syncUndoTemplate() + }) }, setDefaultAreaSize (area, type) { const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid) @@ -3959,7 +4028,7 @@ export default { this.$nextTick(() => { if (this.$el.closest('template-builder')) { - this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template) + this.$el.closest('template-builder').dataset.template = JSON.stringify(toRaw(this.template)) } }) @@ -3992,10 +4061,12 @@ export default { this.$nextTick(() => { if (this.$el.closest('template-builder')) { - this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(this.dynamicDocuments) + this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(toRaw(this.dynamicDocuments)) } }) + this.pushUndo({ amend: true }) + this.save() }, rebuildVariablesSchema ({ disable = true } = {}) { diff --git a/app/javascript/template_builder/dynamic_block_menu.vue b/app/javascript/template_builder/dynamic_block_menu.vue new file mode 100644 index 000000000..191509ee9 --- /dev/null +++ b/app/javascript/template_builder/dynamic_block_menu.vue @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/template_builder/dynamic_document.vue b/app/javascript/template_builder/dynamic_document.vue index 2c7f16c6a..3391e8223 100644 --- a/app/javascript/template_builder/dynamic_document.vue +++ b/app/javascript/template_builder/dynamic_document.vue @@ -22,6 +22,12 @@ v-if="shadow" :to="shadow" > + + {{ listsStyle }} + diff --git a/app/javascript/template_builder/dynamic_menu.vue b/app/javascript/template_builder/dynamic_menu.vue index 9b18198ab..cd6ac19a8 100644 --- a/app/javascript/template_builder/dynamic_menu.vue +++ b/app/javascript/template_builder/dynamic_menu.vue @@ -1,107 +1,360 @@ - - - - - - - - - - - - - - - - - Variable - - - - - - if - - - Condition - - + + + + {{ fontFamily }} + + + + + {{ font.label }} + + + + + + {{ fontSize }} + + + + + {{ size }} + + + + size > fontSize) || fontSize)" + > + + + size < fontSize) || fontSize)" + > + + + + + + + + + + + {{ value }} + + + + + + + + + + + + + + + + + + + + + + {{ marker }} + {{ label }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ colorProperty === 'color' ? t('default') : t('none') }} + + + + + + + + + + + {{ t('variable') }} + + + + + + if + + + {{ t('condition') }} + + +