Compare commits

...
35 Commits
Author SHA1 Message Date
Alex Turchyn c6a7555f54 Merge from docusealco/wip 2026-10-05 12:27:31 +03:00
Pete Matsyburka 0772880bbc download dynamic document 2026-10-05 11:53:57 +03:00
Pete Matsyburka 73339d8cd9 fix editor undo 2026-10-05 11:45:16 +03:00
Pete Matsyburka 25504e8cb1 update routes 2026-10-04 11:04:14 +03:00
Pete Matsyburka 63a874528a improve text selection 2026-10-03 20:07:24 +03:00
Pete Matsyburka c1c99621ff sticky table tools 2026-10-03 18:35:29 +03:00
Pete Matsyburka 5c168f4a29 add page number style 2026-10-03 14:57:11 +03:00
Pete Matsyburka bef825c4e0 optimize add list style 2026-10-03 14:05:38 +03:00
Pete Matsyburka a990d22831 remove pinch to zoom 2026-10-03 14:05:38 +03:00
Pete Matsyburka 1687a372ae adjust editor 2026-10-03 14:05:38 +03:00
Alex Turchyn 9b267d4a07 upload inserted images as attachments 2026-10-03 14:05:38 +03:00
Pete Matsyburka 2189c2e9fe adjust fonts 2026-10-03 14:05:38 +03:00
Alex Turchyn 1e43fe8969 use Word line spacing units and join adjacent lists 2026-10-03 14:05:38 +03:00
Alex Turchyn f77a716658 remove spacing between list items like Word 2026-10-03 14:05:38 +03:00
Alex Turchyn a70ac571e3 simplify dynamic editor code 2026-10-03 14:05:38 +03:00
Alex Turchyn 4f5e72f983 fix table editing 2026-10-03 14:05:38 +03:00
Alex Turchyn 8f47ec6d4e add accessible names to table size counters 2026-10-03 14:05:38 +03:00
Alex Turchyn 9e1bbf2944 fix image resize handles 2026-10-03 14:05:38 +03:00
Alex Turchyn aa7d6e9aaa add pinch zoom and fix touch editing 2026-10-03 14:05:38 +03:00
Alex Turchyn 57c8be33ce adjust dynamic editor menus on mobile 2026-10-03 14:05:38 +03:00
Alex Turchyn ed7109bfc6 store list styles in dynamic document 2026-10-03 14:05:38 +03:00
Alex Turchyn 9e2590489b improve insert menu accessibility 2026-10-03 14:05:38 +03:00
Alex Turchyn d793f00b37 improve dynamic editor 2026-10-03 14:05:38 +03:00
Pete Matsyburka 5896c9a2aa strip edited image 2026-10-03 09:10:31 +03:00
Pete Matsyburka 78b0ce9828 add iban validation 2026-10-01 19:45:55 +03:00
Pete Matsyburka e187644716 refactor otp 2026-10-01 08:57:19 +03:00
Pete Matsyburka a847e7aa9c custom otp email 2026-09-30 19:56:12 +03:00
Pete Matsyburka 0905e7d215 reduce limit 2026-09-30 19:23:58 +03:00
Pete Matsyburka 91147d2f48 fix verify pdf 2026-09-30 19:22:41 +03:00
Pete Matsyburka acce6195b0 rescue validation errors 2026-09-30 13:38:47 +03:00
Pete Matsyburka ce9ab691ec fix document metadata 2026-09-30 12:57:48 +03:00
Pete Matsyburka 4190544edc use ivar 2026-09-28 23:28:13 +03:00
Pete Matsyburka db8e5efbd5 adjust i18n 2026-09-28 17:54:47 +03:00
Pete Matsyburka a5c6f4e40c update gem 2026-09-28 15:32:26 +03:00
Pete Matsyburka 7537d05d1f fix builder resize 2026-09-28 15:30:09 +03:00
41 changed files with 3198 additions and 399 deletions

No files matched your search

+1 -1
View File
@@ -497,7 +497,7 @@ GEM
ruby-vips (2.3.0) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.2.2) rubyzip (3.7.0)
securerandom (0.4.1) securerandom (0.4.1)
semantic_range (3.1.1) semantic_range (3.1.1)
shakapacker (9.7.0) shakapacker (9.7.0)
+1 -1
View File
@@ -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 - Files storage on disk or AWS S3, Google Storage, Azure Cloud
- Automatic PDF eSignature - Automatic PDF eSignature
- PDF signature verification - PDF signature verification
- Users management - User management
- Mobile-optimized - Mobile-optimized
- 7 UI languages with signing available in 14 languages - 7 UI languages with signing available in 14 languages
- API and Webhooks for integrations - API and Webhooks for integrations
@@ -98,7 +98,7 @@ module Api
render json: build_create_json(submissions) render json: build_create_json(submissions)
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError, rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
DownloadUtils::UnableToDownload => e Submitters::SubmitValues::ValidationError, DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
+2 -1
View File
@@ -78,7 +78,8 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true, render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
with_events: false, params:) 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) Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
@@ -10,12 +10,10 @@ class SubmitFormEmail2fasController < ApplicationController
def create def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) 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 }) SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
cookies.encrypted[:email_2fa_slug] = cookies.encrypted[:email_2fa_slug] =
@@ -17,12 +17,12 @@ class SubmitFormMetadataController < ApplicationController
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
end 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| text_runs = documents.to_h do |document|
[ [
document.uuid, 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 end
@@ -4,19 +4,19 @@ class SubmitFormPrefillAttachmentsController < ActionController::API
include ActiveStorage::SetCurrent include ActiveStorage::SetCurrent
def create 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 return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end end
uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment) 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:) 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]) render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
end end
@@ -6,7 +6,17 @@ class TemplateDocumentsController < ApplicationController
FILES_TTL = 5.minutes FILES_TTL = 5.minutes
def index 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 end
def create def create
+14
View File
@@ -13,6 +13,7 @@ const en = {
must_be_valid_email: 'Must be a valid email address', 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_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_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_numbers_only: 'Must contain numbers only',
must_contain_letters_only: 'Must contain letters 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.', 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_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_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_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_numbers_only: 'Debe contener solo números',
must_contain_letters_only: 'Debe contener solo letras', 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.', 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_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_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_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_numbers_only: 'Deve contenere solo numeri',
must_contain_letters_only: 'Deve contenere solo lettere', 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à.", 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_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_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_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_numbers_only: 'Darf nur Zahlen enthalten',
must_contain_letters_only: 'Darf nur Buchstaben 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.', 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_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_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_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_numbers_only: 'Doit contenir uniquement des chiffres',
must_contain_letters_only: 'Doit contenir uniquement des lettres', 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é.", 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_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_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_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_numbers_only: 'Może zawierać tylko cyfry',
must_contain_letters_only: 'Może zawierać tylko litery', 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.', 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_email: 'Має бути дійсною адресою електронної пошти',
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)', must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)', must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
must_be_valid_iban: 'Має бути дійсним 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: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
@@ -846,6 +853,7 @@ const cs = {
must_be_valid_email: 'Musí být platná e-mailová adresa', 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_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_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_numbers_only: 'Musí obsahovat pouze čísla',
must_contain_letters_only: 'Musí obsahovat pouze písmena', 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.', 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_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_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_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_numbers_only: 'Deve conter apenas números',
must_contain_letters_only: 'Deve conter apenas letras', 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.', 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_email: 'חייבת להיות כתובת אימייל חוקית',
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)', must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)', must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
must_be_valid_iban: 'חייב להיות 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: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
@@ -1203,6 +1213,7 @@ const nl = {
must_be_valid_email: 'Moet een geldig e-mailadres zijn', 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_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_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_numbers_only: 'Mag alleen cijfers bevatten',
must_contain_letters_only: 'Mag alleen letters 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.', 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_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)', must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)', must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
must_be_valid_iban: 'يجب أن يكون 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: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
@@ -1441,6 +1453,7 @@ const ko = {
must_be_valid_email: '유효한 이메일 주소여야 합니다', must_be_valid_email: '유효한 이메일 주소여야 합니다',
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)', must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)', must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
must_be_valid_iban: '유효한 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: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
@@ -1560,6 +1573,7 @@ const ja = {
must_be_valid_email: '有効なメールアドレスである必要があります', must_be_valid_email: '有効なメールアドレスである必要があります',
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)', must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)', must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
must_be_valid_iban: '有効な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: '本人確認を進めるには、すべての必須項目を入力してください。',
+4 -5
View File
@@ -483,7 +483,7 @@ export default {
this.clearCountdown() this.clearCountdown()
try { try {
const payload = { ...this.form, submitter_slug: this.submitterSlug } const payload = { ...this.form }
if (payload.dob) { if (payload.dob) {
payload.dob = payload.dob.replace(/-/g, '') payload.dob = payload.dob.replace(/-/g, '')
@@ -497,7 +497,7 @@ export default {
payload.phone = payload.phone.replace(/^\+1/, '') 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', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
...this.fetchOptions, ...this.fetchOptions,
@@ -548,13 +548,12 @@ export default {
}, {}) }, {})
try { 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', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
token: this.token, token: this.token,
answers: formattedAnswers, answers: formattedAnswers,
reference: this.reference, reference: this.reference
submitter_slug: this.submitterSlug
}), }),
...this.fetchOptions, ...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers } headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
@@ -156,7 +156,7 @@ export default {
return this.provider === 'paypal' return this.provider === 'paypal'
}, },
paymentEndpoint () { paymentEndpoint () {
return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments' return `/s/${this.submitterSlug}/${this.isPaypal ? 'paypal_payments' : 'stripe_payments'}`
}, },
sessionId () { sessionId () {
return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token')) 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) { if (this.sessionId) {
return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, { return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
...this.fetchOptions, ...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers } headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
}).then(async (resp) => { }).then(async (resp) => {
@@ -294,7 +291,6 @@ export default {
fetch(this.baseUrl + this.paymentEndpoint, { fetch(this.baseUrl + this.paymentEndpoint, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug,
field_uuid: this.field.uuid, field_uuid: this.field.uuid,
check_status: checkStatus, check_status: checkStatus,
referer: this.normalizedPaymentUrl() referer: this.normalizedPaymentUrl()
@@ -285,7 +285,7 @@ export default {
}, 1000) }, 1000)
}, },
sendVerificationCode () { sendVerificationCode () {
return fetch(this.baseUrl + '/api/send_phone_verification_code', { const options = {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug, submitter_slug: this.submitterSlug,
@@ -293,6 +293,14 @@ export default {
phone: this.fullInternationalPhoneValue phone: this.fullInternationalPhoneValue
}), }),
headers: { 'Content-Type': 'application/json' } 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) => { }).then(async (resp) => {
if ([422, 429].includes(resp.status)) { if ([422, 429].includes(resp.status)) {
const data = await resp.json() const data = await resp.json()
@@ -34,6 +34,7 @@
<input <input
v-if="!isTextArea" v-if="!isTextArea"
:id="field.uuid" :id="field.uuid"
ref="input"
v-model="text" v-model="text"
:maxlength="cellsMaxLegth" :maxlength="cellsMaxLegth"
dir="auto" dir="auto"
@@ -46,6 +47,7 @@
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text" type="text"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@input="validateValue($event.target)"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
<textarea <textarea
@@ -137,6 +139,9 @@ export default {
return null return null
} }
}, },
isIbanValidation () {
return this.patternMessageKeys[this.field.validation?.pattern] === 'must_be_valid_iban'
},
patternMessageKeys () { patternMessageKeys () {
return { return {
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn', '^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn',
@@ -144,6 +149,7 @@ export default {
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email', '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email',
'^https?://.*': 'must_be_valid_url', '^https?://.*': 'must_be_valid_url',
'^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip', '^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip',
'^[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}$': 'must_be_valid_iban',
'^[0-9]+$': 'must_contain_numbers_only', '^[0-9]+$': 'must_contain_numbers_only',
'^[a-zA-Z]+$': 'must_contain_letters_only' '^[a-zA-Z]+$': 'must_contain_letters_only'
} }
@@ -186,8 +192,21 @@ export default {
}) })
} }
} }
this.validateValue(this.$refs.input)
}, },
methods: { methods: {
validateValue (input) {
if (this.isIbanValidation) {
input.setCustomValidity(this.isValidIban(input.value) ? '' : this.t('must_be_valid_iban'))
}
},
isValidIban (value) {
const iban = value.replace(/\s/g, '')
const digits = (iban.slice(4) + iban.slice(0, 4)).replace(/[A-Z]/g, (char) => char.charCodeAt(0) - 55)
return !iban || digits.split('').reduce((remainder, digit) => (remainder * 10 + parseInt(digit)) % 97, 0) === 1
},
resizeTextarea () { resizeTextarea () {
const textarea = this.$refs.textarea const textarea = this.$refs.textarea
@@ -162,10 +162,9 @@ export default {
}, },
methods: { methods: {
start () { 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', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug,
redirect_url: document.location.href redirect_url: document.location.href
}), }),
...this.fetchOptions, ...this.fetchOptions,
@@ -206,11 +205,8 @@ export default {
}) })
}, },
async submit () { async submit () {
const resp = await fetch(this.baseUrl + '/api/identity_verification', { const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification`, {
method: 'POST', method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
...this.fetchOptions, ...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers } headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
}) })
@@ -272,7 +272,7 @@ export default {
FieldSubmitter, FieldSubmitter,
IconX IconX
}, },
inject: ['t', 'isMobile', 'nativePlatform'], inject: ['t', 'nativePlatform'],
props: { props: {
template: { template: {
type: Object, type: Object,
+119 -48
View File
@@ -906,7 +906,7 @@
<button <button
type="button" type="button"
class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20" class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20"
@click="zoomLevel = 1" @click="resetZoomLevel"
> >
<IconX class="w-4 h-4 stroke-2" /> <IconX class="w-4 h-4 stroke-2" />
</button> </button>
@@ -1007,6 +1007,9 @@ export default {
return { return {
template: this.template, template: this.template,
save: this.save, save: this.save,
pushUndo: this.pushUndo,
undo: this.undo,
redo: this.redo,
t: this.t, t: this.t,
assignDropAreaSize: this.assignDropAreaSize, assignDropAreaSize: this.assignDropAreaSize,
currencies: this.currencies, currencies: this.currencies,
@@ -1640,18 +1643,20 @@ export default {
this.selectedSubmitter = this.template.submitters[0] this.selectedSubmitter = this.template.submitters[0]
}, },
mounted () { mounted () {
this.undoStack = [JSON.stringify(this.template)] this.undoStack = []
this.redoStack = [] this.redoStack = []
this.undoStack.push(this.buildUndoData())
this.$nextTick(() => { 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('keyup', this.onKeyUp)
document.addEventListener('template-builder:apply-revision', this.onApplyRevisionEvent) document.addEventListener('template-builder:apply-revision', this.onApplyRevisionEvent)
window.addEventListener('keydown', this.onKeyDown) window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
window.addEventListener('dragleave', this.onWindowDragLeave) window.addEventListener('dragleave', this.onWindowDragLeave)
this.$nextTick(() => { this.$nextTick(() => {
@@ -1675,7 +1680,7 @@ export default {
document.removeEventListener('template-builder:apply-revision', this.onApplyRevisionEvent) document.removeEventListener('template-builder:apply-revision', this.onApplyRevisionEvent)
window.removeEventListener('keydown', this.onKeyDown) window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize) this.resizeObserver.disconnect()
window.removeEventListener('dragleave', this.onWindowDragLeave) window.removeEventListener('dragleave', this.onWindowDragLeave)
}, },
beforeUpdate () { beforeUpdate () {
@@ -2287,6 +2292,8 @@ export default {
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic) const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
this.pushUndo()
dynamicDocumentRefs.forEach((ref) => ref.update()) dynamicDocumentRefs.forEach((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false }) this.rebuildVariablesSchema({ disable: false })
@@ -2414,42 +2421,85 @@ export default {
this.drawOption = null this.drawOption = null
}, },
undo () { undo () {
this.selectedAreasRef.value = []
if (this.undoStack.length > 1) { if (this.undoStack.length > 1) {
this.undoStack.pop() this.undoStack.pop()
const stringData = this.undoStack[this.undoStack.length - 1] const undoData = this.undoStack[this.undoStack.length - 1]
const currentStringData = JSON.stringify(this.template) const currentUndoData = this.buildUndoData()
if (stringData && stringData !== currentStringData) { if (!this.isSameUndoData(undoData, currentUndoData)) {
this.redoStack.push(currentStringData) this.redoStack.push(currentUndoData)
Object.assign(this.template, JSON.parse(stringData)) this.applyUndoData(undoData, currentUndoData)
this.save()
} }
} }
}, },
redo () { redo () {
const stringData = this.redoStack.pop() this.selectedAreasRef.value = []
this.lastRedoData = stringData
const currentStringData = JSON.stringify(this.template)
if (stringData && stringData !== currentStringData) { const undoData = this.redoStack.pop()
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) { const currentUndoData = this.buildUndoData()
this.undoStack.push(currentStringData)
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 breakpointLg = 1024
const breakpointMd = 768 const breakpointMd = 768
const width = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth const width = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth
this.isBreakpointLg = width < breakpointLg if (width) {
this.isBreakpointMd = width < breakpointMd this.isBreakpointLg = width < breakpointLg
this.isBreakpointMd = width < breakpointMd
}
}, },
onPagesWheel (event) { onPagesWheel (event) {
const isFastScroll = Math.abs(event.deltaY) > 1 const isFastScroll = Math.abs(event.deltaY) > 1
@@ -2462,23 +2512,33 @@ export default {
event.preventDefault() event.preventDefault()
const oldZoom = this.zoomLevel this.setZoomLevel(this.zoomLevel - event.deltaY * 0.006, event.clientX, event.clientY)
const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006)) },
resetZoomLevel () {
if (nextZoom === oldZoom) return
const rect = this.$refs.pagesContainer.getBoundingClientRect() const rect = this.$refs.pagesContainer.getBoundingClientRect()
const cursorX = event.clientX - rect.left
const cursorY = event.clientY - rect.top this.setZoomLevel(1, rect.left + rect.width / 2, (Math.max(rect.top, 0) + Math.min(rect.bottom, window.innerHeight)) / 2)
const ratio = nextZoom / oldZoom },
const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX setZoomLevel (zoomLevel, clientX, clientY) {
const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY 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.zoomLevel = nextZoom
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.pagesContainer.scrollLeft = nextScrollLeft this.documentRefs.filter((ref) => ref.isDynamic).forEach((ref) => ref.updateContainerWidth())
this.$refs.pagesContainer.scrollTop = nextScrollTop
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) { setDocumentRefs (el) {
@@ -2597,15 +2657,11 @@ export default {
event.stopImmediatePropagation() event.stopImmediatePropagation()
event.preventDefault() event.preventDefault()
this.selectedAreasRef.value = []
this.redo() this.redo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') { } else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
event.stopImmediatePropagation() event.stopImmediatePropagation()
event.preventDefault() event.preventDefault()
this.selectedAreasRef.value = []
this.undo() this.undo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) { } else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) {
if (this.selectedAreasRef.value.length > 1) { if (this.selectedAreasRef.value.length > 1) {
@@ -2980,16 +3036,29 @@ export default {
return false return false
} }
}, },
pushUndo () { pushUndo ({ amend = false } = {}) {
const stringData = JSON.stringify(this.template) 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) { if (this.isUndoBatch || (amend && (!isSectionDocsChanged || (!this.redoStack.length && !isTemplateChanged)))) {
this.undoStack.push(stringData) 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) { setDefaultAreaSize (area, type) {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid) const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
@@ -3959,7 +4028,7 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$el.closest('template-builder')) { 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(() => { this.$nextTick(() => {
if (this.$el.closest('template-builder')) { 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() this.save()
}, },
rebuildVariablesSchema ({ disable = true } = {}) { rebuildVariablesSchema ({ disable = true } = {}) {
@@ -0,0 +1,177 @@
<template>
<div
class="absolute z-[5] pointer-events-none select-none"
:style="{ top: coords.top + 'px', left: coords.left + 'px', width: coords.width + 'px', height: coords.height + 'px' }"
>
<div
class="absolute left-0 bottom-0"
:style="{ top: Math.max(coords.top - toolbarHeight - 2, 0) - coords.top + 'px' }"
>
<div
ref="toolbar"
class="sticky top-1.5 flex flex-col sm:flex-row gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg pointer-events-auto"
@mousedown.prevent
>
<div
v-for="(row, rowIndex) in actionRows"
:key="rowIndex"
class="flex items-center gap-0.5"
>
<template
v-for="(group, groupIndex) in row"
:key="groupIndex"
>
<div
v-if="rowIndex > 0 || groupIndex > 0"
class="w-px h-5 bg-neutral-200 mx-1"
:class="{ 'hidden sm:block': rowIndex > 0 && groupIndex === 0 }"
/>
<button
v-for="action in group"
:key="action.title"
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="{ 'text-red-600': action.danger, 'bg-neutral-200': action.active, 'hover:bg-neutral-100': !action.active && !isDisabled(action), 'opacity-50 cursor-not-allowed': isDisabled(action) }"
:title="action.structural && !coords.isRegular ? t('irregular_table_structure') : t(action.title)"
:disabled="isDisabled(action)"
@click="$emit('command', action.command, action.arg)"
>
<component
:is="action.icon"
:width="16"
:height="16"
:stroke-width="1.6"
/>
</button>
</template>
</div>
</div>
</div>
<div
v-if="size"
class="absolute top-0 left-0 border border-dashed border-blue-500"
:style="{ width: size.width + 'px', height: size.height + 'px' }"
/>
<div
v-if="coords.type === 'table'"
class="absolute w-3 h-3 bg-white border-2 border-blue-500 rounded-sm pointer-events-auto cursor-nwse-resize touch-none before:absolute before:-inset-3"
:style="{ left: (size?.width ?? coords.width) - 6 + 'px', top: (size?.height ?? coords.height) - 6 + 'px' }"
:title="t('resize')"
@pointerdown.prevent.stop="startResize"
/>
</div>
</template>
<script>
import { h } from 'vue'
import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconRowInsertTop, IconRowInsertBottom, IconColumnInsertLeft, IconColumnInsertRight, IconRowRemove, IconColumnRemove, IconTrash } from '@tabler/icons-vue'
const IconMergeCells = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
h('path', { d: 'M6 12h4m-2 -2l2 2l-2 2' }),
h('path', { d: 'M18 12h-4m2 -2l-2 2l2 2' })
])
const IconSplitCell = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
h('path', { d: 'M12 4v16' }),
h('path', { d: 'M10 12h-4m2 -2l-2 2l2 2' }),
h('path', { d: 'M14 12h4m-2 -2l2 2l-2 2' })
])
export default {
name: 'DynamicBlockMenu',
inject: ['t'],
props: {
coords: {
type: Object,
required: true
},
getResizeLimits: {
type: Function,
required: true
}
},
emits: ['command', 'resize'],
data () {
return {
size: null,
toolbarHeight: 0
}
},
computed: {
actionRows () {
const alignActions = [
{ command: 'setBlockAlign', arg: 'left', icon: IconAlignLeft, title: 'align_left', active: this.coords.align === 'left' },
{ command: 'setBlockAlign', arg: 'center', icon: IconAlignCenter, title: 'align_center', active: this.coords.align === 'center' },
{ command: 'setBlockAlign', arg: 'right', icon: IconAlignRight, title: 'align_right', active: this.coords.align === 'right' }
]
if (this.coords.type !== 'table') {
return [[alignActions, [{ command: 'deleteSelection', icon: IconTrash, title: 'delete_image', danger: true }]]]
}
return [
[
alignActions,
[
{ command: 'addTableRow', arg: false, icon: IconRowInsertTop, title: 'insert_row_above', structural: true },
{ command: 'addTableRow', arg: true, icon: IconRowInsertBottom, title: 'insert_row_below', structural: true },
{ command: 'addTableColumn', arg: false, icon: IconColumnInsertLeft, title: 'insert_column_left', structural: true },
{ command: 'addTableColumn', arg: true, icon: IconColumnInsertRight, title: 'insert_column_right', structural: true }
]
],
[
[
{ command: 'mergeTableCells', icon: IconMergeCells, title: 'merge_cells', structural: true, disabled: !this.coords.canMerge },
{ command: 'splitTableCell', icon: IconSplitCell, title: 'split_cell', structural: true, disabled: !this.coords.canSplit }
],
[
{ command: 'deleteTableRow', icon: IconRowRemove, title: 'delete_row', danger: true, structural: true },
{ command: 'deleteTableColumn', icon: IconColumnRemove, title: 'delete_column', danger: true, structural: true },
{ command: 'deleteTable', icon: IconTrash, title: 'delete_table', danger: true }
]
]
]
}
},
mounted () {
this.toolbarHeight = this.$refs.toolbar.offsetHeight
},
updated () {
this.toolbarHeight = this.$refs.toolbar.offsetHeight
},
beforeUnmount () {
window.removeEventListener('pointermove', this.onResizeMove)
window.removeEventListener('pointerup', this.onResizeEnd)
},
methods: {
isDisabled (action) {
return (action.structural && !this.coords.isRegular) || action.disabled
},
startResize (event) {
this.resizeStart = { x: event.clientX, y: event.clientY, width: this.coords.width, height: this.coords.height, ...this.getResizeLimits() }
this.size = { width: this.coords.width, height: this.coords.height }
window.addEventListener('pointermove', this.onResizeMove)
window.addEventListener('pointerup', this.onResizeEnd)
},
onResizeMove (event) {
const { x, y, width, height, minHeight, maxWidth } = this.resizeStart
this.size = {
width: Math.min(Math.max(width + event.clientX - x, 20), maxWidth),
height: Math.max(height + event.clientY - y, minHeight)
}
},
onResizeEnd () {
window.removeEventListener('pointermove', this.onResizeMove)
window.removeEventListener('pointerup', this.onResizeEnd)
if (this.size.width !== this.coords.width || this.size.height !== this.coords.height) {
this.$emit('resize', this.size)
}
this.size = null
}
}
}
</script>
@@ -22,6 +22,12 @@
v-if="shadow" v-if="shadow"
:to="shadow" :to="shadow"
> >
<component
:is="'style'"
v-if="listsStyle"
>
{{ listsStyle }}
</component>
<DynamicSection <DynamicSection
v-for="section in sections" v-for="section in sections"
:ref="setSectionRefs" :ref="setSectionRefs"
@@ -44,15 +50,17 @@
@draw="$emit('draw', $event)" @draw="$emit('draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)" @add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
@add-attachment="document.attachments.push($event)"
@add-list="ensureListsStyle"
/> />
</Teleport> </Teleport>
</div> </div>
</template> </template>
<script> <script>
import { ref } from 'vue' import { ref, computed } from 'vue'
import DynamicSection from './dynamic_section.vue' import DynamicSection from './dynamic_section.vue'
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js' import { dynamicStylesheet, tiptapStylesheet, listsCss } from './dynamic_editor.js'
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js' import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
export default { export default {
@@ -61,6 +69,11 @@ export default {
DynamicSection DynamicSection
}, },
inject: ['baseFetch', 'template'], inject: ['baseFetch', 'template'],
provide () {
return {
documentFonts: computed(() => this.documentFonts)
}
},
props: { props: {
document: { document: {
type: Object, type: Object,
@@ -107,6 +120,7 @@ export default {
return { return {
containerWidth: 1040, containerWidth: 1040,
isMounted: false, isMounted: false,
listsStyle: null,
sectionRefs: [] sectionRefs: []
} }
}, },
@@ -134,6 +148,40 @@ export default {
styles () { styles () {
return this.headDom.querySelectorAll('style') return this.headDom.querySelectorAll('style')
}, },
documentFonts () {
const fonts = []
const firstFamily = (value) => value.split(',')[0].trim().replace(/^["']|["']$/g, '')
const add = (label, value) => {
if (label && !fonts.some((font) => font.label === label)) fonts.push({ label, value })
}
this.styles.forEach((style) => {
const css = style.textContent
for (const type of ['minor', 'major']) {
for (const [, name, value] of css.matchAll(new RegExp(`(--[\\w-]+-${type}HAnsi-font):\\s*([^;}]+)`, 'g'))) {
add(firstFamily(value), `var(${name})`)
}
}
for (const [, value] of css.matchAll(/@font-face\s*\{[^}]*?font-family:\s*([^;}]+)/g)) {
add(firstFamily(value), value.trim())
}
})
const skipFamilies = ['symbol', 'wingdings', 'wingdings 2', 'wingdings 3', 'webdings', 'serif', 'sans-serif', 'monospace', 'inherit', 'initial']
const addUsed = (value) => {
if (value && !value.startsWith('var(') && !skipFamilies.includes(firstFamily(value).toLowerCase())) add(firstFamily(value), value.trim())
}
this.styles.forEach((style) => {
for (const [, value] of style.textContent.replace(/@font-face\s*\{[^}]*\}/g, '').matchAll(/font-family:\s*([^;}]+)/g)) addUsed(value)
})
this.bodyDom.querySelectorAll('[style*="font-family"]').forEach((el) => addUsed(el.style.fontFamily))
return fonts
},
shadow () { shadow () {
if (this.isMounted) { if (this.isMounted) {
return this.$refs.shadow.attachShadow({ mode: 'open' }) return this.$refs.shadow.attachShadow({ mode: 'open' })
@@ -142,16 +190,21 @@ export default {
} }
} }
}, },
created () {
this.updatedSectionEditors = new Map()
this.loadListsStyle()
},
mounted () { mounted () {
this.isMounted = true this.isMounted = true
this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet) this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet)
this.containerWidth = this.$refs.container.clientWidth this.updateContainerWidth()
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
if (this.$refs.container) { if (this.$refs.container) {
this.containerWidth = this.$refs.container.clientWidth this.updateContainerWidth()
} }
}) })
@@ -169,6 +222,9 @@ export default {
}, },
methods: { methods: {
mergeSchemaProperties, mergeSchemaProperties,
updateContainerWidth () {
this.containerWidth = this.$refs.container.clientWidth
},
removeArea (area) { removeArea (area) {
this.sectionRefs.forEach((sectionRef) => { this.sectionRefs.forEach((sectionRef) => {
const pos = sectionRef.findAreaNodePos(area.uuid) const pos = sectionRef.findAreaNodePos(area.uuid)
@@ -184,8 +240,18 @@ export default {
} }
}, },
reloadContent () { reloadContent () {
this.loadListsStyle()
this.sectionRefs.forEach((ref) => ref.reloadContent()) this.sectionRefs.forEach((ref) => ref.reloadContent())
}, },
getSectionDocs () {
return this.sectionRefs.map((ref) => [ref.sectionKey, ref.editor.state.doc])
},
restoreSectionDocs (sectionDocs) {
this.sectionRefs.forEach((ref) => {
if (sectionDocs.has(ref.sectionKey)) ref.restoreDoc(sectionDocs.get(ref.sectionKey))
})
},
onBeforeUnload (event) { onBeforeUnload (event) {
if (this.saveTimer) { if (this.saveTimer) {
event.preventDefault() event.preventDefault()
@@ -211,21 +277,38 @@ export default {
onSectionUpdate (section, { editor }) { onSectionUpdate (section, { editor }) {
clearTimeout(this.saveTimer) clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(async () => { this.updatedSectionEditors.set(section.id, editor)
await this.updateSectionAndSave(section, editor)
delete this.saveTimer this.saveTimer = setTimeout(() => this.updateSectionsAndSave(), 1000)
}, 1000) },
loadListsStyle () {
this.listsStyle = this.bodyDom.querySelector('body > style[data-lists]')?.textContent || null
},
ensureListsStyle () {
if (this.listsStyle) return
const style = this.bodyDom.createElement('style')
style.dataset.lists = ''
style.textContent = listsCss
this.bodyDom.body.append(style)
this.listsStyle = listsCss
}, },
updateVariablesSchema () { updateVariablesSchema () {
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body) this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
}, },
updateSectionAndSave (section, editor) { updateSectionsAndSave () {
const target = this.bodyDom.getElementById(section.id) this.updatedSectionEditors.forEach((editor, sectionId) => {
const target = this.bodyDom.getElementById(sectionId)
if (target) { if (target) {
target.innerHTML = this.getHtmlForSave(editor) target.innerHTML = this.getHtmlForSave(editor)
} }
})
this.updatedSectionEditors.clear()
this.document.body = this.bodyDom.body.innerHTML this.document.body = this.bodyDom.body.innerHTML
@@ -245,6 +328,8 @@ export default {
delete this.saveTimer delete this.saveTimer
this.updatedSectionEditors.clear()
this.sectionRefs.forEach(({ section, editor }) => { this.sectionRefs.forEach(({ section, editor }) => {
const target = this.bodyDom.getElementById(section.id) const target = this.bodyDom.getElementById(section.id)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,205 @@
<template>
<div class="absolute top-7 left-0 min-w-36 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg">
<div
class="relative"
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'field' : null"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === 'field' }"
@click="submenu = submenu === 'field' ? null : 'field'"
>
<IconForms
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('field') }}
<IconChevronRight
:width="14"
:height="14"
class="ml-auto"
/>
</button>
<div
v-if="submenu === 'field'"
class="absolute top-0 left-full min-w-40 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="(icon, type) in fieldTypesList"
:key="type"
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@click="[$emit('insert-field', type), $emit('close')]"
>
<component
:is="icon"
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ fieldNames[type] }}
</button>
</div>
</div>
<div
class="relative"
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'table' : null"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === 'table' }"
@click="submenu = submenu === 'table' ? null : 'table'"
>
<IconTable
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('table') }}
<IconChevronRight
:width="14"
:height="14"
class="ml-auto"
/>
</button>
<div
v-if="submenu === 'table'"
class="absolute top-0 left-full w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<div
v-if="!isMobile"
class="grid grid-cols-8 gap-0.5 mb-2"
>
<template
v-for="row in 8"
:key="row"
>
<button
v-for="col in 8"
:key="col"
class="w-4 h-4 border rounded-sm"
:class="row <= tableSize.rows && col <= tableSize.cols ? 'bg-neutral-200 border-neutral-400' : 'bg-white border-neutral-200'"
:aria-label="`${row} × ${col}`"
@pointerenter="$event.pointerType === 'mouse' ? tableSize = { rows: row, cols: col } : null"
@click="[$emit('insert-table', { rows: row, cols: col }), $emit('close')]"
/>
</template>
</div>
<div class="flex items-center justify-center gap-1 text-xs">
<template
v-for="key in ['rows', 'cols']"
:key="key"
>
<span v-if="key === 'cols'">×</span>
<div class="flex items-center border border-neutral-200 rounded-md">
<button
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
:aria-label="t(`decrease_${key}`)"
@click="tableSize[key] = Math.max(tableSize[key] - 1, 1)"
>
<IconMinus
:width="12"
:height="12"
/>
</button>
<span class="w-5 text-center">{{ tableSize[key] }}</span>
<button
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
:aria-label="t(`increase_${key}`)"
@click="tableSize[key] = Math.min(tableSize[key] + 1, 20)"
>
<IconPlus
:width="12"
:height="12"
/>
</button>
</div>
</template>
</div>
<button
v-if="isMobile || tableSize.rows > 8 || tableSize.cols > 8"
class="w-full mt-2 px-2 py-1 rounded-md bg-neutral-100 hover:bg-neutral-200"
@click="[$emit('insert-table', { ...tableSize }), $emit('close')]"
>
{{ t('insert') }}
</button>
</div>
</div>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@mouseenter="submenu = null"
@click="[$emit('insert-variable'), $emit('close')]"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('variable') }}
</button>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@mouseenter="submenu = null"
@click="[$emit('insert-image'), $emit('close')]"
>
<IconPhoto
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('image') }}
</button>
</div>
</template>
<script>
import { IconPlus, IconMinus, IconForms, IconTable, IconPhoto, IconChevronRight, IconBracketsContain } from '@tabler/icons-vue'
import FieldType from './field_type.vue'
export default {
name: 'DynamicInsertMenu',
components: {
IconPlus,
IconMinus,
IconForms,
IconTable,
IconPhoto,
IconChevronRight,
IconBracketsContain
},
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 't', 'fieldTypes', 'isMobile'],
emits: ['insert-field', 'insert-table', 'insert-variable', 'insert-image', 'close'],
data () {
return {
submenu: null,
tableSize: { rows: 3, cols: 3 }
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
skipTypes: FieldType.computed.skipTypes,
fieldIconsSorted: FieldType.computed.fieldIconsSorted,
fieldTypesList () {
return Object.fromEntries(Object.entries(this.fieldIconsSorted).filter(([type]) => {
return this.fieldTypes.includes(type) || ((this.withPhone || type !== 'phone') && (this.withPayment || type !== 'payment') && (this.withVerification || type !== 'verification') && (this.withKba || type !== 'kba'))
}))
}
},
mounted () {
document.addEventListener('mousedown', this.onDocumentMouseDown)
},
beforeUnmount () {
document.removeEventListener('mousedown', this.onDocumentMouseDown)
this.$emit('close')
},
methods: {
onDocumentMouseDown (event) {
if (!event.composedPath().includes(this.$el.parentElement)) {
this.$emit('close')
}
}
}
}
</script>
+575 -109
View File
@@ -1,107 +1,360 @@
<template> <template>
<div <div
v-if="visible" v-if="visible"
class="absolute z-10 flex items-center gap-0.5 px-1.5 py-1 bg-white border border-base-300 rounded-lg shadow select-none" ref="menu"
:style="{ top: (coords.top - 42) + 'px', left: coords.left + 'px', transform: 'translateX(-50%)' }" class="absolute z-10 flex flex-col gap-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg select-none text-sm"
:style="{ top: coords.top + 'px', left: menuLeft + 'px' }"
@mousedown.prevent @mousedown.prevent
> >
<button <div class="flex items-center gap-0.5">
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" <div class="relative">
:class="isBold ? 'bg-base-200' : 'bg-transparent'" <button
title="Bold" class="flex items-center justify-between gap-1 w-24 sm:w-36 h-7 px-2 border border-neutral-200 rounded-md hover:bg-neutral-100"
@click="toggleBold" :title="t('font')"
> @click="toggleDropdown('font')"
<IconBold >
:width="16" <span
:height="16" class="truncate"
/> :style="{ fontFamily }"
</button> >{{ fontFamily }}</span>
<button <IconChevronDown
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" :width="14"
:class="isItalic ? 'bg-base-200' : 'bg-transparent'" :height="14"
title="Italic" />
@click="toggleItalic" </button>
> <div
<IconItalic v-if="dropdown === 'font'"
:width="16" ref="dropdown"
:height="16" :class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
/> class="absolute z-10 top-full mt-1 w-48 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-64 overflow-y-auto"
</button> >
<button <button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" v-for="font in fonts"
:class="isUnderline ? 'bg-base-200' : 'bg-transparent'" :key="font.label"
title="Underline" class="block w-full text-left px-2 py-1 rounded-md truncate"
@click="toggleUnderline" :class="font.label === fontFamily ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
> :style="{ fontFamily: font.value }"
<IconUnderline @click="setSpanStyle('font-family', font.value)"
:width="16" >
:height="16" {{ font.label }}
/> </button>
</button> </div>
<button </div>
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" <div class="relative">
:class="isStrike ? 'bg-base-200' : 'bg-transparent'" <button
title="Strikethrough" class="flex items-center justify-between gap-1 w-12 sm:w-16 h-7 px-2 border border-neutral-200 rounded-md hover:bg-neutral-100"
@click="toggleStrike" :title="t('font_size')"
> @click="toggleDropdown('size')"
<IconStrikethrough >
:width="16" <span>{{ fontSize }}</span>
:height="16" <IconChevronDown
/> :width="14"
</button> :height="14"
<div class="w-px h-5 bg-base-300 mx-1" /> />
<button </button>
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent" <div
title="Wrap in variable" v-if="dropdown === 'size'"
@click="wrapVariable" ref="dropdown"
> :class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
<IconBracketsContain class="absolute z-10 top-full mt-1 w-16 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-64 overflow-y-auto"
:width="16" >
:height="16" <button
:stroke-width="1.6" v-for="size in fontSizes"
/> :key="size"
<span class="px-0.5"> class="block w-full text-left px-2 py-1 rounded-md"
Variable :class="size === fontSize ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
</span> @click="setFontSize(size)"
</button> >
<div class="w-px h-5 bg-base-300 mx-1" /> {{ size }}
<button </button>
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent" </div>
title="Wrap in condition" </div>
@click="wrapCondition" <button
> class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
<svg :title="t('increase_font_size')"
xmlns="http://www.w3.org/2000/svg" @click="setFontSize(fontSizes.find((size) => size > fontSize) || fontSize)"
width="16" >
height="16" <IconTextIncrease
viewBox="0 0 24 24" :width="16"
fill="none" :height="16"
stroke="currentColor" />
stroke-width="1.6" </button>
stroke-linecap="round" <button
stroke-linejoin="round" class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
class="tabler-icon tabler-icon-brackets-contain" :title="t('decrease_font_size')"
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" /> @click="setFontSize([...fontSizes].reverse().find((size) => size < fontSize) || fontSize)"
<text >
x="12" <IconTextDecrease
y="16.5" :width="16"
text-anchor="middle" :height="16"
fill="currentColor" />
stroke="none" </button>
font-size="14" <div class="w-px h-5 bg-neutral-200 mx-1" />
font-weight="600" <div class="relative">
font-family="ui-sans-serif, system-ui, sans-serif" <button
>if</text> class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
</svg> :title="t('line_spacing')"
<span class="px-0.5"> @click="toggleDropdown('lineHeight')"
Condition >
</span> <IconLineHeight
</button> :width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'lineHeight'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-20 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="value in lineHeights"
:key="value"
class="block w-full text-left px-2 py-1 rounded-md"
:class="value === lineHeight ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
@click="setBlockStyle('line-height', `calc(${value} * 1.15)`)"
>
{{ value }}
</button>
</div>
</div>
<div class="relative">
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:title="t('align')"
@click="toggleDropdown('align')"
>
<component
:is="alignIcons[textAlign]"
:width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'align'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-max flex gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="(icon, align) in alignIcons"
:key="align"
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="align === textAlign ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t(align)"
@click="setBlockStyle('text-align', align)"
>
<component
:is="icon"
:width="16"
:height="16"
/>
</button>
</div>
</div>
<div class="relative">
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md"
:class="listStyle ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('bullet_list')"
@click="toggleDropdown('list')"
>
<IconList
:width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'list'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-28 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="({ label, marker }, style) in listStyles"
:key="style"
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="style === listStyle ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
@click="toggleList(style)"
>
<span class="w-4 text-center">{{ marker }}</span>
{{ label }}
</button>
</div>
</div>
</div>
<div class="flex items-center gap-0.5">
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isBold ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('bold')"
@click="toggleBold"
>
<IconBold
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isItalic ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('italic')"
@click="toggleItalic"
>
<IconItalic
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isUnderline ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('underline')"
@click="toggleUnderline"
>
<IconUnderline
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isStrike ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('strikethrough')"
@click="toggleStrike"
>
<IconStrikethrough
:width="16"
:height="16"
/>
</button>
<div class="w-px h-5 bg-neutral-200 mx-1" />
<div
v-for="colorProperty in ['color', 'background-color']"
:key="colorProperty"
class="relative"
>
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:title="colorProperty === 'color' ? t('text_color') : t('background_color')"
@click="toggleDropdown(colorProperty)"
>
<span class="flex flex-col items-center">
<component
:is="colorProperty === 'color' ? 'IconLetterA' : 'IconHighlight'"
:width="16"
:height="14"
/>
<span
class="w-4 h-1 rounded-sm border border-neutral-200"
:style="{ backgroundColor: colorProperty === 'color' ? color : backgroundColor }"
/>
</span>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === colorProperty"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
class="flex items-center gap-1 w-full mb-2 px-2 py-1 rounded-md hover:bg-neutral-100"
@click="setSpanStyle(colorProperty, null)"
>
<IconX
:width="14"
:height="14"
/>
{{ colorProperty === 'color' ? t('default') : t('none') }}
</button>
<div class="grid grid-cols-10 gap-1">
<button
v-for="value in colors"
:key="value"
class="w-4 h-4 rounded-sm border border-neutral-200 hover:scale-125 transition-transform"
:style="{ backgroundColor: value }"
:title="value"
@click="setSpanStyle(colorProperty, value)"
/>
</div>
</div>
</div>
<div class="w-px h-5 bg-neutral-200 mx-1 sm:mx-0.5" />
<button
class="inline-flex items-center justify-center text-xs h-7 px-1 rounded-md"
:class="isCellSelection ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
:title="t('create_variable')"
:disabled="isCellSelection"
@click="wrapVariable"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
<span class="hidden sm:inline px-0.5 font-mono">
{{ t('variable') }}
</span>
</button>
<div class="w-px h-5 bg-neutral-200 mx-1 sm:mx-0.5" />
<button
class="inline-flex items-center justify-center text-xs h-7 px-1 rounded-md"
:class="isCellSelection ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
:title="t('create_condition')"
:disabled="isCellSelection"
@click="wrapCondition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
class="tabler-icon tabler-icon-brackets-contain"
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" />
<text
x="12"
y="16.5"
text-anchor="middle"
fill="currentColor"
stroke="none"
font-size="14"
font-weight="600"
font-family="ui-sans-serif, system-ui, sans-serif"
>if</text>
</svg>
<span class="hidden sm:inline px-0.5 font-mono">
{{ t('condition') }}
</span>
</button>
</div>
</div> </div>
</template> </template>
<script> <script>
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain } from '@tabler/icons-vue' import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain, IconChevronDown, IconTextIncrease, IconTextDecrease, IconLineHeight, IconAlignLeft, IconAlignCenter, IconAlignRight, IconAlignJustified, IconLetterA, IconHighlight, IconList, IconX } from '@tabler/icons-vue'
import { CellSelection } from '@tiptap/pm/tables'
import { findNumbering, numberingListStyle } from './dynamic_editor.js'
export default { export default {
name: 'DynamicMenu', name: 'DynamicMenu',
@@ -110,8 +363,17 @@ export default {
IconItalic, IconItalic,
IconUnderline, IconUnderline,
IconStrikethrough, IconStrikethrough,
IconBracketsContain IconBracketsContain,
IconChevronDown,
IconTextIncrease,
IconTextDecrease,
IconLineHeight,
IconLetterA,
IconHighlight,
IconList,
IconX
}, },
inject: ['t', 'documentFonts'],
props: { props: {
editor: { editor: {
type: Object, type: Object,
@@ -123,23 +385,90 @@ export default {
default: null default: null
} }
}, },
emits: ['add-variable', 'add-condition'], emits: ['add-variable', 'add-condition', 'add-list'],
data () { data () {
return { return {
isMouseDown: false, isMouseDown: false,
isBold: this.editor.isActive('bold'), dropdown: null,
isItalic: this.editor.isActive('italic'), isDropdownAlignedRight: false,
isUnderline: this.editor.isActive('underline'), menuWidth: 0,
isStrike: this.editor.isActive('strike') isBold: false,
isItalic: false,
isUnderline: false,
isStrike: false,
fontFamily: '',
fontSize: 12,
color: '',
backgroundColor: '',
lineHeight: '',
textAlign: 'left',
listStyle: null,
isCellSelection: false
} }
}, },
computed: { computed: {
visible () { visible () {
return !!this.coords && !this.isMouseDown return !!this.coords && !this.isMouseDown
},
menuLeft () {
return Math.max(4, Math.min(this.coords.left - this.menuWidth / 2, this.coords.width - this.menuWidth - 4))
},
standardFonts () {
return [
{ label: 'Arial', value: 'Arial' },
{ label: 'Times New Roman', value: "'Times New Roman'" },
{ label: 'Courier New', value: "'Courier New'" }
]
},
fonts () {
return [
...this.documentFonts.filter((font) => !this.standardFonts.some((standardFont) => standardFont.label === font.label)),
...this.standardFonts
]
},
fontSizes () {
return [8, 9, 10, 10.5, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72]
},
lineHeights () {
return ['1', '1.15', '1.5', '2', '2.5', '3']
},
alignIcons () {
return {
left: IconAlignLeft,
center: IconAlignCenter,
right: IconAlignRight,
justify: IconAlignJustified
}
},
listStyles () {
return {
disc: { label: this.t('dots'), marker: '•' },
dash: { label: this.t('dash'), marker: '–' },
decimal: { label: this.t('numbers'), marker: '1.' }
}
},
colors () {
return [
'#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#efefef', '#f3f3f3', '#ffffff',
'#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff',
'#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc',
'#cc4125', '#e06666', '#f6b26b', '#ffd966', '#93c47d', '#76a5af', '#6d9eeb', '#6fa8dc', '#8e7cc3', '#c27ba0',
'#85200c', '#990000', '#b45f06', '#bf9000', '#38761d', '#134f5c', '#1155cc', '#0b5394', '#351c75', '#741b47'
]
}
},
watch: {
visible (value) {
this.dropdown = null
if (value) {
this.readState()
this.$nextTick(this.measure)
}
} }
}, },
mounted () { mounted () {
this.editor.view.dom.addEventListener('mousedown', this.onMouseDown) this.editor.view.dom.parentElement.addEventListener('mousedown', this.onMouseDown)
document.addEventListener('mouseup', this.onMouseUp) document.addEventListener('mouseup', this.onMouseUp)
@@ -147,18 +476,79 @@ export default {
}, },
beforeUnmount () { beforeUnmount () {
if (!this.editor.isDestroyed) { if (!this.editor.isDestroyed) {
this.editor.view.dom.removeEventListener('mousedown', this.onMouseDown) this.editor.view.dom.parentElement.removeEventListener('mousedown', this.onMouseDown)
this.editor.off('transaction', this.onTransaction) this.editor.off('transaction', this.onTransaction)
} }
document.removeEventListener('mouseup', this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp)
}, },
methods: { methods: {
measure () {
if (this.$refs.menu) {
this.menuWidth = this.$refs.menu.offsetWidth
}
},
toggleDropdown (name) {
this.dropdown = this.dropdown === name ? null : name
this.isDropdownAlignedRight = false
this.$nextTick(() => {
const dropdownEl = [this.$refs.dropdown].flat()[0]
if (!dropdownEl) return
const dropdownRect = dropdownEl.getBoundingClientRect()
const containerRect = this.$el.parentElement.getBoundingClientRect()
this.isDropdownAlignedRight = dropdownRect.right > containerRect.right
})
},
toggleBold () { toggleBold () {
this.editor.chain().focus().toggleBold().run() this.toggleStyledMark('bold', 'font-weight', this.isBold, (style) => parseInt(style.fontWeight) >= 600)
}, },
toggleItalic () { toggleItalic () {
this.editor.chain().focus().toggleItalic().run() this.toggleStyledMark('italic', 'font-style', this.isItalic, (style) => style.fontStyle === 'italic')
},
toggleStyledMark (markName, property, isActive, isStyled) {
const isSegmentStyled = ({ from }) => isStyled(getComputedStyle(this.textElementAt(from)))
if (isActive) {
this.editor.chain().focus().unsetMark(markName).setListMarkerStyle(property, null).run()
if (this.selectedTextSegments().some(isSegmentStyled)) {
this.editor.chain().setSpanStyle(property, 'normal').run()
}
} else {
this.editor.chain().focus().setSpanStyle(property, null).run()
const { state, view } = this.editor
const tr = state.tr
this.selectedTextSegments().forEach((segment) => {
if (!isSegmentStyled(segment)) tr.addMark(segment.from, segment.to, state.schema.marks[markName].create())
})
if (tr.docChanged) view.dispatch(tr)
this.editor.commands.setListMarkerStyle(property, markName)
}
},
selectedTextSegments () {
const { state } = this.editor
const segments = []
state.selection.ranges.forEach(({ $from, $to }) => state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.isText) segments.push({ from: Math.max(pos, $from.pos), to: Math.min(pos + node.nodeSize, $to.pos) })
}))
return segments
},
textElementAt (pos) {
const { node, offset } = this.editor.view.domAtPos(pos, 1)
if (node.nodeType === Node.TEXT_NODE) return node.parentElement
return node.childNodes[offset]?.nodeType === Node.ELEMENT_NODE ? node.childNodes[offset] : node
}, },
toggleUnderline () { toggleUnderline () {
this.editor.chain().focus().toggleUnderline().run() this.editor.chain().focus().toggleUnderline().run()
@@ -166,6 +556,30 @@ export default {
toggleStrike () { toggleStrike () {
this.editor.chain().focus().toggleStrike().run() this.editor.chain().focus().toggleStrike().run()
}, },
setSpanStyle (property, value) {
this.dropdown = null
const chain = this.editor.chain().focus().setSpanStyle(property, value)
if (property !== 'background-color') chain.setListMarkerStyle(property, value)
chain.run()
},
setFontSize (size) {
this.setSpanStyle('font-size', `${size}pt`)
},
setBlockStyle (property, value) {
this.dropdown = null
this.editor.chain().focus().setBlockStyle(property, value).run()
},
toggleList (style) {
this.dropdown = null
if (style !== this.listStyle) this.$emit('add-list')
this.editor.chain().focus().toggleList(style).run()
},
wrapVariable () { wrapVariable () {
const { from, to } = this.editor.state.selection const { from, to } = this.editor.state.selection
const replacement = '[[variable]]' const replacement = '[[variable]]'
@@ -201,10 +615,62 @@ export default {
}, 1) }, 1)
}, },
onTransaction () { onTransaction () {
this.isBold = this.editor.isActive('bold') if (this.visible) {
this.isItalic = this.editor.isActive('italic') this.readState()
this.isUnderline = this.editor.isActive('underline') }
this.isStrike = this.editor.isActive('strike') },
readState () {
const { view, state } = this.editor
const { ranges } = state.selection
const selectionFrom = Math.min(...ranges.map((range) => range.$from.pos))
const selectionTo = Math.max(...ranges.map((range) => range.$to.pos))
let from = null
state.doc.nodesBetween(selectionFrom, selectionTo, (node, pos) => {
if (from === null && node.isText) from = Math.max(pos, selectionFrom)
})
if (from === null) from = selectionFrom
const $from = state.doc.resolve(from)
const el = this.textElementAt(from)
const style = getComputedStyle(el)
const blockDom = $from.parent.isTextblock ? view.nodeDOM($from.before()) : view.dom
const decorations = []
this.isCellSelection = state.selection instanceof CellSelection
this.isBold = parseInt(style.fontWeight) >= 600
this.isItalic = style.fontStyle === 'italic'
this.fontFamily = style.fontFamily.split(',')[0].replace(/["']/g, '').trim()
this.fontSize = Math.round(parseFloat(style.fontSize) * 0.75 * 2) / 2
this.color = style.color
this.backgroundColor = 'transparent'
for (let inlineEl = el; inlineEl && view.dom.contains(inlineEl); inlineEl = inlineEl.parentElement) {
const { textDecorationLine, backgroundColor } = getComputedStyle(inlineEl)
decorations.push(textDecorationLine)
if (inlineEl === blockDom) break
if (this.backgroundColor === 'transparent' && backgroundColor !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'transparent') {
this.backgroundColor = backgroundColor
}
}
this.isUnderline = this.editor.isActive('underline') || decorations.some((line) => line.includes('underline'))
this.isStrike = this.editor.isActive('strike') || decorations.some((line) => line.includes('line-through'))
if ($from.parent.isTextblock) {
const blockStyle = getComputedStyle(blockDom)
const align = blockStyle.textAlign.replace('start', 'left').replace('end', 'right')
this.textAlign = this.alignIcons[align] ? align : 'left'
this.lineHeight = String(Math.round(parseFloat(blockStyle.lineHeight) / parseFloat(blockStyle.fontSize) / 1.15 * 100) / 100)
}
this.listStyle = findNumbering($from.parent) ? numberingListStyle(view, $from.before()) : null
} }
} }
} }
@@ -2,13 +2,17 @@
<div <div
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute" class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': isDrawMode && editable }" :class="{ 'cursor-crosshair': isDrawMode && editable }"
@mousedown="onMarginMouseDown"
> >
<div <div
v-if="isDrawMode && editable && cursorHighlightCoords" v-if="isDrawMode && editable && cursorHighlightCoords"
class="absolute pointer-events-none z-10 bg-black" class="absolute pointer-events-none z-10 bg-black"
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }" :style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
/> />
<div :style="{ zoom: containerWidth / sectionWidthPx }"> <div
:style="{ zoom, '--zoom': zoom }"
:data-zoomed-out="zoom < 0.75 || null"
>
<section <section
:id="section.id" :id="section.id"
ref="editorElement" ref="editorElement"
@@ -17,6 +21,13 @@
:style="section.style.cssText" :style="section.style.cssText"
/> />
</div> </div>
<input
ref="imageInput"
type="file"
accept="image/*"
class="hidden"
@change="onImageSelected"
>
<Teleport <Teleport
v-if="editor" v-if="editor"
:to="container" :to="container"
@@ -30,6 +41,7 @@
:area="selectedArea" :area="selectedArea"
:field="selectedField" :field="selectedField"
:editable="editable" :editable="editable"
:is-mobile="isMobile"
:template="template" :template="template"
:selected-areas-ref="selectedAreasRef" :selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex" :get-field-type-index="getFieldTypeIndex"
@@ -41,9 +53,50 @@
v-if="editable" v-if="editable"
v-show="!selectedAreasRef.value.length" v-show="!selectedAreasRef.value.length"
:editor="editor" :editor="editor"
:coords="dynamicMenuCoords" :coords="isInsertMenuOpen ? null : dynamicMenuCoords"
@add-variable="dynamicMenuCoords = null" @add-variable="dynamicMenuCoords = null"
@add-condition="dynamicMenuCoords = null" @add-condition="dynamicMenuCoords = null"
@add-list="$emit('add-list')"
/>
<div
v-if="editable && insertMenuCoords && !isDrawMode"
class="absolute z-10 select-none text-sm"
:style="{ top: insertMenuCoords.top - 12 + 'px', left: insertMenuCoords.left - 12 + 'px' }"
@mousedown.prevent
>
<button
class="flex items-center justify-center w-6 h-6 rounded-md border border-neutral-200"
:class="isInsertMenuOpen ? 'bg-neutral-100 text-base-content' : 'bg-white text-base-content/40 hover:bg-neutral-100 hover:text-base-content'"
:title="t('insert')"
@click="isInsertMenuOpen = !isInsertMenuOpen"
>
<IconX
v-if="isInsertMenuOpen"
:width="14"
:height="14"
/>
<IconPlus
v-else
:width="14"
:height="14"
/>
</button>
<DynamicInsertMenu
v-if="isInsertMenuOpen"
:key="insertMenuCoords.top"
@close="isInsertMenuOpen = false"
@insert-field="onInsertField"
@insert-table="onInsertTable"
@insert-variable="editor.chain().focus().insertContent('[[variable]]').run()"
@insert-image="$refs.imageInput.click()"
/>
</div>
<DynamicBlockMenu
v-if="editable && blockMenuCoords && !isDrawMode"
:coords="blockMenuCoords"
:get-resize-limits="getBlockResizeLimits"
@command="onBlockCommand"
@resize="onBlockResize"
/> />
<FieldContextMenu <FieldContextMenu
v-if="contextMenu && contextMenuField" v-if="contextMenu && contextMenuField"
@@ -64,20 +117,29 @@
<script> <script>
import { shallowRef } from 'vue' import { shallowRef } from 'vue'
import { DOMSerializer, Fragment } from '@tiptap/pm/model' import { DOMSerializer, Fragment } from '@tiptap/pm/model'
import { Selection, TextSelection } from '@tiptap/pm/state'
import { findTable, CellSelection } from '@tiptap/pm/tables'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { IconPlus, IconX } from '@tabler/icons-vue'
import FieldContextMenu from './field_context_menu.vue' import FieldContextMenu from './field_context_menu.vue'
import AreaTitle from './area_title.vue' import AreaTitle from './area_title.vue'
import DynamicMenu from './dynamic_menu.vue' import DynamicMenu from './dynamic_menu.vue'
import { buildEditor } from './dynamic_editor.js' import DynamicInsertMenu from './dynamic_insert_menu.vue'
import DynamicBlockMenu from './dynamic_block_menu.vue'
import { buildEditor, isRegularTable, resizeTable, tableRowMinHeights } from './dynamic_editor.js'
export default { export default {
name: 'DynamicSection', name: 'DynamicSection',
components: { components: {
DynamicMenu, DynamicMenu,
DynamicInsertMenu,
DynamicBlockMenu,
FieldContextMenu, FieldContextMenu,
AreaTitle AreaTitle,
IconPlus,
IconX
}, },
inject: ['template', 'save', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'], inject: ['template', 'save', 'pushUndo', 'undo', 'redo', 'baseFetch', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor', 'isMobile'],
props: { props: {
section: { section: {
type: Object, type: Object,
@@ -147,12 +209,15 @@ export default {
default: null default: null
} }
}, },
emits: ['update', 'draw', 'set-draw', 'add-custom-field'], emits: ['update', 'draw', 'set-draw', 'add-custom-field', 'add-attachment', 'add-list'],
data () { data () {
return { return {
isAreaDrag: false, isAreaDrag: false,
areaToolbarCoords: null, areaToolbarCoords: null,
dynamicMenuCoords: null, dynamicMenuCoords: null,
insertMenuCoords: null,
isInsertMenuOpen: false,
blockMenuCoords: null,
contextMenu: null, contextMenu: null,
cursorHighlightCoords: null cursorHighlightCoords: null
} }
@@ -196,6 +261,9 @@ export default {
editor () { editor () {
return this.editorRef.value return this.editorRef.value
}, },
sectionKey () {
return `${this.attachmentUuid}:${this.section.id}`
},
sectionWidthPx () { sectionWidthPx () {
const pt = parseFloat(this.section.style.width) const pt = parseFloat(this.section.style.width)
@@ -235,9 +303,19 @@ export default {
if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) { if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) {
this.$nextTick(() => this.setDynamicMenuCoords(this.editor)) this.$nextTick(() => this.setDynamicMenuCoords(this.editor))
} }
if (this.insertMenuCoords && this.editor) {
this.$nextTick(() => this.setInsertMenuCoords(this.editor))
}
if (this.blockMenuCoords && this.editor) {
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
}
} }
}, },
mounted () { mounted () {
this.lastUpdateTime = 0
this.editorRef.value = buildEditor({ this.editorRef.value = buildEditor({
dynamicAreaProps: { dynamicAreaProps: {
template: this.template, template: this.template,
@@ -250,17 +328,36 @@ export default {
onAreaResize: this.onAreaResize, onAreaResize: this.onAreaResize,
onAreaDragStart: this.onAreaDragStart onAreaDragStart: this.onAreaDragStart
}, },
attachmentsIndex: this.attachmentsIndex, getAttachmentsIndex: () => this.attachmentsIndex,
onFieldDrop: this.onFieldDrop, onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy, onFieldDestroy: this.onFieldDestroy,
onUndo: this.undo,
onRedo: this.redo,
renderHtmlForSaveRef: this.renderHtmlForSaveRef, renderHtmlForSaveRef: this.renderHtmlForSaveRef,
editorOptions: { editorOptions: {
element: this.$refs.editorElement, element: this.$refs.editorElement,
editable: this.editable, editable: this.editable,
content: this.section.innerHTML, content: this.section.innerHTML,
onUpdate: (event) => this.$emit('update', event), onUpdate: (event) => {
this.$emit('update', event)
if (event.transaction.getMeta('addToHistory') !== false) {
this.pushUndo({ amend: event.transaction.time - this.lastUpdateTime < 500 })
this.lastUpdateTime = event.transaction.time
}
if (this.blockMenuCoords) {
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
}
},
onSelectionUpdate: this.onSelectionUpdate, onSelectionUpdate: this.onSelectionUpdate,
onBlur: () => { this.dynamicMenuCoords = null } onFocus: this.onSelectionUpdate,
onBlur: () => {
this.dynamicMenuCoords = null
this.insertMenuCoords = null
this.blockMenuCoords = null
}
} }
}) })
@@ -269,6 +366,8 @@ export default {
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove) this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave) this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown) this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
this.$nextTick(() => this.pushUndo({ amend: true }))
}, },
beforeUnmount () { beforeUnmount () {
if (this.editor) { if (this.editor) {
@@ -284,6 +383,19 @@ export default {
reloadContent () { reloadContent () {
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false, parseOptions: this.editor.options.parseOptions }) this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false, parseOptions: this.editor.options.parseOptions })
}, },
restoreDoc (doc) {
const { state, view } = this.editor
const { content } = doc.type.schema === state.schema ? doc : state.schema.nodeFromJSON(doc.toJSON())
const diffEnd = content.findDiffEnd(state.doc.content)
if (diffEnd) {
const tr = state.tr.replaceWith(0, state.doc.content.size, content).setMeta('addToHistory', false)
view.dispatch(tr.setSelection(Selection.near(tr.doc.resolve(diffEnd.a))).scrollIntoView())
this.lastUpdateTime = 0
}
},
findAreaNodePos (areaUuid) { findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`) const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
@@ -402,16 +514,151 @@ export default {
this.selectedAreasRef.value = [area] this.selectedAreasRef.value = [area]
} }
} }
this.blockMenuCoords = null
} else { } else {
this.areaToolbarCoords = null this.areaToolbarCoords = null
this.selectedAreasRef.value = [] this.selectedAreasRef.value = []
if (editor.state.selection.empty) { this.setBlockMenuCoords(editor)
if (selection.node || selection.empty || !editor.isFocused) {
this.dynamicMenuCoords = null this.dynamicMenuCoords = null
} else { } else {
this.setDynamicMenuCoords(editor) this.setDynamicMenuCoords(editor)
} }
} }
if (selection instanceof CellSelection) {
this.insertMenuCoords = null
} else if (editor.isFocused) {
this.setInsertMenuCoords(editor)
}
},
setInsertMenuCoords (editor) {
const coords = editor.view.coordsAtPos(editor.state.selection.head)
const containerRect = this.container.getBoundingClientRect()
const sectionEl = this.$refs.editorElement
const sectionRect = sectionEl.getBoundingClientRect()
const paddingLeft = parseFloat(getComputedStyle(sectionEl).paddingLeft) * this.zoom
this.insertMenuCoords = {
top: (coords.top + coords.bottom) / 2 - containerRect.top,
left: sectionRect.left - containerRect.left + Math.max(paddingLeft / 2, 14)
}
},
setBlockMenuCoords (editor) {
const { selection } = editor.state
const isImage = selection.node?.type.name === 'image'
const table = isImage ? null : findTable(selection.$from)
if (!isImage && !table) {
this.blockMenuCoords = null
return
}
const pos = isImage ? selection.from : table.pos
const dom = editor.view.nodeDOM(pos)
const rect = dom.getBoundingClientRect()
const containerRect = this.container.getBoundingClientRect()
const coords = {
type: isImage ? 'image' : 'table',
pos,
align: 'left',
top: rect.top - containerRect.top,
left: rect.left - containerRect.left,
width: rect.width,
height: rect.height
}
if (isImage) {
coords.align = getComputedStyle(dom.parentElement).textAlign.replace('start', 'left').replace('end', 'right')
} else {
const { marginLeft, marginRight, float } = dom.style
if (float === 'left' || float === 'right') {
coords.align = float
} else if (marginLeft === 'auto') {
coords.align = marginRight === 'auto' ? 'center' : 'right'
} else if (dom.getAttribute('align')) {
coords.align = dom.getAttribute('align')
}
coords.isRegular = isRegularTable(table.node)
coords.canMerge = editor.can().mergeTableCells()
coords.canSplit = editor.can().splitTableCell()
}
this.blockMenuCoords = coords
},
getBlockResizeLimits () {
const dom = this.editor.view.nodeDOM(this.blockMenuCoords.pos)
const rect = dom.getBoundingClientRect()
return {
minHeight: tableRowMinHeights(dom, this.zoom, false).reduce((acc, value) => acc + value, 0),
maxWidth: Math.max(this.$refs.editorElement.getBoundingClientRect().right - rect.left, rect.width)
}
},
onBlockResize ({ width, height }) {
resizeTable(this.editor.view, this.blockMenuCoords.pos, { width: width / this.zoom, height: height / this.zoom }, this.zoom)
this.editor.commands.focus()
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
},
onBlockCommand (command, arg) {
this.editor.chain().focus()[command](arg).run()
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
},
onInsertField (type) {
const { from, to } = this.editor.state.selection
this.insertFieldAtRange({ sourceField: { type }, from, to })
},
onInsertTable ({ rows, cols }) {
const $pos = this.editor.state.tr.deleteSelection().selection.$head
const cell = '<td style="border: 1px solid #000000; padding: 4pt; vertical-align: top"><p style="margin: 0"></p></td>'
const html = `<table style="width: 100%; border-collapse: collapse"><tbody>${`<tr>${cell.repeat(cols)}</tr>`.repeat(rows)}</tbody></table>`
const isEmpty = $pos.parent.isTextblock && !$pos.parent.content.size
const from = !$pos.parent.isTextblock ? $pos.pos : isEmpty ? $pos.before() : $pos.after()
const to = isEmpty ? $pos.after() : from
this.editor.chain().focus()
.deleteSelection()
.insertContentAt({ from, to }, html)
.setTextSelection(from + 4)
.run()
},
async onImageSelected (event) {
const file = event.target.files[0]
event.target.value = ''
if (!file) return
const formData = new FormData()
formData.append('file', file)
const response = await this.baseFetch(`/templates/${this.template.id}/dynamic_documents/${this.attachmentUuid}/attachments`, {
method: 'POST',
body: formData
})
const data = await response.json()
if (!response.ok) return alert(data.error)
this.$emit('add-attachment', data)
await this.$nextTick()
this.editor.chain().focus()
.insertContent({ type: 'image', attrs: { htmlAttrs: { src: `blob:${data.uuid}`, style: 'max-width: 100%' } } })
.run()
}, },
setDynamicMenuCoords (editor) { setDynamicMenuCoords (editor) {
const { from, to } = editor.state.selection const { from, to } = editor.state.selection
@@ -419,11 +666,11 @@ export default {
const start = view.coordsAtPos(from) const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to) const end = view.coordsAtPos(to)
const containerRect = this.container.getBoundingClientRect() const containerRect = this.container.getBoundingClientRect()
const left = (start.left + end.right) / 2 - containerRect.left
this.dynamicMenuCoords = { this.dynamicMenuCoords = {
top: Math.min(start.top, end.top) - containerRect.top, top: Math.max(start.bottom, end.bottom) - containerRect.top + 8,
left: Math.max(80, Math.min(left, containerRect.width - 80)) left: (start.left + end.right) / 2 - containerRect.left,
width: containerRect.width
} }
}, },
onFieldDestroy (node) { onFieldDestroy (node) {
@@ -493,6 +740,8 @@ export default {
if (event.key === 'Escape') { if (event.key === 'Escape') {
this.editor.chain().setNodeSelection(0).blur().run() this.editor.chain().setNodeSelection(0).blur().run()
this.deselectArea() this.deselectArea()
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.stopPropagation()
} }
}, },
onEditorMouseMove (event) { onEditorMouseMove (event) {
@@ -608,25 +857,23 @@ export default {
} }
} }
const newField = { const newField = payload.parentField || {
...field, ...field,
uuid: v4(), uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid, submitter_uuid: this.selectedSubmitter.uuid,
areas: [area] areas: []
} }
newField.areas.push(area)
if (['radio', 'multiple'].includes(field.type) && field.options?.length) { if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid const oldOptionIndex = field.options.findIndex((opt) => opt.uuid === area.option_uuid)
const optionsMap = {}
newField.options = field.options.map((opt) => { if (!payload.parentField) {
const newUuid = v4() newField.options = field.options.map((opt) => ({ ...opt, uuid: v4() }))
optionsMap[opt.uuid] = newUuid }
return { ...opt, uuid: newUuid } area.option_uuid = (newField.options[oldOptionIndex] || newField.options[0]).uuid
})
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
} }
return { field: newField, area } return { field: newField, area }
@@ -642,6 +889,8 @@ export default {
const text = clipboardData.getData('text/plain') const text = clipboardData.getData('text/plain')
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '') const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
if (/class="[^"]*doc-list-/.test(html)) this.$emit('add-list')
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) { if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
return return
} }
@@ -662,10 +911,13 @@ export default {
let lastArea = null let lastArea = null
const parentFieldsIndex = {}
fieldNodes.forEach((fieldNode) => { fieldNodes.forEach((fieldNode) => {
const fieldValue = fieldNode.dataset.field const fieldValue = fieldNode.dataset.field
const areaValue = fieldNode.dataset.area const areaValue = fieldNode.dataset.area
const templateId = fieldNode.dataset.templateId const templateId = fieldNode.dataset.templateId
const sourceFieldUuid = fieldNode.getAttribute('uuid')
if (!fieldValue || !areaValue) { if (!fieldValue || !areaValue) {
return return
@@ -674,11 +926,16 @@ export default {
const { field, area } = this.buildCopiedField({ const { field, area } = this.buildCopiedField({
field: JSON.parse(fieldValue), field: JSON.parse(fieldValue),
area: JSON.parse(areaValue), area: JSON.parse(areaValue),
templateId: Number(templateId) templateId: Number(templateId),
parentField: parentFieldsIndex[sourceFieldUuid]
}) })
this.insertFieldInTemplate(field, insertIndex) if (!parentFieldsIndex[sourceFieldUuid]) {
insertIndex += 1 parentFieldsIndex[sourceFieldUuid] = field
this.insertFieldInTemplate(field, insertIndex)
insertIndex += 1
}
fieldNode.setAttribute('uuid', field.uuid) fieldNode.setAttribute('uuid', field.uuid)
fieldNode.setAttribute('area-uuid', area.uuid) fieldNode.setAttribute('area-uuid', area.uuid)
@@ -746,6 +1003,48 @@ export default {
return true return true
}, },
closestPosAtCoords ({ clientX, clientY }) {
const view = this.editor.view
const rect = view.dom.getBoundingClientRect()
const left = Math.min(Math.max(clientX, rect.left + 1), rect.right - 1)
const top = Math.min(Math.max(clientY, rect.top + 1), rect.bottom - 1)
const caret = document.caretPositionFromPoint?.(left, top, { shadowRoots: [view.root] })
if (caret && view.dom.contains(caret.offsetNode)) {
return view.posAtDOM(caret.offsetNode, caret.offset)
} else {
return view.posAtCoords({ left, top }).pos
}
},
onMarginMouseDown (event) {
const view = this.editor.view
if (event.button !== 0 || !this.editable || this.isDrawMode || view.dom.contains(event.target)) {
return
}
event.preventDefault()
const anchor = this.closestPosAtCoords(event)
const selectTo = (e) => {
const { doc, tr } = view.state
view.dispatch(tr.setSelection(TextSelection.between(doc.resolve(anchor), doc.resolve(this.closestPosAtCoords(e)))))
}
const onMouseUp = () => {
window.removeEventListener('mousemove', selectTo)
window.removeEventListener('mouseup', onMouseUp)
}
selectTo(event)
view.focus()
window.addEventListener('mousemove', selectTo)
window.addEventListener('mouseup', onMouseUp)
},
onEditorPointerDown (event) { onEditorPointerDown (event) {
if (!this.isDrawMode || !this.editable || this.isDraggingField) { if (!this.isDrawMode || !this.editable || this.isDraggingField) {
return return
@@ -19,4 +19,5 @@
:host { :host {
all: initial; all: initial;
-webkit-text-size-adjust: 100%;
} }
@@ -745,6 +745,7 @@ export default {
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email', '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email',
'^https?://.*': 'url', '^https?://.*': 'url',
'^[0-9]{5}(?:-[0-9]{4})?$': 'zip', '^[0-9]{5}(?:-[0-9]{4})?$': 'zip',
'^[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}$': 'iban',
'^[0-9]+$': 'numbers_only', '^[0-9]+$': 'numbers_only',
'^[a-zA-Z]+$': 'letters_only' '^[a-zA-Z]+$': 'letters_only'
} }
+273 -7
View File
@@ -210,6 +210,7 @@ const en = {
email: 'Email', email: 'Email',
url: 'URL', url: 'URL',
zip: 'ZIP', zip: 'ZIP',
iban: 'IBAN',
custom: 'Custom', custom: 'Custom',
numbers_only: 'Numbers only', numbers_only: 'Numbers only',
letters_only: 'Letters only', letters_only: 'Letters only',
@@ -248,7 +249,44 @@ const en = {
no_revisions_yet: 'No revisions yet', no_revisions_yet: 'No revisions yet',
viewing_revision_from: 'Viewing revision from {date}', viewing_revision_from: 'Viewing revision from {date}',
connect_google_drive: 'Connect Google Drive', connect_google_drive: 'Connect Google Drive',
submitting: 'Submitting' submitting: 'Submitting',
font_size: 'Font size',
increase_font_size: 'Increase font size',
decrease_font_size: 'Decrease font size',
line_spacing: 'Line spacing',
justify: 'Justify',
bullet_list: 'List',
dots: 'Dots',
dash: 'Dash',
numbers: 'Numbers',
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
strikethrough: 'Strikethrough',
text_color: 'Text color',
background_color: 'Background color',
variable: 'Variable',
create_variable: 'Wrap in variable',
create_condition: 'Wrap in condition',
insert: 'Insert',
field: 'Field',
table: 'Table',
decrease_rows: 'Fewer rows',
increase_rows: 'More rows',
decrease_cols: 'Fewer columns',
increase_cols: 'More columns',
align_center: 'Align Center',
insert_row_above: 'Insert row above',
insert_row_below: 'Insert row below',
insert_column_left: 'Insert column left',
insert_column_right: 'Insert column right',
merge_cells: 'Merge cells',
split_cell: 'Split cell',
delete_row: 'Delete row',
delete_column: 'Delete column',
delete_table: 'Delete table',
delete_image: 'Delete image',
irregular_table_structure: 'Rows and columns can\'t be edited: irregular table layout'
} }
const es = { const es = {
@@ -463,6 +501,7 @@ const es = {
email: 'Correo electrónico', email: 'Correo electrónico',
url: 'URL', url: 'URL',
zip: 'ZIP', zip: 'ZIP',
iban: 'IBAN',
custom: 'Personalizado', custom: 'Personalizado',
numbers_only: 'Solo números', numbers_only: 'Solo números',
letters_only: 'Solo letras', letters_only: 'Solo letras',
@@ -501,7 +540,44 @@ const es = {
no_revisions_yet: 'Aún no hay revisiones', no_revisions_yet: 'Aún no hay revisiones',
viewing_revision_from: 'Viendo revisión del {date}', viewing_revision_from: 'Viendo revisión del {date}',
connect_google_drive: 'Conectar Google Drive', connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando' submitting: 'Enviando',
font_size: 'Tamaño',
increase_font_size: 'Aumentar tamaño',
decrease_font_size: 'Reducir tamaño',
line_spacing: 'Interlineado',
justify: 'Justificar',
bullet_list: 'Lista',
dots: 'Puntos',
dash: 'Guiones',
numbers: 'Números',
bold: 'Negrita',
italic: 'Cursiva',
underline: 'Subrayado',
strikethrough: 'Tachado',
text_color: 'Color de texto',
background_color: 'Color de fondo',
variable: 'Variable',
create_variable: 'Crear variable',
create_condition: 'Crear condición',
insert: 'Insertar',
field: 'Campo',
table: 'Tabla',
decrease_rows: 'Menos filas',
increase_rows: 'Más filas',
decrease_cols: 'Menos columnas',
increase_cols: 'Más columnas',
align_center: 'Centrar',
insert_row_above: 'Fila arriba',
insert_row_below: 'Fila abajo',
insert_column_left: 'Columna izquierda',
insert_column_right: 'Columna derecha',
merge_cells: 'Combinar celdas',
split_cell: 'Dividir celda',
delete_row: 'Eliminar fila',
delete_column: 'Eliminar columna',
delete_table: 'Eliminar tabla',
delete_image: 'Eliminar imagen',
irregular_table_structure: 'No se pueden editar filas ni columnas: tabla irregular'
} }
const it = { const it = {
@@ -716,6 +792,7 @@ const it = {
email: 'Email', email: 'Email',
url: 'URL', url: 'URL',
zip: 'CAP', zip: 'CAP',
iban: 'IBAN',
custom: 'Personalizzato', custom: 'Personalizzato',
numbers_only: 'Solo numeri', numbers_only: 'Solo numeri',
letters_only: 'Solo lettere', letters_only: 'Solo lettere',
@@ -754,7 +831,44 @@ const it = {
no_revisions_yet: 'Nessuna revisione ancora', no_revisions_yet: 'Nessuna revisione ancora',
viewing_revision_from: 'Visualizzazione revisione del {date}', viewing_revision_from: 'Visualizzazione revisione del {date}',
connect_google_drive: 'Connetti Google Drive', connect_google_drive: 'Connetti Google Drive',
submitting: 'Invio' submitting: 'Invio',
font_size: 'Dimensione',
increase_font_size: 'Aumenta dimensione',
decrease_font_size: 'Riduci dimensione',
line_spacing: 'Interlinea',
justify: 'Giustifica',
bullet_list: 'Elenco',
dots: 'Punti',
dash: 'Trattini',
numbers: 'Numeri',
bold: 'Grassetto',
italic: 'Corsivo',
underline: 'Sottolineato',
strikethrough: 'Barrato',
text_color: 'Colore testo',
background_color: 'Colore sfondo',
variable: 'Variabile',
create_variable: 'Crea variabile',
create_condition: 'Crea condizione',
insert: 'Inserisci',
field: 'Campo',
table: 'Tabella',
decrease_rows: 'Meno righe',
increase_rows: 'Più righe',
decrease_cols: 'Meno colonne',
increase_cols: 'Più colonne',
align_center: 'Centra',
insert_row_above: 'Riga sopra',
insert_row_below: 'Riga sotto',
insert_column_left: 'Colonna a sinistra',
insert_column_right: 'Colonna a destra',
merge_cells: 'Unisci celle',
split_cell: 'Dividi cella',
delete_row: 'Elimina riga',
delete_column: 'Elimina colonna',
delete_table: 'Elimina tabella',
delete_image: 'Elimina immagine',
irregular_table_structure: 'Righe e colonne non modificabili: tabella irregolare'
} }
const pt = { const pt = {
@@ -969,6 +1083,7 @@ const pt = {
email: 'Email', email: 'Email',
url: 'URL', url: 'URL',
zip: 'ZIP', zip: 'ZIP',
iban: 'IBAN',
custom: 'Personalizado', custom: 'Personalizado',
numbers_only: 'Somente números', numbers_only: 'Somente números',
letters_only: 'Somente letras', letters_only: 'Somente letras',
@@ -1007,7 +1122,44 @@ const pt = {
no_revisions_yet: 'Nenhuma revisão ainda', no_revisions_yet: 'Nenhuma revisão ainda',
viewing_revision_from: 'Visualizando revisão de {date}', viewing_revision_from: 'Visualizando revisão de {date}',
connect_google_drive: 'Conectar Google Drive', connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando' submitting: 'Enviando',
font_size: 'Tamanho',
increase_font_size: 'Aumentar tamanho',
decrease_font_size: 'Diminuir tamanho',
line_spacing: 'Espaçamento',
justify: 'Justificar',
bullet_list: 'Lista',
dots: 'Pontos',
dash: 'Traços',
numbers: 'Números',
bold: 'Negrito',
italic: 'Itálico',
underline: 'Sublinhado',
strikethrough: 'Tachado',
text_color: 'Cor do texto',
background_color: 'Cor de fundo',
variable: 'Variável',
create_variable: 'Criar variável',
create_condition: 'Criar condição',
insert: 'Inserir',
field: 'Campo',
table: 'Tabela',
decrease_rows: 'Menos linhas',
increase_rows: 'Mais linhas',
decrease_cols: 'Menos colunas',
increase_cols: 'Mais colunas',
align_center: 'Centralizar',
insert_row_above: 'Linha acima',
insert_row_below: 'Linha abaixo',
insert_column_left: 'Coluna à esquerda',
insert_column_right: 'Coluna à direita',
merge_cells: 'Mesclar células',
split_cell: 'Dividir célula',
delete_row: 'Excluir linha',
delete_column: 'Excluir coluna',
delete_table: 'Excluir tabela',
delete_image: 'Excluir imagem',
irregular_table_structure: 'Linhas e colunas não editáveis: tabela irregular'
} }
const fr = { const fr = {
@@ -1222,6 +1374,7 @@ const fr = {
email: 'E-mail', email: 'E-mail',
url: 'URL', url: 'URL',
zip: 'ZIP', zip: 'ZIP',
iban: 'IBAN',
custom: 'Personnalisé', custom: 'Personnalisé',
numbers_only: 'Chiffres uniquement', numbers_only: 'Chiffres uniquement',
letters_only: 'Lettres uniquement', letters_only: 'Lettres uniquement',
@@ -1260,7 +1413,44 @@ const fr = {
no_revisions_yet: 'Aucune révision pour le moment', no_revisions_yet: 'Aucune révision pour le moment',
viewing_revision_from: 'Affichage de la révision du {date}', viewing_revision_from: 'Affichage de la révision du {date}',
connect_google_drive: 'Connecter Google Drive', connect_google_drive: 'Connecter Google Drive',
submitting: 'Soumission' submitting: 'Soumission',
font_size: 'Taille',
increase_font_size: 'Augmenter la taille',
decrease_font_size: 'Réduire la taille',
line_spacing: 'Interligne',
justify: 'Justifier',
bullet_list: 'Liste',
dots: 'Points',
dash: 'Tirets',
numbers: 'Numéros',
bold: 'Gras',
italic: 'Italique',
underline: 'Souligné',
strikethrough: 'Barré',
text_color: 'Couleur du texte',
background_color: 'Couleur de fond',
variable: 'Variable',
create_variable: 'Créer une variable',
create_condition: 'Créer une condition',
insert: 'Insérer',
field: 'Champ',
table: 'Tableau',
decrease_rows: 'Moins de lignes',
increase_rows: 'Plus de lignes',
decrease_cols: 'Moins de colonnes',
increase_cols: 'Plus de colonnes',
align_center: 'Centrer',
insert_row_above: 'Ligne au-dessus',
insert_row_below: 'Ligne en dessous',
insert_column_left: 'Colonne à gauche',
insert_column_right: 'Colonne à droite',
merge_cells: 'Fusionner',
split_cell: 'Scinder',
delete_row: 'Supprimer la ligne',
delete_column: 'Supprimer la colonne',
delete_table: 'Supprimer le tableau',
delete_image: 'Supprimer l\'image',
irregular_table_structure: 'Lignes et colonnes non modifiables : tableau irrégulier'
} }
const de = { const de = {
@@ -1475,6 +1665,7 @@ const de = {
email: 'E-Mail', email: 'E-Mail',
url: 'URL', url: 'URL',
zip: 'ZIP', zip: 'ZIP',
iban: 'IBAN',
custom: 'Benutzerdefiniert', custom: 'Benutzerdefiniert',
numbers_only: 'Nur Zahlen', numbers_only: 'Nur Zahlen',
letters_only: 'Nur Buchstaben', letters_only: 'Nur Buchstaben',
@@ -1513,7 +1704,44 @@ const de = {
no_revisions_yet: 'Noch keine Revisionen', no_revisions_yet: 'Noch keine Revisionen',
viewing_revision_from: 'Ansicht der Revision vom {date}', viewing_revision_from: 'Ansicht der Revision vom {date}',
connect_google_drive: 'Google Drive verbinden', connect_google_drive: 'Google Drive verbinden',
submitting: 'Übermittlung' submitting: 'Übermittlung',
font_size: 'Schriftgröße',
increase_font_size: 'Schrift vergrößern',
decrease_font_size: 'Schrift verkleinern',
line_spacing: 'Zeilenabstand',
justify: 'Blocksatz',
bullet_list: 'Liste',
dots: 'Punkte',
dash: 'Striche',
numbers: 'Zahlen',
bold: 'Fett',
italic: 'Kursiv',
underline: 'Unterstrichen',
strikethrough: 'Durchgestrichen',
text_color: 'Textfarbe',
background_color: 'Hintergrundfarbe',
variable: 'Variable',
create_variable: 'Variable erstellen',
create_condition: 'Bedingung erstellen',
insert: 'Einfügen',
field: 'Feld',
table: 'Tabelle',
decrease_rows: 'Weniger Zeilen',
increase_rows: 'Mehr Zeilen',
decrease_cols: 'Weniger Spalten',
increase_cols: 'Mehr Spalten',
align_center: 'Zentrieren',
insert_row_above: 'Zeile darüber',
insert_row_below: 'Zeile darunter',
insert_column_left: 'Spalte links',
insert_column_right: 'Spalte rechts',
merge_cells: 'Zellen verbinden',
split_cell: 'Zelle teilen',
delete_row: 'Zeile löschen',
delete_column: 'Spalte löschen',
delete_table: 'Tabelle löschen',
delete_image: 'Bild löschen',
irregular_table_structure: 'Zeilen und Spalten nicht bearbeitbar: unregelmäßige Tabelle'
} }
const nl = { const nl = {
@@ -1728,6 +1956,7 @@ const nl = {
email: 'E-mail', email: 'E-mail',
url: 'URL', url: 'URL',
zip: 'Postcode', zip: 'Postcode',
iban: 'IBAN',
custom: 'Aangepast', custom: 'Aangepast',
numbers_only: 'Alleen cijfers', numbers_only: 'Alleen cijfers',
letters_only: 'Alleen letters', letters_only: 'Alleen letters',
@@ -1766,7 +1995,44 @@ const nl = {
no_revisions_yet: 'Nog geen revisies', no_revisions_yet: 'Nog geen revisies',
viewing_revision_from: 'Revisie van {date} bekijken', viewing_revision_from: 'Revisie van {date} bekijken',
connect_google_drive: 'Verbind Google Drive', connect_google_drive: 'Verbind Google Drive',
submitting: 'Indienen' submitting: 'Indienen',
font_size: 'Tekstgrootte',
increase_font_size: 'Tekst vergroten',
decrease_font_size: 'Tekst verkleinen',
line_spacing: 'Regelafstand',
justify: 'Uitvullen',
bullet_list: 'Lijst',
dots: 'Punten',
dash: 'Streepjes',
numbers: 'Nummers',
bold: 'Vet',
italic: 'Cursief',
underline: 'Onderstrepen',
strikethrough: 'Doorhalen',
text_color: 'Tekstkleur',
background_color: 'Achtergrondkleur',
variable: 'Variabele',
create_variable: 'Maak variabele',
create_condition: 'Maak voorwaarde',
insert: 'Invoegen',
field: 'Veld',
table: 'Tabel',
decrease_rows: 'Minder rijen',
increase_rows: 'Meer rijen',
decrease_cols: 'Minder kolommen',
increase_cols: 'Meer kolommen',
align_center: 'Centreren',
insert_row_above: 'Rij erboven',
insert_row_below: 'Rij eronder',
insert_column_left: 'Kolom links',
insert_column_right: 'Kolom rechts',
merge_cells: 'Cellen samenvoegen',
split_cell: 'Cel splitsen',
delete_row: 'Rij verwijderen',
delete_column: 'Kolom verwijderen',
delete_table: 'Tabel verwijderen',
delete_image: 'Afbeelding verwijderen',
irregular_table_structure: 'Rijen en kolommen niet bewerkbaar: onregelmatige tabel'
} }
export { en, es, it, pt, fr, de, nl } export { en, es, it, pt, fr, de, nl }
@@ -414,7 +414,7 @@ export default {
checkStripeStatus () { checkStripeStatus () {
this.isLoading = true this.isLoading = true
fetch('/api/stripe_connect').then(async (resp) => { fetch('/stripe_connect').then(async (resp) => {
const { status } = await resp.json() const { status } = await resp.json()
if (status === 'connected') { if (status === 'connected') {
@@ -427,7 +427,7 @@ export default {
}) })
}, },
checkPaypalStatus () { checkPaypalStatus () {
fetch('/api/paypal_connect').then(async (resp) => { fetch('/paypal_connect').then(async (resp) => {
const { status } = await resp.json() const { status } = await resp.json()
if (status === 'connected') { if (status === 'connected') {
+11 -2
View File
@@ -181,12 +181,21 @@ class SubmitterMailer < ApplicationMailer
def otp_verification_email(submitter, locale: nil) def otp_verification_email(submitter, locale: nil)
@current_account = submitter.account @current_account = submitter.account
@submitter = submitter @submitter = submitter
@otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':')) @otp_code = OtpChallenges.generate([submitter.email.downcase.squish, submitter.slug].join(':'),
purpose: 'form_email_2fa')
assign_message_metadata('otp_verification_email', submitter) assign_message_metadata('otp_verification_email', submitter)
from =
if Docuseal.multitenant? &&
AccountConfig.exists?(account_id: submitter.account_id, key: 'custom_otp_email', value: true)
from_address_for_submitter(submitter)
else
default_params[:from]
end
I18n.with_locale(locale || submitter.account.locale) do I18n.with_locale(locale || submitter.account.locale) do
mail(to: submitter.email, subject: I18n.t('email_verification')) mail(to: submitter.email, from:, subject: I18n.t('email_verification'))
end end
end end
+12 -2
View File
@@ -5,10 +5,20 @@ class TemplateMailer < ApplicationMailer
@current_account = template.account @current_account = template.account
@template = template @template = template
@otp_code = EmailVerificationCodes.generate([email.downcase.strip, template.slug].join(':')) @otp_code = OtpChallenges.generate([email.downcase.squish, template.slug].join(':'), purpose: 'form_email_2fa')
assign_message_metadata('otp_verification_email', template) assign_message_metadata('otp_verification_email', template)
mail(to: email, subject: I18n.t('email_verification')) from =
if Docuseal.multitenant? &&
AccountConfig.exists?(account_id: template.account_id, key: 'custom_otp_email', value: true)
put_metadata('from_user_id' => template.author_id)
template.author.friendly_name
else
default_params[:from]
end
mail(to: email, from:, subject: I18n.t('email_verification'))
end end
end end
+21
View File
@@ -0,0 +1,21 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: otp_challenges
#
# id :bigint not null, primary key
# attempts :integer default(0), not null
# expire_at :datetime not null
# key :string not null
# nonce :string not null
# verified_at :datetime
# version :integer not null
# created_at :datetime not null
#
# Indexes
#
# index_otp_challenges_on_key_and_version (key,version) UNIQUE
#
class OtpChallenge < ApplicationRecord
end
+1 -1
View File
@@ -82,7 +82,7 @@
</div> </div>
<% templates_order_select_html = capture do %> <% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 && !can?(:manage, :countless) %> <% if params[:q].blank? && @pagy.pages > 1 && !can?(:manage, :countless) %>
<%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000, selected_order: %> <%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 1_000, selected_order: %>
<% end %> <% end %>
<% end %> <% end %>
<%= render 'shared/pagination', pagy: @pagy, items_name: @templates.present? ? 'templates' : 'template_folders', right_additional_html: templates_order_select_html %> <%= render 'shared/pagination', pagy: @pagy, items_name: @templates.present? ? 'templates' : 'template_folders', right_additional_html: templates_order_select_html %>
+1 -1
View File
@@ -52,7 +52,7 @@
<% end %> <% end %>
<% templates_order_select_html = capture do %> <% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 && !can?(:manage, :countless) %> <% if params[:q].blank? && @pagy.pages > 1 && !can?(:manage, :countless) %>
<%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000, selected_order: %> <%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 1_000, selected_order: %>
<% end %> <% end %>
<% end %> <% end %>
<% if @template_folders.present? || @show_default_folder || @show_shared_folder %> <% if @template_folders.present? || @show_default_folder || @show_shared_folder %>
@@ -35,10 +35,12 @@
<%= svg_icon('user', class: 'w-5 h-5 inline') %> <%= svg_icon('user', class: 'w-5 h-5 inline') %>
<span><%= signature.reason %></span> <span><%= signature.reason %></span>
</div> </div>
<div class="flex items-center space-x-1"> <% if signature.signing_time %>
<%= svg_icon('calendar', class: 'w-5 h-5 inline') %> <div class="flex items-center space-x-1">
<span><%= l(signature.signing_time.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %></span> <%= svg_icon('calendar', class: 'w-5 h-5 inline') %>
</div> <span><%= l(signature.signing_time.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %></span>
</div>
<% end %>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('certificate', class: 'w-5 h-5 inline') %> <%= svg_icon('certificate', class: 'w-5 h-5 inline') %>
<span><%= signature.common_name %></span> <span><%= signature.common_name %></span>
+14 -7
View File
@@ -624,6 +624,7 @@ en: &en
form_has_been_declined: Form has been declined. form_has_been_declined: Form has been declined.
form_is_view_only: Form is view only. form_is_view_only: Form is view only.
file_is_missing: File is missing file_is_missing: File is missing
file_is_not_an_image: File is not an image
folder_name_has_been_updated: Folder name has been updated. folder_name_has_been_updated: Folder name has been updated.
unable_to_rename_folder: Unable to rename folder. unable_to_rename_folder: Unable to rename folder.
template_has_been_removed: Template has been removed. template_has_been_removed: Template has been removed.
@@ -683,7 +684,7 @@ en: &en
thirty_days: 30 days thirty_days: 30 days
free: Free free: Free
unlimited_documents_storage: Unlimited documents storage unlimited_documents_storage: Unlimited documents storage
users_management: Users management user_management: User management
ten_field_types: 10 field types ten_field_types: 10 field types
multiple_signers: Multiple signers multiple_signers: Multiple signers
automatic_esignature: Automatic eSignature automatic_esignature: Automatic eSignature
@@ -1781,6 +1782,7 @@ es: &es
form_has_been_declined: El formulario ha sido rechazado. form_has_been_declined: El formulario ha sido rechazado.
form_is_view_only: El formulario es de solo lectura. form_is_view_only: El formulario es de solo lectura.
file_is_missing: Falta el archivo file_is_missing: Falta el archivo
file_is_not_an_image: El archivo no es una imagen
folder_name_has_been_updated: El nombre de la carpeta ha sido actualizado. folder_name_has_been_updated: El nombre de la carpeta ha sido actualizado.
unable_to_rename_folder: No se pudo renombrar la carpeta. unable_to_rename_folder: No se pudo renombrar la carpeta.
template_has_been_removed: La plantilla ha sido eliminada. template_has_been_removed: La plantilla ha sido eliminada.
@@ -1840,7 +1842,7 @@ es: &es
thirty_days: 30 días thirty_days: 30 días
free: Gratis free: Gratis
unlimited_documents_storage: Almacenamiento ilimitado unlimited_documents_storage: Almacenamiento ilimitado
users_management: Gestión de usuarios user_management: Gestión de usuarios
ten_field_types: 10 tipos de campos ten_field_types: 10 tipos de campos
multiple_signers: Múltiples firmantes multiple_signers: Múltiples firmantes
automatic_esignature: Firma electrónica automática automatic_esignature: Firma electrónica automática
@@ -2941,6 +2943,7 @@ it: &it
form_has_been_declined: Il modulo è stato rifiutato. form_has_been_declined: Il modulo è stato rifiutato.
form_is_view_only: Il modulo è di sola lettura. form_is_view_only: Il modulo è di sola lettura.
file_is_missing: File mancante file_is_missing: File mancante
file_is_not_an_image: Il file non è un'immagine
folder_name_has_been_updated: Il nome della cartella è stato aggiornato. folder_name_has_been_updated: Il nome della cartella è stato aggiornato.
unable_to_rename_folder: Impossibile rinominare la cartella. unable_to_rename_folder: Impossibile rinominare la cartella.
template_has_been_removed: Il modello è stato rimosso. template_has_been_removed: Il modello è stato rimosso.
@@ -3000,7 +3003,7 @@ it: &it
thirty_days: 30 giorni thirty_days: 30 giorni
free: Gratuito free: Gratuito
unlimited_documents_storage: Archiviazione illimitata di documenti unlimited_documents_storage: Archiviazione illimitata di documenti
users_management: Gestione utenti user_management: Gestione utenti
ten_field_types: 10 tipi di campi ten_field_types: 10 tipi di campi
multiple_signers: Firmatari multipli multiple_signers: Firmatari multipli
automatic_esignature: Firma elettronica automatica automatic_esignature: Firma elettronica automatica
@@ -4087,6 +4090,7 @@ fr: &fr
form_has_been_declined: Le formulaire a été refusé. form_has_been_declined: Le formulaire a été refusé.
form_is_view_only: Le formulaire est en lecture seule. form_is_view_only: Le formulaire est en lecture seule.
file_is_missing: Fichier manquant file_is_missing: Fichier manquant
file_is_not_an_image: Le fichier n'est pas une image
folder_name_has_been_updated: Le nom du dossier a été mis à jour. folder_name_has_been_updated: Le nom du dossier a été mis à jour.
unable_to_rename_folder: Impossible de renommer le dossier. unable_to_rename_folder: Impossible de renommer le dossier.
template_has_been_removed: Le modèle a été supprimé. template_has_been_removed: Le modèle a été supprimé.
@@ -4146,7 +4150,7 @@ fr: &fr
thirty_days: 30 jours thirty_days: 30 jours
free: Gratuit free: Gratuit
unlimited_documents_storage: Stockage de documents illimité unlimited_documents_storage: Stockage de documents illimité
users_management: Gestion des utilisateurs user_management: Gestion des utilisateurs
ten_field_types: 10 types de champs ten_field_types: 10 types de champs
multiple_signers: Plusieurs signataires multiple_signers: Plusieurs signataires
automatic_esignature: Signature électronique automatique automatic_esignature: Signature électronique automatique
@@ -5243,6 +5247,7 @@ pt: &pt
form_has_been_declined: O formulário foi recusado. form_has_been_declined: O formulário foi recusado.
form_is_view_only: O formulário é somente leitura. form_is_view_only: O formulário é somente leitura.
file_is_missing: O arquivo está ausente file_is_missing: O arquivo está ausente
file_is_not_an_image: O arquivo não é uma imagem
folder_name_has_been_updated: O nome da pasta foi atualizado. folder_name_has_been_updated: O nome da pasta foi atualizado.
unable_to_rename_folder: Não foi possível renomear a pasta. unable_to_rename_folder: Não foi possível renomear a pasta.
template_has_been_removed: O modelo foi removido. template_has_been_removed: O modelo foi removido.
@@ -5302,7 +5307,7 @@ pt: &pt
thirty_days: 30 dias thirty_days: 30 dias
free: Gratuito free: Gratuito
unlimited_documents_storage: Armazenamento ilimitado unlimited_documents_storage: Armazenamento ilimitado
users_management: Gerenciamento de usuários user_management: Gerenciamento de usuários
ten_field_types: 10 tipos de campo ten_field_types: 10 tipos de campo
multiple_signers: Múltiplos signatários multiple_signers: Múltiplos signatários
automatic_esignature: Assinatura eletrônica automática automatic_esignature: Assinatura eletrônica automática
@@ -6402,6 +6407,7 @@ de: &de
form_has_been_declined: Das Formular wurde abgelehnt. form_has_been_declined: Das Formular wurde abgelehnt.
form_is_view_only: Das Formular ist schreibgeschützt. form_is_view_only: Das Formular ist schreibgeschützt.
file_is_missing: Datei fehlt file_is_missing: Datei fehlt
file_is_not_an_image: Datei ist kein Bild
folder_name_has_been_updated: Der Ordnername wurde aktualisiert. folder_name_has_been_updated: Der Ordnername wurde aktualisiert.
unable_to_rename_folder: Der Ordner konnte nicht umbenannt werden. unable_to_rename_folder: Der Ordner konnte nicht umbenannt werden.
template_has_been_removed: Die Vorlage wurde entfernt. template_has_been_removed: Die Vorlage wurde entfernt.
@@ -6461,7 +6467,7 @@ de: &de
thirty_days: 30 Tage thirty_days: 30 Tage
free: Kostenlos free: Kostenlos
unlimited_documents_storage: Unbegrenzter Dokumentenspeicher unlimited_documents_storage: Unbegrenzter Dokumentenspeicher
users_management: Benutzerverwaltung user_management: Benutzerverwaltung
ten_field_types: 10 Feldtypen ten_field_types: 10 Feldtypen
multiple_signers: Mehrere Unterzeichner multiple_signers: Mehrere Unterzeichner
automatic_esignature: Automatische E‑Signatur automatic_esignature: Automatische E‑Signatur
@@ -8089,6 +8095,7 @@ nl: &nl
form_has_been_declined: Formulier is geweigerd. form_has_been_declined: Formulier is geweigerd.
form_is_view_only: Formulier is alleen-lezen. form_is_view_only: Formulier is alleen-lezen.
file_is_missing: Bestand ontbreekt file_is_missing: Bestand ontbreekt
file_is_not_an_image: Bestand is geen afbeelding
folder_name_has_been_updated: Mapnaam is bijgewerkt. folder_name_has_been_updated: Mapnaam is bijgewerkt.
unable_to_rename_folder: Kan map niet hernoemen. unable_to_rename_folder: Kan map niet hernoemen.
template_has_been_removed: Sjabloon is verwijderd. template_has_been_removed: Sjabloon is verwijderd.
@@ -8148,7 +8155,7 @@ nl: &nl
thirty_days: 30 dagen thirty_days: 30 dagen
free: Gratis free: Gratis
unlimited_documents_storage: Onbeperkte documentopslag unlimited_documents_storage: Onbeperkte documentopslag
users_management: Gebruikersbeheer user_management: Gebruikersbeheer
ten_field_types: 10 veldtypen ten_field_types: 10 veldtypen
multiple_signers: Meerdere ondertekenaars multiple_signers: Meerdere ondertekenaars
automatic_esignature: Automatische e-handtekening automatic_esignature: Automatische e-handtekening
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreateOtpChallenges < ActiveRecord::Migration[8.1]
def change
create_table :otp_challenges do |t|
t.string :key, null: false
t.integer :version, null: false
t.string :nonce, null: false
t.integer :attempts, null: false, default: 0
t.datetime :expire_at, null: false
t.datetime :verified_at
t.datetime :created_at, null: false
t.index %i[key version], unique: true
end
end
end
+12 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_08_19_091500) do ActiveRecord::Schema[8.1].define(version: 2026_09_30_061927) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@@ -322,6 +322,17 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_19_091500) do
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end end
create_table "otp_challenges", force: :cascade do |t|
t.integer "attempts", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "expire_at", null: false
t.string "key", null: false
t.string "nonce", null: false
t.datetime "verified_at"
t.integer "version", null: false
t.index ["key", "version"], name: "index_otp_challenges_on_key_and_version", unique: true
end
create_table "search_entries", force: :cascade do |t| create_table "search_entries", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
-27
View File
@@ -1,27 +0,0 @@
# frozen_string_literal: true
module EmailVerificationCodes
DRIFT_BEHIND = 5.minutes
module_function
def generate(value)
totp = ROTP::TOTP.new(build_totp_secret(value))
totp.at(Time.current)
end
def verify(code, value)
totp = ROTP::TOTP.new(build_totp_secret(value))
totp.verify(code, drift_behind: DRIFT_BEHIND)
end
def build_totp_secret(value)
ROTP::Base32.encode(
Digest::SHA1.digest(
[Rails.application.secret_key_base, 'form_email_2fa', value].join(':')
)
)
end
end
+63
View File
@@ -0,0 +1,63 @@
# frozen_string_literal: true
module OtpChallenges
CODE_TTL = 10.minutes
REUSE_MIN_TTL = 5.minutes
MAX_ATTEMPTS = 20
MAX_TOTAL_ATTEMPTS = 100
ATTEMPTS_WINDOW = 23.hours
module_function
def generate(value, purpose:)
key = build_key(value, purpose)
latest_challenge = OtpChallenge.where(key:).order(version: :desc).first
otp_challenge =
if latest_challenge && !latest_challenge.verified_at? && latest_challenge.expire_at > REUSE_MIN_TTL.from_now
latest_challenge
else
version = latest_challenge ? latest_challenge.version + 1 : 1
OtpChallenge.create_with(nonce: SecureRandom.base58(16), expire_at: CODE_TTL.from_now)
.create_or_find_by!(key:, version:)
end
build_hotp(value, purpose, otp_challenge.nonce).at(0)
end
def verify(code, value, purpose:)
challenges = OtpChallenge.where(key: build_key(value, purpose))
recent_challenges = challenges.where(expire_at: ATTEMPTS_WINDOW.ago..).order(version: :desc).to_a
latest_challenge = recent_challenges.first
return false if latest_challenge.nil? || latest_challenge.verified_at? || latest_challenge.expire_at.past?
raise RateLimit::LimitApproached if recent_challenges.sum(&:attempts) >= MAX_ATTEMPTS
raise RateLimit::LimitApproached if challenges.sum(:attempts) >= MAX_TOTAL_ATTEMPTS
if build_hotp(value, purpose, latest_challenge.nonce).verify(code.to_s, 0)
OtpChallenge.where(id: latest_challenge.id, verified_at: nil).update_all(verified_at: Time.current) == 1
else
latest_challenge.increment!(:attempts)
OtpChallenge.current_transaction.after_rollback { OtpChallenge.update_counters(latest_challenge.id, attempts: 1) }
false
end
end
def build_key(value, purpose)
Digest::SHA256.hexdigest([purpose, value].join(':'))
end
def build_hotp(value, purpose, nonce)
ROTP::HOTP.new(
ROTP::Base32.encode(
Digest::SHA1.digest([Rails.application.secret_key_base, purpose, value, nonce].join(':'))
)
)
end
end
+3 -3
View File
@@ -280,12 +280,12 @@ module Submitters
email_digest = Digest::MD5.base64digest(submitter.email.downcase.squish) email_digest = Digest::MD5.base64digest(submitter.email.downcase.squish)
RateLimit.call("verify-2fa-code-#{email_digest}", limit: 2, ttl: 45.seconds, enabled: true) RateLimit.call("verify-2fa-code-#{email_digest}", limit: 2, ttl: 45.seconds, enabled: true)
RateLimit.call("verify-2fa-code-1h-#{email_digest}", limit: 10, ttl: 1.hour, enabled: true)
RateLimit.call("verify-2fa-code-1d-#{email_digest}", limit: 20, ttl: 1.day, enabled: true)
link_2fa_key = [submitter.email.downcase.squish, submitter.submission.template.slug].join(':') link_2fa_key = [submitter.email.downcase.squish, submitter.submission.template.slug].join(':')
raise InvalidOtp, I18n.t(:invalid_code) unless EmailVerificationCodes.verify(otp, link_2fa_key) unless OtpChallenges.verify(otp.to_s.gsub(/\D/, ''), link_2fa_key, purpose: 'form_email_2fa')
raise InvalidOtp, I18n.t(:invalid_code)
end
true true
end end
+1 -1
View File
@@ -266,7 +266,7 @@ module Templates
['.png', 'image/png', {}] ['.png', 'image/png', {}]
end end
data = image.write_to_buffer(extension, **format_args) data = image.write_to_buffer(extension, strip: true, **format_args)
blob = ActiveStorage::Blob.create_and_upload!( blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(data), io: StringIO.new(data),
-1
View File
@@ -20,7 +20,6 @@
"@tiptap/extension-dropcursor": "^3.19.0", "@tiptap/extension-dropcursor": "^3.19.0",
"@tiptap/extension-gapcursor": "^3.19.0", "@tiptap/extension-gapcursor": "^3.19.0",
"@tiptap/extension-hard-break": "^3.19.0", "@tiptap/extension-hard-break": "^3.19.0",
"@tiptap/extension-history": "^3.19.0",
"@tiptap/extension-italic": "^3.19.0", "@tiptap/extension-italic": "^3.19.0",
"@tiptap/extension-link": "^3.19.0", "@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-paragraph": "^3.19.0", "@tiptap/extension-paragraph": "^3.19.0",
-5
View File
@@ -1796,11 +1796,6 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.19.0.tgz#7120524cec9ed4b957963693cb4c57cbecbaecf8" resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.19.0.tgz#7120524cec9ed4b957963693cb4c57cbecbaecf8"
integrity sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew== integrity sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==
"@tiptap/extension-history@^3.19.0":
version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-3.19.0.tgz#dc3a52f4eb16b7ac077c28284684b558c03adf00"
integrity sha512-hhN5nL7Pqcd9iomzeUUMKnmSQieKNlJ2FUgf2dHUqqvn4pWvcHA6P6UwdDNhuKFivSJNNMqtajkOvO4bYq1KPw==
"@tiptap/extension-italic@^3.19.0": "@tiptap/extension-italic@^3.19.0":
version "3.19.0" version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz#af2a9c095ec846e379041f3e17e1dd101a5a4bf8" resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz#af2a9c095ec846e379041f3e17e1dd101a5a4bf8"