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 | 3 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 %>
|
<% uuid = local_assigns[:uuid] || SecureRandom.uuid %>
|
||||||
<input type="checkbox" id="<%= uuid %>" class="modal-toggle">
|
<% title_id = "#{uuid}-title" %>
|
||||||
<div id="<%= local_assigns[:id] %>" class="modal items-start !animate-none overflow-y-auto">
|
<%= 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">
|
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none">
|
||||||
<% if local_assigns[:title] %>
|
<% if local_assigns[:title] %>
|
||||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||||
<span>
|
<span id="<%= title_id %>">
|
||||||
<%= local_assigns[:title] %>
|
<%= local_assigns[:title] %>
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="modal-backdrop" for="<%= uuid %>"></label>
|
<form method="dialog" class="modal-backdrop">
|
||||||
</div>
|
<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
|
||||||