add cell field type

pull/105/head
Alex Turchyn 2 years ago
parent 419ede5b1d
commit 897df83633

@ -17,7 +17,7 @@ module Api
schema: [%i[attachment_uuid name]], schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]], submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, fields: [[:uuid, :submitter_uuid, :name, :type, :required,
{ options: [], areas: [%i[x y w h attachment_uuid page]] }]]) { options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]])
end end
end end
end end

@ -41,8 +41,6 @@ window.customElements.define('template-builder', class extends HTMLElement {
template: reactive(JSON.parse(this.dataset.template)) template: reactive(JSON.parse(this.dataset.template))
}) })
this.app.config.globalProperties.$t = (key) => TemplateBuilder.i18n[key] || key
this.app.mount(this.appElem) this.app.mount(this.appElem)
this.appendChild(this.appElem) this.appendChild(this.appElem)

@ -82,6 +82,19 @@
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/> />
</div> </div>
<div
v-else-if="field.type === 'cells'"
class="w-full flex items-center"
>
<div
v-for="(char, index) in modelValue"
:key="index"
class="text-center flex-none"
:style="{ width: (area.cell_w / area.w * 100) + '%' }"
>
{{ char }}
</div>
</div>
<div <div
v-else v-else
class="flex items-center px-0.5" class="flex items-center px-0.5"
@ -100,7 +113,7 @@
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3 } from '@tabler/icons-vue'
export default { export default {
name: 'FieldArea', name: 'FieldArea',
@ -153,6 +166,7 @@ export default {
fieldNames () { fieldNames () {
return { return {
text: 'Text', text: 'Text',
cells: 'Text',
signature: 'Signature', signature: 'Signature',
date: 'Date', date: 'Date',
image: 'Image', image: 'Image',
@ -173,6 +187,7 @@ export default {
select: IconSelect, select: IconSelect,
checkbox: IconCheckbox, checkbox: IconCheckbox,
radio: IconCircleDot, radio: IconCircleDot,
cells: IconColumns3,
multiple: IconChecks multiple: IconChecks
} }
}, },

@ -33,7 +33,7 @@
type="hidden" type="hidden"
> >
<div class="mt-4"> <div class="mt-4">
<div v-if="currentField.type === 'text'"> <div v-if="['cells', 'text'].includes(currentField.type)">
<label <label
v-if="currentField.name" v-if="currentField.name"
:for="currentField.uuid" :for="currentField.uuid"
@ -369,10 +369,14 @@ export default {
}) })
}, },
saveStep () { saveStep () {
return fetch(this.submitPath, { if (this.isCompleted) {
method: 'POST', return Promise.resolve({})
body: new FormData(this.$refs.form) } else {
}) return fetch(this.submitPath, {
method: 'POST',
body: new FormData(this.$refs.form)
})
}
}, },
async submitStep () { async submitStep () {
this.isSubmitting = true this.isSubmitting = true

@ -6,10 +6,29 @@
@mousedown.stop="startDrag" @mousedown.stop="startDrag"
> >
<div <div
v-if="isSelected || !field?.type" v-if="isSelected || isDraw"
class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none" class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none"
:class="borderColors[submitterIndex]" :class="borderColors[submitterIndex]"
/> />
<div
v-if="field.type === 'cells' && (isSelected || isDraw)"
class="top-0 bottom-0 right-0 left-0 absolute"
>
<div
v-for="(cellW, index) in cells"
:key="index"
class="absolute top-0 bottom-0 border-r"
:class="borderColors[submitterIndex]"
:style="{ left: (cellW / area.w * 100) + '%' }"
>
<span
v-if="index === 0"
class="h-2.5 w-2.5 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-ew-resize z-10"
style="left: -4px"
@mousedown.stop="startResizeCell"
/>
</div>
</div>
<div <div
v-if="field?.type" v-if="field?.type"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap group-hover:flex group-hover:z-10" class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap group-hover:flex group-hover:z-10"
@ -118,6 +137,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
isDraw: {
type: Boolean,
required: false,
default: false
},
field: { field: {
type: Object, type: Object,
required: false, required: false,
@ -135,7 +159,21 @@ export default {
}, },
computed: { computed: {
defaultName: Field.computed.defaultName, defaultName: Field.computed.defaultName,
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
cells () {
const cells = []
let currentWidth = 0
while (currentWidth + (this.area.cell_w + this.area.cell_w / 4) < this.area.w) {
currentWidth += this.area.cell_w || 9999999
cells.push(currentWidth)
}
return cells
},
submitter () { submitter () {
return this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid) return this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
}, },
@ -187,6 +225,29 @@ export default {
}, 1) }, 1)
} }
}, },
startResizeCell (e) {
document.addEventListener('mousemove', this.onResizeCell)
document.addEventListener('mouseup', this.stopResizeCell)
this.$emit('start-resize', 'ew')
},
stopResizeCell (e) {
document.removeEventListener('mousemove', this.onResizeCell)
document.removeEventListener('mouseup', this.stopResizeCell)
this.$emit('stop-resize')
this.save()
},
onResizeCell (e) {
if (e.toElement.id === 'mask') {
const positionX = e.layerX / (e.toElement.clientWidth - 1)
if (positionX > this.area.x) {
this.area.cell_w = positionX - this.area.x
}
}
},
maybeUpdateOptions () { maybeUpdateOptions () {
if (!['radio', 'multiple', 'select'].includes(this.field.type)) { if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options delete this.field.options
@ -195,6 +256,14 @@ export default {
if (['select', 'multiple', 'radio'].includes(this.field.type)) { if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [''] this.field.options ||= ['']
} }
(this.field.areas || []).forEach((area) => {
if (this.field.type === 'cells') {
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
} else {
delete area.cell_w
}
})
}, },
onNameBlur (e) { onNameBlur (e) {
this.isNameFocus = false this.isNameFocus = false
@ -256,7 +325,7 @@ export default {
document.addEventListener('mousemove', this.resize) document.addEventListener('mousemove', this.resize)
document.addEventListener('mouseup', this.stopResize) document.addEventListener('mouseup', this.stopResize)
this.$emit('start-resize') this.$emit('start-resize', 'nwse')
}, },
stopResize () { stopResize () {
document.removeEventListener('mousemove', this.resize) document.removeEventListener('mousemove', this.resize)

@ -85,7 +85,7 @@
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:document="document" :document="document"
:is-drag="!!dragFieldType" :is-drag="!!dragFieldType"
:is-draw="!!drawField" :draw-field="drawField"
@draw="onDraw" @draw="onDraw"
@drop-field="onDropfield" @drop-field="onDropfield"
@remove-area="removeArea" @remove-area="removeArea"
@ -141,12 +141,10 @@ import Contenteditable from './contenteditable'
import DocumentPreview from './preview' import DocumentPreview from './preview'
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop } from '@tabler/icons-vue' import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import i18n from './i18n'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
export default { export default {
name: 'TemplateBuilder', name: 'TemplateBuilder',
i18n,
components: { components: {
Upload, Upload,
Document, Document,
@ -352,6 +350,10 @@ export default {
fieldArea.h = baseArea.h fieldArea.h = baseArea.h
fieldArea.y = fieldArea.y - baseArea.h / 2 fieldArea.y = fieldArea.y - baseArea.h / 2
if (this.dragFieldType === 'cells') {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
}
field.areas = [fieldArea] field.areas = [fieldArea]
this.selectedAreaRef.value = fieldArea this.selectedAreaRef.value = fieldArea

@ -7,7 +7,7 @@
:number="index" :number="index"
:areas="areasIndex[index]" :areas="areasIndex[index]"
:is-drag="isDrag" :is-drag="isDrag"
:is-draw="isDraw" :draw-field="drawField"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:image="image" :image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })" @drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@ -38,10 +38,10 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
isDraw: { drawField: {
type: Boolean, type: Object,
required: false, required: false,
default: false default: null
}, },
isDrag: { isDrag: {
type: Boolean, type: Boolean,

@ -206,12 +206,13 @@ export default {
} }
}, },
computed: { computed: {
fieldNames: FieldType.computed.fieldNames,
defaultName () { defaultName () {
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field) const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field' const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field'
return `${this.$t(this.field.type)} ${suffix} ${typeIndex + 1}` return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
}, },
areas () { areas () {
return this.field.areas || [] return this.field.areas || []
@ -241,6 +242,14 @@ export default {
if (['radio', 'multiple', 'select'].includes(this.field.type)) { if (['radio', 'multiple', 'select'].includes(this.field.type)) {
this.field.options ||= [''] this.field.options ||= ['']
} }
(this.field.areas || []).forEach((area) => {
if (this.field.type === 'cells') {
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
} else {
delete area.cell_w
}
})
}, },
onNameBlur (e) { onNameBlur (e) {
if (e.target.innerText.trim()) { if (e.target.innerText.trim()) {

@ -2,7 +2,7 @@
<span class="dropdown"> <span class="dropdown">
<label <label
tabindex="0" tabindex="0"
:title="$t(modelValue)" :title="fieldNames[modelValue]"
class="cursor-pointer" class="cursor-pointer"
> >
<component <component
@ -33,7 +33,7 @@
:stroke-width="1.6" :stroke-width="1.6"
:width="20" :width="20"
/> />
{{ $t(type) }} {{ fieldNames[type] }}
</a> </a>
</li> </li>
</ul> </ul>
@ -41,7 +41,7 @@
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3 } from '@tabler/icons-vue'
export default { export default {
name: 'FiledTypeDropdown', name: 'FiledTypeDropdown',
@ -68,6 +68,20 @@ export default {
}, },
emits: ['update:model-value'], emits: ['update:model-value'],
computed: { computed: {
fieldNames () {
return {
text: 'Text',
signature: 'Signature',
date: 'Date',
image: 'Image',
file: 'File',
select: 'Select',
checkbox: 'Checkbox',
multiple: 'Multiple',
radio: 'Radio',
cells: 'Cells'
}
},
fieldIcons () { fieldIcons () {
return { return {
text: IconTextSize, text: IconTextSize,
@ -77,6 +91,7 @@ export default {
file: IconPaperclip, file: IconPaperclip,
select: IconSelect, select: IconSelect,
checkbox: IconCheckbox, checkbox: IconCheckbox,
cells: IconColumns3,
multiple: IconChecks, multiple: IconChecks,
radio: IconCircleDot radio: IconCircleDot
} }

@ -62,7 +62,7 @@
<div class="flex items-center flex-col px-2 py-2"> <div class="flex items-center flex-col px-2 py-2">
<component :is="icon" /> <component :is="icon" />
<span class="text-xs mt-1"> <span class="text-xs mt-1">
{{ $t(type) }} {{ fieldNames[type] }}
</span> </span>
</div> </div>
</button> </button>
@ -98,6 +98,7 @@ export default {
}, },
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'], emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
computed: { computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
submitterFields () { submitterFields () {
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid) return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)

@ -1,11 +0,0 @@
export default {
text: 'Text',
signature: 'Signature',
date: 'Date',
image: 'Image',
file: 'File',
select: 'Select',
checkbox: 'Checkbox',
multiple: 'Multiple',
radio: 'Radio'
}

@ -18,24 +18,25 @@
:ref="setAreaRefs" :ref="setAreaRefs"
:area="item.area" :area="item.area"
:field="item.field" :field="item.field"
@start-resize="[showMask = true, isResize = true]" @start-resize="resizeDirection = $event"
@stop-resize="[showMask = false, isResize = false]" @stop-resize="resizeDirection = null"
@start-drag="[showMask = true, isMove = true]" @start-drag="isMove = true"
@stop-drag="[showMask = false, isMove = false]" @stop-drag="isMove = false"
@remove="$emit('remove-area', item.area)" @remove="$emit('remove-area', item.area)"
/> />
<FieldArea <FieldArea
v-if="newArea" v-if="newArea"
:field="{ submitter_uuid: selectedSubmitter.uuid }" :is-draw="true"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || 'text' }"
:area="newArea" :area="newArea"
/> />
</div> </div>
<div <div
v-show="isDrag || showMask" v-show="resizeDirection || isMove || isDrag || showMask"
id="mask" id="mask"
ref="mask" ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute z-10" class="top-0 bottom-0 left-0 right-0 absolute z-10"
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': isResize || isDraw }" :class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove" @pointermove="onPointermove"
@dragover.prevent @dragover.prevent
@drop="onDrop" @drop="onDrop"
@ -66,10 +67,10 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
isDraw: { drawField: {
type: Boolean, type: Object,
required: false, required: false,
default: false default: null
}, },
isDrag: { isDrag: {
type: Boolean, type: Boolean,
@ -87,11 +88,17 @@ export default {
areaRefs: [], areaRefs: [],
showMask: false, showMask: false,
isMove: false, isMove: false,
isResize: false, resizeDirection: null,
newArea: null newArea: null
} }
}, },
computed: { computed: {
resizeDirectionClasses () {
return {
nwse: 'cursor-nwse-resize',
ew: 'cursor-ew-resize'
}
},
width () { width () {
return this.image.metadata.width return this.image.metadata.width
}, },
@ -148,19 +155,29 @@ export default {
this.newArea.y = e.layerY / this.$refs.mask.clientHeight this.newArea.y = e.layerY / this.$refs.mask.clientHeight
} }
if (this.drawField?.type === 'cells') {
this.newArea.cell_w = this.newArea.h * (this.$refs.mask.clientHeight / this.$refs.mask.clientWidth)
}
this.newArea.w = Math.abs(dx) this.newArea.w = Math.abs(dx)
this.newArea.h = Math.abs(dy) this.newArea.h = Math.abs(dy)
} }
}, },
onPointerup (e) { onPointerup (e) {
if (this.newArea) { if (this.newArea) {
this.$emit('draw', { const area = {
x: this.newArea.x, x: this.newArea.x,
y: this.newArea.y, y: this.newArea.y,
w: this.newArea.w, w: this.newArea.w,
h: this.newArea.h, h: this.newArea.h,
page: this.number page: this.number
}) }
if ('cell_w' in this.newArea) {
area.cell_w = this.newArea.cell_w
}
this.$emit('draw', area)
} }
this.showMask = false this.showMask = false

@ -14,6 +14,15 @@
<div class="w-full p-[0.2vw] flex items-center justify-center"> <div class="w-full p-[0.2vw] flex items-center justify-center">
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %> <%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div> </div>
<% elsif field['type'] == 'cells' %>
<% cell_width = area['cell_w'] / area['w'] * 100 %>
<div class="w-full flex items-center">
<% (0..(area['w'] / area['cell_w']).ceil).each do |index| %>
<% if value[index] %>
<div class="text-center flex-none" style="width: <%= cell_width %>%;"><%= value[index] %></div>
<% end %>
<% end %>
</div>
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<div class="flex items-center px-0.5"> <div class="flex items-center px-0.5">
<%= l(Date.parse(value)) %> <%= l(Date.parse(value)) %>

@ -19,6 +19,8 @@ module Submissions
# rubocop:disable Metrics # rubocop:disable Metrics
def call(submitter) def call(submitter)
layouter = HexaPDF::Layout::TextLayouter.new(valign: :center) layouter = HexaPDF::Layout::TextLayouter.new(valign: :center)
cell_layouter = HexaPDF::Layout::TextLayouter.new(valign: :center, align: :center)
template = submitter.submission.template template = submitter.submission.template
cert = submitter.submission.template.account.encrypted_configs cert = submitter.submission.template.account.encrypted_configs
@ -129,6 +131,17 @@ module Submissions
width: PdfIcons::WIDTH * scale, width: PdfIcons::WIDTH * scale,
height: PdfIcons::HEIGHT * scale height: PdfIcons::HEIGHT * scale
) )
when 'cells'
cell_width = area['cell_w'] * width
value.chars.each_with_index do |char, index|
text = HexaPDF::Layout::TextFragment.create(char, font: pdf.fonts.add(FONT_NAME),
font_size: FONT_SIZE)
cell_layouter.fit([text], cell_width, area['h'] * height)
.draw(canvas, ((area['x'] * width) + (cell_width * index)),
height - (area['y'] * height))
end
else else
value = I18n.l(Date.parse(value)) if field['type'] == 'date' value = I18n.l(Date.parse(value)) if field['type'] == 'date'

Loading…
Cancel
Save