mirror of
https://github.com/docusealco/docuseal.git
synced 2026-06-22 20:00:30 +00:00
add dynamic documents
This commit is contained in:
@@ -36,6 +36,7 @@ COPY ./config/shakapacker.yml ./config/shakapacker.yml
|
||||
COPY ./postcss.config.js ./postcss.config.js
|
||||
COPY ./tailwind.config.js ./tailwind.config.js
|
||||
COPY ./tailwind.form.config.js ./tailwind.form.config.js
|
||||
COPY ./tailwind.dynamic.config.js ./tailwind.dynamic.config.js
|
||||
COPY ./tailwind.application.config.js ./tailwind.application.config.js
|
||||
COPY ./app/javascript ./app/javascript
|
||||
COPY ./app/views ./app/views
|
||||
|
||||
@@ -117,7 +117,7 @@ module Api
|
||||
conditions: [%i[field_uuid value action operation]],
|
||||
options: [%i[value uuid]],
|
||||
validation: %i[message pattern min max step],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
|
||||
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -6,9 +6,17 @@ class PreviewDocumentPageController < ActionController::API
|
||||
FORMAT = Templates::ProcessDocument::FORMAT
|
||||
|
||||
def show
|
||||
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
|
||||
result_data =
|
||||
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
|
||||
|
||||
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
|
||||
attachment =
|
||||
if result_data.is_a?(Array) && result_data.compact_blank.size == 2
|
||||
attachment_id, attachment_uuid = result_data
|
||||
|
||||
ActiveStorage::Attachment.find_by(id: attachment_id, uuid: attachment_uuid)
|
||||
elsif result_data
|
||||
ActiveStorage::Attachment.find_by(uuid: result_data)
|
||||
end
|
||||
|
||||
return head :not_found unless attachment
|
||||
|
||||
|
||||
@@ -172,6 +172,8 @@ class StartFormController < ApplicationController
|
||||
submitters: [submitter],
|
||||
source: :link)
|
||||
|
||||
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
|
||||
|
||||
submitter.account_id = submitter.submission.account_id
|
||||
|
||||
submitter
|
||||
|
||||
@@ -51,18 +51,7 @@ class SubmissionsController < ApplicationController
|
||||
emails: params[:emails],
|
||||
params: params.merge('send_completed_email' => true))
|
||||
else
|
||||
submissions_attrs = submissions_params[:submission].to_h.values
|
||||
|
||||
submissions_attrs, _, new_fields =
|
||||
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template, add_fields: true)
|
||||
|
||||
Submissions.create_from_submitters(template: @template,
|
||||
user: current_user,
|
||||
source: :invite,
|
||||
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
|
||||
submissions_attrs:,
|
||||
new_fields:,
|
||||
params: params.merge('send_completed_email' => true))
|
||||
create_submissions(@template, submissions_params, params)
|
||||
end
|
||||
|
||||
WebhookUrls.enqueue_events(submissions, 'submission.created')
|
||||
@@ -97,6 +86,21 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def create_submissions(template, submissions_params, params)
|
||||
submissions_attrs = submissions_params[:submission].to_h.values
|
||||
|
||||
submissions_attrs, _, new_fields =
|
||||
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, template, add_fields: true)
|
||||
|
||||
Submissions.create_from_submitters(template: template,
|
||||
user: current_user,
|
||||
source: :invite,
|
||||
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
|
||||
submissions_attrs:,
|
||||
new_fields:,
|
||||
params: params.merge('send_completed_email' => true))
|
||||
end
|
||||
|
||||
def save_template_message(template, params)
|
||||
template.preferences['request_email_subject'] = params[:subject] if params[:subject].present?
|
||||
template.preferences['request_email_body'] = params[:body] if params[:body].present?
|
||||
|
||||
@@ -16,7 +16,7 @@ class TemplateDocumentsController < ApplicationController
|
||||
|
||||
old_fields_hash = @template.fields.hash
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, params, extract_fields: true)
|
||||
documents, = Templates::CreateAttachments.call(@template, params, extract_fields: true)
|
||||
|
||||
schema = documents.map do |doc|
|
||||
{ attachment_uuid: doc.uuid, name: doc.filename.base }
|
||||
@@ -27,7 +27,7 @@ class TemplateDocumentsController < ApplicationController
|
||||
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
|
||||
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
|
||||
documents: documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
methods: %i[metadata signed_key],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class TemplatesController < ApplicationController
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
methods: %i[metadata signed_key],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
@@ -95,10 +95,11 @@ class TemplatesController < ApplicationController
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
:name,
|
||||
{ schema: [[:attachment_uuid, :google_drive_file_id, :name,
|
||||
{ schema: [[:attachment_uuid, :google_drive_file_id, :name, :dynamic,
|
||||
{ conditions: [%i[field_uuid value action operation]] }]],
|
||||
submitters: [%i[name uuid is_requester linked_to_uuid invite_via_field_uuid
|
||||
invite_by_uuid optional_invite_by_uuid email order]],
|
||||
variables_schema: {},
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type,
|
||||
:required, :readonly, :default_value,
|
||||
:title, :description, :prefillable,
|
||||
@@ -107,7 +108,7 @@ class TemplatesController < ApplicationController
|
||||
conditions: [%i[field_uuid value action operation]],
|
||||
options: [%i[value uuid]],
|
||||
validation: %i[message pattern min max step],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
|
||||
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]] }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,21 +9,23 @@ class TemplatesDebugController < ApplicationController
|
||||
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
|
||||
attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
|
||||
|
||||
data = attachment.download
|
||||
if attachment
|
||||
data = attachment.download
|
||||
|
||||
unless attachment.image?
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
unless attachment.image?
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
fields = Templates::FindAcroFields.call(pdf, attachment, data)
|
||||
fields = Templates::FindAcroFields.call(pdf, attachment, data)
|
||||
end
|
||||
|
||||
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
|
||||
|
||||
attachment.metadata['pdf'] ||= {}
|
||||
attachment.metadata['pdf']['fields'] = fields
|
||||
|
||||
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
|
||||
end
|
||||
|
||||
fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
|
||||
|
||||
attachment.metadata['pdf'] ||= {}
|
||||
attachment.metadata['pdf']['fields'] = fields
|
||||
|
||||
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
|
||||
|
||||
debug_file if DEBUG_FILE.present?
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
@@ -34,7 +36,7 @@ class TemplatesDebugController < ApplicationController
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
methods: %i[metadata signed_key],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
@@ -58,9 +60,16 @@ class TemplatesDebugController < ApplicationController
|
||||
|
||||
params = { files: [file] }
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, params)
|
||||
documents, dynamic_documents = Templates::CreateAttachments.call(@template, params,
|
||||
dynamic: DEBUG_FILE.ends_with?('.docx'))
|
||||
|
||||
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
|
||||
schema = documents.map do |doc|
|
||||
{
|
||||
attachment_uuid: doc.uuid,
|
||||
name: doc.filename.base,
|
||||
dynamic: dynamic_documents.find { |e| e.uuid == doc.uuid }.present?
|
||||
}
|
||||
end
|
||||
|
||||
@template.update!(schema:)
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ class TemplatesPreviewController < ApplicationController
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
methods: %i[metadata signed_key],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
|
||||
@@ -12,7 +12,7 @@ class TemplatesUploadsController < ApplicationController
|
||||
|
||||
save_template!(@template, url_params)
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
|
||||
documents, = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
|
||||
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
|
||||
|
||||
if @template.fields.blank?
|
||||
|
||||
@@ -160,6 +160,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
this.app = createApp(TemplateBuilder, {
|
||||
template,
|
||||
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
|
||||
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
|
||||
backgroundColor: '#faf7f5',
|
||||
locale: this.dataset.locale,
|
||||
withPhone: this.dataset.withPhone === 'true',
|
||||
@@ -177,6 +178,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withSendButton: this.dataset.withSendButton !== 'false',
|
||||
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
|
||||
withConditions: this.dataset.withConditions === 'true',
|
||||
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
|
||||
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
|
||||
withReplaceAndCloneUpload: true,
|
||||
withDownload: true,
|
||||
|
||||
@@ -31,144 +31,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
|
||||
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
|
||||
style="top: -25px; height: 25px"
|
||||
@mousedown.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<FieldSubmitter
|
||||
v-if="field.type != 'heading' && field.type != 'strikethrough'"
|
||||
v-model="field.submitter_uuid"
|
||||
class="border-r roles-dropdown"
|
||||
:compact="true"
|
||||
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
|
||||
:allow-add-new="!defaultSubmitters.length"
|
||||
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
|
||||
:submitters="template.submitters"
|
||||
@update:model-value="save"
|
||||
@click="selectedAreasRef.value = [area]"
|
||||
/>
|
||||
<FieldType
|
||||
v-model="field.type"
|
||||
:button-width="27"
|
||||
:editable="editable && !defaultField"
|
||||
:button-classes="'px-1'"
|
||||
:menu-classes="'bg-white rounded-t-none'"
|
||||
@update:model-value="[maybeUpdateOptions(), save()]"
|
||||
@click="selectedAreasRef.value = [area]"
|
||||
/>
|
||||
<span
|
||||
v-if="field.type !== 'checkbox' || field.name"
|
||||
ref="name"
|
||||
:contenteditable="editable && !defaultField && field.type !== 'heading'"
|
||||
dir="auto"
|
||||
class="pr-1 cursor-text outline-none block"
|
||||
style="min-width: 2px"
|
||||
@paste.prevent="onPaste"
|
||||
@keydown.enter.prevent="onNameEnter"
|
||||
@focus="onNameFocus"
|
||||
@blur="onNameBlur"
|
||||
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
|
||||
<div
|
||||
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
|
||||
class="flex items-center ml-1.5"
|
||||
>
|
||||
<input
|
||||
v-if="!isValueInput && !isSelectInput"
|
||||
:id="`required-checkbox-${field.uuid}`"
|
||||
v-model="field.required"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs no-animation rounded"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<label
|
||||
v-if="!isValueInput && !isSelectInput"
|
||||
:for="`required-checkbox-${field.uuid}`"
|
||||
class="label text-xs"
|
||||
@click.prevent="field.required = !field.required"
|
||||
@mousedown.prevent
|
||||
>{{ t('required') }}</label>
|
||||
<input
|
||||
v-if="isValueInput || isSelectInput"
|
||||
:id="`readonly-checkbox-${field.uuid}`"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs no-animation rounded"
|
||||
:checked="!(field.readonly ?? true)"
|
||||
@change="field.readonly = !(field.readonly ?? true)"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<label
|
||||
v-if="isValueInput || isSelectInput"
|
||||
:for="`readonly-checkbox-${field.uuid}`"
|
||||
class="label text-xs"
|
||||
@click.prevent="field.readonly = !(field.readonly ?? true)"
|
||||
@mousedown.prevent
|
||||
>{{ t('editable') }}</label>
|
||||
<span
|
||||
v-if="field.type !== 'payment' && !isValueInput"
|
||||
class="dropdown dropdown-end field-area-settings-dropdown"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<label
|
||||
ref="settingsButton"
|
||||
tabindex="0"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer flex items-center"
|
||||
style="height: 25px"
|
||||
@focus="isSettingsFocus = true"
|
||||
@blur="maybeBlurSettings"
|
||||
>
|
||||
<IconDotsVertical class="w-5 h-5" />
|
||||
</label>
|
||||
<ul
|
||||
v-if="renderDropdown"
|
||||
ref="settingsDropdown"
|
||||
tabindex="0"
|
||||
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
|
||||
:style="{ backgroundColor: 'white' }"
|
||||
@dragstart.prevent.stop
|
||||
@click="closeDropdown"
|
||||
@focusout="maybeBlurSettings"
|
||||
>
|
||||
<FieldSettings
|
||||
v-if="isMobile"
|
||||
:field="field"
|
||||
:default-field="defaultField"
|
||||
:editable="editable"
|
||||
:background-color="'white'"
|
||||
:with-required="false"
|
||||
:with-areas="false"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-font="isShowFontModal = true"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@add-custom-field="$emit('add-custom-field')"
|
||||
@click-condition="isShowConditionsModal = true"
|
||||
@save="save"
|
||||
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="whitespace-normal"
|
||||
>
|
||||
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
|
||||
</div>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="editable"
|
||||
class="pr-1"
|
||||
:title="t('remove')"
|
||||
@click.prevent="$emit('remove')"
|
||||
>
|
||||
<IconX width="14" />
|
||||
</button>
|
||||
</div>
|
||||
<AreaTitle
|
||||
ref="title"
|
||||
:area="area"
|
||||
:field="field"
|
||||
:template="template"
|
||||
:selected-areas-ref="selectedAreasRef"
|
||||
:get-field-type-index="getFieldTypeIndex"
|
||||
:default-field="defaultField"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:editable="editable"
|
||||
:is-mobile="isMobile"
|
||||
:is-value-input="isValueInput"
|
||||
:is-select-input="isSelectInput"
|
||||
@change="save"
|
||||
@remove="$emit('remove')"
|
||||
@scroll-to="$emit('scroll-to', $event)"
|
||||
@add-custom-field="$emit('add-custom-field')"
|
||||
/>
|
||||
<div
|
||||
ref="touchValueTarget"
|
||||
class="flex h-full w-full field-area"
|
||||
@@ -333,85 +215,20 @@
|
||||
@mousedown.stop="startResize"
|
||||
@touchstart="startTouchResize"
|
||||
/>
|
||||
<Teleport
|
||||
v-if="isShowFormulaModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FormulaModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="save"
|
||||
@close="isShowFormulaModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowFontModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FontModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="save"
|
||||
@close="isShowFontModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowConditionsModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<ConditionsModal
|
||||
:item="field"
|
||||
:build-default-name="buildDefaultName"
|
||||
:default-field="defaultField"
|
||||
@save="save"
|
||||
@close="isShowConditionsModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowDescriptionModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<DescriptionModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="save"
|
||||
@close="isShowDescriptionModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import FieldType from './field_type'
|
||||
import Field from './field'
|
||||
import FieldSettings from './field_settings'
|
||||
import FormulaModal from './formula_modal'
|
||||
import FontModal from './font_modal'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import DescriptionModal from './description_modal'
|
||||
import { IconX, IconCheck, IconDotsVertical } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
import AreaTitle from './area_title'
|
||||
import { IconCheck } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
components: {
|
||||
FieldType,
|
||||
IconCheck,
|
||||
FieldSettings,
|
||||
FormulaModal,
|
||||
FontModal,
|
||||
IconDotsVertical,
|
||||
DescriptionModal,
|
||||
ConditionsModal,
|
||||
FieldSubmitter,
|
||||
IconX
|
||||
AreaTitle
|
||||
},
|
||||
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef', 'getFieldTypeIndex'],
|
||||
props: {
|
||||
@@ -493,17 +310,10 @@ export default {
|
||||
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
|
||||
data () {
|
||||
return {
|
||||
isShowFormulaModal: false,
|
||||
isShowFontModal: false,
|
||||
isShowConditionsModal: false,
|
||||
isContenteditable: false,
|
||||
isSettingsFocus: false,
|
||||
isShowDescriptionModal: false,
|
||||
isResize: false,
|
||||
isDragged: false,
|
||||
isMoved: false,
|
||||
renderDropdown: false,
|
||||
isNameFocus: false,
|
||||
isHeadingSelected: false,
|
||||
textOverflowChars: 0,
|
||||
dragFrom: { x: 0, y: 0 }
|
||||
@@ -592,9 +402,6 @@ export default {
|
||||
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable ||
|
||||
(this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}')))
|
||||
},
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
},
|
||||
defaultName () {
|
||||
return this.buildDefaultName(this.field)
|
||||
},
|
||||
@@ -616,13 +423,6 @@ export default {
|
||||
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
|
||||
}
|
||||
},
|
||||
optionIndexText () {
|
||||
if (this.area.option_uuid && this.field.options) {
|
||||
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
cells () {
|
||||
const cells = []
|
||||
|
||||
@@ -705,9 +505,6 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName: Field.methods.buildDefaultName,
|
||||
closeDropdown () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
},
|
||||
buildAreaOptionValue (area) {
|
||||
const option = this.optionsUuidIndex[area.option_uuid]
|
||||
|
||||
@@ -792,23 +589,6 @@ export default {
|
||||
return number
|
||||
}
|
||||
},
|
||||
maybeBlurSettings (e) {
|
||||
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
|
||||
this.isSettingsFocus = false
|
||||
}
|
||||
},
|
||||
onNameFocus (e) {
|
||||
this.selectedAreasRef.value = [this.area]
|
||||
|
||||
this.isNameFocus = true
|
||||
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
|
||||
|
||||
if (!this.field.name) {
|
||||
setTimeout(() => {
|
||||
this.$refs.name.innerText = ' '
|
||||
}, 1)
|
||||
}
|
||||
},
|
||||
startResizeCell (e) {
|
||||
this.$el.getRootNode().addEventListener('mousemove', this.onResizeCell)
|
||||
this.$el.getRootNode().addEventListener('mouseup', this.stopResizeCell)
|
||||
@@ -843,53 +623,6 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
maybeUpdateOptions () {
|
||||
delete this.field.default_value
|
||||
|
||||
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
|
||||
delete this.field.options
|
||||
}
|
||||
|
||||
if (this.field.type === 'heading') {
|
||||
this.field.readonly = true
|
||||
}
|
||||
|
||||
if (this.field.type === 'strikethrough') {
|
||||
this.field.readonly = true
|
||||
this.field.default_value = true
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
|
||||
this.field.options ||= [{ value: '', uuid: v4() }]
|
||||
}
|
||||
|
||||
(this.field.areas || []).forEach((area) => {
|
||||
if (this.field.type === 'cells') {
|
||||
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
|
||||
} else {
|
||||
delete area.cell_w
|
||||
}
|
||||
})
|
||||
},
|
||||
onNameBlur (e) {
|
||||
if (e.relatedTarget === this.$refs.settingsButton) {
|
||||
this.isSettingsFocus = true
|
||||
}
|
||||
|
||||
const text = this.$refs.name.innerText.trim()
|
||||
|
||||
this.isNameFocus = false
|
||||
this.$refs.name.style.minWidth = ''
|
||||
|
||||
if (text) {
|
||||
this.field.name = text
|
||||
} else {
|
||||
this.field.name = ''
|
||||
this.$refs.name.innerText = this.defaultName
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
onDefaultValueBlur (e) {
|
||||
const text = this.$refs.defaultValue.innerText.trim()
|
||||
|
||||
@@ -927,9 +660,6 @@ export default {
|
||||
this.$refs.defaultValue.blur()
|
||||
}
|
||||
},
|
||||
onNameEnter (e) {
|
||||
this.$refs.name.blur()
|
||||
},
|
||||
resize (e) {
|
||||
if (e.target.id === 'mask') {
|
||||
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
|
||||
@@ -1124,7 +854,7 @@ export default {
|
||||
this.selectedAreasRef.value = [this.area]
|
||||
}
|
||||
|
||||
this.$refs?.name?.blur()
|
||||
this.$refs?.title?.$refs?.name?.blur()
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
|
||||
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
|
||||
style="top: -25px; height: 25px"
|
||||
@mousedown.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<FieldSubmitter
|
||||
v-if="field.type != 'heading' && field.type != 'strikethrough'"
|
||||
v-model="field.submitter_uuid"
|
||||
class="border-r roles-dropdown"
|
||||
:compact="true"
|
||||
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
|
||||
:allow-add-new="!defaultSubmitters.length"
|
||||
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
|
||||
:submitters="template.submitters"
|
||||
@update:model-value="$emit('change')"
|
||||
@click="selectedAreasRef.value = [area]"
|
||||
/>
|
||||
<FieldType
|
||||
v-model="field.type"
|
||||
:button-width="27"
|
||||
:editable="editable && !defaultField"
|
||||
:button-classes="'px-1'"
|
||||
:menu-classes="'bg-white rounded-t-none'"
|
||||
@update:model-value="[maybeUpdateOptions(), $emit('change')]"
|
||||
@click="selectedAreasRef.value = [area]"
|
||||
/>
|
||||
<span
|
||||
v-if="field.type !== 'checkbox' || field.name"
|
||||
ref="name"
|
||||
:contenteditable="editable && !defaultField && field.type !== 'heading'"
|
||||
dir="auto"
|
||||
class="pr-1 cursor-text outline-none block"
|
||||
style="min-width: 2px"
|
||||
@paste.prevent="onPaste"
|
||||
@keydown.enter.prevent="onNameEnter"
|
||||
@focus="onNameFocus"
|
||||
@blur="onNameBlur"
|
||||
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
|
||||
<div
|
||||
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
|
||||
class="flex items-center ml-1.5"
|
||||
>
|
||||
<input
|
||||
v-if="!isValueInput && !isSelectInput"
|
||||
:id="`required-checkbox-${field.uuid}`"
|
||||
v-model="field.required"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs no-animation rounded"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<label
|
||||
v-if="!isValueInput && !isSelectInput"
|
||||
:for="`required-checkbox-${field.uuid}`"
|
||||
class="label text-xs"
|
||||
@click.prevent="field.required = !field.required"
|
||||
@mousedown.prevent
|
||||
>{{ t('required') }}</label>
|
||||
<input
|
||||
v-if="isValueInput || isSelectInput"
|
||||
:id="`readonly-checkbox-${field.uuid}`"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs no-animation rounded"
|
||||
:checked="!(field.readonly ?? true)"
|
||||
@change="field.readonly = !(field.readonly ?? true)"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<label
|
||||
v-if="isValueInput || isSelectInput"
|
||||
:for="`readonly-checkbox-${field.uuid}`"
|
||||
class="label text-xs"
|
||||
@click.prevent="field.readonly = !(field.readonly ?? true)"
|
||||
@mousedown.prevent
|
||||
>{{ t('editable') }}</label>
|
||||
<span
|
||||
v-if="field.type !== 'payment' && !isValueInput"
|
||||
class="dropdown dropdown-end field-area-settings-dropdown"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<label
|
||||
ref="settingsButton"
|
||||
tabindex="0"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer flex items-center"
|
||||
style="height: 25px"
|
||||
@focus="isSettingsFocus = true"
|
||||
@blur="maybeBlurSettings"
|
||||
>
|
||||
<IconDotsVertical class="w-5 h-5" />
|
||||
</label>
|
||||
<ul
|
||||
v-if="renderDropdown"
|
||||
ref="settingsDropdown"
|
||||
tabindex="0"
|
||||
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
|
||||
:style="{ backgroundColor: 'white' }"
|
||||
@dragstart.prevent.stop
|
||||
@click="closeDropdown"
|
||||
@focusout="maybeBlurSettings"
|
||||
>
|
||||
<FieldSettings
|
||||
v-if="isMobile"
|
||||
:field="field"
|
||||
:default-field="defaultField"
|
||||
:editable="editable"
|
||||
:background-color="'white'"
|
||||
:with-required="false"
|
||||
:with-areas="false"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-font="isShowFontModal = true"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@add-custom-field="$emit('add-custom-field')"
|
||||
@click-condition="isShowConditionsModal = true"
|
||||
@save="$emit('change')"
|
||||
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="whitespace-normal"
|
||||
>
|
||||
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
|
||||
</div>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="editable"
|
||||
class="pr-1"
|
||||
:title="t('remove')"
|
||||
@click.prevent="$emit('remove')"
|
||||
>
|
||||
<IconX width="14" />
|
||||
</button>
|
||||
<Teleport
|
||||
v-if="isShowFormulaModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FormulaModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="$emit('change')"
|
||||
@close="isShowFormulaModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowFontModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FontModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="$emit('change')"
|
||||
@close="isShowFontModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowConditionsModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<ConditionsModal
|
||||
:item="field"
|
||||
:build-default-name="buildDefaultName"
|
||||
:default-field="defaultField"
|
||||
@save="$emit('change')"
|
||||
@close="isShowConditionsModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-if="isShowDescriptionModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<DescriptionModal
|
||||
:field="field"
|
||||
:editable="editable && !defaultField"
|
||||
:default-field="defaultField"
|
||||
:build-default-name="buildDefaultName"
|
||||
@save="$emit('change')"
|
||||
@close="isShowDescriptionModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import FieldType from './field_type'
|
||||
import Field from './field'
|
||||
import FieldSettings from './field_settings'
|
||||
import FormulaModal from './formula_modal'
|
||||
import FontModal from './font_modal'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import DescriptionModal from './description_modal'
|
||||
import { IconX, IconDotsVertical } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'AreaTitle',
|
||||
components: {
|
||||
FieldType,
|
||||
FieldSettings,
|
||||
FormulaModal,
|
||||
FontModal,
|
||||
IconDotsVertical,
|
||||
DescriptionModal,
|
||||
ConditionsModal,
|
||||
FieldSubmitter,
|
||||
IconX
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
selectedAreasRef: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
getFieldTypeIndex: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
area: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
field: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
defaultField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withPrefillable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isValueInput: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isSelectInput: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['remove', 'scroll-to', 'add-custom-field', 'change'],
|
||||
data () {
|
||||
return {
|
||||
isShowFormulaModal: false,
|
||||
isShowFontModal: false,
|
||||
isShowConditionsModal: false,
|
||||
isShowDescriptionModal: false,
|
||||
isSettingsFocus: false,
|
||||
renderDropdown: false,
|
||||
isNameFocus: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
submitter () {
|
||||
return this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
|
||||
},
|
||||
isSelected () {
|
||||
return this.selectedAreasRef.value.includes(this.area)
|
||||
},
|
||||
isInMultiSelection () {
|
||||
return this.selectedAreasRef.value.length >= 2 && this.isSelected
|
||||
},
|
||||
optionIndexText () {
|
||||
if (this.area.option_uuid && this.field.options) {
|
||||
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
defaultName () {
|
||||
return this.buildDefaultName(this.field)
|
||||
},
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName: Field.methods.buildDefaultName,
|
||||
closeDropdown () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
},
|
||||
maybeBlurSettings (e) {
|
||||
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
|
||||
this.isSettingsFocus = false
|
||||
}
|
||||
},
|
||||
onNameFocus (e) {
|
||||
this.selectedAreasRef.value = [this.area]
|
||||
|
||||
this.isNameFocus = true
|
||||
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
|
||||
|
||||
if (!this.field.name) {
|
||||
setTimeout(() => {
|
||||
this.$refs.name.innerText = ' '
|
||||
}, 1)
|
||||
}
|
||||
},
|
||||
onNameBlur (e) {
|
||||
if (e.relatedTarget === this.$refs.settingsButton) {
|
||||
this.isSettingsFocus = true
|
||||
}
|
||||
|
||||
const text = this.$refs.name.innerText.trim()
|
||||
|
||||
this.isNameFocus = false
|
||||
this.$refs.name.style.minWidth = ''
|
||||
|
||||
if (text) {
|
||||
this.field.name = text
|
||||
} else {
|
||||
this.field.name = ''
|
||||
this.$refs.name.innerText = this.defaultName
|
||||
}
|
||||
|
||||
this.$emit('change')
|
||||
},
|
||||
onNameEnter (e) {
|
||||
this.$refs.name.blur()
|
||||
},
|
||||
onPaste (e) {
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
|
||||
const selection = this.$el.getRootNode().getSelection()
|
||||
|
||||
if (selection.rangeCount) {
|
||||
selection.deleteFromDocument()
|
||||
selection.getRangeAt(0).insertNode(document.createTextNode(text))
|
||||
selection.collapseToEnd()
|
||||
}
|
||||
},
|
||||
maybeUpdateOptions () {
|
||||
delete this.field.default_value
|
||||
|
||||
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
|
||||
delete this.field.options
|
||||
}
|
||||
|
||||
if (this.field.type === 'heading') {
|
||||
this.field.readonly = true
|
||||
}
|
||||
|
||||
if (this.field.type === 'strikethrough') {
|
||||
this.field.readonly = true
|
||||
this.field.default_value = true
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
|
||||
this.field.options ||= [{ value: '', uuid: v4() }]
|
||||
}
|
||||
|
||||
(this.field.areas || []).forEach((area) => {
|
||||
if (this.field.type === 'cells') {
|
||||
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
|
||||
} else {
|
||||
delete area.cell_w
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -81,7 +81,7 @@
|
||||
/>
|
||||
<template v-else>
|
||||
<form
|
||||
v-if="withSignYourselfButton && undefinedSubmitters.length < 2"
|
||||
v-if="withSignYourselfButton && undefinedSubmitters.length < 2 && (!template.variables_schema || Object.keys(template.variables_schema).length === 0)"
|
||||
target="_blank"
|
||||
data-turbo="false"
|
||||
class="inline"
|
||||
@@ -274,6 +274,8 @@
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:with-replace-button="withUploadButton"
|
||||
:editable="editable"
|
||||
:dynamic-documents="dynamicDocuments"
|
||||
:with-dynamic-documents="withDynamicDocuments"
|
||||
:template="template"
|
||||
@scroll-to="scrollIntoDocument(item)"
|
||||
@remove="onDocumentRemove"
|
||||
@@ -352,10 +354,20 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<template
|
||||
v-for="document in sortedDocuments"
|
||||
v-for="(document, index) in sortedDocuments"
|
||||
:key="document.uuid"
|
||||
>
|
||||
<DynamicDocument
|
||||
v-if="template.schema[index].dynamic"
|
||||
:ref="setDocumentRefs"
|
||||
:editable="editable"
|
||||
:document="dynamicDocuments.find((dynamicDocument) => dynamicDocument.uuid === document.uuid)"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:drag-field="dragField"
|
||||
@update="onDynamicDocumentUpdate"
|
||||
/>
|
||||
<Document
|
||||
v-else
|
||||
:ref="setDocumentRefs"
|
||||
:areas-index="fieldAreasIndex[document.uuid]"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
@@ -505,6 +517,7 @@
|
||||
@change-submitter="selectedSubmitter = $event"
|
||||
@drag-end="[dragField = null, $refs.dragPlaceholder.dragPlaceholder = null]"
|
||||
@scroll-to-area="scrollToArea"
|
||||
@rebuild-variables-schema="rebuildVariablesSchema"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -592,12 +605,13 @@ import MobileFields from './mobile_fields'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
import { ref, computed, toRaw } from 'vue'
|
||||
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
|
||||
import * as i18n from './i18n'
|
||||
|
||||
export default {
|
||||
name: 'TemplateBuilder',
|
||||
components: {
|
||||
DynamicDocument: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_document')),
|
||||
Upload,
|
||||
DragPlaceholder,
|
||||
Document,
|
||||
@@ -725,6 +739,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
dynamicDocuments: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
customFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -846,6 +865,11 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
withDynamicDocuments: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withDocumentsList: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -2520,7 +2544,7 @@ export default {
|
||||
onDocumentReplace (data) {
|
||||
const { replaceSchemaItem, schema, documents } = data
|
||||
// eslint-disable-next-line camelcase
|
||||
const { google_drive_file_id, ...cleanedReplaceSchemaItem } = replaceSchemaItem
|
||||
const { google_drive_file_id, dynamic, ...cleanedReplaceSchemaItem } = replaceSchemaItem
|
||||
|
||||
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
|
||||
this.template.documents.push(...documents)
|
||||
@@ -2654,7 +2678,12 @@ export default {
|
||||
} else {
|
||||
this.isSaving = true
|
||||
|
||||
this.save().then(() => {
|
||||
this.documentRefs.filter((ref) => ref.update).map((ref) => ref.update())
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
|
||||
const dynamicDocumentSaves = this.documentRefs.filter((ref) => ref.saveBody).map((ref) => ref.saveBody())
|
||||
|
||||
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
|
||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
@@ -2893,7 +2922,8 @@ export default {
|
||||
name: this.template.name,
|
||||
schema: this.template.schema,
|
||||
submitters: this.template.submitters,
|
||||
fields: this.template.fields
|
||||
fields: this.template.fields,
|
||||
variables_schema: this.template.variables_schema
|
||||
}
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
@@ -2902,6 +2932,104 @@ export default {
|
||||
this.onSave(this.template)
|
||||
}
|
||||
})
|
||||
},
|
||||
onDynamicDocumentUpdate () {
|
||||
this.rebuildVariablesSchema()
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$el.closest('template-builder')) {
|
||||
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(this.dynamicDocuments)
|
||||
}
|
||||
})
|
||||
|
||||
this.reconcileDynamicFields()
|
||||
},
|
||||
rebuildVariablesSchema ({ disable = true } = {}) {
|
||||
const parsed = {}
|
||||
|
||||
const dynamicDocumentRef = this.documentRefs.find((e) => e.mergeSchemaProperties)
|
||||
|
||||
this.documentRefs.forEach((ref) => {
|
||||
if (ref.updateVariablesSchema) {
|
||||
ref.updateVariablesSchema()
|
||||
}
|
||||
})
|
||||
|
||||
this.dynamicDocuments.forEach((doc) => {
|
||||
if (doc.variables_schema) {
|
||||
dynamicDocumentRef.mergeSchemaProperties(parsed, doc.variables_schema)
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.template.variables_schema) {
|
||||
this.template.variables_schema = parsed
|
||||
} else {
|
||||
this.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
|
||||
}
|
||||
},
|
||||
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (!existing[key]) {
|
||||
existing[key] = parsed[key]
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(existing)) {
|
||||
if (!parsed[key]) {
|
||||
if (disable) {
|
||||
existing[key].disabled = true
|
||||
} else {
|
||||
delete existing[key]
|
||||
}
|
||||
} else {
|
||||
delete existing[key].disabled
|
||||
|
||||
if (!existing[key].form_type) {
|
||||
existing[key].type = parsed[key].type
|
||||
}
|
||||
|
||||
if (parsed[key].items) {
|
||||
if (!existing[key].items) {
|
||||
existing[key].items = parsed[key].items
|
||||
} else if (existing[key].items.properties && parsed[key].items.properties) {
|
||||
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
|
||||
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
|
||||
existing[key].items.type = parsed[key].items.type
|
||||
}
|
||||
}
|
||||
|
||||
if (existing[key].properties && parsed[key].properties) {
|
||||
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
reconcileDynamicFields () {
|
||||
const dynamicFieldUuids = new Set()
|
||||
|
||||
this.dynamicDocuments.forEach((doc) => {
|
||||
const body = doc.body || ''
|
||||
const uuidRegex = /uuid="([^"]+)"/g
|
||||
let match
|
||||
|
||||
while ((match = uuidRegex.exec(body)) !== null) {
|
||||
dynamicFieldUuids.add(match[1])
|
||||
}
|
||||
})
|
||||
|
||||
const toRemove = this.template.fields.filter((field) => {
|
||||
if (field.areas && field.areas.length > 0) return false
|
||||
|
||||
return field.uuid && !dynamicFieldUuids.has(field.uuid)
|
||||
})
|
||||
|
||||
toRemove.forEach((field) => {
|
||||
this.template.fields.splice(this.template.fields.indexOf(field), 1)
|
||||
})
|
||||
|
||||
if (toRemove.length) {
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export default {
|
||||
return this.previewImagesIndex[i] || reactive({
|
||||
metadata: { ...lazyloadMetadata },
|
||||
id: Math.random().toString(),
|
||||
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
|
||||
url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}.jpg`
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<span
|
||||
class="items-center select-none cursor-pointer relative overflow-visible text-base-content/80 font-sans"
|
||||
:class="[bgColorClass, iconOnlyField ? 'justify-center' : '']"
|
||||
:draggable="editable"
|
||||
:style="[nodeStyle]"
|
||||
@mousedown="selectArea"
|
||||
@click.stop
|
||||
@dragstart="onDragStart"
|
||||
@contextmenu.prevent.stop="onContextMenu"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-0 pointer-events-none border-solid"
|
||||
:class="borderColorClass"
|
||||
:style="{ borderWidth: (isSelected ? 1 : 0) + 'px' }"
|
||||
/>
|
||||
<component
|
||||
:is="fieldIcons[field?.type || 'text']"
|
||||
v-if="field && !field.default_value"
|
||||
width="100%"
|
||||
height="100%"
|
||||
:stroke-width="1.5"
|
||||
:class="iconOnlyField ? 'shrink min-h-0 max-h-full max-w-6 opacity-70 m-auto p-0.5' : 'shrink min-h-0 max-h-full max-w-4 opacity-70 mx-0.5 pl-0.5'"
|
||||
/>
|
||||
<span
|
||||
v-if="field?.default_value"
|
||||
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 font-normal pl-0.5"
|
||||
>{{ field.default_value }}</span>
|
||||
<span
|
||||
v-else-if="field && !iconOnlyField"
|
||||
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5"
|
||||
>{{ displayLabel }}</span>
|
||||
<span
|
||||
class="absolute rounded-full bg-white border border-gray-400 shadow-md cursor-nwse-resize z-10"
|
||||
:style="{ width: resizeHandleSize + 'px', height: resizeHandleSize + 'px', right: (-4 / zoom) + 'px', bottom: (-4 / zoom) + 'px' }"
|
||||
@pointerdown.prevent.stop="onResizeStart"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldArea from './area'
|
||||
import FieldType from './field_type'
|
||||
|
||||
export default {
|
||||
name: 'DynamicArea',
|
||||
props: {
|
||||
fieldUuid: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
areaUuid: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
template: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
nodeStyle: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
selectedAreasRef: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
getPos: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
editor: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
getZoom: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
onAreaContextMenu: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
onAreaResize: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
onAreaDragStart: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
t: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
findFieldArea: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getFieldTypeIndex: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isResizing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldArea () {
|
||||
return this.findFieldArea(this.areaUuid)
|
||||
},
|
||||
area () {
|
||||
return this.fieldArea?.area
|
||||
},
|
||||
field () {
|
||||
return this.fieldArea?.field
|
||||
},
|
||||
fieldIcons: FieldArea.computed.fieldIcons,
|
||||
fieldNames: FieldArea.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
borderColors () {
|
||||
return [
|
||||
'border-red-500/80',
|
||||
'border-sky-500/80',
|
||||
'border-emerald-500/80',
|
||||
'border-yellow-300/80',
|
||||
'border-purple-600/80',
|
||||
'border-pink-500/80',
|
||||
'border-cyan-500/80',
|
||||
'border-orange-500/80',
|
||||
'border-lime-500/80',
|
||||
'border-indigo-500/80'
|
||||
]
|
||||
},
|
||||
bgColors () {
|
||||
return [
|
||||
'bg-red-100',
|
||||
'bg-sky-100',
|
||||
'bg-emerald-100',
|
||||
'bg-yellow-100',
|
||||
'bg-purple-100',
|
||||
'bg-pink-100',
|
||||
'bg-cyan-100',
|
||||
'bg-orange-100',
|
||||
'bg-lime-100',
|
||||
'bg-indigo-100'
|
||||
]
|
||||
},
|
||||
isSelected () {
|
||||
return this.selectedAreasRef.value.some((a) => a === this.area)
|
||||
},
|
||||
zoom () {
|
||||
return this.getZoom()
|
||||
},
|
||||
submitterIndex () {
|
||||
if (!this.field) return 0
|
||||
|
||||
const submitter = this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
|
||||
|
||||
return submitter ? this.template.submitters.indexOf(submitter) : 0
|
||||
},
|
||||
borderColorClass () {
|
||||
return this.borderColors[this.submitterIndex % this.borderColors.length]
|
||||
},
|
||||
bgColorClass () {
|
||||
return this.bgColors[this.submitterIndex % this.bgColors.length]
|
||||
},
|
||||
resizeHandleSize () {
|
||||
return this.zoom > 0 ? Math.round(10 / this.zoom) : 10
|
||||
},
|
||||
iconOnlyField () {
|
||||
return ['radio', 'multiple', 'checkbox', 'initials'].includes(this.field?.type)
|
||||
},
|
||||
defaultName () {
|
||||
if (!this.field) return 'text'
|
||||
|
||||
const typeIndex = this.getFieldTypeIndex(this.field)
|
||||
|
||||
return `${this.fieldLabels[this.field.type] || this.fieldNames[this.field.type] || this.field.type} ${typeIndex + 1}`
|
||||
},
|
||||
displayLabel () {
|
||||
return this.field?.name || this.defaultName
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectArea () {
|
||||
this.editor.commands.setNodeSelection(this.getPos())
|
||||
},
|
||||
onDragStart (e) {
|
||||
if (this.isResizing) {
|
||||
e.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const pos = this.getPos()
|
||||
|
||||
if (pos == null) {
|
||||
e.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const root = this.$el
|
||||
const rect = root.getBoundingClientRect()
|
||||
const zoom = this.zoom || 1
|
||||
const clone = root.cloneNode(true)
|
||||
|
||||
clone.querySelector('[class*="cursor-nwse-resize"]')?.remove()
|
||||
clone.style.cssText = `position:fixed;top:-1000px;width:${rect.width / zoom}px;height:${rect.height / zoom}px;display:${root.style.display};vertical-align:${root.style.verticalAlign};zoom:${zoom}`
|
||||
|
||||
document.body.appendChild(clone)
|
||||
|
||||
e.dataTransfer.setDragImage(clone, e.offsetX, e.offsetY)
|
||||
|
||||
requestAnimationFrame(() => clone.remove())
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
|
||||
this.onAreaDragStart()
|
||||
},
|
||||
onContextMenu (e) {
|
||||
this.onAreaContextMenu(this.area, e)
|
||||
},
|
||||
onResizeStart (e) {
|
||||
if (!this.editable) return
|
||||
|
||||
this.isResizing = true
|
||||
|
||||
this.selectArea()
|
||||
|
||||
const handle = e.target
|
||||
|
||||
handle.setPointerCapture(e.pointerId)
|
||||
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const startWidth = this.$el.offsetWidth
|
||||
const startHeight = this.$el.offsetHeight
|
||||
|
||||
const onResizeMove = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
this.nodeStyle.width = startWidth + (e.clientX - startX) / this.zoom + 'px'
|
||||
this.nodeStyle.height = startHeight + (e.clientY - startY) / this.zoom + 'px'
|
||||
|
||||
this.onAreaResize(this.$el.getBoundingClientRect())
|
||||
}
|
||||
|
||||
const onResizeEnd = () => {
|
||||
if (!this.isResizing) return
|
||||
|
||||
this.isResizing = false
|
||||
|
||||
handle.removeEventListener('pointermove', onResizeMove)
|
||||
handle.removeEventListener('pointerup', onResizeEnd)
|
||||
|
||||
const pos = this.getPos()
|
||||
|
||||
const tr = this.editor.view.state.tr.setNodeMarkup(pos, undefined, {
|
||||
...this.editor.view.state.doc.nodeAt(pos)?.attrs,
|
||||
width: this.nodeStyle.width,
|
||||
height: this.nodeStyle.height
|
||||
})
|
||||
|
||||
this.editor.view.dispatch(tr)
|
||||
this.editor.commands.setNodeSelection(pos)
|
||||
}
|
||||
|
||||
handle.addEventListener('pointermove', onResizeMove)
|
||||
handle.addEventListener('pointerup', onResizeEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="relative"
|
||||
style="container-type: inline-size;"
|
||||
>
|
||||
<div ref="shadow" />
|
||||
<template
|
||||
v-for="style in styles"
|
||||
:key="style.innerText"
|
||||
>
|
||||
<Teleport
|
||||
v-if="shadow"
|
||||
:to="style.innerText.includes('@font-face {') ? 'head' : shadow"
|
||||
>
|
||||
<component :is="'style'">
|
||||
{{ style.innerText }}
|
||||
</component>
|
||||
</Teleport>
|
||||
</template>
|
||||
<Teleport
|
||||
v-if="shadow"
|
||||
:to="shadow"
|
||||
>
|
||||
<DynamicSection
|
||||
v-for="section in sections"
|
||||
:ref="setSectionRefs"
|
||||
:key="section.id"
|
||||
:container="$refs.container"
|
||||
:editable="editable"
|
||||
:section="section"
|
||||
:container-width="containerWidth"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:drag-field="dragField"
|
||||
:attachment-uuid="document.uuid"
|
||||
@update="onSectionUpdate(section, $event)"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DynamicSection from './dynamic_section.vue'
|
||||
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
|
||||
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
|
||||
|
||||
export default {
|
||||
name: 'TemplateDynamicDocument',
|
||||
components: {
|
||||
DynamicSection
|
||||
},
|
||||
inject: ['baseFetch', 'template'],
|
||||
props: {
|
||||
document: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
selectedSubmitter: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
dragField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update'],
|
||||
data () {
|
||||
return {
|
||||
containerWidth: 1040,
|
||||
isMounted: false,
|
||||
sectionRefs: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
attachmentsIndex () {
|
||||
return (this.document.attachments || []).reduce((acc, att) => {
|
||||
acc[att.uuid] = att.url
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
bodyDom () {
|
||||
return new DOMParser().parseFromString(this.document.body, 'text/html')
|
||||
},
|
||||
headDom () {
|
||||
return new DOMParser().parseFromString(this.document.head, 'text/html')
|
||||
},
|
||||
sections () {
|
||||
return this.bodyDom.querySelectorAll('section')
|
||||
},
|
||||
styles () {
|
||||
return this.headDom.querySelectorAll('style')
|
||||
},
|
||||
shadow () {
|
||||
if (this.isMounted) {
|
||||
return this.$refs.shadow.attachShadow({ mode: 'open' })
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.isMounted = true
|
||||
|
||||
this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet)
|
||||
|
||||
this.containerWidth = this.$refs.container.clientWidth
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.$refs.container) {
|
||||
this.containerWidth = this.$refs.container.clientWidth
|
||||
}
|
||||
})
|
||||
|
||||
this.resizeObserver.observe(this.$refs.container)
|
||||
|
||||
window.addEventListener('beforeunload', this.onBeforeUnload)
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('beforeunload', this.onBeforeUnload)
|
||||
|
||||
this.resizeObserver.unobserve(this.$refs.container)
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.sectionRefs = []
|
||||
},
|
||||
methods: {
|
||||
mergeSchemaProperties,
|
||||
setSectionRefs (ref) {
|
||||
if (ref) {
|
||||
this.sectionRefs.push(ref)
|
||||
}
|
||||
},
|
||||
onBeforeUnload (event) {
|
||||
if (this.saveTimer) {
|
||||
event.preventDefault()
|
||||
|
||||
event.returnValue = ''
|
||||
|
||||
return ''
|
||||
}
|
||||
},
|
||||
scrollToArea (area) {
|
||||
this.sectionRefs.forEach(({ editor }) => {
|
||||
const el = editor.view.dom.querySelector(`[data-area-uuid="${area.uuid}"]`)
|
||||
|
||||
if (el) {
|
||||
editor.chain().focus().setNodeSelection(editor.view.posAtDOM(el, 0)).run()
|
||||
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
},
|
||||
onSectionUpdate (section, { editor }) {
|
||||
clearTimeout(this.saveTimer)
|
||||
|
||||
this.saveTimer = setTimeout(async () => {
|
||||
await this.updateSectionAndSave(section, editor)
|
||||
|
||||
delete this.saveTimer
|
||||
}, 1000)
|
||||
},
|
||||
updateVariablesSchema () {
|
||||
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
|
||||
},
|
||||
updateSectionAndSave (section, editor) {
|
||||
const target = this.bodyDom.getElementById(section.id)
|
||||
|
||||
if (target) {
|
||||
target.innerHTML = editor.getHTML()
|
||||
}
|
||||
|
||||
this.document.body = this.bodyDom.body.innerHTML
|
||||
|
||||
this.updateVariablesSchema()
|
||||
|
||||
this.$emit('update', this.document)
|
||||
|
||||
return this.saveBody()
|
||||
},
|
||||
updateAndSave () {
|
||||
this.update()
|
||||
|
||||
return this.saveBody()
|
||||
},
|
||||
update () {
|
||||
clearTimeout(this.saveTimer)
|
||||
|
||||
delete this.saveTimer
|
||||
|
||||
this.sectionRefs.forEach(({ section, editor }) => {
|
||||
const target = this.bodyDom.getElementById(section.id)
|
||||
|
||||
target.innerHTML = editor.getHTML()
|
||||
})
|
||||
|
||||
this.document.body = this.bodyDom.body.innerHTML
|
||||
|
||||
this.updateVariablesSchema()
|
||||
|
||||
this.$emit('update', this.document)
|
||||
},
|
||||
saveBody () {
|
||||
clearTimeout(this.saveTimer)
|
||||
|
||||
delete this.saveTimer
|
||||
|
||||
return this.baseFetch(`/templates/${this.template.id}/dynamic_documents/${this.document.uuid}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ body: this.bodyDom.body.innerHTML }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,768 @@
|
||||
import { Editor, Extension, Node, Mark } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import HardBreak from '@tiptap/extension-hard-break'
|
||||
import History from '@tiptap/extension-history'
|
||||
import Gapcursor from '@tiptap/extension-gapcursor'
|
||||
import Dropcursor from '@tiptap/extension-dropcursor'
|
||||
import { createApp, reactive } from 'vue'
|
||||
import DynamicArea from './dynamic_area.vue'
|
||||
import styles from './dynamic_styles.scss'
|
||||
|
||||
export const dynamicStylesheet = new CSSStyleSheet()
|
||||
|
||||
dynamicStylesheet.replaceSync(styles[0][1])
|
||||
|
||||
export const tiptapStylesheet = new CSSStyleSheet()
|
||||
|
||||
tiptapStylesheet.replaceSync(
|
||||
`.ProseMirror {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
white-space: break-spaces;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
|
||||
}
|
||||
|
||||
.ProseMirror [contenteditable="false"] {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ProseMirror [contenteditable="false"] [contenteditable="true"] {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
img.ProseMirror-separator {
|
||||
display: inline !important;
|
||||
border: none !important;
|
||||
margin: 0 !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid black;
|
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes ProseMirror-cursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::-moz-selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection * {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
.variable-highlight {
|
||||
background-color: #fef3c7;
|
||||
}`)
|
||||
|
||||
function collectDomAttrs (dom) {
|
||||
const attrs = {}
|
||||
|
||||
for (let i = 0; i < dom.attributes.length; i++) {
|
||||
attrs[dom.attributes[i].name] = dom.attributes[i].value
|
||||
}
|
||||
|
||||
return { htmlAttrs: attrs }
|
||||
}
|
||||
|
||||
function collectSpanDomAttrs (dom) {
|
||||
const result = collectDomAttrs(dom)
|
||||
|
||||
if (result.htmlAttrs.style) {
|
||||
const temp = document.createElement('span')
|
||||
|
||||
temp.style.cssText = result.htmlAttrs.style
|
||||
|
||||
if (['bold', '700'].includes(temp.style.fontWeight)) {
|
||||
temp.style.removeProperty('font-weight')
|
||||
}
|
||||
|
||||
if (temp.style.fontStyle === 'italic') {
|
||||
temp.style.removeProperty('font-style')
|
||||
}
|
||||
|
||||
if (temp.style.textDecoration === 'underline') {
|
||||
temp.style.removeProperty('text-decoration')
|
||||
}
|
||||
|
||||
if (temp.style.cssText) {
|
||||
result.htmlAttrs.style = temp.style.cssText
|
||||
} else {
|
||||
delete result.htmlAttrs.style
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function createBlockNode (name, tag, content) {
|
||||
return Node.create({
|
||||
name,
|
||||
group: 'block',
|
||||
content: content || 'block+',
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag, getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return [tag, node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const CustomParagraph = Node.create({
|
||||
name: 'paragraph',
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'p', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['p', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const CustomHeading = Node.create({
|
||||
name: 'heading',
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} },
|
||||
level: { default: 1 }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [1, 2, 3, 4, 5, 6].map((level) => ({
|
||||
tag: `h${level}`,
|
||||
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
|
||||
}))
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const SectionNode = createBlockNode('section', 'section')
|
||||
const ArticleNode = createBlockNode('article', 'article')
|
||||
const DivNode = createBlockNode('div', 'div')
|
||||
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
|
||||
const PreNode = createBlockNode('pre', 'pre')
|
||||
const OrderedListNode = createBlockNode('orderedList', 'ol', '(listItem | block)+')
|
||||
const BulletListNode = createBlockNode('bulletList', 'ul', '(listItem | block)+')
|
||||
|
||||
const ListItemNode = Node.create({
|
||||
name: 'listItem',
|
||||
content: 'block+',
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'li', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['li', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableNode = Node.create({
|
||||
name: 'table',
|
||||
group: 'block',
|
||||
content: '(colgroup | tableHead | tableBody | tableRow)+',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'table', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['table', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableHead = Node.create({
|
||||
name: 'tableHead',
|
||||
content: 'tableRow+',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'thead', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['thead', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableBody = Node.create({
|
||||
name: 'tableBody',
|
||||
content: 'tableRow+',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'tbody', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['tbody', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableRow = Node.create({
|
||||
name: 'tableRow',
|
||||
content: '(tableCell | tableHeader)+',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'tr', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['tr', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableCell = Node.create({
|
||||
name: 'tableCell',
|
||||
content: 'block*',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'td', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['td', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TableHeader = Node.create({
|
||||
name: 'tableHeader',
|
||||
content: 'block*',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'th', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['th', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const ImageNode = Node.create({
|
||||
name: 'image',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
draggable: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'img', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['img', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const ColGroupNode = Node.create({
|
||||
name: 'colgroup',
|
||||
group: 'block',
|
||||
content: 'col*',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'colgroup', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['colgroup', node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const ColNode = Node.create({
|
||||
name: 'col',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'col', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['col', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const CustomBold = Mark.create({
|
||||
name: 'bold',
|
||||
parseHTML () {
|
||||
return [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }]
|
||||
},
|
||||
renderHTML () {
|
||||
return ['strong', 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
toggleBold: () => ({ commands }) => commands.toggleMark(this.name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
'Mod-b': () => this.editor.commands.toggleBold()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CustomItalic = Mark.create({
|
||||
name: 'italic',
|
||||
parseHTML () {
|
||||
return [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }]
|
||||
},
|
||||
renderHTML () {
|
||||
return ['em', 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
toggleItalic: () => ({ commands }) => commands.toggleMark(this.name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
'Mod-i': () => this.editor.commands.toggleItalic()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CustomUnderline = Mark.create({
|
||||
name: 'underline',
|
||||
parseHTML () {
|
||||
return [{ tag: 'u' }, { style: 'text-decoration=underline' }]
|
||||
},
|
||||
renderHTML () {
|
||||
return ['u', 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
toggleUnderline: () => ({ commands }) => commands.toggleMark(this.name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
'Mod-u': () => this.editor.commands.toggleUnderline()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CustomStrike = Mark.create({
|
||||
name: 'strike',
|
||||
parseHTML () {
|
||||
return [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }]
|
||||
},
|
||||
renderHTML () {
|
||||
return ['s', 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
toggleStrike: () => ({ commands }) => commands.toggleMark(this.name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
'Mod-Shift-s': () => this.editor.commands.toggleStrike()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const EmptySpanNode = Node.create({
|
||||
name: 'emptySpan',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{
|
||||
tag: 'span',
|
||||
priority: 60,
|
||||
getAttrs (dom) {
|
||||
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
|
||||
return collectDomAttrs(dom)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['span', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const SpanMark = Mark.create({
|
||||
name: 'span',
|
||||
excludes: '',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['span', mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const LinkMark = Mark.create({
|
||||
name: 'link',
|
||||
excludes: '',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'a', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['a', mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const SubscriptMark = Mark.create({
|
||||
name: 'subscript',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'sub', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['sub', mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const SuperscriptMark = Mark.create({
|
||||
name: 'superscript',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'sup', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['sup', mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const TabHandler = Extension.create({
|
||||
name: 'tabHandler',
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
Tab: () => {
|
||||
this.editor.commands.insertContent('\t')
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const variableHighlightKey = new PluginKey('variableHighlight')
|
||||
|
||||
function buildDecorations (doc) {
|
||||
const decorations = []
|
||||
const regex = /\[\[[^\]]*\]\]/g
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return
|
||||
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(node.text)) !== null) {
|
||||
const from = pos + match.index
|
||||
const to = from + match[0].length
|
||||
|
||||
decorations.push(Decoration.inline(from, to, { class: 'variable-highlight' }))
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
const VariableHighlight = Extension.create({
|
||||
name: 'variableHighlight',
|
||||
addProseMirrorPlugins () {
|
||||
return [
|
||||
new Plugin({
|
||||
key: variableHighlightKey,
|
||||
state: {
|
||||
init (_, { doc }) {
|
||||
return buildDecorations(doc)
|
||||
},
|
||||
apply (tr, oldSet) {
|
||||
if (tr.docChanged) {
|
||||
return buildDecorations(tr.doc)
|
||||
}
|
||||
|
||||
return oldSet
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations (state) {
|
||||
return this.getState(state)
|
||||
},
|
||||
handleTextInput (view, from, to, text) {
|
||||
if (text !== '[') return false
|
||||
|
||||
const { state } = view
|
||||
const charBefore = state.doc.textBetween(Math.max(from - 1, 0), from)
|
||||
|
||||
if (charBefore !== '[') return false
|
||||
|
||||
const tr = state.tr.insertText('[]]', from, to)
|
||||
|
||||
tr.setSelection(state.selection.constructor.create(tr.doc, from + 1))
|
||||
|
||||
view.dispatch(tr)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop, onFieldDestroy, editorOptions }) {
|
||||
const FieldNode = Node.create({
|
||||
name: 'fieldNode',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
addAttributes () {
|
||||
return {
|
||||
uuid: { default: null },
|
||||
areaUuid: { default: null },
|
||||
width: { default: '124px' },
|
||||
height: { default: null },
|
||||
verticalAlign: { default: 'text-bottom' },
|
||||
display: { default: 'inline-flex' }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{
|
||||
tag: 'dynamic-field',
|
||||
getAttrs (dom) {
|
||||
return {
|
||||
uuid: dom.getAttribute('uuid'),
|
||||
areaUuid: dom.getAttribute('area-uuid'),
|
||||
width: dom.style.width,
|
||||
height: dom.style.height,
|
||||
display: dom.style.display,
|
||||
verticalAlign: dom.style.verticalAlign
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['dynamic-field', {
|
||||
uuid: node.attrs.uuid,
|
||||
'area-uuid': node.attrs.areaUuid,
|
||||
style: `width: ${node.attrs.width}; height: ${node.attrs.height}; display: ${node.attrs.display}; vertical-align: ${node.attrs.verticalAlign};`
|
||||
}]
|
||||
},
|
||||
addNodeView () {
|
||||
return ({ node, getPos, editor }) => {
|
||||
const dom = document.createElement('span')
|
||||
|
||||
const nodeStyle = reactive({
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
verticalAlign: node.attrs.verticalAlign,
|
||||
display: node.attrs.display
|
||||
})
|
||||
|
||||
dom.dataset.areaUuid = node.attrs.areaUuid
|
||||
|
||||
const shadow = dom.attachShadow({ mode: 'open' })
|
||||
|
||||
shadow.adoptedStyleSheets = [dynamicStylesheet]
|
||||
|
||||
const app = createApp(DynamicArea, {
|
||||
fieldUuid: node.attrs.uuid,
|
||||
areaUuid: node.attrs.areaUuid,
|
||||
nodeStyle,
|
||||
getPos,
|
||||
editor,
|
||||
editable: editorOptions.editable,
|
||||
...dynamicAreaProps
|
||||
})
|
||||
|
||||
app.mount(shadow)
|
||||
|
||||
return {
|
||||
dom,
|
||||
update (updatedNode) {
|
||||
if (updatedNode.attrs.areaUuid === node.attrs.areaUuid) {
|
||||
nodeStyle.width = updatedNode.attrs.width
|
||||
nodeStyle.height = updatedNode.attrs.height
|
||||
nodeStyle.verticalAlign = updatedNode.attrs.verticalAlign
|
||||
nodeStyle.display = updatedNode.attrs.display
|
||||
}
|
||||
},
|
||||
destroy () {
|
||||
onFieldDestroy(node)
|
||||
|
||||
app.unmount()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const FieldDropPlugin = Extension.create({
|
||||
name: 'fieldDrop',
|
||||
addProseMirrorPlugins () {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('fieldDrop'),
|
||||
props: {
|
||||
handleDrop: onFieldDrop
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const DynamicImageNode = ImageNode.extend({
|
||||
renderHTML ({ node }) {
|
||||
const { loading, ...attrs } = node.attrs.htmlAttrs
|
||||
|
||||
return ['img', attrs]
|
||||
},
|
||||
addNodeView () {
|
||||
return ({ node }) => {
|
||||
const dom = document.createElement('img')
|
||||
|
||||
const attrs = { ...node.attrs.htmlAttrs }
|
||||
|
||||
const blobUuid = attrs.src?.startsWith('blob:') && attrs.src.slice(5)
|
||||
|
||||
if (blobUuid && attachmentsIndex[blobUuid]) {
|
||||
attrs.src = attachmentsIndex[blobUuid]
|
||||
}
|
||||
|
||||
dom.setAttribute('loading', 'lazy')
|
||||
|
||||
Object.entries(attrs).forEach(([k, v]) => dom.setAttribute(k, v))
|
||||
|
||||
return { dom }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
HardBreak,
|
||||
History,
|
||||
Gapcursor,
|
||||
Dropcursor,
|
||||
CustomBold,
|
||||
CustomItalic,
|
||||
CustomUnderline,
|
||||
CustomStrike,
|
||||
CustomParagraph,
|
||||
CustomHeading,
|
||||
SectionNode,
|
||||
ArticleNode,
|
||||
DivNode,
|
||||
BlockquoteNode,
|
||||
PreNode,
|
||||
OrderedListNode,
|
||||
BulletListNode,
|
||||
ListItemNode,
|
||||
TableNode,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
ColGroupNode,
|
||||
ColNode,
|
||||
DynamicImageNode,
|
||||
EmptySpanNode,
|
||||
LinkMark,
|
||||
SpanMark,
|
||||
SubscriptMark,
|
||||
SuperscriptMark,
|
||||
VariableHighlight,
|
||||
TabHandler,
|
||||
FieldNode,
|
||||
FieldDropPlugin
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
style: 'outline: none'
|
||||
}
|
||||
},
|
||||
parseOptions: {
|
||||
preserveWhitespace: true
|
||||
},
|
||||
injectCSS: false,
|
||||
...editorOptions
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div
|
||||
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"
|
||||
:style="{ top: (coords.top - 42) + 'px', left: coords.left + 'px', transform: 'translateX(-50%)' }"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isBold ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Bold"
|
||||
@click="toggleBold"
|
||||
>
|
||||
<IconBold
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isItalic ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Italic"
|
||||
@click="toggleItalic"
|
||||
>
|
||||
<IconItalic
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isUnderline ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Underline"
|
||||
@click="toggleUnderline"
|
||||
>
|
||||
<IconUnderline
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isStrike ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Strikethrough"
|
||||
@click="toggleStrike"
|
||||
>
|
||||
<IconStrikethrough
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-base-300 mx-1" />
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
|
||||
title="Wrap in variable"
|
||||
@click="wrapVariable"
|
||||
>
|
||||
<IconBracketsContain
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span class="px-0.5">
|
||||
Variable
|
||||
</span>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-base-300 mx-1" />
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
|
||||
title="Wrap in condition"
|
||||
@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="px-0.5">
|
||||
Condition
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'DynamicMenu',
|
||||
components: {
|
||||
IconBold,
|
||||
IconItalic,
|
||||
IconUnderline,
|
||||
IconStrikethrough,
|
||||
IconBracketsContain
|
||||
},
|
||||
props: {
|
||||
editor: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
coords: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['add-variable', 'add-condition'],
|
||||
data () {
|
||||
return {
|
||||
isMouseDown: false,
|
||||
isBold: this.editor.isActive('bold'),
|
||||
isItalic: this.editor.isActive('italic'),
|
||||
isUnderline: this.editor.isActive('underline'),
|
||||
isStrike: this.editor.isActive('strike')
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
visible () {
|
||||
return !!this.coords && !this.isMouseDown
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.editor.view.dom.addEventListener('mousedown', this.onMouseDown)
|
||||
|
||||
document.addEventListener('mouseup', this.onMouseUp)
|
||||
|
||||
this.editor.on('transaction', this.onTransaction)
|
||||
},
|
||||
beforeUnmount () {
|
||||
if (!this.editor.isDestroyed) {
|
||||
this.editor.view.dom.removeEventListener('mousedown', this.onMouseDown)
|
||||
this.editor.off('transaction', this.onTransaction)
|
||||
}
|
||||
|
||||
document.removeEventListener('mouseup', this.onMouseUp)
|
||||
},
|
||||
methods: {
|
||||
toggleBold () {
|
||||
this.editor.chain().focus().toggleBold().run()
|
||||
},
|
||||
toggleItalic () {
|
||||
this.editor.chain().focus().toggleItalic().run()
|
||||
},
|
||||
toggleUnderline () {
|
||||
this.editor.chain().focus().toggleUnderline().run()
|
||||
},
|
||||
toggleStrike () {
|
||||
this.editor.chain().focus().toggleStrike().run()
|
||||
},
|
||||
wrapVariable () {
|
||||
const { from, to } = this.editor.state.selection
|
||||
const replacement = '[[variable]]'
|
||||
const varFrom = from + 2
|
||||
const varTo = varFrom + 8
|
||||
|
||||
this.editor.chain().focus()
|
||||
.insertContentAt({ from, to }, replacement)
|
||||
.setTextSelection({ from: varFrom, to: varTo })
|
||||
.run()
|
||||
|
||||
this.$emit('add-variable')
|
||||
},
|
||||
wrapCondition () {
|
||||
const { from, to } = this.editor.state.selection
|
||||
const endText = '[[end]]'
|
||||
const ifText = '[[if:variable]]'
|
||||
|
||||
this.editor.chain().focus()
|
||||
.insertContentAt(to, endText)
|
||||
.insertContentAt(from, ifText)
|
||||
.setTextSelection({ from: from + 5, to: from + 13 })
|
||||
.run()
|
||||
|
||||
this.$emit('add-condition')
|
||||
},
|
||||
onMouseDown () {
|
||||
this.isMouseDown = true
|
||||
},
|
||||
onMouseUp () {
|
||||
setTimeout(() => {
|
||||
this.isMouseDown = false
|
||||
}, 1)
|
||||
},
|
||||
onTransaction () {
|
||||
this.isBold = this.editor.isActive('bold')
|
||||
this.isItalic = this.editor.isActive('italic')
|
||||
this.isUnderline = this.editor.isActive('underline')
|
||||
this.isStrike = this.editor.isActive('strike')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,487 @@
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<div :style="{ zoom: containerWidth / sectionWidthPx }">
|
||||
<section
|
||||
:id="section.id"
|
||||
ref="editorElement"
|
||||
:class="section.classList.value"
|
||||
:style="section.style.cssText"
|
||||
/>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="editor"
|
||||
:to="container"
|
||||
>
|
||||
<div
|
||||
v-if="areaToolbarCoords && selectedField && selectedArea && !isAreaDrag"
|
||||
class="absolute z-10"
|
||||
:style="{ left: areaToolbarCoords.left + 'px', top: areaToolbarCoords.top + 'px' }"
|
||||
>
|
||||
<AreaTitle
|
||||
:area="selectedArea"
|
||||
:field="selectedField"
|
||||
:editable="editable"
|
||||
:template="template"
|
||||
:selected-areas-ref="selectedAreasRef"
|
||||
:get-field-type-index="getFieldTypeIndex"
|
||||
@remove="onRemoveSelectedArea"
|
||||
@change="onSelectedAreaChange"
|
||||
/>
|
||||
</div>
|
||||
<DynamicMenu
|
||||
v-if="editable"
|
||||
v-show="!selectedAreasRef.value.length"
|
||||
:editor="editor"
|
||||
:coords="dynamicMenuCoords"
|
||||
@add-variable="dynamicMenuCoords = null"
|
||||
@add-condition="dynamicMenuCoords = null"
|
||||
/>
|
||||
<FieldContextMenu
|
||||
v-if="contextMenu && contextMenuField"
|
||||
:context-menu="contextMenu"
|
||||
:field="contextMenuField"
|
||||
:with-copy-to-all-pages="false"
|
||||
@close="closeContextMenu"
|
||||
@delete="onContextMenuDelete"
|
||||
@save="save"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { shallowRef } from 'vue'
|
||||
import { v4 } from 'uuid'
|
||||
import FieldContextMenu from './field_context_menu.vue'
|
||||
import AreaTitle from './area_title.vue'
|
||||
import DynamicMenu from './dynamic_menu.vue'
|
||||
import { buildEditor } from './dynamic_editor.js'
|
||||
|
||||
export default {
|
||||
name: 'DynamicSection',
|
||||
components: {
|
||||
DynamicMenu,
|
||||
FieldContextMenu,
|
||||
AreaTitle
|
||||
},
|
||||
inject: ['template', 'save', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'],
|
||||
props: {
|
||||
section: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
container: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
containerWidth: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
attachmentsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
selectedSubmitter: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
dragField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
attachmentUuid: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update'],
|
||||
data () {
|
||||
return {
|
||||
isAreaDrag: false,
|
||||
areaToolbarCoords: null,
|
||||
dynamicMenuCoords: null,
|
||||
contextMenu: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
defaultHeight () {
|
||||
return CSS.supports('height', '1lh') ? '1lh' : '1em'
|
||||
},
|
||||
fieldAreaIndex () {
|
||||
return (this.template.fields || []).reduce((acc, field) => {
|
||||
field.areas?.forEach((area) => {
|
||||
acc[area.uuid] = { area, field }
|
||||
})
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
defaultSizes () {
|
||||
return {
|
||||
checkbox: { width: '18px', height: '18px' },
|
||||
radio: { width: '18px', height: '18px' },
|
||||
multiple: { width: '18px', height: '18px' },
|
||||
signature: { width: '140px', height: '50px' },
|
||||
initials: { width: '40px', height: '32px' },
|
||||
stamp: { width: '150px', height: '80px' },
|
||||
kba: { width: '150px', height: '80px' },
|
||||
verification: { width: '150px', height: '80px' },
|
||||
image: { width: '200px', height: '100px' },
|
||||
date: { width: '100px', height: this.defaultHeight },
|
||||
text: { width: '120px', height: this.defaultHeight },
|
||||
cells: { width: '120px', height: this.defaultHeight },
|
||||
file: { width: '120px', height: this.defaultHeight },
|
||||
payment: { width: '120px', height: this.defaultHeight },
|
||||
number: { width: '80px', height: this.defaultHeight },
|
||||
select: { width: '120px', height: this.defaultHeight },
|
||||
phone: { width: '120px', height: this.defaultHeight }
|
||||
}
|
||||
},
|
||||
editorRef: () => shallowRef(),
|
||||
editor () {
|
||||
return this.editorRef.value
|
||||
},
|
||||
sectionWidthPx () {
|
||||
const pt = parseFloat(this.section.style.width)
|
||||
|
||||
return pt * (96 / 72)
|
||||
},
|
||||
zoom () {
|
||||
return this.containerWidth / this.sectionWidthPx
|
||||
},
|
||||
isDraggingField () {
|
||||
return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField)
|
||||
},
|
||||
selectedArea () {
|
||||
return this.selectedAreasRef.value[0]
|
||||
},
|
||||
selectedField () {
|
||||
if (this.selectedArea) {
|
||||
return this.fieldAreaIndex[this.selectedArea.uuid]?.field
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
contextMenuField () {
|
||||
if (this.contextMenu?.areaUuid) {
|
||||
return this.fieldAreaIndex[this.contextMenu.areaUuid].field
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
containerWidth () {
|
||||
this.closeContextMenu()
|
||||
|
||||
if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) {
|
||||
this.$nextTick(() => this.setDynamicMenuCoords(this.editor))
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.initEditor()
|
||||
},
|
||||
beforeUnmount () {
|
||||
if (this.editor) {
|
||||
this.editor.destroy()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initEditor () {
|
||||
this.editorRef.value = buildEditor({
|
||||
dynamicAreaProps: {
|
||||
template: this.template,
|
||||
t: this.t,
|
||||
selectedAreasRef: this.selectedAreasRef,
|
||||
getFieldTypeIndex: this.getFieldTypeIndex,
|
||||
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
|
||||
getZoom: () => this.zoom,
|
||||
onAreaContextMenu: this.onAreaContextMenu,
|
||||
onAreaResize: this.onAreaResize,
|
||||
onAreaDragStart: this.onAreaDragStart
|
||||
},
|
||||
attachmentsIndex: this.attachmentsIndex,
|
||||
onFieldDrop: this.onFieldDrop,
|
||||
onFieldDestroy: this.onFieldDestroy,
|
||||
editorOptions: {
|
||||
element: this.$refs.editorElement,
|
||||
editable: this.editable,
|
||||
content: this.section.innerHTML,
|
||||
onUpdate: (event) => this.$emit('update', event),
|
||||
onSelectionUpdate: this.onSelectionUpdate,
|
||||
onBlur: () => { this.dynamicMenuCoords = null }
|
||||
}
|
||||
})
|
||||
},
|
||||
findAreaNodePos (areaUuid) {
|
||||
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
|
||||
|
||||
return this.editor.view.posAtDOM(el, 0)
|
||||
},
|
||||
removeArea (area) {
|
||||
const { field } = this.fieldAreaIndex[area.uuid]
|
||||
const areaIndex = field.areas.indexOf(area)
|
||||
|
||||
if (areaIndex !== -1) {
|
||||
field.areas.splice(areaIndex, 1)
|
||||
}
|
||||
|
||||
if (field.areas.length === 0) {
|
||||
this.template.fields.splice(this.template.fields.indexOf(field), 1)
|
||||
}
|
||||
|
||||
const pos = this.findAreaNodePos(area.uuid)
|
||||
|
||||
this.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
|
||||
|
||||
this.save()
|
||||
},
|
||||
onSelectionUpdate ({ editor }) {
|
||||
const { selection } = editor.state
|
||||
|
||||
if (selection.node?.type.name === 'fieldNode') {
|
||||
const { areaUuid } = selection.node.attrs
|
||||
|
||||
const field = this.fieldAreaIndex[areaUuid]?.field
|
||||
|
||||
if (field) {
|
||||
const area = field.areas.find((a) => a.uuid === areaUuid)
|
||||
|
||||
if (area) {
|
||||
const dom = editor.view.nodeDOM(selection.from)
|
||||
const areaEl = dom.shadowRoot.firstElementChild
|
||||
|
||||
if (areaEl) {
|
||||
const rect = areaEl.getBoundingClientRect()
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
|
||||
this.areaToolbarCoords = {
|
||||
left: rect.left - containerRect.left,
|
||||
top: rect.top - containerRect.top
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedAreasRef.value = [area]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.areaToolbarCoords = null
|
||||
this.selectedAreasRef.value = []
|
||||
|
||||
if (editor.state.selection.empty) {
|
||||
this.dynamicMenuCoords = null
|
||||
} else {
|
||||
this.setDynamicMenuCoords(editor)
|
||||
}
|
||||
}
|
||||
},
|
||||
setDynamicMenuCoords (editor) {
|
||||
const { from, to } = editor.state.selection
|
||||
const view = editor.view
|
||||
const start = view.coordsAtPos(from)
|
||||
const end = view.coordsAtPos(to)
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
const left = (start.left + end.right) / 2 - containerRect.left
|
||||
|
||||
this.dynamicMenuCoords = {
|
||||
top: Math.min(start.top, end.top) - containerRect.top,
|
||||
left: Math.max(80, Math.min(left, containerRect.width - 80))
|
||||
}
|
||||
},
|
||||
onFieldDestroy (node) {
|
||||
this.selectedAreasRef.value = []
|
||||
|
||||
const { areaUuid } = node.attrs
|
||||
|
||||
let nodeExistsInDoc = false
|
||||
|
||||
this.editor.state.doc.descendants((docNode) => {
|
||||
if (docNode.attrs.areaUuid === areaUuid) {
|
||||
nodeExistsInDoc = true
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (nodeExistsInDoc) return
|
||||
|
||||
const fieldArea = this.fieldAreaIndex[areaUuid]
|
||||
|
||||
if (!fieldArea) return
|
||||
|
||||
const field = fieldArea.field
|
||||
|
||||
const areaIndex = field.areas.findIndex((a) => a.uuid === areaUuid)
|
||||
|
||||
if (areaIndex !== -1) {
|
||||
field.areas.splice(areaIndex, 1)
|
||||
}
|
||||
|
||||
if (!field.areas?.length) {
|
||||
this.template.fields.splice(this.template.fields.indexOf(field), 1)
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
onAreaResize (rect) {
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
|
||||
this.areaToolbarCoords = {
|
||||
left: rect.left - containerRect.left,
|
||||
top: rect.top - containerRect.top
|
||||
}
|
||||
},
|
||||
onAreaDragStart () {
|
||||
this.isAreaDrag = true
|
||||
},
|
||||
onAreaContextMenu (area, e) {
|
||||
this.contextMenu = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
areaUuid: area.uuid
|
||||
}
|
||||
},
|
||||
deselectArea () {
|
||||
this.areaToolbarCoords = null
|
||||
this.selectedAreasRef.value = []
|
||||
},
|
||||
closeContextMenu () {
|
||||
this.contextMenu = null
|
||||
},
|
||||
onContextMenuDelete () {
|
||||
const menu = this.contextMenu
|
||||
const fieldArea = this.fieldAreaIndex[menu.areaUuid]
|
||||
|
||||
if (fieldArea) {
|
||||
this.removeArea(fieldArea.area)
|
||||
}
|
||||
|
||||
this.closeContextMenu()
|
||||
this.deselectArea()
|
||||
},
|
||||
onRemoveSelectedArea () {
|
||||
this.removeArea(this.selectedArea)
|
||||
|
||||
this.deselectArea()
|
||||
this.save()
|
||||
},
|
||||
onSelectedAreaChange () {
|
||||
this.save()
|
||||
},
|
||||
onFieldDrop (view, event, _slice, moved) {
|
||||
this.isAreaDrag = false
|
||||
|
||||
if (moved) {
|
||||
return
|
||||
}
|
||||
|
||||
const draggedField = this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField
|
||||
|
||||
if (!draggedField) return false
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
||||
|
||||
if (!pos) return false
|
||||
|
||||
const fieldType = draggedField.type || 'text'
|
||||
const dims = this.defaultSizes[fieldType] || this.defaultSizes.text
|
||||
const areaUuid = v4()
|
||||
|
||||
const existingField = this.fieldsDragFieldRef?.value
|
||||
|
||||
if (existingField) {
|
||||
if (!this.template.fields.includes(existingField)) {
|
||||
this.template.fields.push(existingField)
|
||||
}
|
||||
|
||||
existingField.areas = existingField.areas || []
|
||||
existingField.areas.push({ uuid: areaUuid, attachment_uuid: this.attachmentUuid })
|
||||
|
||||
const nodeType = view.state.schema.nodes.fieldNode
|
||||
const fieldNode = nodeType.create({
|
||||
uuid: existingField.uuid,
|
||||
areaUuid,
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
})
|
||||
|
||||
const tr = view.state.tr.insert(pos.pos, fieldNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
} else {
|
||||
const newField = {
|
||||
name: draggedField.name || '',
|
||||
uuid: v4(),
|
||||
required: fieldType !== 'checkbox',
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
type: fieldType,
|
||||
areas: [{ uuid: areaUuid, attachment_uuid: this.attachmentUuid }]
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(fieldType)) {
|
||||
if (draggedField.options?.length) {
|
||||
newField.options = draggedField.options.map((opt) => ({
|
||||
value: typeof opt === 'string' ? opt : opt.value,
|
||||
uuid: v4()
|
||||
}))
|
||||
} else {
|
||||
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldType === 'datenow') {
|
||||
newField.type = 'date'
|
||||
newField.readonly = true
|
||||
newField.default_value = '{{date}}'
|
||||
}
|
||||
|
||||
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
|
||||
newField.readonly = true
|
||||
|
||||
if (fieldType === 'strikethrough') {
|
||||
newField.default_value = true
|
||||
}
|
||||
}
|
||||
|
||||
this.template.fields.push(newField)
|
||||
|
||||
const nodeType = view.state.schema.nodes.fieldNode
|
||||
const fieldNode = nodeType.create({
|
||||
uuid: newField.uuid,
|
||||
areaUuid,
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
})
|
||||
|
||||
const tr = view.state.tr.insert(pos.pos, fieldNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
}
|
||||
|
||||
this.fieldsDragFieldRef.value = null
|
||||
this.customDragFieldRef.value = null
|
||||
|
||||
this.editor.chain().focus().setNodeSelection(pos.pos).run()
|
||||
|
||||
this.save()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
@config "../../../tailwind.dynamic.config.js";
|
||||
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
::before,
|
||||
::after {
|
||||
--tw-content: '';
|
||||
}
|
||||
|
||||
:host {
|
||||
all: initial;
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div class="group">
|
||||
<div class="flex items-center justify-between py-1.5 px-0.5">
|
||||
<div class="flex items-center space-x-1 min-w-0">
|
||||
<FieldType
|
||||
:model-value="formType"
|
||||
:editable="editable"
|
||||
:button-width="18"
|
||||
:menu-classes="'mt-1.5'"
|
||||
:menu-style="{ backgroundColor: dropdownBgColor }"
|
||||
@update:model-value="onTypeChange"
|
||||
/>
|
||||
<span
|
||||
class="truncate"
|
||||
:title="path"
|
||||
>{{ displayName }}</span>
|
||||
<span
|
||||
v-if="isArray"
|
||||
class="text-xs bg-base-200 rounded px-1 flex-shrink-0"
|
||||
>{{ t('list') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="editable"
|
||||
class="flex items-center flex-shrink-0"
|
||||
>
|
||||
<span
|
||||
class="dropdown dropdown-end"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||
>
|
||||
<IconSettings
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</label>
|
||||
<ul
|
||||
v-if="renderDropdown"
|
||||
tabindex="0"
|
||||
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<div
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
|
||||
@change="onTypeChange($event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="varType in variableTypes"
|
||||
:key="varType"
|
||||
:value="varType"
|
||||
:selected="varType === formType"
|
||||
>{{ t(varType) }}</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>{{ t('type') }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="formType === 'number'"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
|
||||
@change="[schema.format = $event.target.value, save()]"
|
||||
>
|
||||
<option
|
||||
v-for="format in numberFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
:selected="format === schema.format || (format === 'none' && !schema.format)"
|
||||
>{{ formatNumber(123456789.567, format) }}</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>{{ t('format') }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="['text', 'number'].includes(formType)"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-model="schema.default_value"
|
||||
:type="formType === 'number' ? 'number' : 'text'"
|
||||
:placeholder="t('default_value')"
|
||||
dir="auto"
|
||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
|
||||
@blur="save"
|
||||
>
|
||||
<label
|
||||
v-if="schema.default_value"
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>{{ t('default_value') }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="formType === 'date'"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
:value="schema.format || 'MM/DD/YYYY'"
|
||||
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
|
||||
@change="[schema.format = $event.target.value, save()]"
|
||||
>
|
||||
<option
|
||||
v-for="format in dateFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>{{ formatDate(new Date(), format) }}</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>{{ t('format') }}</label>
|
||||
</div>
|
||||
<li
|
||||
v-if="formType === 'date'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
:checked="schema.default_value === '{{date}}'"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@change="[schema.default_value = $event.target.checked ? '{{date}}' : undefined, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('current_date') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<div
|
||||
v-if="['radio', 'select'].includes(formType)"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
dir="auto"
|
||||
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
|
||||
@change="[schema.default_value = $event.target.value || undefined, save()]"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
:selected="!schema.default_value"
|
||||
>{{ t('none') }}</option>
|
||||
<option
|
||||
v-for="opt in (schema.options || [])"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
:selected="schema.default_value === opt"
|
||||
>{{ opt }}</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: dropdownBgColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>{{ t('default_value') }}</label>
|
||||
</div>
|
||||
<li
|
||||
v-if="formType === 'checkbox'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
:checked="schema.default_value === true"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@change="[schema.default_value = $event.target.checked || undefined, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('checked') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li @click.stop>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
:checked="schema.required !== false"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@change="[schema.required = $event.target.checked, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('required') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="['radio', 'select'].includes(formType) && schema.options"
|
||||
ref="options"
|
||||
class="pl-2 pr-1 pb-1.5 space-y-1.5"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in schema.options"
|
||||
:key="index"
|
||||
class="flex space-x-1.5 items-center"
|
||||
>
|
||||
<span class="text-sm w-3.5 select-none">{{ index + 1 }}.</span>
|
||||
<input
|
||||
:value="option"
|
||||
class="w-full input input-primary input-xs text-sm bg-transparent"
|
||||
type="text"
|
||||
dir="auto"
|
||||
:placeholder="`${t('option')} ${index + 1}`"
|
||||
@blur="[schema.options.splice(index, 1, $event.target.value), save()]"
|
||||
@keydown.enter="$event.target.value ? onOptionEnter(index, $event.target.value) : null"
|
||||
>
|
||||
<button
|
||||
class="text-sm w-3.5"
|
||||
tabindex="-1"
|
||||
@click="[schema.options.splice(index, 1), save()]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="text-center text-sm w-full pb-1"
|
||||
@click="addOptionAndFocus((schema.options || []).length)"
|
||||
>
|
||||
+ {{ t('add_option') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldType from './field_type'
|
||||
import { IconSettings } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'DynamicVariable',
|
||||
components: {
|
||||
FieldType,
|
||||
IconSettings
|
||||
},
|
||||
inject: ['t', 'save', 'backgroundColor'],
|
||||
provide () {
|
||||
return {
|
||||
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
|
||||
}
|
||||
},
|
||||
props: {
|
||||
path: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
groupKey: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
schema: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isArray: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
displayName () {
|
||||
if (this.groupKey) {
|
||||
const prefix = this.groupKey + (this.path.startsWith(this.groupKey + '[].') ? '[].' : '.')
|
||||
|
||||
return this.path.slice(prefix.length)
|
||||
} else {
|
||||
return this.path
|
||||
}
|
||||
},
|
||||
dropdownBgColor () {
|
||||
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
|
||||
},
|
||||
schemaTypeToFormType () {
|
||||
return { string: 'text', number: 'number', boolean: 'checkbox', date: 'date' }
|
||||
},
|
||||
formType () {
|
||||
return this.schema.form_type || this.schemaTypeToFormType[this.schema.type] || 'text'
|
||||
},
|
||||
variableTypes () {
|
||||
return ['text', 'number', 'date', 'checkbox', 'radio', 'select']
|
||||
},
|
||||
formTypeToSchemaType () {
|
||||
return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' }
|
||||
},
|
||||
numberFormats () {
|
||||
return [
|
||||
'none',
|
||||
'usd',
|
||||
'eur',
|
||||
'gbp',
|
||||
'comma',
|
||||
'dot',
|
||||
'space'
|
||||
]
|
||||
},
|
||||
dateFormats () {
|
||||
const formats = [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, YYYY',
|
||||
'MMMM D, YYYY',
|
||||
'D MMM YYYY',
|
||||
'D MMMM YYYY'
|
||||
]
|
||||
|
||||
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
|
||||
formats.push('YYYY년 MM월 DD일')
|
||||
}
|
||||
|
||||
if (this.schema.format && !formats.includes(this.schema.format)) {
|
||||
formats.unshift(this.schema.format)
|
||||
}
|
||||
|
||||
return formats
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onTypeChange (newType) {
|
||||
this.schema.type = this.formTypeToSchemaType[newType] || 'string'
|
||||
this.schema.form_type = newType
|
||||
|
||||
if (['radio', 'select'].includes(newType)) {
|
||||
if (!this.schema.options || !this.schema.options.length) {
|
||||
this.schema.options = ['', '']
|
||||
}
|
||||
} else {
|
||||
delete this.schema.options
|
||||
delete this.schema.default_value
|
||||
delete this.schema.format
|
||||
}
|
||||
|
||||
this.save()
|
||||
},
|
||||
onOptionEnter (index, value) {
|
||||
this.schema.options.splice(index, 1, value)
|
||||
this.schema.options.splice(index + 1, 0, '')
|
||||
|
||||
this.save()
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.options.querySelectorAll('input')[index + 1]?.focus()
|
||||
})
|
||||
},
|
||||
addOptionAndFocus (index) {
|
||||
if (!this.schema.options) {
|
||||
this.schema.options = []
|
||||
}
|
||||
|
||||
this.schema.options.splice(index, 0, '')
|
||||
this.save()
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.options.querySelectorAll('input')[index]?.focus()
|
||||
})
|
||||
},
|
||||
formatNumber (number, format) {
|
||||
if (format === 'comma') {
|
||||
return new Intl.NumberFormat('en-US').format(number)
|
||||
} else if (format === 'usd') {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
|
||||
} else if (format === 'gbp') {
|
||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
|
||||
} else if (format === 'eur') {
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
|
||||
} else if (format === 'dot') {
|
||||
return new Intl.NumberFormat('de-DE').format(number)
|
||||
} else if (format === 'space') {
|
||||
return new Intl.NumberFormat('fr-FR').format(number)
|
||||
} else {
|
||||
return number
|
||||
}
|
||||
},
|
||||
formatDate (date, format) {
|
||||
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
|
||||
const dayFormats = { D: 'numeric', DD: '2-digit' }
|
||||
const yearFormats = { YYYY: 'numeric', YY: '2-digit' }
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], {
|
||||
day: dayFormats[format.match(/D+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)]
|
||||
}).formatToParts(date)
|
||||
|
||||
return format
|
||||
.replace(/D+/, parts.find((p) => p.type === 'day').value)
|
||||
.replace(/M+/, parts.find((p) => p.type === 'month').value)
|
||||
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
|
||||
},
|
||||
closeDropdown () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="!schemaEntries.length"
|
||||
class="text-center py-4 px-2"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{{ t('no_variables') }}
|
||||
</p>
|
||||
<p class="text-sm mt-1">
|
||||
{{ t('no_variables_description') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<template
|
||||
v-for="([key, node], index) in schemaEntries"
|
||||
:key="key"
|
||||
>
|
||||
<div v-if="isGroup(node)">
|
||||
<hr
|
||||
v-if="index > 0"
|
||||
class="border-base-300"
|
||||
>
|
||||
<label class="peer flex items-center py-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden peer"
|
||||
checked
|
||||
>
|
||||
<IconChevronDown
|
||||
class="hidden peer-checked:block"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<IconChevronRight
|
||||
class="block peer-checked:hidden"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span class="ml-1">{{ key }}</span>
|
||||
<span
|
||||
v-if="node.type === 'array'"
|
||||
class="text-xs bg-base-200 rounded px-1 ml-1"
|
||||
>{{ t('list') }}</span>
|
||||
</label>
|
||||
<div class="hidden peer-has-[:checked]:block pl-3.5">
|
||||
<template
|
||||
v-for="[varNode, varPath] in nestedVariables(node, key)"
|
||||
:key="varPath"
|
||||
>
|
||||
<hr class="border-base-300">
|
||||
<DynamicVariable
|
||||
:path="varPath"
|
||||
:group-key="key"
|
||||
:editable="editable"
|
||||
:schema="varNode"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<hr
|
||||
v-if="index > 0"
|
||||
class="border-base-300"
|
||||
>
|
||||
<DynamicVariable
|
||||
:path="key"
|
||||
:editable="editable"
|
||||
:schema="node.type === 'array' && node.items ? node.items : node"
|
||||
:is-array="node.type === 'array'"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DynamicVariable from './dynamic_variable'
|
||||
import { IconChevronDown, IconChevronRight } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'DynamicVariables',
|
||||
components: {
|
||||
DynamicVariable,
|
||||
IconChevronDown,
|
||||
IconChevronRight
|
||||
},
|
||||
inject: ['t', 'template', 'save', 'backgroundColor'],
|
||||
props: {
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
schemaEntries () {
|
||||
return Object.entries(this.template.variables_schema || {}).filter(([, node]) => !node.disabled)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isGroup (node) {
|
||||
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.properties)
|
||||
},
|
||||
nestedVariables (node, groupKey) {
|
||||
const properties = node.type === 'array' ? node.items?.properties : node.properties
|
||||
|
||||
if (!properties) return []
|
||||
|
||||
const prefix = node.type === 'array' ? `${groupKey}[]` : groupKey
|
||||
|
||||
return this.collectLeafVariables(properties, prefix)
|
||||
},
|
||||
collectLeafVariables (properties, prefix) {
|
||||
return Object.entries(properties).reduce((result, [key, node]) => {
|
||||
if (node.disabled) return result
|
||||
|
||||
const path = `${prefix}.${key}`
|
||||
|
||||
if (node.type === 'object' && node.properties) {
|
||||
result.push(...this.collectLeafVariables(node.properties, path))
|
||||
} else if (node.type === 'array' && node.items?.properties) {
|
||||
result.push(...this.collectLeafVariables(node.items.properties, `${path}[]`))
|
||||
} else {
|
||||
result.push([node, path])
|
||||
}
|
||||
|
||||
return result
|
||||
}, [])
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,559 @@
|
||||
const KEYWORDS = ['if', 'else', 'for', 'end']
|
||||
const TYPE_PRIORITY = { string: 3, number: 2, boolean: 1 }
|
||||
const AND_OR_REGEXP = /\s+(AND|OR)\s+/i
|
||||
const COMPARISON_OPERATORS_REGEXP = />=|<=|!=|==|>|<|=/
|
||||
|
||||
function buildTokens (elem, acc = []) {
|
||||
if (elem.nodeType === Node.TEXT_NODE) {
|
||||
if (elem.textContent) {
|
||||
const text = elem.textContent
|
||||
const re = /[[\]]/g
|
||||
let match
|
||||
let found = false
|
||||
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
found = true
|
||||
|
||||
acc.push({
|
||||
elem,
|
||||
value: match[0],
|
||||
textLength: text.length,
|
||||
index: match.index
|
||||
})
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
acc.push({ elem, value: '', textLength: 0, index: 0 })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const child of elem.childNodes) {
|
||||
buildTokens(child, acc)
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
function tokensPair (cur, nxt) {
|
||||
if (cur.elem === nxt.elem) {
|
||||
return cur.elem.textContent.slice(cur.index + 1, nxt.index).trim() === ''
|
||||
} else {
|
||||
return cur.elem.textContent.slice(cur.index + 1).trim() === '' &&
|
||||
nxt.elem.textContent.slice(0, nxt.index).trim() === ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildTags (tokens) {
|
||||
const normalized = []
|
||||
|
||||
for (let i = 0; i < tokens.length - 1; i++) {
|
||||
const cur = tokens[i]
|
||||
const nxt = tokens[i + 1]
|
||||
|
||||
if (cur.value === '[' && nxt.value === '[' && tokensPair(cur, nxt)) {
|
||||
normalized.push(['open', cur])
|
||||
} else if (cur.value === ']' && nxt.value === ']' && tokensPair(cur, nxt)) {
|
||||
normalized.push(['close', nxt])
|
||||
}
|
||||
}
|
||||
|
||||
const tags = []
|
||||
|
||||
for (let i = 0; i < normalized.length - 1; i++) {
|
||||
const [curOp, openToken] = normalized[i]
|
||||
const [nxtOp, closeToken] = normalized[i + 1]
|
||||
|
||||
if (curOp === 'open' && nxtOp === 'close') {
|
||||
tags.push({ openToken, closeToken, value: '' })
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
function findTextNodesInBranch (elements, toElem, acc) {
|
||||
if (!elements || elements.length === 0) return acc
|
||||
|
||||
for (const elem of elements) {
|
||||
if (elem.nodeType === Node.TEXT_NODE) {
|
||||
acc.push(elem)
|
||||
} else {
|
||||
findTextNodesInBranch(Array.from(elem.childNodes), toElem, acc)
|
||||
}
|
||||
|
||||
if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
function findTextNodesBetween (fromElem, toElem, acc = []) {
|
||||
if (fromElem === toElem) return [fromElem]
|
||||
|
||||
let currentElement = fromElem
|
||||
|
||||
while (true) {
|
||||
const parent = currentElement.parentNode
|
||||
|
||||
if (!parent) return acc
|
||||
|
||||
const children = Array.from(parent.childNodes)
|
||||
const startIndex = children.indexOf(currentElement)
|
||||
|
||||
if (startIndex === -1) return acc
|
||||
|
||||
const elementsInBranch = children.slice(startIndex)
|
||||
|
||||
findTextNodesInBranch(elementsInBranch, toElem, acc)
|
||||
|
||||
if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc
|
||||
|
||||
let p = elementsInBranch[0].parentNode
|
||||
|
||||
while (p && !p.nextSibling) {
|
||||
p = p.parentNode
|
||||
}
|
||||
|
||||
if (!p || !p.nextSibling) return acc
|
||||
|
||||
currentElement = p.nextSibling
|
||||
}
|
||||
}
|
||||
|
||||
function mapTagValues (tags) {
|
||||
for (const tag of tags) {
|
||||
const textNodes = findTextNodesBetween(tag.openToken.elem, tag.closeToken.elem)
|
||||
|
||||
for (const elem of textNodes) {
|
||||
let part
|
||||
|
||||
if (tag.openToken.elem === elem && tag.closeToken.elem === elem) {
|
||||
part = elem.textContent.slice(tag.openToken.index, tag.closeToken.index + 1)
|
||||
} else if (tag.openToken.elem === elem) {
|
||||
part = elem.textContent.slice(tag.openToken.index)
|
||||
} else if (tag.closeToken.elem === elem) {
|
||||
part = elem.textContent.slice(0, tag.closeToken.index + 1)
|
||||
} else {
|
||||
part = elem.textContent
|
||||
}
|
||||
|
||||
tag.value += part
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
function parseTagTypeName (tagString) {
|
||||
const val = tagString.replace(/[[\]]/g, '').trim()
|
||||
const parts = val.split(':').map((s) => s.trim())
|
||||
|
||||
if (parts.length === 2 && KEYWORDS.includes(parts[0])) {
|
||||
return [parts[0], parts[1]]
|
||||
} else if (KEYWORDS.includes(val)) {
|
||||
return [val, null]
|
||||
} else {
|
||||
return ['var', val]
|
||||
}
|
||||
}
|
||||
|
||||
function isSimpleVariable (str) {
|
||||
const s = str.trim()
|
||||
|
||||
return !AND_OR_REGEXP.test(s) &&
|
||||
!COMPARISON_OPERATORS_REGEXP.test(s) &&
|
||||
!s.includes('(') &&
|
||||
!s.includes('!') &&
|
||||
!s.includes('&&') &&
|
||||
!s.includes('||') &&
|
||||
!s.startsWith('"') &&
|
||||
!s.startsWith("'") &&
|
||||
!/^-?\d/.test(s) &&
|
||||
!/^(true|false)$/i.test(s)
|
||||
}
|
||||
|
||||
function tokenizeCondition (str) {
|
||||
const tokens = []
|
||||
let pos = 0
|
||||
|
||||
str = str.trim()
|
||||
|
||||
while (pos < str.length) {
|
||||
const rest = str.slice(pos)
|
||||
let m
|
||||
|
||||
if ((m = rest.match(/^\s+/))) {
|
||||
pos += m[0].length
|
||||
} else if ((m = rest.match(/^(>=|<=|!=|==|>|<|=)/))) {
|
||||
tokens.push({ type: 'operator', value: m[1] })
|
||||
pos += m[1].length
|
||||
} else if (rest[0] === '!') {
|
||||
tokens.push({ type: 'not', value: '!' })
|
||||
pos += 1
|
||||
} else if (rest[0] === '(') {
|
||||
tokens.push({ type: 'lparen', value: '(' })
|
||||
pos += 1
|
||||
} else if (rest[0] === ')') {
|
||||
tokens.push({ type: 'rparen', value: ')' })
|
||||
pos += 1
|
||||
} else if (rest.startsWith('&&')) {
|
||||
tokens.push({ type: 'and', value: 'AND' })
|
||||
pos += 2
|
||||
} else if ((m = rest.match(/^AND\b/i))) {
|
||||
tokens.push({ type: 'and', value: 'AND' })
|
||||
pos += 3
|
||||
} else if (rest.startsWith('||')) {
|
||||
tokens.push({ type: 'or', value: 'OR' })
|
||||
pos += 2
|
||||
} else if ((m = rest.match(/^OR\b/i))) {
|
||||
tokens.push({ type: 'or', value: 'OR' })
|
||||
pos += 2
|
||||
} else if ((m = rest.match(/^"([^"]*)"/) || rest.match(/^'([^']*)'/))) {
|
||||
tokens.push({ type: 'string', value: m[1] })
|
||||
pos += m[0].length
|
||||
} else if ((m = rest.match(/^(-?\d+\.?\d*)/))) {
|
||||
tokens.push({ type: 'number', value: m[1].includes('.') ? parseFloat(m[1]) : parseInt(m[1], 10) })
|
||||
pos += m[1].length
|
||||
} else if ((m = rest.match(/^(true|false)\b/i))) {
|
||||
tokens.push({ type: 'boolean', value: m[1].toLowerCase() === 'true' })
|
||||
pos += m[1].length
|
||||
} else if ((m = rest.match(/^([\p{L}_][\p{L}\p{N}_.]*)/u))) {
|
||||
tokens.push({ type: 'variable', value: m[1] })
|
||||
pos += m[1].length
|
||||
} else {
|
||||
pos += 1
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function parseOrExpr (tokens, pos) {
|
||||
let left, right
|
||||
|
||||
;[left, pos] = parseAndExpr(tokens, pos)
|
||||
|
||||
while (pos < tokens.length && tokens[pos].type === 'or') {
|
||||
pos += 1
|
||||
;[right, pos] = parseAndExpr(tokens, pos)
|
||||
left = { type: 'or', left, right }
|
||||
}
|
||||
|
||||
return [left, pos]
|
||||
}
|
||||
|
||||
function parseAndExpr (tokens, pos) {
|
||||
let left, right
|
||||
|
||||
;[left, pos] = parsePrimary(tokens, pos)
|
||||
|
||||
while (pos < tokens.length && tokens[pos].type === 'and') {
|
||||
pos += 1
|
||||
;[right, pos] = parsePrimary(tokens, pos)
|
||||
left = { type: 'and', left, right }
|
||||
}
|
||||
|
||||
return [left, pos]
|
||||
}
|
||||
|
||||
function parsePrimary (tokens, pos) {
|
||||
if (pos >= tokens.length) return [null, pos]
|
||||
|
||||
if (tokens[pos].type === 'not') {
|
||||
const [child, p] = parsePrimary(tokens, pos + 1)
|
||||
|
||||
return [{ type: 'not', child }, p]
|
||||
}
|
||||
|
||||
if (tokens[pos].type === 'lparen') {
|
||||
const [node, p] = parseOrExpr(tokens, pos + 1)
|
||||
|
||||
return [node, p < tokens.length && tokens[p].type === 'rparen' ? p + 1 : p]
|
||||
}
|
||||
|
||||
return parseComparisonOrPresence(tokens, pos)
|
||||
}
|
||||
|
||||
function parseComparisonOrPresence (tokens, pos) {
|
||||
if (pos >= tokens.length || tokens[pos].type !== 'variable') return [null, pos]
|
||||
|
||||
const variableName = tokens[pos].value
|
||||
|
||||
pos += 1
|
||||
|
||||
if (pos < tokens.length && tokens[pos].type === 'operator') {
|
||||
let operator = tokens[pos].value
|
||||
|
||||
if (operator === '=') operator = '=='
|
||||
|
||||
pos += 1
|
||||
|
||||
if (pos < tokens.length && ['string', 'number', 'variable', 'boolean'].includes(tokens[pos].type)) {
|
||||
const valueToken = tokens[pos]
|
||||
|
||||
return [{
|
||||
type: 'comparison',
|
||||
variableName,
|
||||
operator,
|
||||
value: valueToken.value,
|
||||
valueIsVariable: valueToken.type === 'variable'
|
||||
}, pos + 1]
|
||||
}
|
||||
}
|
||||
|
||||
return [{ type: 'presence', variableName }, pos]
|
||||
}
|
||||
|
||||
function parseCondition (conditionString) {
|
||||
const stripped = conditionString.trim()
|
||||
|
||||
if (stripped.startsWith('!') && isSimpleVariable(stripped.slice(1))) {
|
||||
return { type: 'not', child: { type: 'presence', variableName: stripped.slice(1) } }
|
||||
}
|
||||
|
||||
if (isSimpleVariable(stripped)) {
|
||||
return { type: 'presence', variableName: stripped }
|
||||
}
|
||||
|
||||
const tokens = tokenizeCondition(stripped)
|
||||
const [ast] = parseOrExpr(tokens, 0)
|
||||
|
||||
return ast
|
||||
}
|
||||
|
||||
function extractConditionVariables (node, acc = []) {
|
||||
if (!node) return acc
|
||||
|
||||
switch (node.type) {
|
||||
case 'or':
|
||||
case 'and':
|
||||
extractConditionVariables(node.left, acc)
|
||||
extractConditionVariables(node.right, acc)
|
||||
break
|
||||
case 'not':
|
||||
extractConditionVariables(node.child, acc)
|
||||
break
|
||||
case 'comparison':
|
||||
acc.push({
|
||||
name: node.variableName,
|
||||
type: node.valueIsVariable ? null : (typeof node.value === 'boolean' ? 'boolean' : (typeof node.value === 'number' ? 'number' : 'string'))
|
||||
})
|
||||
|
||||
if (node.valueIsVariable) {
|
||||
acc.push({ name: node.value, type: null })
|
||||
}
|
||||
|
||||
break
|
||||
case 'presence':
|
||||
acc.push({ name: node.variableName, type: 'boolean' })
|
||||
break
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
function singularize (word) {
|
||||
if (word.endsWith('ies')) return word.slice(0, -3) + 'y'
|
||||
if (word.endsWith('ches') || word.endsWith('shes')) return word.slice(0, -2)
|
||||
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2)
|
||||
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
|
||||
|
||||
return word
|
||||
}
|
||||
|
||||
function buildOperators (tags) {
|
||||
const operators = []
|
||||
const stack = [{ children: operators, operator: null }]
|
||||
|
||||
for (const tag of tags) {
|
||||
const [type, variableName] = parseTagTypeName(tag.value)
|
||||
|
||||
switch (type) {
|
||||
case 'for':
|
||||
case 'if': {
|
||||
const operator = { type, variableName, tag, children: [] }
|
||||
|
||||
if (type === 'if') {
|
||||
try {
|
||||
operator.condition = parseCondition(variableName)
|
||||
} catch (e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
stack[stack.length - 1].children.push(operator)
|
||||
stack.push({ children: operator.children, operator })
|
||||
break
|
||||
}
|
||||
case 'else': {
|
||||
const current = stack[stack.length - 1]
|
||||
|
||||
if (current.operator && current.operator.type === 'if') {
|
||||
current.operator.elseTag = tag
|
||||
current.operator.elseChildren = []
|
||||
current.children = current.operator.elseChildren
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'end': {
|
||||
const popped = stack.pop()
|
||||
|
||||
if (popped.operator) {
|
||||
popped.operator.endTag = tag
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'var':
|
||||
stack[stack.length - 1].children.push({ type, variableName, tag })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return operators
|
||||
}
|
||||
|
||||
function assignNestedSchema (propertiesHash, parentProperties, keyString, value) {
|
||||
const keys = keyString.split('.')
|
||||
const lastKey = keys.pop()
|
||||
|
||||
let currentLevel = null
|
||||
|
||||
if (keys.length > 0 && parentProperties[keys[0]]) {
|
||||
currentLevel = keys.reduce((current, key) => {
|
||||
if (!current[key]) current[key] = { type: 'object', properties: {} }
|
||||
|
||||
return current[key].properties
|
||||
}, parentProperties)
|
||||
}
|
||||
|
||||
if (!currentLevel) {
|
||||
currentLevel = keys.reduce((current, key) => {
|
||||
if (!current[key]) current[key] = { type: 'object', properties: {} }
|
||||
|
||||
return current[key].properties
|
||||
}, propertiesHash)
|
||||
}
|
||||
|
||||
currentLevel[lastKey] = value
|
||||
}
|
||||
|
||||
function assignNestedSchemaWithPriority (propertiesHash, parentProperties, keyString, newType) {
|
||||
const keys = keyString.split('.')
|
||||
const lastKey = keys.pop()
|
||||
|
||||
let currentLevel = null
|
||||
|
||||
if (keys.length > 0 && parentProperties[keys[0]]) {
|
||||
currentLevel = keys.reduce((current, key) => {
|
||||
if (!current[key]) current[key] = { type: 'object', properties: {} }
|
||||
|
||||
return current[key].properties
|
||||
}, parentProperties)
|
||||
}
|
||||
|
||||
if (!currentLevel) {
|
||||
currentLevel = keys.reduce((current, key) => {
|
||||
if (!current[key]) current[key] = { type: 'object', properties: {} }
|
||||
|
||||
return current[key].properties
|
||||
}, propertiesHash)
|
||||
}
|
||||
|
||||
const existing = currentLevel[lastKey]
|
||||
|
||||
if (existing && (TYPE_PRIORITY[newType] || 0) <= (TYPE_PRIORITY[existing.type] || 0)) return
|
||||
|
||||
currentLevel[lastKey] = { type: newType }
|
||||
}
|
||||
|
||||
function processConditionVariables (condition, propertiesHash, parentProperties) {
|
||||
const variables = extractConditionVariables(condition)
|
||||
|
||||
for (const varInfo of variables) {
|
||||
assignNestedSchemaWithPriority(propertiesHash, parentProperties, varInfo.name, varInfo.type || 'boolean')
|
||||
}
|
||||
}
|
||||
|
||||
function processOperators (operators, propertiesHash = {}, parentProperties = {}) {
|
||||
if (!operators || operators.length === 0) return propertiesHash
|
||||
|
||||
for (const op of operators) {
|
||||
switch (op.type) {
|
||||
case 'var': {
|
||||
if (!op.variableName.includes('.') && parentProperties[op.variableName]) {
|
||||
const item = parentProperties[op.variableName]
|
||||
|
||||
if (item && item.type === 'object' && item.properties && Object.keys(item.properties).length === 0) {
|
||||
delete item.properties
|
||||
item.type = 'string'
|
||||
}
|
||||
} else {
|
||||
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'string' })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'if':
|
||||
if (op.condition) {
|
||||
processConditionVariables(op.condition, propertiesHash, parentProperties)
|
||||
}
|
||||
|
||||
processOperators(op.children, propertiesHash, parentProperties)
|
||||
processOperators(op.elseChildren, propertiesHash, parentProperties)
|
||||
break
|
||||
case 'for': {
|
||||
const parts = op.variableName.split('.')
|
||||
const singularKey = singularize(parts[parts.length - 1])
|
||||
|
||||
let itemProperties = parentProperties[singularKey]?.items
|
||||
itemProperties = itemProperties || propertiesHash[parts[0]]?.items
|
||||
itemProperties = itemProperties || { type: 'object', properties: {} }
|
||||
|
||||
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'array', items: itemProperties })
|
||||
processOperators(op.children, propertiesHash, { ...parentProperties, [singularKey]: itemProperties })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return propertiesHash
|
||||
}
|
||||
|
||||
function mergeSchemaProperties (target, source) {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (!target[key]) {
|
||||
target[key] = source[key]
|
||||
} else if (target[key].type === 'object' && source[key].type === 'object') {
|
||||
if (!target[key].properties) target[key].properties = {}
|
||||
if (source[key].properties) {
|
||||
mergeSchemaProperties(target[key].properties, source[key].properties)
|
||||
}
|
||||
} else if (target[key].type === 'array' && source[key].type === 'array') {
|
||||
if (source[key].items && source[key].items.properties) {
|
||||
if (!target[key].items) {
|
||||
target[key].items = source[key].items
|
||||
} else if (target[key].items.properties) {
|
||||
mergeSchemaProperties(target[key].items.properties, source[key].items.properties)
|
||||
}
|
||||
} else if (source[key].items && !target[key].items) {
|
||||
target[key].items = source[key].items
|
||||
}
|
||||
} else if ((TYPE_PRIORITY[source[key].type] || 0) > (TYPE_PRIORITY[target[key].type] || 0)) {
|
||||
target[key] = source[key]
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
function buildVariablesSchema (dom) {
|
||||
const tokens = buildTokens(dom)
|
||||
const tags = mapTagValues(buildTags(tokens))
|
||||
const operators = buildOperators(tags)
|
||||
|
||||
return processOperators(operators)
|
||||
}
|
||||
|
||||
export { buildVariablesSchema, mergeSchemaProperties, buildOperators, buildTokens, buildTags, mapTagValues }
|
||||
@@ -516,7 +516,7 @@
|
||||
</label>
|
||||
</li>
|
||||
<hr
|
||||
v-if="withCopyToAllPages || withAreas || withCustomFields"
|
||||
v-if="(withCopyToAllPages && canCopyToAllPages) || withAreas || withCustomFields"
|
||||
class="pb-0.5 mt-0.5"
|
||||
>
|
||||
<template v-if="withAreas">
|
||||
@@ -561,7 +561,7 @@
|
||||
</li>
|
||||
</template>
|
||||
<li
|
||||
v-if="withCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)"
|
||||
v-if="withCopyToAllPages && canCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)"
|
||||
class="field-settings-copy-to-all-pages"
|
||||
>
|
||||
<a
|
||||
@@ -681,6 +681,15 @@ export default {
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
canCopyToAllPages () {
|
||||
const firstArea = this.field.areas[0]
|
||||
|
||||
if (firstArea) {
|
||||
return firstArea.page !== null && firstArea.page !== undefined
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
numberFormats () {
|
||||
return [
|
||||
'none',
|
||||
@@ -744,7 +753,7 @@ export default {
|
||||
return ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone']
|
||||
},
|
||||
sortedAreas () {
|
||||
return (this.field.areas || []).sort((a, b) => {
|
||||
return (this.field.areas || []).filter((e) => e.page !== null && e.page !== undefined).sort((a, b) => {
|
||||
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
<template>
|
||||
<div :class="withStickySubmitters ? 'sticky top-0 z-[1]' : ''">
|
||||
<div
|
||||
:class="withStickySubmitters ? 'sticky top-0 z-[1]' : ''"
|
||||
:style="withStickySubmitters ? { backgroundColor } : {}"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<FieldSubmitter
|
||||
:model-value="selectedSubmitter.uuid"
|
||||
class="roles-dropdown w-full rounded-lg roles-dropdown"
|
||||
:style="withStickySubmitters ? { backgroundColor } : {}"
|
||||
:submitters="submitters"
|
||||
:menu-style="{ overflow: 'auto', display: 'flex', flexDirection: 'row', maxHeight: 'calc(100vh - 120px)', backgroundColor: ['', null, 'transparent'].includes(backgroundColor) ? 'white' : backgroundColor }"
|
||||
:editable="editable && !defaultSubmitters.length"
|
||||
@new-submitter="save"
|
||||
@remove="removeSubmitter"
|
||||
@name-change="save"
|
||||
@update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))"
|
||||
@update:model-value="[$emit('change-submitter', submitters.find((s) => s.uuid === $event)), isShowVariables = false]"
|
||||
/>
|
||||
<button
|
||||
v-if="hasDynamicDocuments"
|
||||
class="flex-shrink-0 rounded-md border hover:border-content flex items-center justify-center self-stretch"
|
||||
:class="isShowVariables ? 'border-base-content bg-base-content text-base-100' : 'border-base-300'"
|
||||
style="width: 44px"
|
||||
:title="t('variables')"
|
||||
@click.prevent="toggleVariables"
|
||||
>
|
||||
<IconBracketsContain
|
||||
:width="22"
|
||||
:height="22"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<DynamicVariables
|
||||
v-if="isShowVariables"
|
||||
:editable="editable"
|
||||
class="mt-1"
|
||||
/>
|
||||
<div
|
||||
v-if="!isShowVariables"
|
||||
ref="fields"
|
||||
class="fields mt-2"
|
||||
:class="{ 'mb-1': !withCustomFields || !customFields.length }"
|
||||
@@ -42,7 +65,7 @@
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="submitterDefaultFields.length && editable">
|
||||
<div v-if="!isShowVariables && submitterDefaultFields.length && editable">
|
||||
<hr class="mb-2">
|
||||
<template v-if="isShowFieldSearch">
|
||||
<input
|
||||
@@ -110,7 +133,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="editable && withCustomFields && (customFields.length || newCustomField)"
|
||||
v-if="!isShowVariables && editable && withCustomFields && (customFields.length || newCustomField)"
|
||||
class="tabs w-full mb-1.5"
|
||||
>
|
||||
<a
|
||||
@@ -127,7 +150,7 @@
|
||||
>{{ t('custom') }}</a>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCustomTab && editable && (customFields.length || newCustomField)"
|
||||
v-if="!isShowVariables && showCustomTab && editable && (customFields.length || newCustomField)"
|
||||
ref="customFields"
|
||||
class="custom-fields"
|
||||
@dragover.prevent="onCustomFieldDragover"
|
||||
@@ -195,7 +218,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
|
||||
v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
|
||||
id="field-types-grid"
|
||||
class="grid grid-cols-3 gap-1 pb-2 fields-grid"
|
||||
>
|
||||
@@ -283,7 +306,7 @@
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="fields.length < 4 && editable && withHelp && !showTourStartForm"
|
||||
v-if="!isShowVariables && fields.length < 4 && editable && withHelp && !showTourStartForm"
|
||||
class="text-xs p-2 border border-base-200 rounded"
|
||||
>
|
||||
<ul class="list-disc list-outside ml-3">
|
||||
@@ -299,7 +322,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="withFieldsDetection && editable && fields.length < 2"
|
||||
v-if="!isShowVariables && withFieldsDetection && editable && fields.length < 2 && !template.schema.some((item) => item.dynamic)"
|
||||
class="my-2"
|
||||
>
|
||||
<button
|
||||
@@ -336,7 +359,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
|
||||
v-show="!isShowVariables && fields.length < 4 && editable && withHelp && showTourStartForm"
|
||||
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
|
||||
>
|
||||
<div class="text-center text-sm">
|
||||
@@ -359,7 +382,8 @@ import Field from './field'
|
||||
import CustomField from './custom_field'
|
||||
import FieldType from './field_type'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles, IconBracketsContain } from '@tabler/icons-vue'
|
||||
import IconDrag from './icon_drag'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
@@ -374,7 +398,9 @@ export default {
|
||||
IconInnerShadowTop,
|
||||
FieldSubmitter,
|
||||
IconDrag,
|
||||
IconLock
|
||||
IconLock,
|
||||
IconBracketsContain,
|
||||
DynamicVariables: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_variables'))
|
||||
},
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
|
||||
props: {
|
||||
@@ -475,7 +501,7 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema'],
|
||||
data () {
|
||||
return {
|
||||
fieldPagesLoaded: null,
|
||||
@@ -483,12 +509,16 @@ export default {
|
||||
newCustomField: null,
|
||||
showCustomTab: false,
|
||||
defaultFieldsSearch: '',
|
||||
customFieldsSearch: ''
|
||||
customFieldsSearch: '',
|
||||
isShowVariables: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
hasDynamicDocuments () {
|
||||
return this.template.schema.some((item) => item.dynamic)
|
||||
},
|
||||
numberOfPages () {
|
||||
return this.template.documents.reduce((acc, doc) => {
|
||||
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
|
||||
@@ -556,6 +586,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleVariables () {
|
||||
this.$emit('rebuild-variables-schema')
|
||||
this.isShowVariables = !this.isShowVariables
|
||||
},
|
||||
onDragstart (event, field) {
|
||||
this.removeDragOverlay(event)
|
||||
|
||||
@@ -582,6 +616,10 @@ export default {
|
||||
delete customField.prefillable
|
||||
delete customField.conditions
|
||||
|
||||
if (Array.isArray(customField.areas)) {
|
||||
customField.areas = customField.areas.filter((area) => area.page !== null && area.page !== undefined)
|
||||
}
|
||||
|
||||
customField.areas?.forEach((area) => {
|
||||
delete area.attachment_uuid
|
||||
delete area.page
|
||||
|
||||
@@ -49,6 +49,10 @@ const en = {
|
||||
with_logo: 'With logo',
|
||||
unchecked: 'Unchecked',
|
||||
price: 'Price',
|
||||
type: 'Type',
|
||||
list: 'list',
|
||||
no_variables: 'No variables yet',
|
||||
no_variables_description: 'Add [[variable]] marks to your document to create dynamic content variables.',
|
||||
type_value: 'Type value',
|
||||
equal: 'Equal',
|
||||
not_equal: 'Not equal',
|
||||
@@ -73,6 +77,7 @@ const en = {
|
||||
up: 'Up',
|
||||
down: 'Down',
|
||||
checked: 'Checked',
|
||||
current_date: 'Current date',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
any: 'Any',
|
||||
@@ -104,6 +109,7 @@ const en = {
|
||||
option: 'Option',
|
||||
options: 'Options',
|
||||
condition: 'Condition',
|
||||
make_dynamic: 'Make dynamic',
|
||||
first_party: 'First Party',
|
||||
second_party: 'Second Party',
|
||||
third_party: 'Third Party',
|
||||
@@ -244,6 +250,10 @@ const es = {
|
||||
search_field: 'Campo de búsqueda',
|
||||
field_not_found: 'Campo no encontrado',
|
||||
clear: 'Borrar',
|
||||
type: 'Tipo',
|
||||
list: 'lista',
|
||||
no_variables: 'Aún sin variables',
|
||||
no_variables_description: 'Agregue marcas [[variable]] a su documento para crear variables de contenido dinámico.',
|
||||
type_value: 'Escriba valor',
|
||||
align: 'Alinear',
|
||||
resize: 'Redimensionar',
|
||||
@@ -277,6 +287,7 @@ const es = {
|
||||
remove_condition: 'Eliminar condición',
|
||||
add_condition: 'Agregar condición',
|
||||
condition: 'Condición',
|
||||
make_dynamic: 'Hacer dinámico',
|
||||
formula: 'Fórmula',
|
||||
edit: 'Editar',
|
||||
settings: 'Configuración',
|
||||
@@ -286,6 +297,7 @@ const es = {
|
||||
are_you_sure_: '¿Estás seguro?',
|
||||
sign_yourself: 'Firma tú mismo',
|
||||
checked: 'Seleccionado',
|
||||
current_date: 'Fecha actual',
|
||||
send: 'Enviar',
|
||||
remove: 'Eliminar',
|
||||
save: 'Guardar',
|
||||
@@ -475,6 +487,10 @@ const it = {
|
||||
with_logo: 'Con logo',
|
||||
unchecked: 'Non selezionato',
|
||||
price: 'Prezzo',
|
||||
type: 'Tipo',
|
||||
list: 'lista',
|
||||
no_variables: 'Ancora nessuna variabile',
|
||||
no_variables_description: 'Aggiungi marcatori [[variable]] al documento per creare variabili di contenuto dinamico.',
|
||||
type_value: 'Inserisci valore',
|
||||
equal: 'Uguale',
|
||||
not_equal: 'Non uguale',
|
||||
@@ -499,6 +515,7 @@ const it = {
|
||||
up: 'Su',
|
||||
down: 'Giù',
|
||||
checked: 'Selezionato',
|
||||
current_date: 'Data corrente',
|
||||
save: 'Salva',
|
||||
cancel: 'Annulla',
|
||||
any: 'Qualsiasi',
|
||||
@@ -530,6 +547,7 @@ const it = {
|
||||
option: 'Opzione',
|
||||
options: 'Opzioni',
|
||||
condition: 'Condizione',
|
||||
make_dynamic: 'Rendi dinamico',
|
||||
first_party: 'Prima parte',
|
||||
second_party: 'Seconda parte',
|
||||
third_party: 'Terza parte',
|
||||
@@ -670,6 +688,10 @@ const pt = {
|
||||
search_field: 'Campo de busca',
|
||||
field_not_found: 'Campo não encontrado',
|
||||
clear: 'Limpar',
|
||||
type: 'Tipo',
|
||||
list: 'lista',
|
||||
no_variables: 'Ainda sem variáveis',
|
||||
no_variables_description: 'Adicione marcações [[variable]] ao documento para criar variáveis de conteúdo dinâmico.',
|
||||
type_value: 'Digite valor',
|
||||
add_all_required_fields_to_continue: 'Adicione todos os campos obrigatórios para continuar',
|
||||
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?',
|
||||
@@ -703,6 +725,7 @@ const pt = {
|
||||
select_value_: 'Selecionar valor...',
|
||||
remove_condition: 'Remover condição',
|
||||
condition: 'Condição',
|
||||
make_dynamic: 'Tornar dinâmico',
|
||||
formula: 'Fórmula',
|
||||
edit: 'Editar',
|
||||
settings: 'Configurações',
|
||||
@@ -712,6 +735,7 @@ const pt = {
|
||||
are_you_sure_: 'Tem certeza?',
|
||||
sign_yourself: 'Assine você mesmo',
|
||||
checked: 'Marcado',
|
||||
current_date: 'Data atual',
|
||||
send: 'Enviar',
|
||||
remove: 'Remover',
|
||||
save: 'Salvar',
|
||||
@@ -901,6 +925,10 @@ const fr = {
|
||||
with_logo: 'Avec logo',
|
||||
unchecked: 'Décoché',
|
||||
price: 'Prix',
|
||||
type: 'Type',
|
||||
list: 'liste',
|
||||
no_variables: 'Pas encore de variables',
|
||||
no_variables_description: 'Ajoutez des balises [[variable]] à votre document pour créer des variables de contenu dynamique.',
|
||||
type_value: 'Saisir une valeur',
|
||||
equal: 'Égal',
|
||||
not_equal: 'Différent',
|
||||
@@ -925,6 +953,7 @@ const fr = {
|
||||
up: 'Haut',
|
||||
down: 'Bas',
|
||||
checked: 'Coché',
|
||||
current_date: 'Date du jour',
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
any: "N'importe lequel",
|
||||
@@ -956,6 +985,7 @@ const fr = {
|
||||
option: 'Option',
|
||||
options: 'Options',
|
||||
condition: 'Condition',
|
||||
make_dynamic: 'Rendre dynamique',
|
||||
first_party: 'Première partie',
|
||||
second_party: 'Deuxième partie',
|
||||
third_party: 'Troisième partie',
|
||||
@@ -1114,6 +1144,10 @@ const de = {
|
||||
with_logo: 'Mit Logo',
|
||||
unchecked: 'Nicht markiert',
|
||||
price: 'Preis',
|
||||
type: 'Typ',
|
||||
list: 'Liste',
|
||||
no_variables: 'Noch keine Variablen',
|
||||
no_variables_description: 'Fügen Sie [[variable]]-Markierungen zu Ihrem Dokument hinzu, um dynamische Inhaltsvariablen zu erstellen.',
|
||||
type_value: 'Wert eingeben',
|
||||
equal: 'Gleich',
|
||||
not_equal: 'Ungleich',
|
||||
@@ -1138,6 +1172,7 @@ const de = {
|
||||
up: 'Nach oben',
|
||||
down: 'Nach unten',
|
||||
checked: 'Markiert',
|
||||
current_date: 'Aktuelles Datum',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
any: 'Beliebig',
|
||||
@@ -1169,6 +1204,7 @@ const de = {
|
||||
option: 'Option',
|
||||
options: 'Optionen',
|
||||
condition: 'Bedingung',
|
||||
make_dynamic: 'Dynamisch machen',
|
||||
first_party: 'Erste Partei',
|
||||
second_party: 'Zweite Partei',
|
||||
third_party: 'Dritte Partei',
|
||||
@@ -1327,6 +1363,10 @@ const nl = {
|
||||
with_logo: 'Met logo',
|
||||
unchecked: 'Niet aangevinkt',
|
||||
price: 'Prijs',
|
||||
type: 'Type',
|
||||
list: 'lijst',
|
||||
no_variables: 'Nog geen variabelen',
|
||||
no_variables_description: 'Voeg [[variable]]-markeringen toe aan uw document om dynamische inhoudsvariabelen te maken.',
|
||||
type_value: 'Typ waarde',
|
||||
equal: 'Gelijk aan',
|
||||
not_equal: 'Niet gelijk aan',
|
||||
@@ -1351,6 +1391,7 @@ const nl = {
|
||||
up: 'Omhoog',
|
||||
down: 'Omlaag',
|
||||
checked: 'Aangevinkt',
|
||||
current_date: 'Huidige datum',
|
||||
save: 'Opslaan',
|
||||
cancel: 'Annuleren',
|
||||
any: 'Elke',
|
||||
@@ -1382,6 +1423,7 @@ const nl = {
|
||||
option: 'Optie',
|
||||
options: 'Opties',
|
||||
condition: 'Voorwaarde',
|
||||
make_dynamic: 'Dynamisch maken',
|
||||
first_party: 'Eerste partij',
|
||||
second_party: 'Tweede partij',
|
||||
third_party: 'Derde partij',
|
||||
|
||||
@@ -18,20 +18,18 @@
|
||||
>
|
||||
<div
|
||||
style="width: 26px"
|
||||
class="flex flex-col justify-between group-hover:opacity-100"
|
||||
:class="{'opacity-0': !item.conditions?.length }"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
|
||||
@click.stop="isShowConditionsModal = true"
|
||||
>
|
||||
<IconRouteAltLeft
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="item.conditions?.length"
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
|
||||
@click.stop="isShowConditionsModal = true"
|
||||
>
|
||||
<IconRouteAltLeft
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="">
|
||||
<ReplaceButton
|
||||
@@ -44,51 +42,105 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col justify-between opacity-0 group-hover:opacity-100"
|
||||
class="flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="dropdown dropdown-end group-hover:opacity-100 has-[label:focus]:opacity-100"
|
||||
:class="{ 'dropdown-open': isMakeDynamicLoading, 'opacity-0': !isMakeDynamicLoading }"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button px-0"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop
|
||||
>
|
||||
<IconDotsVertical
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</label>
|
||||
<ul
|
||||
v-if="renderDropdown"
|
||||
tabindex="0"
|
||||
class="mt-1.5 dropdown-content p-1 shadow-lg rounded-lg border border-neutral-200 z-50 bg-white"
|
||||
style="min-width: 170px"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
|
||||
@click.stop="isShowConditionsModal = true; closeDropdown()"
|
||||
>
|
||||
<span class="flex items-center space-x-2">
|
||||
<IconRouteAltLeft class="w-4 h-4" />
|
||||
<span>{{ t('condition') }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="item.conditions?.length"
|
||||
class="bg-neutral-200 rounded px-1 leading-3"
|
||||
style="font-size: 9px;"
|
||||
>{{ item.conditions.length }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="!item.dynamic">
|
||||
<button
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
|
||||
@click.stop="$emit('reorder', item); closeDropdown()"
|
||||
>
|
||||
<IconSortDescending2 class="w-4 h-4" />
|
||||
<span>{{ t('reorder_fields') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="withDynamicDocuments && !item.dynamic && document.metadata?.original_uuid">
|
||||
<button
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
|
||||
:disabled="isMakeDynamicLoading"
|
||||
@click.stop="makeDynamic"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isMakeDynamicLoading"
|
||||
class="w-4 h-4 animate-spin"
|
||||
/>
|
||||
<IconBolt
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>{{ t('make_dynamic') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<hr class="my-1 border-neutral-200">
|
||||
<li>
|
||||
<button
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm text-red-600"
|
||||
@click.stop="$emit('remove', item); closeDropdown()"
|
||||
>
|
||||
<IconTrashX class="w-4 h-4" />
|
||||
<span>{{ t('remove') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
<div
|
||||
v-if="withArrows"
|
||||
class="flex flex-col space-y-1 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<button
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop="$emit('remove', item)"
|
||||
@click.stop="$emit('up', item)"
|
||||
>
|
||||
×
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col space-y-1"
|
||||
>
|
||||
<span
|
||||
:data-tip="t('reorder_fields')"
|
||||
class="tooltip tooltip-left before:text-xs"
|
||||
<button
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop="$emit('down', item)"
|
||||
>
|
||||
<button
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
|
||||
@click.stop="$emit('reorder', item)"
|
||||
>
|
||||
<IconSortDescending2
|
||||
:width="18"
|
||||
:height="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<template v-if="withArrows">
|
||||
<button
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop="$emit('up', item)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop="$emit('down', item)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</template>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,7 +181,7 @@
|
||||
<script>
|
||||
import Contenteditable from './contenteditable'
|
||||
import Upload from './upload'
|
||||
import { IconRouteAltLeft, IconSortDescending2 } from '@tabler/icons-vue'
|
||||
import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import ReplaceButton from './replace'
|
||||
import GoogleDriveDocumentSettings from './google_drive_document_settings'
|
||||
@@ -140,13 +192,17 @@ export default {
|
||||
name: 'DocumentPreview',
|
||||
components: {
|
||||
Contenteditable,
|
||||
IconInnerShadowTop,
|
||||
IconRouteAltLeft,
|
||||
ConditionsModal,
|
||||
ReplaceButton,
|
||||
GoogleDriveDocumentSettings,
|
||||
IconSortDescending2
|
||||
IconSortDescending2,
|
||||
IconDotsVertical,
|
||||
IconTrashX,
|
||||
IconBolt
|
||||
},
|
||||
inject: ['t', 'getFieldTypeIndex'],
|
||||
inject: ['t', 'getFieldTypeIndex', 'baseFetch'],
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
@@ -175,16 +231,27 @@ export default {
|
||||
required: true,
|
||||
default: true
|
||||
},
|
||||
dynamicDocuments: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
withDynamicDocuments: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withArrows: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder'],
|
||||
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder', 'make-dynamic'],
|
||||
data () {
|
||||
return {
|
||||
isShowConditionsModal: false
|
||||
isShowConditionsModal: false,
|
||||
isMakeDynamicLoading: false,
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -200,6 +267,32 @@ export default {
|
||||
methods: {
|
||||
upload: Upload.methods.upload,
|
||||
buildDefaultName: Field.methods.buildDefaultName,
|
||||
closeDropdown () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
},
|
||||
makeDynamic () {
|
||||
this.isMakeDynamicLoading = true
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/dynamic_documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uuid: this.document.uuid }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
const dynamicDocument = await resp.json()
|
||||
|
||||
if (dynamicDocument.uuid) {
|
||||
this.dynamicDocuments.push(dynamicDocument)
|
||||
}
|
||||
|
||||
this.template.schema.find((item) => item.attachment_uuid === dynamicDocument.uuid).dynamic = true
|
||||
|
||||
this.$emit('change')
|
||||
}).finally(() => {
|
||||
this.isMakeDynamicLoading = false
|
||||
})
|
||||
},
|
||||
onUpdateName (value) {
|
||||
this.item.name = value
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class GenerateAttachmentPreviewJob
|
||||
include Sidekiq::Job
|
||||
|
||||
InvalidFormat = Class.new(StandardError)
|
||||
|
||||
sidekiq_options queue: :images
|
||||
|
||||
def perform(params = {})
|
||||
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
|
||||
|
||||
if attachment.content_type == Templates::ProcessDocument::PDF_CONTENT_TYPE
|
||||
Templates::ProcessDocument.generate_pdf_preview_images(attachment, attachment.download)
|
||||
elsif attachment.image?
|
||||
Templates::ProcessDocument.generate_preview_image(attachment, attachment.download)
|
||||
else
|
||||
raise InvalidFormat, attachment.id
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,7 +13,7 @@
|
||||
# Indexes
|
||||
#
|
||||
# index_document_generation_events_on_submitter_id (submitter_id)
|
||||
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))
|
||||
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: dynamic_documents
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# body :text not null
|
||||
# head :text
|
||||
# sha1 :text not null
|
||||
# uuid :uuid not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# template_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_dynamic_documents_on_template_id (template_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (template_id => templates.id)
|
||||
#
|
||||
class DynamicDocument < ApplicationRecord
|
||||
belongs_to :template
|
||||
|
||||
has_many_attached :attachments
|
||||
|
||||
has_many :versions, class_name: 'DynamicDocumentVersion', dependent: :destroy
|
||||
|
||||
before_validation :set_sha1
|
||||
|
||||
def set_sha1
|
||||
self.sha1 = Digest::SHA1.hexdigest(body)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: dynamic_document_versions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# areas :text not null
|
||||
# sha1 :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# dynamic_document_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_on_dynamic_document_id_sha1_3503adf557 (dynamic_document_id,sha1) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (dynamic_document_id => dynamic_documents.id)
|
||||
#
|
||||
class DynamicDocumentVersion < ApplicationRecord
|
||||
belongs_to :dynamic_document
|
||||
|
||||
has_one_attached :document
|
||||
|
||||
attribute :areas, :string, default: -> { [] }
|
||||
|
||||
serialize :areas, coder: JSON
|
||||
end
|
||||
@@ -20,7 +20,7 @@
|
||||
#
|
||||
# index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
|
||||
# index_email_events_on_email (email)
|
||||
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'permanent_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[]))
|
||||
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('permanent_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))
|
||||
# index_email_events_on_emailable (emailable_type,emailable_id)
|
||||
# index_email_events_on_message_id (message_id)
|
||||
#
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_lock_events_on_event_name_and_key (event_name,key) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))
|
||||
# index_lock_events_on_event_name_and_key (event_name,key) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))
|
||||
# index_lock_events_on_key (key)
|
||||
#
|
||||
class LockEvent < ApplicationRecord
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# name :text
|
||||
# preferences :text not null
|
||||
# slug :string not null
|
||||
# source :text not null
|
||||
# source :string not null
|
||||
# submitters_order :string not null
|
||||
# template_fields :text
|
||||
# template_schema :text
|
||||
@@ -75,6 +75,17 @@ class Submission < ApplicationRecord
|
||||
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
|
||||
through: :template, source: :documents_attachments
|
||||
|
||||
has_many :template_schema_static_documents,
|
||||
->(e) { where(uuid: e.template_schema.reject { |s| s['dynamic'] }.pluck('attachment_uuid')) },
|
||||
through: :template, source: :documents_attachments
|
||||
|
||||
has_many :template_schema_dynamic_document_versions,
|
||||
->(e) { where(sha1: e.template_schema.select { |s| s['dynamic'] }.pluck('dynamic_document_sha1')) },
|
||||
through: :template, source: :dynamic_document_versions
|
||||
|
||||
has_many :template_schema_dynamic_document_attachments,
|
||||
through: :template_schema_dynamic_document_versions, source: :document_attachment
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
scope :pending, lambda {
|
||||
@@ -110,13 +121,51 @@ class Submission < ApplicationRecord
|
||||
end
|
||||
|
||||
def schema_documents
|
||||
if template_id?
|
||||
template_schema_documents
|
||||
return documents_attachments unless template_id?
|
||||
|
||||
dynamic_count = template_schema&.count { |e| e['dynamic'] }.to_i
|
||||
|
||||
if template.variables_schema.blank?
|
||||
if dynamic_count > 0
|
||||
if dynamic_count == template_schema.size
|
||||
template_schema_dynamic_document_attachments
|
||||
else
|
||||
template_schema_dynamic_and_static_document_attachments
|
||||
end
|
||||
else
|
||||
template_schema_documents
|
||||
end
|
||||
elsif dynamic_count > 0 && dynamic_count != template_schema.size
|
||||
template_schema_submission_dynamic_and_static_document_attachments
|
||||
else
|
||||
documents_attachments
|
||||
end
|
||||
end
|
||||
|
||||
def template_schema_submission_dynamic_and_static_document_attachments
|
||||
@template_schema_submission_dynamic_and_static_document_attachments ||=
|
||||
ActiveStorage::Attachment.where(
|
||||
ActiveStorage::Attachment.arel_table[:id].in(
|
||||
template_schema_static_documents.select(:id).arel.union(
|
||||
:all,
|
||||
documents_attachments.select(:id).arel
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def template_schema_dynamic_and_static_document_attachments
|
||||
@template_schema_dynamic_and_static_document_attachments ||=
|
||||
ActiveStorage::Attachment.where(
|
||||
ActiveStorage::Attachment.arel_table[:id].in(
|
||||
template_schema_static_documents.select(:id).arel.union(
|
||||
:all,
|
||||
template_schema_dynamic_document_attachments.select(:id).arel
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def fields_uuid_index
|
||||
@fields_uuid_index ||= (template_fields || template.fields).index_by { |f| f['uuid'] }
|
||||
end
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# index_submission_events_on_created_at (created_at)
|
||||
# index_submission_events_on_submission_id (submission_id)
|
||||
# index_submission_events_on_submitter_id (submitter_id)
|
||||
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))
|
||||
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY (ARRAY[('send_sms'::character varying)::text, ('send_2fa_sms'::character varying)::text]))
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
|
||||
@@ -70,6 +70,12 @@ class Template < ApplicationRecord
|
||||
has_many :submissions, dependent: :destroy
|
||||
has_many :template_sharings, dependent: :destroy
|
||||
has_many :template_accesses, dependent: :destroy
|
||||
has_many :dynamic_documents, dependent: :destroy
|
||||
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
|
||||
|
||||
has_many :schema_dynamic_documents, lambda { |e|
|
||||
where(uuid: e.schema.select { |e| e['dynamic'] }.pluck('attachment_uuid'))
|
||||
}, class_name: 'DynamicDocument', dependent: :destroy, inverse_of: :template
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
|
||||
<dynamic-list class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<div class="card card-compact bg-base-300/40" data-targets="dynamic-list.items">
|
||||
<div class="card-body">
|
||||
<div class="<%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
|
||||
<div>
|
||||
<div class="absolute right-4 top-5">
|
||||
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
|
||||
<%= svg_icon('trash', class: 'w-4 h-4') %>
|
||||
@@ -86,13 +86,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? && local_assigns[:recipient_form_fields].blank? %>
|
||||
<% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? && local_assigns[:recipient_form_fields].blank? && local_assigns[:variables_form].blank? %>
|
||||
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
|
||||
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
|
||||
<span><%= t('add_new') %></span>
|
||||
</a>
|
||||
<% end %>
|
||||
</dynamic-list>
|
||||
<%= local_assigns[:variables_form] %>
|
||||
<div>
|
||||
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
|
||||
<%= render 'send_email', f:, template: %>
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
|
||||
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
|
||||
<% if submitters.size == 1 %>
|
||||
<% if submitters.size == 1 && local_assigns[:variables_form] %>
|
||||
<% item = submitters.first %>
|
||||
<div class="grid gap-1">
|
||||
<submitter-item class="grid md:grid-cols-2 gap-1">
|
||||
<div class="form-control">
|
||||
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
|
||||
<submitters-autocomplete data-field="email">
|
||||
<linked-input data-target-id="<%= "email_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
|
||||
<%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: t('email'), required: true, value: item['email'].presence || (params[:selfsign] || item['is_requester'] ? current_user.email : ''), id: "email_#{item['uuid']}" %>
|
||||
</linked-input>
|
||||
</submitters-autocomplete>
|
||||
</div>
|
||||
<div class="form-control flex">
|
||||
<submitters-autocomplete data-field="name">
|
||||
<linked-input data-target-id="<%= "email_name_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
|
||||
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="base-input !h-10 w-full" placeholder="<%= "#{t('name')} (#{t('optional')})" %>" value="<%= params[:selfsign] || item['is_requester'] ? current_user.full_name : '' %>" dir="auto" id="email_name_<%= item['uuid'] %>">
|
||||
</linked-input>
|
||||
</submitters-autocomplete>
|
||||
</div>
|
||||
</submitter-item>
|
||||
</div>
|
||||
<% elsif submitters.size == 1 %>
|
||||
<submitter-item class="form-control">
|
||||
<emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 40 : 1) : nil %>">
|
||||
<submitters-autocomplete data-field="email" class="block relative">
|
||||
@@ -13,8 +34,8 @@
|
||||
<% else %>
|
||||
<dynamic-list class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<div class="card card-compact bg-base-300/40" data-targets="dynamic-list.items">
|
||||
<div class="card-body">
|
||||
<div class="<%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
|
||||
<div>
|
||||
<div class="absolute right-4 top-5">
|
||||
<a href="#" data-action="click:dynamic-list#removeItem" class="-top-3 relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
|
||||
<%= svg_icon('trash', class: 'w-4 h-4') %>
|
||||
@@ -38,7 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% if params[:selfsign].blank? %>
|
||||
<% if params[:selfsign].blank? && local_assigns[:variables_form].blank? %>
|
||||
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
|
||||
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
|
||||
<span><%= t('add_new') %></span>
|
||||
@@ -46,6 +67,7 @@
|
||||
<% end %>
|
||||
</dynamic-list>
|
||||
<% end %>
|
||||
<%= local_assigns[:variables_form] %>
|
||||
<div>
|
||||
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
|
||||
<%= render 'send_email', f:, template: %>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
|
||||
<dynamic-list class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<div class="card card-compact bg-base-300/40" data-targets="dynamic-list.items">
|
||||
<div class="card-body">
|
||||
<div class="<%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
|
||||
<div>
|
||||
<div class="absolute right-4 top-5">
|
||||
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
|
||||
<%= svg_icon('trash', class: 'w-4 h-4') %>
|
||||
@@ -49,13 +49,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% if params[:selfsign].blank? %>
|
||||
<% if params[:selfsign].blank? && local_assigns[:variables_form].blank? %>
|
||||
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
|
||||
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
|
||||
<span><%= t('add_new') %></span>
|
||||
</a>
|
||||
<% end %>
|
||||
</dynamic-list>
|
||||
<%= local_assigns[:variables_form] %>
|
||||
<div>
|
||||
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
|
||||
<%= render 'send_sms', f:, checked: true %>
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
<% default_tab = cookies.permanent[:add_recipients_tab].presence || 'email' %>
|
||||
<% recipient_form_fields = Accounts.load_recipient_form_fields(current_account) if prefillable_fields.blank? %>
|
||||
<% only_detailed = require_phone_2fa || require_email_2fa || prefillable_fields.present? || recipient_form_fields.present? %>
|
||||
<% with_list = @template.variables_schema.blank? %>
|
||||
<% variables_form = render 'variables_form', schema: @template.variables_schema if @template.variables_schema.present? && @template.variables_schema.any? { |_, v| !v['disabled'] } %>
|
||||
<%= render 'shared/turbo_modal_large', title: params[:selfsign] ? t('add_recipients') : t('add_new_recipients') do %>
|
||||
<% options = [only_detailed ? nil : [t('via_email'), 'email'], only_detailed ? nil : [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], [t('upload_list'), 'list']].compact %>
|
||||
<% options = [only_detailed ? nil : [t('via_email'), 'email'], only_detailed ? nil : [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], with_list ? [t('upload_list'), 'list'] : nil].compact %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
|
||||
<div class="flex justify-center">
|
||||
<% options.each_with_index do |(label, value), index| %>
|
||||
@@ -21,18 +23,20 @@
|
||||
<div class="px-5 mb-5 mt-4">
|
||||
<% unless only_detailed %>
|
||||
<div id="email" class="<%= 'hidden' if default_tab != 'email' %>">
|
||||
<%= render 'email_form', template: @template %>
|
||||
<%= render 'email_form', template: @template, variables_form: %>
|
||||
</div>
|
||||
<div id="phone" class="<%= 'hidden' if default_tab != 'phone' %>">
|
||||
<%= render 'phone_form', template: @template %>
|
||||
<%= render 'phone_form', template: @template, variables_form: %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div id="detailed" class="<%= 'hidden' if !only_detailed && default_tab != 'detailed' %>">
|
||||
<%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields: %>
|
||||
</div>
|
||||
<div id="list" class="hidden">
|
||||
<%= render 'list_form', template: @template %>
|
||||
<%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields:, variables_form: %>
|
||||
</div>
|
||||
<% if with_list %>
|
||||
<div id="list" class="hidden">
|
||||
<%= render 'list_form', template: @template %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'submissions/error' %>
|
||||
</div>
|
||||
<%= content_for(:modal_extra) %>
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
|
||||
<% lazyload_metadata = document.preview_images.first&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
|
||||
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
|
||||
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
|
||||
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %>
|
||||
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
|
||||
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>">
|
||||
<div class="top-0 bottom-0 left-0 right-0 absolute">
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
|
||||
<% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
|
||||
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
|
||||
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
|
||||
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %>
|
||||
<page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
|
||||
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>">
|
||||
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
|
||||
|
||||
@@ -49,9 +49,9 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= link_to template_share_link_path(template), class: 'absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2', data: { turbo_frame: :modal } do %>
|
||||
<%= link_to template_share_link_path(template), class: "absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2 #{'btn-disabled text-base-100' if template.variables_schema.present?}", data: { turbo_frame: :modal } do %>
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<%= svg_icon('link', class: 'w-4 h-4 md:w-6 md:h-6 text-white') %>
|
||||
<%= svg_icon('link', class: 'w-4 h-4 md:w-6 md:h-6') %>
|
||||
<span><%= t('link') %></span>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<span class="mr-1"><%= t('send_to_recipients') %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if Templates.filter_undefined_submitters(@template.submitters).size == 1 %>
|
||||
<% if Templates.filter_undefined_submitters(@template.submitters).size == 1 && @template.variables_schema.blank? %>
|
||||
<%= button_to start_form_path(@template.slug), params: { selfsign: true }, method: :put, class: 'white-button w-full', form: { style: 'display: inline', target: '_blank', data: { turbo: false } } do %>
|
||||
<%= svg_icon('writing', class: 'w-6 h-6') %>
|
||||
<span class="mr-1"><%= t('sign_it_yourself') %></span>
|
||||
|
||||
@@ -152,7 +152,7 @@ Rails.application.configure do
|
||||
template_id: params[:template_id],
|
||||
submission_id: params[:submission_id],
|
||||
submitter_id: params[:submitter_id],
|
||||
sig: (params[:signed_uuid] || params[:signed_id]).to_s.split('--').first,
|
||||
sig: (params[:signed_key] || params[:signed_uuid] || params[:signed_id]).to_s.split('--').first,
|
||||
slug: (params[:slug] ||
|
||||
params[:submitter_slug] ||
|
||||
params[:submission_slug] ||
|
||||
|
||||
@@ -8,6 +8,10 @@ ActiveSupport.on_load(:active_storage_attachment) do
|
||||
def signed_uuid
|
||||
@signed_uuid ||= ApplicationRecord.signed_id_verifier.generate(uuid, expires_in: 6.hours, purpose: :attachment)
|
||||
end
|
||||
|
||||
def signed_key
|
||||
@signed_key ||= ApplicationRecord.signed_id_verifier.generate([id, uuid], expires_in: 6.hours, purpose: :attachment)
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
|
||||
@@ -29,6 +29,7 @@ en: &en
|
||||
pro: Pro
|
||||
thanks: Thanks
|
||||
private: Private
|
||||
_variables: Variables
|
||||
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Make all newly created templates private to their creator and admins by default.
|
||||
create_templates_with_admin_access_by_default: Create templates with admin access by default
|
||||
require_email_2fa: Require email 2FA
|
||||
@@ -1049,6 +1050,7 @@ es: &es
|
||||
stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada.
|
||||
re_connect_stripe: Volver a conectar Stripe
|
||||
private: Privado
|
||||
_variables: Variables
|
||||
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Hacer que todas las plantillas recién creadas sean privadas para su creador y los administradores por defecto.
|
||||
create_templates_with_admin_access_by_default: Crear plantillas con acceso de administrador por defecto
|
||||
require_email_2fa: Requerir 2FA por correo electrónico
|
||||
@@ -2051,6 +2053,7 @@ it: &it
|
||||
stripe_account_has_been_connected: L'account Stripe è stato collegato.
|
||||
re_connect_stripe: Ricollega Stripe
|
||||
private: Privato
|
||||
_variables: Variabili
|
||||
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Rendere tutte le nuove template private per il creatore e gli amministratori per impostazione predefinita.
|
||||
create_templates_with_admin_access_by_default: Crea modelli con accesso amministratore per impostazione predefinita
|
||||
require_email_2fa: Richiedi 2FA email
|
||||
@@ -3054,6 +3057,7 @@ fr: &fr
|
||||
stripe_account_has_been_connected: Le compte Stripe a été connecté.
|
||||
re_connect_stripe: Reconnecter Stripe
|
||||
private: Privé
|
||||
_variables: Variables
|
||||
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Rendre tous les nouveaux modèles privés pour leur créateur et les administrateurs par défaut.
|
||||
create_templates_with_admin_access_by_default: Créer des modèles avec un accès administrateur par défaut
|
||||
require_email_2fa: Exiger la 2FA par email
|
||||
@@ -4053,6 +4057,7 @@ pt: &pt
|
||||
stripe_account_has_been_connected: Conta Stripe foi conectada.
|
||||
re_connect_stripe: Reconectar Stripe
|
||||
private: Privado
|
||||
_variables: Variáveis
|
||||
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Tornar todos os modelos recém-criados privados para seu criador e administradores por padrão.
|
||||
create_templates_with_admin_access_by_default: Criar modelos com acesso de administrador por padrão
|
||||
require_email_2fa: Exigir 2FA por email
|
||||
@@ -5041,6 +5046,7 @@ de: &de
|
||||
pro: Pro
|
||||
thanks: Danke
|
||||
private: Privat
|
||||
_variables: Variablen
|
||||
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Alle neu erstellten Vorlagen standardmäßig nur für ihren Ersteller und Administratoren sichtbar machen.
|
||||
create_templates_with_admin_access_by_default: Vorlagen standardmäßig mit Administratorzugriff erstellen
|
||||
require_email_2fa: E-Mail 2FA erforderlich
|
||||
@@ -6431,6 +6437,7 @@ nl: &nl
|
||||
pro: Pro
|
||||
thanks: Bedankt
|
||||
private: Privé
|
||||
_variables: Variabelen
|
||||
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Maak alle nieuw aangemaakte sjablonen standaard privé voor hun maker en admins.
|
||||
create_templates_with_admin_access_by_default: Sjablonen standaard met admin-toegang maken
|
||||
require_email_2fa: E-mail 2FA vereist
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ Rails.application.routes.draw do
|
||||
resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields'
|
||||
resources :submissions_export, only: %i[index new]
|
||||
end
|
||||
resources :preview_document_page, only: %i[show], path: '/preview/:signed_uuid'
|
||||
resources :preview_document_page, only: %i[show], path: '/preview/:signed_key'
|
||||
resource :blobs_proxy, only: %i[show], path: '/file/:signed_uuid/*filename',
|
||||
controller: 'api/active_storage_blobs_proxy'
|
||||
resource :blobs_proxy, only: %i[show], path: '/blobs_proxy/:signed_uuid/*filename',
|
||||
|
||||
@@ -14,7 +14,7 @@ const configs = generateWebpackConfig({
|
||||
concatenateModules: !process.env.BUNDLE_ANALYZE,
|
||||
splitChunks: {
|
||||
chunks (chunk) {
|
||||
return chunk.name !== 'rollbar'
|
||||
return chunk.name !== 'rollbar' && chunk.name !== 'dynamic-editor'
|
||||
},
|
||||
cacheGroups: {
|
||||
default: false,
|
||||
@@ -39,8 +39,14 @@ const configs = generateWebpackConfig({
|
||||
].filter(Boolean)
|
||||
})
|
||||
|
||||
configs.module.rules[3].exclude = /dynamic_styles\.scss$/
|
||||
|
||||
configs.module = merge({
|
||||
rules: [
|
||||
{
|
||||
test: /dynamic_styles\.scss$/,
|
||||
use: ['css-loader', 'postcss-loader', 'sass-loader']
|
||||
},
|
||||
{
|
||||
test: /\.vue$/,
|
||||
use: [{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateDynamicDocuments < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :dynamic_documents do |t|
|
||||
t.uuid :uuid, null: false
|
||||
t.references :template, null: false, foreign_key: true, index: true
|
||||
t.text :body, null: false
|
||||
t.text :head
|
||||
t.text :sha1, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateDynamicDocumentVersions < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :dynamic_document_versions do |t|
|
||||
t.references :dynamic_document, null: false, foreign_key: true, index: false
|
||||
t.string :sha1, null: false
|
||||
t.text :areas, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :dynamic_document_versions, %i[dynamic_document_id sha1], unique: true
|
||||
end
|
||||
end
|
||||
+22
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_11_25_194305) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_02_16_162053) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "plpgsql"
|
||||
@@ -168,6 +168,26 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_25_194305) do
|
||||
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
|
||||
end
|
||||
|
||||
create_table "dynamic_document_versions", force: :cascade do |t|
|
||||
t.text "areas", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "dynamic_document_id", null: false
|
||||
t.string "sha1", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["dynamic_document_id", "sha1"], name: "idx_on_dynamic_document_id_sha1_3503adf557", unique: true
|
||||
end
|
||||
|
||||
create_table "dynamic_documents", force: :cascade do |t|
|
||||
t.text "body", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.text "head"
|
||||
t.text "sha1", null: false
|
||||
t.bigint "template_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.uuid "uuid", null: false
|
||||
t.index ["template_id"], name: "index_dynamic_documents_on_template_id"
|
||||
end
|
||||
|
||||
create_table "email_events", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "emailable_type", null: false
|
||||
@@ -507,6 +527,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_25_194305) do
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "document_generation_events", "submitters"
|
||||
add_foreign_key "dynamic_documents", "templates"
|
||||
add_foreign_key "email_events", "accounts"
|
||||
add_foreign_key "email_messages", "accounts"
|
||||
add_foreign_key "email_messages", "users", column: "author_id"
|
||||
|
||||
@@ -47,6 +47,7 @@ module Params
|
||||
email_format(params, :bcc_completed, message: 'bcc_completed email is invalid')
|
||||
email_format(params, :reply_to, message: 'reply_to email is invalid')
|
||||
type(params, :message, Hash)
|
||||
type(params, :variables, Hash)
|
||||
type(params, :submitters, Array)
|
||||
|
||||
in_path(params, :message, skip_blank: true) do |message_params|
|
||||
|
||||
@@ -37,7 +37,7 @@ Puma::Plugin.create do
|
||||
wait_for_redis!
|
||||
|
||||
configs = Sidekiq.configure_embed do |config|
|
||||
config.logger.level = Logger::INFO
|
||||
config.logger.level = Rails.env.development? ? Logger::DEBUG : Logger::INFO
|
||||
sidekiq_config = YAML.load_file('config/sidekiq.yml')
|
||||
sidekiq_config['queues'] << 'fields' if ENV['DEMO'] == 'true'
|
||||
config.queues = sidekiq_config['queues']
|
||||
|
||||
+5
-5
@@ -80,10 +80,8 @@ module Submissions
|
||||
|
||||
def preload_with_pages(submission)
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [submission],
|
||||
associations: [
|
||||
submission.template_id? ? { template_schema_documents: :blob } : { documents_attachments: :blob }
|
||||
]
|
||||
records: submission.schema_documents,
|
||||
associations: [:blob]
|
||||
).call
|
||||
|
||||
total_pages =
|
||||
@@ -92,7 +90,7 @@ module Submissions
|
||||
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: submission.schema_documents,
|
||||
associations: [:blob, { preview_images_attachments: :blob }]
|
||||
associations: [{ preview_images_attachments: :blob }]
|
||||
).call
|
||||
end
|
||||
|
||||
@@ -117,6 +115,8 @@ module Submissions
|
||||
preferences:,
|
||||
sent_at: mark_as_sent ? Time.current : nil)
|
||||
|
||||
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submission)
|
||||
|
||||
submission.save!
|
||||
|
||||
if submission.expire_at?
|
||||
|
||||
@@ -71,6 +71,7 @@ module Submissions
|
||||
preferences: preferences.merge(submission_preferences))
|
||||
end
|
||||
|
||||
maybe_set_dynamic_documents(submission)
|
||||
maybe_set_template_fields(submission, attrs[:submitters], with_template:, new_fields:)
|
||||
|
||||
if submission.submitters.size > template.submitters.size
|
||||
@@ -97,6 +98,44 @@ module Submissions
|
||||
submissions
|
||||
end
|
||||
|
||||
def maybe_set_dynamic_documents(submission)
|
||||
return submission unless submission.template_id?
|
||||
|
||||
template = submission.template
|
||||
|
||||
return submission if template.variables_schema.present? ||
|
||||
submission.variables_schema.present?
|
||||
|
||||
areas_index = {}
|
||||
submission.template_schema = []
|
||||
|
||||
template.schema.each do |item|
|
||||
if item['dynamic']
|
||||
dynamic_document = template.schema_dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
|
||||
dynamic_document_version = DynamicDocuments::EnsureVersionGenerated.call(dynamic_document)
|
||||
|
||||
dynamic_document_version.areas.each { |area| areas_index[area['uuid']] = area }
|
||||
|
||||
submission.template_schema << item.deep_dup.merge('dynamic_document_sha1' => dynamic_document.sha1)
|
||||
else
|
||||
submission.template_schema << item.deep_dup
|
||||
end
|
||||
end
|
||||
|
||||
submission.template_fields = template.fields.deep_dup
|
||||
|
||||
submission.template_fields.each do |field|
|
||||
field['areas'].to_a.each do |area|
|
||||
dynamic_area = areas_index[area['uuid']]
|
||||
|
||||
area.merge!(dynamic_area) if dynamic_area
|
||||
end
|
||||
end
|
||||
|
||||
submission
|
||||
end
|
||||
|
||||
def maybe_enqueue_expire_at(submissions)
|
||||
submissions.each do |submission|
|
||||
next unless submission.expire_at?
|
||||
@@ -159,7 +198,8 @@ module Submissions
|
||||
end
|
||||
|
||||
if template_fields != (submission.template_fields || submission.template.fields) || new_fields.present? ||
|
||||
submitters_attrs.any? { |e| e[:completed].present? } || !with_template || submission.variables.present?
|
||||
submitters_attrs.any? { |e| e[:completed].present? } || !with_template || submission.variables.present? ||
|
||||
submission.template&.variables_schema.present?
|
||||
submission.template_fields = new_fields ? new_fields + template_fields : template_fields
|
||||
submission.template_schema = submission.template.schema if submission.template_schema.blank?
|
||||
submission.variables_schema = submission.template.variables_schema if submission.template &&
|
||||
|
||||
@@ -10,6 +10,7 @@ module Templates
|
||||
|
||||
template.external_id = external_id
|
||||
template.shared_link = original_template.shared_link
|
||||
template.variables_schema = original_template.variables_schema
|
||||
template.author = author
|
||||
template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})"
|
||||
|
||||
|
||||
@@ -36,11 +36,9 @@ module Templates
|
||||
next unless new_attachment_uuid
|
||||
|
||||
new_document =
|
||||
template.documents_attachments.new(
|
||||
uuid: new_attachment_uuid,
|
||||
blob_id: document.blob_id
|
||||
)
|
||||
template.documents_attachments.new(uuid: new_attachment_uuid, blob_id: document.blob_id)
|
||||
|
||||
maybe_clone_dynamic_document(template, original_template, new_document, document)
|
||||
clone_document_preview_images_attachments(document:, new_document:)
|
||||
|
||||
new_document
|
||||
@@ -51,6 +49,32 @@ module Templates
|
||||
attachments
|
||||
end
|
||||
|
||||
def maybe_clone_dynamic_document(template, original_template, document, original_document)
|
||||
schema_item = original_template.schema.find { |e| e['attachment_uuid'] == original_document.uuid }
|
||||
|
||||
return unless schema_item
|
||||
return unless schema_item['dynamic']
|
||||
|
||||
dynamic_document = original_template.dynamic_documents.find { |e| e.uuid == original_document.uuid }
|
||||
|
||||
return unless dynamic_document
|
||||
|
||||
new_dynamic_document = template.dynamic_documents.new(
|
||||
uuid: document.uuid,
|
||||
body: dynamic_document.body,
|
||||
head: dynamic_document.head
|
||||
)
|
||||
|
||||
dynamic_document.attachments_attachments.each do |attachment|
|
||||
new_dynamic_document.attachments_attachments.new(
|
||||
uuid: attachment.uuid,
|
||||
blob_id: attachment.blob_id
|
||||
)
|
||||
end
|
||||
|
||||
new_dynamic_document
|
||||
end
|
||||
|
||||
def clone_document_preview_images_attachments(document:, new_document:)
|
||||
document.preview_images_attachments.each do |preview_image|
|
||||
new_document.preview_images_attachments.new(blob_id: preview_image.blob_id)
|
||||
|
||||
@@ -24,10 +24,18 @@ module Templates
|
||||
|
||||
module_function
|
||||
|
||||
def call(template, params, extract_fields: false)
|
||||
extract_zip_files(params[:files].presence || params[:file]).flat_map do |file|
|
||||
handle_file_types(template, file, params, extract_fields:)
|
||||
def call(template, params, extract_fields: false, dynamic: false)
|
||||
documents = []
|
||||
dynamic_documents = []
|
||||
|
||||
extract_zip_files(params[:files].presence || params[:file]).each do |file|
|
||||
docs, dynamic_docs = handle_file_types(template, file, params, extract_fields:, dynamic:)
|
||||
|
||||
documents.push(*docs)
|
||||
dynamic_documents.push(*dynamic_docs)
|
||||
end
|
||||
|
||||
[documents, dynamic_documents]
|
||||
end
|
||||
|
||||
def handle_pdf_or_image(template, file, document_data = nil, params = {}, extract_fields: false)
|
||||
@@ -108,12 +116,12 @@ module Templates
|
||||
extracted_files
|
||||
end
|
||||
|
||||
def handle_file_types(template, file, params, extract_fields:)
|
||||
def handle_file_types(template, file, params, extract_fields:, dynamic: false)
|
||||
if file.content_type.include?('image') || file.content_type == PDF_CONTENT_TYPE
|
||||
return handle_pdf_or_image(template, file, file.read, params, extract_fields:)
|
||||
return [handle_pdf_or_image(template, file, file.read, params, extract_fields:), []]
|
||||
end
|
||||
|
||||
raise InvalidFileType, file.content_type
|
||||
raise InvalidFileType, "#{file.content_type}/#{dynamic}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ module Templates
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def call(template, params = {}, extract_fields: false)
|
||||
documents = Templates::CreateAttachments.call(template, params, extract_fields:)
|
||||
documents, = Templates::CreateAttachments.call(template, params, extract_fields:)
|
||||
submitter = template.submitters.first
|
||||
|
||||
documents.each_with_index do |document, index|
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/extension-bold": "^3.19.0",
|
||||
"@tiptap/extension-document": "^3.19.0",
|
||||
"@tiptap/extension-dropcursor": "^3.19.0",
|
||||
"@tiptap/extension-gapcursor": "^3.19.0",
|
||||
"@tiptap/extension-hard-break": "^3.19.0",
|
||||
"@tiptap/extension-history": "^3.19.0",
|
||||
"@tiptap/extension-italic": "^3.19.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/extension-paragraph": "^3.19.0",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const path = require('path')
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
path.resolve(__dirname, 'app/javascript/template_builder/dynamic_area.vue'),
|
||||
path.resolve(__dirname, 'app/javascript/template_builder/dynamic_section.vue')
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'base-100': '#faf7f5',
|
||||
'base-200': '#efeae6',
|
||||
'base-300': '#e7e2df',
|
||||
'base-content': '#291334'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1787,11 +1787,26 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.19.0.tgz#dfa6889cff748d489e0bc1028918bf4571372ba5"
|
||||
integrity sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A==
|
||||
|
||||
"@tiptap/extension-dropcursor@^3.19.0":
|
||||
version "3.19.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.19.0.tgz#fbef441944842f23fe0a35154b519103166a4848"
|
||||
integrity sha512-sf3dEZXiLvsGqVK2maUIzXY6qtYYCvBumag7+VPTMGQ0D4hiZ1X/4ukt4+6VXDg5R2WP1CoIt/QvUetUjWNhbQ==
|
||||
|
||||
"@tiptap/extension-gapcursor@^3.19.0":
|
||||
version "3.19.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.19.0.tgz#64e5462a4ab2f0bd110738410dcbf3597d76349f"
|
||||
integrity sha512-w7DACS4oSZaDWjz7gropZHPc9oXqC9yERZTcjWxyORuuIh1JFf0TRYspleK+OK28plK/IftojD/yUDn1MTRhvA==
|
||||
|
||||
"@tiptap/extension-hard-break@^3.19.0":
|
||||
version "3.19.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.19.0.tgz#7120524cec9ed4b957963693cb4c57cbecbaecf8"
|
||||
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":
|
||||
version "3.19.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz#af2a9c095ec846e379041f3e17e1dd101a5a4bf8"
|
||||
@@ -6030,9 +6045,9 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor
|
||||
prosemirror-model "^1.21.0"
|
||||
|
||||
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.41.4:
|
||||
version "1.41.5"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.5.tgz#3e152d14af633f2f5a73aba24e6130c63f643b2b"
|
||||
integrity sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==
|
||||
version "1.41.6"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.6.tgz#949d0407a91e36f6024db2191b8d3058dfd18838"
|
||||
integrity sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==
|
||||
dependencies:
|
||||
prosemirror-model "^1.20.0"
|
||||
prosemirror-state "^1.0.0"
|
||||
|
||||
Reference in New Issue
Block a user