style builder ui

pull/105/head
Alex Turchyn 2 years ago
parent c0caed53ce
commit d0c0da0939

@ -1,14 +1,35 @@
<template>
<div
class="bg-red-100 absolute opacity-70"
class="absolute overflow-visible group"
:style="positionStyle"
@mousedown="startDrag"
@pointerdown.stop
@mousedown.stop="startDrag"
>
<div
v-if="field"
class="flex items-center justify-center h-full w-full"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap hidden group-hover:block group-hover:z-10"
style="top: -25px; height: 25px"
@mousedown.stop
@pointerdown.stop
>
{{ field?.name || field.type }}
<button
v-for="(component, type, index) in iconComponents"
:key="type"
class="px-0.5 hover:text-base-100 hover:bg-base-content transition-colors"
:class="{ 'bg-base-content text-base-100': field.type === type, 'rounded-tl': index === 0, 'rounded-tr': index === 4 }"
@click="changeTypeTo(type)"
>
<component
:is="component"
:width="20"
stroke-width="1.5"
/>
</button>
</div>
<div
class="bg-red-100 opacity-70 flex items-center justify-center h-full w-full"
>
{{ field?.name || field?.type }}
</div>
<span
class="h-2 w-2 right-0 bottom-0 bg-red-900 absolute cursor-nwse-resize"
@ -18,6 +39,8 @@
</template>
<script>
import { IconTextSize, IconWriting, IconCalendarEvent, IconPhoto, IconCheckbox } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
props: {
@ -47,6 +70,15 @@ export default {
}
},
computed: {
iconComponents () {
return {
text: IconTextSize,
signature: IconWriting,
date: IconCalendarEvent,
image: IconPhoto,
checkbox: IconCheckbox
}
},
positionStyle () {
const { x, y, w, h } = this.bounds
@ -59,9 +91,14 @@ export default {
}
},
methods: {
changeTypeTo (type) {
this.field.type = type
},
resize (e) {
if (e.toElement.id === 'mask') {
this.bounds.w = e.layerX / e.toElement.clientWidth - this.bounds.x
this.bounds.h = e.layerY / e.toElement.clientHeight - this.bounds.y
}
},
drag (e) {
if (e.toElement.id === 'mask') {

@ -1,9 +1,9 @@
<template>
<div
style="max-width: 1600px"
class="mx-auto px-4"
class="mx-auto pl-4"
>
<div class="flex justify-between py-1.5 items-center">
<div class="flex justify-between py-1.5 items-center pr-4">
<div class="flex space-x-3">
<a href="/">
<Logo />
@ -42,7 +42,7 @@
>
<div
ref="previews"
class="overflow-auto w-52 flex-none pr-4 mt-0.5 pt-0.5"
class="overflow-auto w-52 flex-none pr-3 mt-0.5 pt-0.5"
>
<DocumentPreview
v-for="(item, index) in template.schema"
@ -64,7 +64,7 @@
</div>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<div class="px-3">
<div class="pr-3.5 pl-0.5">
<Document
v-for="document in sortedDocuments"
:key="document.uuid"
@ -79,12 +79,12 @@
</div>
</div>
<div
class="relative w-72 flex-none"
class="relative w-80 flex-none pt-0.5 pr-4"
:class="drawField ? 'overflow-hidden' : 'overflow-auto'"
>
<div
v-if="drawField"
class="sticky inset-0 bg-white h-full"
class="sticky inset-0 bg-base-100 h-full"
>
Draw {{ drawField.name }} field on the page
<button @click="drawField = false">
@ -92,13 +92,13 @@
</button>
</div>
<div>
FIelds
<Fields
ref="fields"
v-model:fields="template.fields"
@set-draw="drawField = $event"
@set-drag="dragFieldType = $event"
@drag-end="dragFieldType = null"
@scroll-to-area="scrollToArea"
/>
</div>
</div>
@ -114,6 +114,7 @@ import Logo from './logo'
import Contenteditable from './contenteditable'
import DocumentPreview from './preview'
import { IconUsersPlus, IconDeviceFloppy } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'TemplateBuilder',
@ -189,10 +190,22 @@ export default {
}
},
onDraw (area) {
if (this.drawField) {
this.drawField.areas ||= []
this.drawField.areas.push(area)
this.drawField = null
} else {
const field = {
name: '',
uuid: v4(),
required: true,
type: 'text',
areas: [area]
}
this.template.fields.push(field)
}
},
onDropfield (area) {
this.$refs.fields.addField(this.dragFieldType, area)
@ -245,6 +258,11 @@ export default {
this.isSaving = false
})
},
scrollToArea (area) {
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
documentRef.scrollToArea(area)
},
save () {
return fetch(`/api/templates/${this.template.id}`, {
method: 'PUT',

@ -1,19 +1,25 @@
<template>
<div class="group flex items-center relative overflow-visible">
<div
class="group relative overflow-visible"
:class="{ 'flex items-center': !iconInline }"
>
<span
ref="contenteditable"
contenteditable
style="min-width: 2px"
class="peer outline-none"
:class="iconInline ? 'inline' : 'block'"
class="peer outline-none focus:block"
@keydown.enter.prevent="onEnter"
@focus="$emit('focus', $event)"
@blur="onBlur"
>
{{ value }}
</div>
</span>
<IconPencil
contenteditable="false"
class="absolute ml-1 cursor-pointer inline opacity-0 group-hover:opacity-100 peer-focus:opacity-0 align-middle"
:style="{ right: -(1.1 * iconWidth) + 'px' }"
class="cursor-pointer ml-1 flex-none opacity-0 group-hover:opacity-100 align-middle peer-focus:hidden"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
:class="{ 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:width="iconWidth"
@click="onPencilClick"
/>
@ -34,13 +40,18 @@ export default {
required: false,
default: ''
},
iconInline: {
type: Boolean,
required: false,
default: false
},
iconWidth: {
type: Number,
required: false,
default: 30
}
},
emits: ['update:model-value'],
emits: ['update:model-value', 'focus', 'blur'],
data () {
return {
value: ''
@ -59,6 +70,7 @@ export default {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
this.$emit('update:model-value', this.value)
this.$emit('blur', e)
},
onPencilClick () {
this.$refs.contenteditable.focus()

@ -3,11 +3,11 @@
<Page
v-for="(image, index) in sortedPreviewImages"
:key="image.id"
:ref="setPageRefs"
:number="index"
:areas="areasIndex[index]"
:is-draw="isDraw"
:is-drag="isDrag"
:class="{ 'cursor-crosshair': isDraw }"
:image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })"
@ -44,10 +44,28 @@ export default {
}
},
emits: ['draw', 'drop-field'],
data () {
return {
pageRefs: []
}
},
computed: {
sortedPreviewImages () {
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))
}
},
beforeUpdate () {
this.pageRefs = []
},
methods: {
scrollToArea (area) {
this.pageRefs[area.page].areaRefs.find((e) => e.bounds === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
},
setPageRefs (el) {
if (el) {
this.pageRefs.push(el)
}
}
}
}
</script>

@ -1,86 +1,243 @@
<template>
<div>
<div
class="group pb-2"
@mouseleave="closeDropdown"
>
<div
class="border border-base-content rounded rounded-tr-none relative group"
>
<div class="flex items-center justify-between space-x-1">
<div class="flex items-center p-1 space-x-1">
<span class="dropdown">
<label
tabindex="0"
title="Type"
class="cursor-pointer"
>
<component
:is="fieldIcons[field.type]"
width="18"
:stroke-width="1.6"
/>
</label>
<ul
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52"
@click="closeDropdown"
>
<li
v-for="(name, type) in fieldNames"
:key="type"
>
<a
href="#"
class="text-sm py-1 px-2"
:class="{ 'active': type === field.type }"
@click.prevent="field.type = type"
>
<component
:is="fieldIcons[type]"
:stroke-width="1.6"
:width="20"
/>
{{ name }}
</a>
</li>
</ul>
</span>
<Contenteditable
ref="name"
:model-value="field.name || defaultName"
:icon-inline="true"
:icon-width="19"
@focus="onNameFocus"
@blur="onNameBlur"
/>
</div>
<div class="flex items-center space-x-1 opacity-0 group-hover:opacity-100">
<span class="dropdown dropdown-end">
<label
tabindex="0"
title="Areas"
class="cursor-pointer"
>
<IconShape
:width="20"
:stroke-width="1.6"
/>
</label>
<ul
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52"
@click="closeDropdown"
>
<li
v-for="(area, index) in field.areas || []"
:key="index"
>
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('scroll-to', area)"
>
<IconShape
:width="20"
:stroke-width="1.6"
/>
Page {{ area.page + 1 }}
</a>
</li>
<li>
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('set-draw', field)"
>
<IconNewSection
:width="20"
:stroke-width="1.6"
/>
Draw New Area
</a>
</li>
</ul>
</span>
<button @click="$emit('remove', field)">
Remove
<IconTrashX
:width="20"
:stroke-width="1.6"
/>
</button>
<div>
{{ field.type }}
</div>
<div v-if="field.type !== 'signature'">
<label>Name</label>
<input
v-model="field.name"
type="text"
required
<div class="flex flex-col pr-1">
<button
title="Up"
style="font-size: 10px; margin-bottom: -2px"
@click="$emit('move-up')"
>
</button>
<button
title="Down"
style="font-size: 10px; margin-top: -2px"
@click="$emit('move-down')"
>
</button>
</div>
</div>
<div>
</div>
<div
v-if="field.options"
class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
>
<div
v-for="(option, index) in field.options"
:key="index"
class="flex"
class="flex space-x-1.5 items-center"
>
<span class="text-sm">
{{ index + 1 }}.
</span>
<input
v-model="field.options[index]"
class="w-full input input-primary input-xs text-sm"
type="text"
required
>
<button @click="field.options.splice(index, 1)">
Remove
</button>
</div>
<button
v-if="field.options"
@click="field.options.push('')"
>
Add option
</button>
</div>
<div>
<div
v-for="(area, index) in areas"
:key="index"
class="text-sm"
@click="field.options.splice(index, 1)"
>
Area {{ index + 1 }}
<button @click="removeArea(area)">
&times;
</button>
</div>
<button
class="block"
@click="$emit('set-draw', field)"
v-if="field.options"
class="text-center text-sm w-full pb-1"
@click="field.options.push('')"
>
Draw area
+ Add option
</button>
</div>
<div>
<input
:id="`field_required_${field.uuid}`"
v-model="field.required"
type="checkbox"
required
>
<label :for="`field_required_${field.uuid}`">Required</label>
</div>
</div>
</template>
<script>
import Contenteditable from './contenteditable'
import { IconTextSize, IconWriting, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconShape, IconNewSection, IconTrashX } from '@tabler/icons-vue'
export default {
name: 'TemplateField',
components: {
Contenteditable,
IconShape,
IconNewSection,
IconTrashX
},
props: {
field: {
type: Object,
required: true
},
typeIndex: {
type: Number,
required: false,
default: 0
}
},
emits: ['set-draw', 'remove'],
emits: ['set-draw', 'remove', 'move-up', 'move-down', 'scroll-to'],
computed: {
defaultName () {
return `${this.fieldNames[this.field.type]} Field ${this.typeIndex + 1}`
},
areas () {
return this.field.areas || []
},
fieldNames () {
return {
text: 'Text',
signature: 'Signature',
date: 'Date',
image: 'Image',
attachment: 'File',
select: 'Select',
checkbox: 'Checkbox',
radio: 'Radio'
}
},
fieldIcons () {
return {
text: IconTextSize,
signature: IconWriting,
date: IconCalendarEvent,
image: IconPhoto,
attachment: IconPaperclip,
select: IconSelect,
checkbox: IconCheckbox,
radio: IconCircleDot
}
}
},
methods: {
onNameFocus (e) {
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.$refs.contenteditable.innerText = ' '
}, 1)
}
},
closeDropdown () {
document.activeElement.blur()
},
onNameBlur (e) {
if (e.target.innerText.trim()) {
this.field.name = e.target.innerText.trim()
} else {
this.field.name = ''
this.$refs.name.$refs.contenteditable.innerText = this.defaultName
}
},
removeArea (area) {
this.field.areas.splice(this.field.areas.indexOf(area), 1)
}

@ -1,30 +1,35 @@
<template>
<div class="space-y-2">
<div class="mb-2">
<Field
v-for="field in fields"
:key="field.uuid"
class="border"
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
@remove="fields.splice(fields.indexOf($event), 1)"
@move-up="move(field, -1)"
@move-down="move(field, 1)"
@scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)"
/>
</div>
<div class="grid grid-cols-3 gap-1">
<button
v-for="item in fieldTypes"
:key="item.type"
draggable="true"
class="w-full flex items-center justify-center"
class="flex items-center justify-center border border-dashed border-gray-300 bg-base-100 w-full rounded relative"
@dragstart="onDragstart(item.value)"
@dragend="$emit('drag-end')"
@click="addField(item.value)"
>
<div class="w-0 absolute left-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="cursor-move"
class="cursor-grab"
width="18"
height="18"
viewBox="0 0 24 24"
stroke-width="2"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
@ -35,18 +40,28 @@
d="M0 0h24v24H0z"
fill="none"
/>
<path d="M4 6l16 0" />
<path d="M4 12l16 0" />
<path d="M4 18l16 0" />
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
Add {{ item.label }}
&plus;
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="item.icon" />
<span class="text-xs mt-1">
{{ item.label }}
</span>
</div>
</button>
</div>
</template>
<script>
import Field from './field'
import { v4 } from 'uuid'
import { IconTextSize, IconWriting, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot } from '@tabler/icons-vue'
export default {
name: 'TemplateFields',
@ -59,18 +74,18 @@ export default {
required: true
}
},
emits: ['set-draw', 'set-drag', 'drag-end'],
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area'],
computed: {
fieldTypes () {
return [
{ label: 'Text', value: 'text' },
{ label: 'Signature', value: 'signature' },
{ label: 'Date', value: 'date' },
{ label: 'Image', value: 'image' },
{ label: 'Attachment', value: 'attachment' },
{ label: 'Select', value: 'select' },
{ label: 'Checkbox', value: 'checkbox' },
{ label: 'Radio Group', value: 'radio' }
{ label: 'Text', value: 'text', icon: IconTextSize },
{ label: 'Signature', value: 'signature', icon: IconWriting },
{ label: 'Date', value: 'date', icon: IconCalendarEvent },
{ label: 'Image', value: 'image', icon: IconPhoto },
{ label: 'File', value: 'attachment', icon: IconPaperclip },
{ label: 'Select', value: 'select', icon: IconSelect },
{ label: 'Checkbox', value: 'checkbox', icon: IconCheckbox },
{ label: 'Radio', value: 'radio', icon: IconCircleDot }
]
}
},
@ -78,6 +93,19 @@ export default {
onDragstart (fieldType) {
this.$emit('set-drag', fieldType)
},
move (field, direction) {
const currentIndex = this.fields.indexOf(field)
this.fields.splice(currentIndex, 1)
if (currentIndex + direction > this.fields.length) {
this.fields.unshift(field)
} else if (currentIndex + direction < 0) {
this.fields.push(field)
} else {
this.fields.splice(currentIndex + direction, 0, field)
}
},
addField (type, area = null) {
const field = {
name: type === 'signature' ? 'Signature' : '',

@ -1,5 +1,5 @@
<template>
<div class="relative">
<div class="relative cursor-crosshair">
<img
ref="image"
:src="image.url"
@ -10,10 +10,12 @@
>
<div
class="top-0 bottom-0 left-0 right-0 absolute"
@pointerdown="onStartDraw"
>
<FieldArea
v-for="(item, i) in areas"
:key="i"
:ref="setAreaRefs"
:bounds="item.area"
:field="item.field"
@start-resize="showMask = true"
@ -27,11 +29,10 @@
/>
</div>
<div
v-show="isDraw || isDrag || showMask"
v-show="isDrag || showMask"
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute"
@pointerdown="onPointerdown"
@pointermove="onPointermove"
@dragover.prevent
@drop="onDrop"
@ -58,11 +59,6 @@ export default {
required: false,
default: () => []
},
isDraw: {
type: Boolean,
required: false,
default: false
},
isDrag: {
type: Boolean,
required: false,
@ -76,6 +72,7 @@ export default {
emits: ['draw', 'drop-field'],
data () {
return {
areaRefs: [],
showMask: false,
newArea: null
}
@ -88,7 +85,15 @@ export default {
return this.image.metadata.height
}
},
beforeUpdate () {
this.areaRefs = []
},
methods: {
setAreaRefs (el) {
if (el) {
this.areaRefs.push(el)
}
},
onDrop (e) {
this.$emit('drop-field', {
x: e.layerX / this.$refs.mask.clientWidth,
@ -98,8 +103,10 @@ export default {
page: this.number
})
},
onPointerdown (e) {
if (this.isDraw) {
onStartDraw (e) {
this.showMask = true
this.$nextTick(() => {
this.newArea = {
initialX: e.layerX / this.$refs.mask.clientWidth,
initialY: e.layerY / this.$refs.mask.clientHeight,
@ -108,7 +115,7 @@ export default {
w: 0,
h: 0
}
}
})
},
onPointermove (e) {
if (this.newArea) {
@ -132,16 +139,17 @@ export default {
}
},
onPointerup (e) {
if (this.isDraw && this.newArea) {
if (this.newArea) {
this.$emit('draw', {
x: this.newArea.x,
y: this.newArea.y,
w: Math.max(this.newArea.w, this.$refs.mask.clientWidth / 5 / this.$refs.mask.clientWidth),
h: Math.max(this.newArea.h, this.$refs.mask.clientWidth / 30 / this.$refs.mask.clientWidth),
w: this.newArea.w,
h: this.newArea.h,
page: this.number
})
}
this.showMask = false
this.newArea = null
}
}

@ -46,10 +46,11 @@
</div>
</div>
</div>
<div class="flex py-2">
<div class="flex pb-2 pt-1.5">
<Contenteditable
:model-value="item.name"
:icon-width="16"
style="max-width: 95%"
class="mx-auto"
@update:model-value="onUpdateName"
/>

@ -15,7 +15,7 @@
<turbo-frame id="modal"></turbo-frame>
<%= render 'shared/navbar' %>
<% if flash.present? %>
<div id="flash" class="absolute top-0 w-full">
<div id="flash" class="absolute top-0 w-full h-0">
<div class="max-w-xl mx-auto mt-1.5">
<div class="alert py-3">
<div class="flex w-full justify-between">

@ -5599,9 +5599,9 @@ yaml@^1.10.0:
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^2.1.1:
version "2.2.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.2.tgz#ec551ef37326e6d42872dad1970300f8eb83a073"
integrity sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==
version "2.3.1"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b"
integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==
yocto-queue@^0.1.0:
version "0.1.0"

Loading…
Cancel
Save