mirror of
https://github.com/docusealco/docuseal.git
synced 2026-06-23 04:10:11 +00:00
add screen reader mode
This commit is contained in:
@@ -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
|
||||
@@ -47,13 +47,13 @@ export default class extends HTMLElement {
|
||||
|
||||
this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
|
||||
this.classList.add('translate-y-0', 'opacity-100')
|
||||
this.querySelectorAll('[tabindex]').forEach((el) => { el.tabIndex = 0 })
|
||||
this.inert = false
|
||||
}
|
||||
|
||||
hideButtons () {
|
||||
this.classList.remove('translate-y-0', 'opacity-100')
|
||||
this.classList.add('-translate-y-10', 'opacity-0')
|
||||
this.querySelectorAll('[tabindex]').forEach((el) => { el.tabIndex = -1 })
|
||||
this.inert = true
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.classList.contains('-translate-y-10')) {
|
||||
|
||||
@@ -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>
|
||||
@@ -4,11 +4,11 @@
|
||||
dir="auto"
|
||||
:style="[computedStyle, fontStyle]"
|
||||
:class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
|
||||
:role="submittable && field.type !== 'checkbox' && field.type !== 'radio' && field.type !== 'multiple' ? 'button' : undefined"
|
||||
:tabindex="submittable ? 0 : undefined"
|
||||
:aria-label="submittable ? fieldAreaLabel : undefined"
|
||||
@keydown.enter.prevent="submittable ? $el.click() : undefined"
|
||||
@keydown.space.prevent="submittable ? $el.click() : undefined"
|
||||
:role="submittable && !isNativeInputField ? 'button' : undefined"
|
||||
:tabindex="submittable && !isNativeInputField ? 0 : undefined"
|
||||
:aria-label="submittable && !isNativeInputField ? fieldAreaLabel : undefined"
|
||||
@keydown.enter.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
|
||||
@keydown.space.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
|
||||
>
|
||||
<div
|
||||
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
|
||||
@@ -23,6 +23,7 @@
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="max-h-10 text-base-content"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -155,6 +156,7 @@
|
||||
v-if="submittable"
|
||||
type="radio"
|
||||
:value="false"
|
||||
:name="`radio-area-${field.uuid}`"
|
||||
:aria-label="optionValue(option)"
|
||||
class="aspect-square checked:checkbox checked:checkbox-xs"
|
||||
:class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
@@ -408,6 +410,9 @@ export default {
|
||||
kba: this.t('kba')
|
||||
}
|
||||
},
|
||||
isNativeInputField () {
|
||||
return ['checkbox', 'radio', 'multiple'].includes(this.field.type)
|
||||
},
|
||||
fieldAreaLabel () {
|
||||
const name = this.field.name || this.fieldNames[this.field.type] || this.field.type
|
||||
if (this.area.option_uuid && this.option) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<ul v-if="value.length" :aria-label="t('uploaded_files')" class="list-none p-0 m-0">
|
||||
<ul
|
||||
v-if="value.length"
|
||||
:aria-label="t('uploaded_files')"
|
||||
class="list-none p-0 m-0"
|
||||
>
|
||||
<li
|
||||
v-for="(val, index) in value"
|
||||
:key="index"
|
||||
@@ -20,7 +24,7 @@
|
||||
<IconPaperclip
|
||||
:width="16"
|
||||
class="flex-none"
|
||||
:heigh="16"
|
||||
:height="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
@@ -35,7 +39,7 @@
|
||||
>
|
||||
<IconTrashX
|
||||
:width="18"
|
||||
:heigh="19"
|
||||
:height="19"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
class="mx-auto max-w-md flex flex-col completed-form"
|
||||
dir="auto"
|
||||
role="status"
|
||||
aria-live="assertive"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
|
||||
<IconCircleCheck
|
||||
class="inline text-green-600"
|
||||
aria-hidden="true"
|
||||
:width="30"
|
||||
:height="30"
|
||||
/>
|
||||
@@ -47,7 +47,10 @@
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconMail v-else />
|
||||
<IconMail
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{ t('send_copy_via_email') }}
|
||||
</span>
|
||||
@@ -63,7 +66,10 @@
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconDownload v-else />
|
||||
<IconDownload
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{ t('download') }}
|
||||
</span>
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
|
||||
@click.prevent="[setCurrentDate(), $emit('focus')]"
|
||||
>
|
||||
<IconCalendarCheck :width="16" aria-hidden="true" />
|
||||
<IconCalendarCheck
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('set_today') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
<template>
|
||||
<Teleport
|
||||
v-if="withAccessibilityAreas === null && !isAccessibilityMode"
|
||||
to="#sr_only_content"
|
||||
>
|
||||
<button
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
|
||||
@click="isAccessibilityMode = true"
|
||||
>
|
||||
{{ t('enter_screen_reader_mode') }}
|
||||
</button>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-for="item in (withAccessibilityAreas === null ? schema : [])"
|
||||
:key="item.attachment_uuid"
|
||||
:to="`#document-${item.attachment_uuid} .sr_only_content`"
|
||||
>
|
||||
<button
|
||||
v-if="!isAccessibilityMode"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
|
||||
@click="isAccessibilityMode = true"
|
||||
>
|
||||
{{ t('enter_screen_reader_mode') }}
|
||||
</button>
|
||||
</Teleport>
|
||||
<AccessibilityAreas
|
||||
v-if="withAccessibilityAreas || isAccessibilityMode"
|
||||
ref="areas"
|
||||
:submitter-slug="submitterSlug"
|
||||
:steps="stepFields"
|
||||
:readonly-conditional-fields="readonlyConditionalFields"
|
||||
:readonly-conditional-field-values="readonlyConditionalFieldValues"
|
||||
:formula-fields="formulaFields"
|
||||
:values="values"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:submitter="submitter"
|
||||
:scroll-el="scrollEl"
|
||||
:current-step="currentStepFields"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames"
|
||||
:scroll-padding="scrollPadding"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:fetch-options="fetchOptions"
|
||||
:filled-fields-index="filledFieldsIndex"
|
||||
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
|
||||
/>
|
||||
<FieldAreas
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode"
|
||||
ref="areas"
|
||||
:steps="stepFields"
|
||||
:values="values"
|
||||
@@ -14,6 +61,7 @@
|
||||
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
|
||||
/>
|
||||
<FieldAreas
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode"
|
||||
:steps="readonlyConditionalFields.map((e) => [e])"
|
||||
:values="readonlyConditionalFieldValues"
|
||||
:submitter="submitter"
|
||||
@@ -21,7 +69,7 @@
|
||||
:submittable="false"
|
||||
/>
|
||||
<FormulaFieldAreas
|
||||
v-if="formulaFields.length"
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
|
||||
:fields="formulaFields"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:values="values"
|
||||
@@ -567,6 +615,7 @@
|
||||
<nav
|
||||
v-if="stepFields.length < 80"
|
||||
:aria-label="t('form_progress')"
|
||||
:aria-hidden="isCompleted"
|
||||
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none"
|
||||
>
|
||||
<div class="flex items-center flex-wrap steps-progress">
|
||||
@@ -597,6 +646,7 @@
|
||||
<script>
|
||||
import FieldAreas from './areas'
|
||||
import FormulaFieldAreas from './formula_areas'
|
||||
import AccessibilityAreas from './accessibility_areas'
|
||||
import ImageStep from './image_step'
|
||||
import SignatureStep from './signature_step'
|
||||
import InitialsStep from './initials_step'
|
||||
@@ -655,6 +705,7 @@ export default {
|
||||
name: 'SubmissionForm',
|
||||
components: {
|
||||
FieldAreas,
|
||||
AccessibilityAreas,
|
||||
ImageStep,
|
||||
SignatureStep,
|
||||
AppearsOn,
|
||||
@@ -837,6 +888,16 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
filledFieldsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withAccessibilityAreas: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -947,7 +1008,8 @@ export default {
|
||||
isSubmitting: false,
|
||||
isSubmittingComplete: false,
|
||||
submittedValues: {},
|
||||
recalculateButtonDisabledKey: ''
|
||||
recalculateButtonDisabledKey: '',
|
||||
isAccessibilityMode: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -3,7 +3,6 @@ const en = {
|
||||
form_progress: 'Form progress',
|
||||
close: 'Close',
|
||||
uploaded_files: 'Uploaded files',
|
||||
minimize: 'Minimize',
|
||||
signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Please upload an image file',
|
||||
@@ -104,7 +103,8 @@ const en = {
|
||||
files: 'Files',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
|
||||
browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.',
|
||||
wait_countdown_seconds: 'Wait {countdown} seconds'
|
||||
wait_countdown_seconds: 'Wait {countdown} seconds',
|
||||
enter_screen_reader_mode: 'Enter screen reader mode'
|
||||
}
|
||||
|
||||
const es = {
|
||||
@@ -212,7 +212,8 @@ const es = {
|
||||
files: 'Archivos',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
|
||||
browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.',
|
||||
wait_countdown_seconds: 'Espera {countdown} segundos'
|
||||
wait_countdown_seconds: 'Espera {countdown} segundos',
|
||||
enter_screen_reader_mode: 'Activar modo lector de pantalla'
|
||||
}
|
||||
|
||||
const it = {
|
||||
@@ -320,7 +321,8 @@ const it = {
|
||||
files: 'File',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
|
||||
browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.',
|
||||
wait_countdown_seconds: 'Attendi {countdown} secondi'
|
||||
wait_countdown_seconds: 'Attendi {countdown} secondi',
|
||||
enter_screen_reader_mode: 'Attiva modalità lettore di schermo'
|
||||
}
|
||||
|
||||
const de = {
|
||||
@@ -428,7 +430,8 @@ const de = {
|
||||
files: 'Dateien',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
|
||||
browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.',
|
||||
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten'
|
||||
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten',
|
||||
enter_screen_reader_mode: 'Screenreader-Modus aktivieren'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
@@ -536,7 +539,8 @@ const fr = {
|
||||
files: 'Fichiers',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
|
||||
browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.',
|
||||
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes'
|
||||
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes',
|
||||
enter_screen_reader_mode: 'Activer le mode lecteur d\'écran'
|
||||
}
|
||||
|
||||
const pl = {
|
||||
@@ -644,7 +648,8 @@ const pl = {
|
||||
files: 'Pliki',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
|
||||
browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.',
|
||||
wait_countdown_seconds: 'Poczekaj {countdown} sekund'
|
||||
wait_countdown_seconds: 'Poczekaj {countdown} sekund',
|
||||
enter_screen_reader_mode: 'Włącz tryb czytnika ekranu'
|
||||
}
|
||||
|
||||
const uk = {
|
||||
@@ -752,7 +757,8 @@ const uk = {
|
||||
files: 'Файли',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
|
||||
browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.',
|
||||
wait_countdown_seconds: 'Зачекайте {countdown} секунд'
|
||||
wait_countdown_seconds: 'Зачекайте {countdown} секунд',
|
||||
enter_screen_reader_mode: 'Увімкнути режим читання з екрану'
|
||||
}
|
||||
|
||||
const cs = {
|
||||
@@ -860,7 +866,8 @@ const cs = {
|
||||
files: 'Soubory',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
|
||||
browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.',
|
||||
wait_countdown_seconds: 'Počkejte {countdown} sekund'
|
||||
wait_countdown_seconds: 'Počkejte {countdown} sekund',
|
||||
enter_screen_reader_mode: 'Zapnout režim čtečky obrazovky'
|
||||
}
|
||||
|
||||
const pt = {
|
||||
@@ -968,7 +975,8 @@ const pt = {
|
||||
files: 'Arquivos',
|
||||
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
|
||||
browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.',
|
||||
wait_countdown_seconds: 'Aguarde {countdown} segundos'
|
||||
wait_countdown_seconds: 'Aguarde {countdown} segundos',
|
||||
enter_screen_reader_mode: 'Ativar modo leitor de tela'
|
||||
}
|
||||
|
||||
const he = {
|
||||
@@ -1076,7 +1084,8 @@ const he = {
|
||||
files: 'קבצים',
|
||||
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
|
||||
browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.',
|
||||
wait_countdown_seconds: 'המתן {countdown} שניות'
|
||||
wait_countdown_seconds: 'המתן {countdown} שניות',
|
||||
enter_screen_reader_mode: 'הפעל מצב קורא מסך'
|
||||
}
|
||||
|
||||
const nl = {
|
||||
@@ -1184,7 +1193,8 @@ const nl = {
|
||||
files: 'Bestanden',
|
||||
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
|
||||
browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.',
|
||||
wait_countdown_seconds: 'Wacht {countdown} seconden'
|
||||
wait_countdown_seconds: 'Wacht {countdown} seconden',
|
||||
enter_screen_reader_mode: 'Schermlezer-modus inschakelen'
|
||||
}
|
||||
|
||||
const ar = {
|
||||
@@ -1292,7 +1302,8 @@ const ar = {
|
||||
files: 'الملفات',
|
||||
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
|
||||
browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.',
|
||||
wait_countdown_seconds: 'انتظر {countdown} ثانية'
|
||||
wait_countdown_seconds: 'انتظر {countdown} ثانية',
|
||||
enter_screen_reader_mode: 'تفعيل وضع قارئ الشاشة'
|
||||
}
|
||||
|
||||
const ko = {
|
||||
@@ -1400,7 +1411,8 @@ const ko = {
|
||||
files: '파일',
|
||||
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
|
||||
browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.',
|
||||
wait_countdown_seconds: '{countdown}초 기다리세요'
|
||||
wait_countdown_seconds: '{countdown}초 기다리세요',
|
||||
enter_screen_reader_mode: '스크린 리더 모드 활성화'
|
||||
}
|
||||
|
||||
const ja = {
|
||||
@@ -1508,7 +1520,8 @@ const ja = {
|
||||
files: 'ファイル',
|
||||
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
|
||||
browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。',
|
||||
wait_countdown_seconds: '{countdown} 秒お待ちください'
|
||||
wait_countdown_seconds: '{countdown} 秒お待ちください',
|
||||
enter_screen_reader_mode: 'スクリーンリーダーモードを有効にする'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja }
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
class="btn btn-outline btn-sm reupload-button"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
<IconReload :width="16" aria-hidden="true" />
|
||||
<IconReload
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('reupload') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,10 @@
|
||||
class="btn btn-outline font-medium btn-sm type-text-button"
|
||||
@click="toggleTextInput"
|
||||
>
|
||||
<IconTextSize :width="16" aria-hidden="true" />
|
||||
<IconTextSize
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('type') }}
|
||||
</span>
|
||||
@@ -47,7 +50,10 @@
|
||||
class="btn btn-outline font-medium btn-sm type-text-button"
|
||||
@click="toggleTextInput"
|
||||
>
|
||||
<IconSignature :width="16" aria-hidden="true" />
|
||||
<IconSignature
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('draw') }}
|
||||
</span>
|
||||
@@ -57,10 +63,19 @@
|
||||
class="md:tooltip"
|
||||
:data-tip="t('click_to_upload')"
|
||||
>
|
||||
<label role="button" tabindex="0" :aria-label="t('click_to_upload')" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button" @keydown.enter.prevent="$el.querySelector('input')?.click()" @keydown.space.prevent="$el.querySelector('input')?.click()">
|
||||
<IconUpload :width="16" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('click_to_upload')"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
|
||||
@click="$refs.uploadInput.click()"
|
||||
>
|
||||
<IconUpload
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
:key="uploadImageInputKey"
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*"
|
||||
@@ -69,7 +84,7 @@
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('upload') }}
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
@@ -77,7 +92,10 @@
|
||||
class="btn font-medium btn-outline btn-sm clear-canvas-button"
|
||||
@click="remove"
|
||||
>
|
||||
<IconReload :width="16" aria-hidden="true" />
|
||||
<IconReload
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('clear') }}
|
||||
</button>
|
||||
<button
|
||||
@@ -86,7 +104,10 @@
|
||||
class="btn font-medium btn-outline btn-sm clear-canvas-button"
|
||||
@click="clear"
|
||||
>
|
||||
<IconReload :width="16" aria-hidden="true" />
|
||||
<IconReload
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('clear') }}
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -69,13 +69,11 @@
|
||||
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
|
||||
:data-tip="t('take_photo')"
|
||||
>
|
||||
<label
|
||||
role="button"
|
||||
tabindex="0"
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('take_photo')"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
|
||||
@keydown.enter.prevent="$el.querySelector('input')?.click()"
|
||||
@keydown.space.prevent="$el.querySelector('input')?.click()"
|
||||
@click="$refs.takePhotoInput.click()"
|
||||
>
|
||||
<IconCamera
|
||||
:width="16"
|
||||
@@ -83,6 +81,7 @@
|
||||
/>
|
||||
<input
|
||||
:key="uploadImageInputKey"
|
||||
ref="takePhotoInput"
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*"
|
||||
@@ -91,7 +90,7 @@
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('upload') }}
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
<% uuid = local_assigns[:uuid] || SecureRandom.uuid %>
|
||||
<% 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 %>
|
||||
<%= 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">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<% font = field.dig('preferences', 'font') %>
|
||||
<% font_type = field.dig('preferences', 'font_type') %>
|
||||
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
|
||||
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
|
||||
<field-value dir="auto" aria-hidden="true" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
|
||||
<% if field['type'] == 'signature' %>
|
||||
<% is_narrow = area['h'].positive? && ((area['w'] * local_assigns[:page_width]).to_f / (area['h'] * local_assigns[:page_height])) > 4.5 %>
|
||||
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
|
||||
@@ -48,6 +48,7 @@
|
||||
</div>
|
||||
<% elsif field['type'] == 'checkbox' %>
|
||||
<div class="w-full flex items-center justify-center">
|
||||
<span class="sr-only">☑</span>
|
||||
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
|
||||
</div>
|
||||
<% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %>
|
||||
@@ -55,6 +56,7 @@
|
||||
<% option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}" %>
|
||||
<% if Array.wrap(value).include?(option_name) %>
|
||||
<div class="w-full flex items-center justify-center">
|
||||
<span class="sr-only">☑</span>
|
||||
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<% end %>
|
||||
</span>
|
||||
<span>
|
||||
<button type="submit" form="resend_code_form" class="link"><%= t(:re_send_email) %></button>
|
||||
<button type="submit" form="resend_code_form" id="resend_label" class="link"><%= t(:re_send_email) %></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<% delegate_modal_id = nil %>
|
||||
<div style="max-height: -webkit-fill-available;">
|
||||
<main id="scrollbox">
|
||||
<div id="sr_only_content"></div>
|
||||
<div class="mx-auto block pb-72" style="max-width: 1000px">
|
||||
<%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %>
|
||||
<%= local_assigns[:banner_html] || capture do %>
|
||||
@@ -63,10 +64,10 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</header>
|
||||
<scroll-buttons class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
|
||||
<scroll-buttons inert class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
|
||||
<% if @form_configs[:with_delegate] %>
|
||||
<modal-button data-target="<%= delegate_modal_id %>">
|
||||
<button id="delegate_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:delegate) %>" tabindex="-1">
|
||||
<button id="delegate_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:delegate) %>">
|
||||
<span class="min-[1366px]:inline hidden px-3">
|
||||
<%= t(:delegate) %>
|
||||
</span>
|
||||
@@ -77,12 +78,12 @@
|
||||
</modal-button>
|
||||
<% if @form_configs[:with_decline] %>
|
||||
<modal-button data-target="<%= decline_modal_id %>">
|
||||
<button id="decline_button_mobile" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>" tabindex="-1">
|
||||
<button id="decline_button_mobile" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
|
||||
<%= svg_icon('x', class: 'w-5 h-5') %>
|
||||
</button>
|
||||
</modal-button>
|
||||
<% end %>
|
||||
<download-button role="button" tabindex="-1" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
|
||||
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
|
||||
<span data-target="download-button.defaultButton">
|
||||
<%= svg_icon('download', class: 'w-5 h-5') %>
|
||||
</span>
|
||||
@@ -93,7 +94,7 @@
|
||||
<% else %>
|
||||
<% if @form_configs[:with_decline] %>
|
||||
<modal-button data-target="<%= decline_modal_id %>">
|
||||
<button id="decline_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:decline) %>" tabindex="-1">
|
||||
<button id="decline_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:decline) %>">
|
||||
<span class="min-[1366px]:inline hidden px-3">
|
||||
<%= t(:decline) %>
|
||||
</span>
|
||||
@@ -103,7 +104,7 @@
|
||||
</button>
|
||||
</modal-button>
|
||||
<% end %>
|
||||
<download-button role="button" tabindex="-1" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
|
||||
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
|
||||
<span data-target="download-button.defaultButton">
|
||||
<%= svg_icon('download', class: 'w-5 h-5') %>
|
||||
</span>
|
||||
@@ -117,6 +118,7 @@
|
||||
<% schema.each do |item| %>
|
||||
<% document = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
|
||||
<div id="document-<%= document.uuid %>">
|
||||
<div class="sr_only_content"></div>
|
||||
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
|
||||
<% 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 %>
|
||||
|
||||
@@ -150,6 +150,7 @@ Rails.application.routes.draw do
|
||||
resources :decline, only: %i[create], controller: 'submit_form_decline'
|
||||
resources :delegate, only: %i[create], controller: 'submit_form_delegate'
|
||||
resources :invite, only: %i[create], controller: 'submit_form_invite'
|
||||
resources :metadata, only: %i[index], controller: 'submit_form_metadata'
|
||||
resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development?
|
||||
get :completed
|
||||
get :delegated
|
||||
|
||||
@@ -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
|
||||
+10
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
@@ -168,6 +168,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
|
||||
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
|
||||
end
|
||||
|
||||
create_table "document_metadata", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "blob_checksum", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.text "text_runs", null: false
|
||||
t.index ["account_id", "blob_checksum"], name: "index_document_metadata_on_account_id_and_blob_checksum", unique: true
|
||||
end
|
||||
|
||||
create_table "dynamic_document_versions", force: :cascade do |t|
|
||||
t.text "areas", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -553,6 +561,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) 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 "document_metadata", "accounts"
|
||||
add_foreign_key "dynamic_document_versions", "dynamic_documents"
|
||||
add_foreign_key "dynamic_documents", "templates"
|
||||
add_foreign_key "email_events", "accounts"
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +39,16 @@ class Pdfium
|
||||
FPDF_RENDER_FORCEHALFTONE = 0x400
|
||||
FPDF_PRINTING = 0x800
|
||||
|
||||
TextObject = Struct.new(:content, :x, :y, :w, :h, :font_size) do
|
||||
def endx
|
||||
@endx ||= x + w
|
||||
end
|
||||
|
||||
def endy
|
||||
@endy ||= y + h
|
||||
end
|
||||
end
|
||||
|
||||
TextNode = Struct.new(:content, :x, :y, :w, :h) do
|
||||
def endx
|
||||
@endx ||= x + w
|
||||
@@ -117,6 +127,10 @@ class Pdfium
|
||||
attach_function :FPDFPathSegment_GetType, [:FPDF_PATHSEGMENT], :int
|
||||
attach_function :FPDFPathSegment_GetPoint, %i[FPDF_PATHSEGMENT pointer pointer], :int
|
||||
|
||||
# Text page object functions (per-run Tj/TJ extraction)
|
||||
attach_function :FPDFTextObj_GetText, %i[FPDF_PAGEOBJECT FPDF_TEXTPAGE pointer ulong], :ulong
|
||||
attach_function :FPDFTextObj_GetFontSize, %i[FPDF_PAGEOBJECT pointer], :int
|
||||
|
||||
# Page object types
|
||||
FPDF_PAGEOBJ_UNKNOWN = 0
|
||||
FPDF_PAGEOBJ_TEXT = 1
|
||||
@@ -515,6 +529,90 @@ class Pdfium
|
||||
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
|
||||
end
|
||||
|
||||
def text_objects
|
||||
return @text_objects if @text_objects
|
||||
|
||||
ensure_not_closed!
|
||||
|
||||
@text_objects = []
|
||||
|
||||
object_count = Pdfium.FPDFPage_CountObjects(page_ptr)
|
||||
|
||||
return @text_objects if object_count.zero?
|
||||
|
||||
text_page = Pdfium.FPDFText_LoadPage(page_ptr)
|
||||
|
||||
if text_page.null?
|
||||
Pdfium.check_last_error("Failed to load text page #{page_index}")
|
||||
|
||||
raise PdfiumError, "Failed to load text page #{page_index}, pointer is NULL."
|
||||
end
|
||||
|
||||
left_ptr = FFI::MemoryPointer.new(:float)
|
||||
bottom_ptr = FFI::MemoryPointer.new(:float)
|
||||
right_ptr = FFI::MemoryPointer.new(:float)
|
||||
top_ptr = FFI::MemoryPointer.new(:float)
|
||||
font_size_ptr = FFI::MemoryPointer.new(:float)
|
||||
|
||||
object_count.times do |i|
|
||||
page_object = Pdfium.FPDFPage_GetObject(page_ptr, i)
|
||||
|
||||
next if page_object.null?
|
||||
|
||||
next unless Pdfium.FPDFPageObj_GetType(page_object) == Pdfium::FPDF_PAGEOBJ_TEXT
|
||||
|
||||
needed_bytes = Pdfium.FPDFTextObj_GetText(page_object, text_page, FFI::Pointer::NULL, 0)
|
||||
|
||||
next if needed_bytes < 4
|
||||
|
||||
buffer = FFI::MemoryPointer.new(:uint8, needed_bytes)
|
||||
|
||||
written = Pdfium.FPDFTextObj_GetText(page_object, text_page, buffer, needed_bytes)
|
||||
|
||||
next if written < 4
|
||||
|
||||
content = buffer.read_bytes(written - 2).force_encoding('UTF-16LE').encode('UTF-8')
|
||||
|
||||
next if content.empty?
|
||||
|
||||
next if Pdfium.FPDFPageObj_GetBounds(page_object, left_ptr, bottom_ptr, right_ptr, top_ptr).zero?
|
||||
|
||||
obj_left = left_ptr.read_float
|
||||
obj_bottom = bottom_ptr.read_float
|
||||
obj_right = right_ptr.read_float
|
||||
obj_top = top_ptr.read_float
|
||||
|
||||
obj_width = obj_right - obj_left
|
||||
obj_height = obj_top - obj_bottom
|
||||
|
||||
next if obj_width <= 0 || obj_height <= 0
|
||||
|
||||
font_size =
|
||||
if Pdfium.FPDFTextObj_GetFontSize(page_object, font_size_ptr) == 0
|
||||
obj_height
|
||||
else
|
||||
font_size_ptr.read_float
|
||||
end
|
||||
|
||||
font_size = 8 if font_size == 1
|
||||
|
||||
norm_x = obj_left / width
|
||||
norm_y = (height - obj_top) / height
|
||||
norm_w = obj_width / width
|
||||
norm_h = obj_height / height
|
||||
|
||||
@text_objects << TextObject.new(content, norm_x, norm_y, norm_w, norm_h, font_size)
|
||||
end
|
||||
|
||||
y_threshold = 4.0 / width
|
||||
|
||||
@text_objects = @text_objects.sort do |a, b|
|
||||
(a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy
|
||||
end
|
||||
ensure
|
||||
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
|
||||
end
|
||||
|
||||
def line_nodes
|
||||
return @line_nodes if @line_nodes
|
||||
|
||||
|
||||
@@ -613,7 +613,7 @@ RSpec.describe 'Signing Form' do
|
||||
visit submit_form_path(slug: submitter.slug)
|
||||
|
||||
find('#expand_form_button').click
|
||||
click_link 'Type'
|
||||
click_button 'Type'
|
||||
fill_in 'signature_text_input', with: 'John Doe'
|
||||
click_button 'Sign and Complete'
|
||||
|
||||
@@ -752,7 +752,7 @@ RSpec.describe 'Signing Form' do
|
||||
visit submit_form_path(slug: submitter.slug)
|
||||
|
||||
find('#expand_form_button').click
|
||||
click_link 'Draw'
|
||||
click_button 'Draw'
|
||||
draw_canvas
|
||||
click_button 'Complete'
|
||||
|
||||
@@ -1169,7 +1169,7 @@ RSpec.describe 'Signing Form' do
|
||||
|
||||
find('#decline_button').click
|
||||
fill_in 'reason', with: 'I do not agree with the terms'
|
||||
click_button 'Decline'
|
||||
within('dialog[open]') { click_button 'Decline' }
|
||||
|
||||
expect(page).to have_content('Form has been declined')
|
||||
|
||||
@@ -1193,7 +1193,7 @@ RSpec.describe 'Signing Form' do
|
||||
|
||||
find('#delegate_button').click
|
||||
fill_in 'email', with: 'delegate@example.com'
|
||||
click_button 'Delegate'
|
||||
within('dialog[open]') { click_button 'Delegate' }
|
||||
|
||||
expect(page).to have_content('Document has been delegated')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user