mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 06:42:32 -05:00
Merge from docusealco/wip
This commit is contained in:
41 files changed
+3198
-399
No files matched your search
+1
-1
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: '本人確認を進めるには、すべての必須項目を入力してください。',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user