Compare commits
19 Commits
911e55ccc3
...
eea44bda34
| Author | SHA1 | Date |
|---|---|---|
|
|
eea44bda34 | 2 weeks ago |
|
|
7cdf263da1 | 2 weeks ago |
|
|
ee65a5693c | 2 weeks ago |
|
|
0af6ccf35f | 2 weeks ago |
|
|
888f1ec6df | 2 weeks ago |
|
|
c95a8616ac | 3 weeks ago |
|
|
5ea6289b7a | 3 weeks ago |
|
|
d4a79ca5db | 3 weeks ago |
|
|
70015ce1c4 | 3 weeks ago |
|
|
6b85c28944 | 3 weeks ago |
|
|
6c289cf273 | 3 weeks ago |
|
|
a64bc3c618 | 3 weeks ago |
|
|
46cf1e3067 | 3 weeks ago |
|
|
565e1eb2bc | 3 weeks ago |
|
|
e689687805 | 3 weeks ago |
|
|
3c3b61fb47 | 3 weeks ago |
|
|
1355d350c5 | 3 weeks ago |
|
|
fda911e178 | 3 weeks ago |
|
|
f995e1864c | 3 weeks ago |
@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormMetadataController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return head :not_found if submitter.declined_at? ||
|
||||
submitter.completed_at? ||
|
||||
submitter.submission.archived_at? ||
|
||||
submitter.submission.expired? ||
|
||||
submitter.submission.template&.archived_at? ||
|
||||
submitter.account.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
|
||||
submission = submitter.submission
|
||||
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
|
||||
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: submitter.uuid)
|
||||
|
||||
documents = schema.filter_map do |item|
|
||||
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
|
||||
end
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
|
||||
|
||||
text_runs = documents.to_h do |document|
|
||||
[
|
||||
document.uuid,
|
||||
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
|
||||
]
|
||||
end
|
||||
|
||||
render json: { text_runs: }
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesShareLinkQrController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show
|
||||
return render :disabled, layout: 'plain' unless @template.shared_link?
|
||||
|
||||
shared_link_url = start_form_url(slug: @template.slug, host: form_link_host)
|
||||
|
||||
@qr_svg_code = RQRCode::QRCode.new(shared_link_url, level: :m).as_svg(viewbox: true)
|
||||
|
||||
@page_size =
|
||||
if TimeUtils.timezone_abbr(current_account.timezone, Time.current.beginning_of_year).in?(TimeUtils::US_TIMEZONES)
|
||||
'Letter'
|
||||
else
|
||||
'A4'
|
||||
end
|
||||
|
||||
render :show, layout: false
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,18 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const dialog = document.getElementById(this.dataset.target)
|
||||
|
||||
this.querySelector('button').addEventListener('click', () => {
|
||||
if (dialog) {
|
||||
dialog.inert = false
|
||||
dialog.showModal()
|
||||
}
|
||||
})
|
||||
|
||||
if (dialog) {
|
||||
dialog.addEventListener('close', () => {
|
||||
dialog.inert = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<template
|
||||
v-for="(pages, docUuid) in textRuns"
|
||||
:key="docUuid"
|
||||
>
|
||||
<template
|
||||
v-for="(_, pageIndex) in pages"
|
||||
:key="pageIndex"
|
||||
>
|
||||
<template
|
||||
v-for="(pageElem, i) in [findPageElement(docUuid, pageIndex)]"
|
||||
:key="i"
|
||||
>
|
||||
<Teleport
|
||||
v-if="pageElem"
|
||||
:to="pageElem"
|
||||
>
|
||||
<template
|
||||
v-for="(item, index) in sortedItemsForPage(docUuid, pageIndex)"
|
||||
:key="index"
|
||||
>
|
||||
<template v-if="item.type === 'text_group'">
|
||||
<span
|
||||
class="absolute overflow-hidden text-transparent select-none pointer-events-none"
|
||||
:style="{ left: item.x * 100 + '%', top: item.y * 100 + '%', width: item.w * 100 + '%', height: item.h * 100 + '%' }"
|
||||
>{{ item.text }}</span>
|
||||
<span
|
||||
v-for="(run, runIndex) in item.items"
|
||||
:key="runIndex"
|
||||
aria-hidden="true"
|
||||
class="absolute overflow-hidden text-transparent"
|
||||
:style="{ left: run.x * 100 + '%', top: run.y * 100 + '%', width: run.w * 100 + '%', height: run.h * 100 + '%', fontSize: run.font_size ? (run.font_size / 10) + 'cqmin' : undefined, textAlign: 'justify', textAlignLast: 'justify', textJustify: 'inter-character' }"
|
||||
>{{ run.text }}</span>
|
||||
</template>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'field_area'"
|
||||
:ref="setAreaRef"
|
||||
v-model="values[item.field.uuid]"
|
||||
:values="values"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
:submittable="true"
|
||||
:page-width="1400"
|
||||
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
|
||||
:field-index="item.fieldIndex"
|
||||
:is-inline-size="isInlineSize"
|
||||
:scroll-padding="scrollPadding"
|
||||
:submitter="submitter"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:with-signature-id="withSignatureId"
|
||||
:is-active="currentStep === item.step"
|
||||
:with-label="withLabel && !withFieldPlaceholder && item.step.length < 2"
|
||||
:is-value-set="item.step.some((f) => f.uuid in values)"
|
||||
:attachments-index="attachmentsIndex"
|
||||
@click="[$emit('focus-step', item.stepIndex), maybeScrollOnClick(item.field, item.area)]"
|
||||
/>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'readonly_field_area'"
|
||||
:model-value="readonlyConditionalFieldValues[item.field.uuid]"
|
||||
:values="readonlyConditionalFieldValues"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
:submittable="false"
|
||||
:page-width="1400"
|
||||
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
|
||||
:field-index="item.fieldIndex"
|
||||
:is-inline-size="isInlineSize"
|
||||
:submitter="submitter"
|
||||
:attachments-index="attachmentsIndex"
|
||||
/>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'formula_area' && isMathLoaded"
|
||||
:model-value="calculateFormula(item.field)"
|
||||
:is-inline-size="isInlineSize"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
:submittable="false"
|
||||
:field-index="item.fieldIndex"
|
||||
/>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldArea from './area'
|
||||
import FormulaAreas from './formula_areas'
|
||||
import FieldAreas from './areas'
|
||||
|
||||
export default {
|
||||
name: 'AccessibilityAreas',
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
props: {
|
||||
submitterSlug: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
filledFieldsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
steps: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
readonlyConditionalFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
readonlyConditionalFieldValues: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
formulaFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
readonlyValues: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
submitter: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
currentStep: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
withFieldPlaceholder: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withLabel: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
scrollPadding: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '-80px'
|
||||
},
|
||||
scrollEl: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
attachmentsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
fetchOptions: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['focus-step'],
|
||||
data () {
|
||||
return {
|
||||
isMathLoaded: false,
|
||||
math: null,
|
||||
textRuns: {},
|
||||
areaRefs: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldValuesIndex () {
|
||||
return this.filledFieldsIndex || this.extractStaticValues()
|
||||
},
|
||||
isMobileContainer: FieldAreas.computed.isMobileContainer,
|
||||
isInlineSize: FieldAreas.computed.isInlineSize,
|
||||
fieldsUuidIndex () {
|
||||
return this.formulaFields.reduce((acc, field) => {
|
||||
acc[field.uuid] = field
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
fieldAreasIndex () {
|
||||
const index = Object.create(null)
|
||||
|
||||
this.steps.forEach((step, stepIndex) => {
|
||||
step.forEach((field, fieldIndex) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
index[area.attachment_uuid] ||= Object.create(null)
|
||||
index[area.attachment_uuid][area.page] ||= []
|
||||
index[area.attachment_uuid][area.page].push({
|
||||
type: 'field_area',
|
||||
field,
|
||||
area,
|
||||
step,
|
||||
stepIndex,
|
||||
fieldIndex,
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
w: area.w,
|
||||
h: area.h
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return index
|
||||
},
|
||||
formulaAreasIndex () {
|
||||
const index = Object.create(null)
|
||||
|
||||
this.formulaFields.forEach((field, fieldIndex) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
index[area.attachment_uuid] ||= Object.create(null)
|
||||
index[area.attachment_uuid][area.page] ||= []
|
||||
index[area.attachment_uuid][area.page].push({
|
||||
type: 'formula_area',
|
||||
field,
|
||||
area,
|
||||
fieldIndex,
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
w: area.w,
|
||||
h: area.h
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return index
|
||||
},
|
||||
readonlyFieldAreasIndex () {
|
||||
const index = Object.create(null)
|
||||
|
||||
this.readonlyConditionalFields.forEach((field, fieldIndex) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
index[area.attachment_uuid] ||= Object.create(null)
|
||||
index[area.attachment_uuid][area.page] ||= []
|
||||
index[area.attachment_uuid][area.page].push({
|
||||
type: 'readonly_field_area',
|
||||
field,
|
||||
area,
|
||||
fieldIndex,
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
w: area.w,
|
||||
h: area.h
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return index
|
||||
}
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.areaRefs = []
|
||||
},
|
||||
async mounted () {
|
||||
const [metadataResult] = await Promise.all([
|
||||
fetch(this.baseUrl + `/s/${this.submitterSlug}/metadata`, {
|
||||
...this.fetchOptions
|
||||
}).then((r) => r.json()).catch(() => ({})),
|
||||
this.loadMath()
|
||||
])
|
||||
|
||||
this.textRuns = metadataResult.text_runs || {}
|
||||
},
|
||||
methods: {
|
||||
normalizeFormula: FormulaAreas.methods.normalizeFormula,
|
||||
calculateFormula: FormulaAreas.methods.calculateFormula,
|
||||
scrollInContainer: FieldAreas.methods.scrollInContainer,
|
||||
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
|
||||
scrollIntoField: FieldAreas.methods.scrollIntoField,
|
||||
maybeScrollOnClick: FieldAreas.methods.maybeScrollOnClick,
|
||||
setAreaRef (el) {
|
||||
if (el) {
|
||||
this.areaRefs.push(el)
|
||||
}
|
||||
},
|
||||
async loadMath () {
|
||||
if (this.formulaFields.length && !this.isMathLoaded) {
|
||||
const { Calculator } = await import('./calculator')
|
||||
|
||||
this.math = new Calculator()
|
||||
this.isMathLoaded = true
|
||||
}
|
||||
},
|
||||
extractStaticValues () {
|
||||
const result = Object.create(null)
|
||||
const root = this.$root.$el?.parentNode?.getRootNode() || document
|
||||
const pageContainers = root.querySelectorAll('page-container')
|
||||
|
||||
pageContainers.forEach((container) => {
|
||||
const overlay = container.querySelector('[id^="page-"]')
|
||||
|
||||
if (!overlay) return
|
||||
|
||||
const parts = overlay.id.split('-')
|
||||
const pageIndex = parseInt(parts[parts.length - 1])
|
||||
const docUuid = parts.slice(1, -1).join('-')
|
||||
|
||||
const fieldValues = overlay.querySelectorAll('field-value')
|
||||
|
||||
if (!fieldValues.length) return
|
||||
|
||||
result[docUuid] ||= Object.create(null)
|
||||
result[docUuid][pageIndex] = []
|
||||
|
||||
fieldValues.forEach((el) => {
|
||||
const style = el.style
|
||||
const x = parseFloat(style.left) / 100
|
||||
const y = parseFloat(style.top) / 100
|
||||
const w = parseFloat(style.width) / 100
|
||||
const h = parseFloat(style.height) / 100
|
||||
const text = el.textContent.trim()
|
||||
|
||||
if (text) {
|
||||
result[docUuid][pageIndex].push({ type: 'static_value', text, x, y, w, h })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
findPageElement (docUuid, pageIndex) {
|
||||
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${docUuid}-${pageIndex}`)
|
||||
},
|
||||
sortedItemsForPage (docUuid, pageIndex) {
|
||||
const items = []
|
||||
|
||||
const pageTextRuns = this.textRuns[docUuid]?.[pageIndex] || []
|
||||
|
||||
pageTextRuns.forEach((run) => {
|
||||
items.push({
|
||||
type: 'text_run',
|
||||
text: run.text,
|
||||
x: run.x,
|
||||
y: run.y,
|
||||
w: run.w,
|
||||
h: run.h,
|
||||
font_size: run.font_size
|
||||
})
|
||||
})
|
||||
|
||||
const fieldAreas = this.fieldAreasIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...fieldAreas)
|
||||
|
||||
const readonlyFieldAreas = this.readonlyFieldAreasIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...readonlyFieldAreas)
|
||||
|
||||
const formulaAreas = this.formulaAreasIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...formulaAreas)
|
||||
|
||||
const pageFieldValues = this.fieldValuesIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...pageFieldValues)
|
||||
|
||||
items.sort((a, b) => {
|
||||
const aCenterY = a.y + a.h / 2
|
||||
const bCenterY = b.y + b.h / 2
|
||||
const lineThreshold = Math.min(a.h, b.h) / 2
|
||||
|
||||
if (Math.abs(aCenterY - bCenterY) < lineThreshold) {
|
||||
return a.x - b.x
|
||||
}
|
||||
|
||||
return aCenterY - bCenterY
|
||||
})
|
||||
|
||||
const grouped = []
|
||||
let currentGroup = null
|
||||
|
||||
const closeGroup = () => {
|
||||
if (!currentGroup) return
|
||||
|
||||
const groupItems = currentGroup.items
|
||||
const minX = Math.min(...groupItems.map((i) => i.x))
|
||||
const minY = Math.min(...groupItems.map((i) => i.y))
|
||||
const maxEndX = Math.max(...groupItems.map((i) => i.x + i.w))
|
||||
const maxEndY = Math.max(...groupItems.map((i) => i.y + i.h))
|
||||
|
||||
currentGroup.x = minX
|
||||
currentGroup.y = minY
|
||||
currentGroup.w = maxEndX - minX
|
||||
currentGroup.h = maxEndY - minY
|
||||
currentGroup.text = groupItems.map((i) => i.text).join(' ').replace(/\s+/g, ' ').trim()
|
||||
|
||||
grouped.push(currentGroup)
|
||||
currentGroup = null
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const isTextLike = item.type === 'text_run' || item.type === 'static_value'
|
||||
|
||||
if (!isTextLike) {
|
||||
closeGroup()
|
||||
grouped.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentGroup) {
|
||||
currentGroup = { type: 'text_group', items: [] }
|
||||
}
|
||||
|
||||
currentGroup.items.push(item)
|
||||
}
|
||||
|
||||
closeGroup()
|
||||
|
||||
return grouped
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: document_metadata
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# blob_checksum :string not null
|
||||
# text_runs :text not null
|
||||
# created_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_document_metadata_on_account_id_and_blob_checksum (account_id,blob_checksum) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
class DocumentMetadata < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
attribute :text_runs, :string, default: -> { {} }
|
||||
|
||||
serialize :text_runs, coder: JSON
|
||||
end
|
||||
|
Before Width: | Height: | Size: 389 B After Width: | Height: | Size: 408 B |
|
Before Width: | Height: | Size: 397 B After Width: | Height: | Size: 416 B |
|
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 431 B |
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 337 B |
|
Before Width: | Height: | Size: 448 B After Width: | Height: | Size: 467 B |
|
After Width: | Height: | Size: 529 B |
|
After Width: | Height: | Size: 797 B |
|
Before Width: | Height: | Size: 409 B After Width: | Height: | Size: 428 B |
|
Before Width: | Height: | Size: 416 B After Width: | Height: | Size: 435 B |
|
Before Width: | Height: | Size: 584 B After Width: | Height: | Size: 603 B |
|
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 309 B |
@ -1,18 +1,22 @@
|
||||
<% uuid = local_assigns[:uuid] || SecureRandom.uuid %>
|
||||
<input type="checkbox" id="<%= uuid %>" class="modal-toggle">
|
||||
<div id="<%= local_assigns[:id] %>" class="modal items-start !animate-none overflow-y-auto">
|
||||
<% title_id = "#{uuid}-title" %>
|
||||
<%= tag.dialog id: uuid, class: 'modal items-start overflow-y-auto', inert: true, 'aria-labelledby': (title_id if local_assigns[:title]) do %>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none">
|
||||
<% if local_assigns[:title] %>
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span>
|
||||
<span id="<%= title_id %>">
|
||||
<%= local_assigns[:title] %>
|
||||
</span>
|
||||
<label for="<%= uuid %>" class="text-xl">×</label>
|
||||
<form method="dialog">
|
||||
<button class="text-xl cursor-pointer w-6 h-6" aria-label="<%= t('close') %>">×</button>
|
||||
</form>
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<%= yield %>
|
||||
</div>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="<%= uuid %>"></label>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button aria-label="<%= t('close') %>"></button>
|
||||
</form>
|
||||
<% end %>
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
<%= t('powered_by') %>
|
||||
<a href="<%= Docuseal::PRODUCT_URL %>" target="_blank" rel="noopener"><%= Docuseal.product_name %></a>
|
||||
@ -0,0 +1,2 @@
|
||||
<%= render 'shared/logo' %>
|
||||
<span><%= Docuseal.product_name %></span>
|
||||
@ -0,0 +1,10 @@
|
||||
<div class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
|
||||
<p class="text-xl font-semibold text-center">
|
||||
<%= t('share_link_is_currently_disabled') %>
|
||||
</p>
|
||||
<% if can?(:update, @template) %>
|
||||
<toggle-submit class="block">
|
||||
<%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: template_share_link_qr_path(@template) }, method: :post, data: { turbo: false }, class: 'base-button w-full' %>
|
||||
</toggle-submit>
|
||||
<% end %>
|
||||
</div>
|
||||
@ -0,0 +1,288 @@
|
||||
<!DOCTYPE html>
|
||||
<% page_width_css = @page_size == 'Letter' ? 8.5 * 96.0 : 210.0 * 96.0 / 25.4 %>
|
||||
<% page_height_css = @page_size == 'Letter' ? 11.0 * 96.0 : 297.0 * 96.0 / 25.4 %>
|
||||
<% page_width = @page_size == 'Letter' ? '8.5in' : '210mm' %>
|
||||
<% page_cqw = ->(px) { format('%.6fcqw', px / page_width_css * 100.0) } %>
|
||||
<html lang="<%= I18n.locale %>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= @template.name %></title>
|
||||
<style>
|
||||
@page {
|
||||
size: <%= @page_size %> portrait;
|
||||
margin: 0.5in;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #111;
|
||||
background: #faf7f5;
|
||||
}
|
||||
|
||||
.qr-page-wrapper {
|
||||
container-type: size;
|
||||
width: min(100vw, <%= page_width %>);
|
||||
max-width: 100%;
|
||||
aspect-ratio: <%= format('%<width>.6f / %<height>.6f', width: page_width_css, height: page_height_css) %>;
|
||||
margin: 24px auto;
|
||||
}
|
||||
|
||||
.qr-page {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: <%= page_cqw.call(72) %>;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.qr-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: <%= page_cqw.call(10) %>;
|
||||
font-size: <%= page_cqw.call(20) %>;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.qr-logo svg {
|
||||
width: <%= page_cqw.call(32) %>;
|
||||
height: <%= page_cqw.call(32) %>;
|
||||
}
|
||||
|
||||
.qr-logo img {
|
||||
height: <%= page_cqw.call(50) %>;
|
||||
}
|
||||
|
||||
.qr-content {
|
||||
align-self: center;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
margin-bottom: <%= page_cqw.call(80) %>;
|
||||
}
|
||||
|
||||
.qr-header {
|
||||
font-size: <%= page_cqw.call(36) %>;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
padding: 0 <%= page_cqw.call(8) %>;
|
||||
margin-bottom: <%= page_cqw.call(48) %>;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.qr-main svg {
|
||||
display: block;
|
||||
width: <%= page_cqw.call(480) %>;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
|
||||
.qr-footer {
|
||||
font-size: <%= page_cqw.call(22) %>;
|
||||
line-height: 1.4;
|
||||
padding: 0 <%= page_cqw.call(8) %>;
|
||||
margin-top: <%= page_cqw.call(48) %>;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.qr-branding {
|
||||
align-self: end;
|
||||
text-align: center;
|
||||
font-size: <%= page_cqw.call(11) %>;
|
||||
color: #6b7280;
|
||||
padding-top: <%= page_cqw.call(24) %>;
|
||||
}
|
||||
|
||||
.qr-branding a {
|
||||
color: #4b5563;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[contenteditable="true"] {
|
||||
outline: 1px dashed #cbd5e1;
|
||||
outline-offset: 6px;
|
||||
cursor: text;
|
||||
transition: outline-color 0.15s ease;
|
||||
}
|
||||
|
||||
[contenteditable="true"]:hover {
|
||||
outline-color: #94a3b8;
|
||||
}
|
||||
|
||||
[contenteditable="true"]:focus {
|
||||
outline: 1px dashed #291334;
|
||||
outline-offset: 6px;
|
||||
}
|
||||
|
||||
.print-button {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
top: 24px;
|
||||
z-index: 100;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
height: 3rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
border: 1px solid #291334;
|
||||
border-radius: 1.9rem;
|
||||
background-color: #291334;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1em;
|
||||
font-family: inherit;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.print-button:hover {
|
||||
background-color: #1a0c22;
|
||||
border-color: #1a0c22;
|
||||
}
|
||||
|
||||
.print-button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.print-button:focus-visible {
|
||||
outline: 2px solid #291334;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.print-button svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 820px) {
|
||||
.print-button {
|
||||
top: auto;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
html, body {
|
||||
background: #ffffff;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.qr-page-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-page {
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
padding: 0.25in;
|
||||
page-break-inside: avoid;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.qr-logo {
|
||||
gap: 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.qr-logo svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.qr-logo img {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.qr-content {
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.qr-header {
|
||||
font-size: 36px;
|
||||
padding: 0 8px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.qr-main svg {
|
||||
width: 5in;
|
||||
height: 5in;
|
||||
}
|
||||
|
||||
.qr-footer {
|
||||
font-size: 22px;
|
||||
padding: 0 8px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.qr-branding {
|
||||
font-size: 11px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.print-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[contenteditable="true"],
|
||||
[contenteditable="true"]:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="qr-page-wrapper">
|
||||
<div class="qr-page">
|
||||
<div class="qr-logo">
|
||||
<%= render 'logo' %>
|
||||
</div>
|
||||
<div class="qr-content">
|
||||
<div class="qr-header" contenteditable="true" spellcheck="false"><%= @template.name %></div>
|
||||
<div class="qr-main">
|
||||
<%== @qr_svg_code %>
|
||||
</div>
|
||||
<div class="qr-footer" contenteditable="true" spellcheck="false">
|
||||
<%= t('scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document') %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qr-branding">
|
||||
<%= render 'branding' %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="qr-print-button" class="print-button">
|
||||
<%= svg_icon('printer') %>
|
||||
<span><%= t('print') %></span>
|
||||
</button>
|
||||
<script nonce="<%= content_security_policy_nonce %>">
|
||||
document.getElementById('qr-print-button').addEventListener('click', function () {
|
||||
window.print();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateDocumentMetadata < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :document_metadata do |t|
|
||||
t.references :account, null: false, foreign_key: true, index: false
|
||||
t.string :blob_checksum, null: false
|
||||
t.text :text_runs, null: false
|
||||
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
|
||||
add_index :document_metadata, %i[account_id blob_checksum], unique: true
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DocumentMetadatas
|
||||
module_function
|
||||
|
||||
def find_or_create_for_document(document, account_id:)
|
||||
checksum = document.blob.checksum
|
||||
|
||||
metadata = DocumentMetadata.find_by(account_id:, blob_checksum: checksum)
|
||||
metadata ||= DocumentMetadata.create!(account_id:, blob_checksum: checksum, text_runs: build_text_runs(document))
|
||||
|
||||
metadata
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
retry
|
||||
end
|
||||
|
||||
def build_text_runs(document)
|
||||
number_of_pages = document.metadata.dig('pdf', 'number_of_pages').to_i
|
||||
|
||||
return {} if number_of_pages.zero?
|
||||
|
||||
Pdfium::Document.open_bytes(document.download) do |doc|
|
||||
(0...doc.page_count).each_with_object({}) do |page_index, acc|
|
||||
page = doc.get_page(page_index)
|
||||
|
||||
acc[page_index] = page.text_objects.map do |node|
|
||||
{ text: node.content, x: node.x, y: node.y, w: node.w, h: node.h, font_size: node.font_size }
|
||||
end
|
||||
ensure
|
||||
page&.close
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module Tools
|
||||
module LoadTemplate
|
||||
SCHEMA = {
|
||||
name: 'load_template',
|
||||
title: 'Load Template',
|
||||
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
template_id: {
|
||||
type: 'integer',
|
||||
description: 'Template identifier'
|
||||
}
|
||||
},
|
||||
required: %w[template_id]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def call(arguments, _current_user, current_ability)
|
||||
template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
|
||||
|
||||
return { content: [{ type: 'text', text: 'Template not found' }], isError: true } unless template
|
||||
|
||||
current_ability.authorize!(:read, template)
|
||||
|
||||
submitters_index = template.submitters.index_by { |s| s['uuid'] }
|
||||
|
||||
roles = template.submitters.pluck('name')
|
||||
|
||||
fields = template.fields.filter_map do |field|
|
||||
next if field['name'].blank?
|
||||
|
||||
{
|
||||
name: field['name'],
|
||||
type: field['type'],
|
||||
role: submitters_index[field['submitter_uuid']]&.dig('name')
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
roles: roles,
|
||||
fields: fields
|
||||
}.to_json
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||