adjust dynamic editor

pull/555/merge
Pete Matsyburka 6 days ago
parent 44db775b89
commit db5dbd6ac2

@ -364,7 +364,14 @@
:document="dynamicDocuments.find((dynamicDocument) => dynamicDocument.uuid === document.uuid)" :document="dynamicDocuments.find((dynamicDocument) => dynamicDocument.uuid === document.uuid)"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:drag-field="dragField" :drag-field="dragField"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:draw-option="drawOption"
@update="onDynamicDocumentUpdate" @update="onDynamicDocumentUpdate"
@draw="clearDrawField"
@add-custom-field="addCustomField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
/> />
<Document <Document
v-else v-else
@ -509,6 +516,8 @@
:show-tour-start-form="showTourStartForm" :show-tour-start-form="showTourStartForm"
@add-field="addField" @add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]" @set-draw="[drawField = $event.field, drawOption = $event.option]"
@remove-field="onRemoveField"
@remove-submitter="onRemoveSubmitter"
@select-submitter="selectedSubmitter = $event" @select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]" @set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-draw-custom-field="[drawCustomField = $event, showDrawField = true]" @set-draw-custom-field="[drawCustomField = $event, showDrawField = true]"
@ -1155,6 +1164,32 @@ export default {
this.drawCustomField = null this.drawCustomField = null
this.showDrawField = false this.showDrawField = false
}, },
onRemoveField (field) {
if (this.dynamicDocuments.length) {
field.areas?.forEach((area) => {
this.documentRefs.forEach((documentRef) => {
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
documentRef.removeArea(area)
}
})
})
}
},
onRemoveSubmitter (submitter) {
if (this.dynamicDocuments.length) {
this.template.fields.forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
field.areas?.forEach((area) => {
this.documentRefs.forEach((documentRef) => {
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
documentRef.removeArea(area)
}
})
})
}
})
}
},
toggleSelectMode () { toggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value this.isSelectModeRef.value = !this.isSelectModeRef.value
@ -2512,60 +2547,80 @@ export default {
this.save() this.save()
}, },
onDocumentRemove (item) { removeAreasByAttachmentUuid (attachmentUuid) {
if (window.confirm(this.t('are_you_sure_'))) { const removedFieldUuids = []
this.template.schema.splice(this.template.schema.indexOf(item), 1)
const removedFieldUuids = [] this.selectedAreasRef.value = this.selectedAreasRef.value.filter((area) => area.attachment_uuid !== attachmentUuid)
this.template.fields.forEach((field) => { this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => { [...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) { if (area.attachment_uuid === attachmentUuid) {
field.areas.splice(field.areas.indexOf(area), 1) field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid) removedFieldUuids.push(field.uuid)
} }
})
}) })
})
this.template.fields = this.template.fields.reduce((acc, f) => { this.template.fields = this.template.fields.reduce((acc, field) => {
if (removedFieldUuids.includes(f.uuid) && !f.areas?.length) { if (removedFieldUuids.includes(field.uuid) && !field.areas?.length) {
this.removeFieldConditions(f) this.removeFieldConditions(field)
} else { } else {
acc.push(f) acc.push(field)
} }
return acc
}, [])
},
onDocumentRemove (item) {
if (window.confirm(this.t('are_you_sure_'))) {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
return acc this.removeAreasByAttachmentUuid(item.attachment_uuid)
}, [])
this.save() this.save()
} }
}, },
onDocumentReplace (data) { onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data const { replaceSchemaItem, schema, documents } = data
const isReplacingDynamicDocument = !!replaceSchemaItem.dynamic
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
const { google_drive_file_id, dynamic, ...cleanedReplaceSchemaItem } = replaceSchemaItem const { google_drive_file_id, dynamic, ...cleanedReplaceSchemaItem } = replaceSchemaItem
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] }) this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
this.template.documents.push(...documents) this.template.documents.push(...documents)
if (isReplacingDynamicDocument) {
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
const dynamicDocumentIndex = this.dynamicDocuments.findIndex((doc) => doc.uuid === replaceSchemaItem.attachment_uuid)
if (dynamicDocumentIndex !== -1) {
this.dynamicDocuments.splice(dynamicDocumentIndex, 1)
}
}
if (data.fields) { if (data.fields) {
this.template.fields = data.fields this.template.fields = data.fields
const removedFieldUuids = [] if (isReplacingDynamicDocument) {
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
} else {
const removedFieldUuids = []
this.template.fields.forEach((field) => { this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => { [...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) { if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1) field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid) removedFieldUuids.push(field.uuid)
} }
})
}) })
})
this.template.fields = this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length) this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
}
} }
if (data.submitters) { if (data.submitters) {
@ -2576,13 +2631,15 @@ export default {
} }
} }
this.template.fields.forEach((field) => { if (!isReplacingDynamicDocument) {
(field.areas || []).forEach((area) => { this.template.fields.forEach((field) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) { (field.areas || []).forEach((area) => {
area.attachment_uuid = schema[0].attachment_uuid if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
} area.attachment_uuid = schema[0].attachment_uuid
}
})
}) })
}) }
if (this.onUpload) { if (this.onUpload) {
this.onUpload(this.template) this.onUpload(this.template)
@ -2694,9 +2751,13 @@ export default {
scrollToArea (area) { scrollToArea (area) {
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid) const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
documentRef.scrollToArea(area) if (documentRef.isDynamic) {
this.selectedAreasRef.value = []
} else {
this.selectedAreasRef.value = [area]
}
this.selectedAreasRef.value = [area] documentRef.scrollToArea(area)
}, },
baseFetch (path, options = {}) { baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, { return fetch(this.baseUrl + path, {
@ -2942,15 +3003,15 @@ export default {
} }
}) })
this.reconcileDynamicFields() this.save()
}, },
rebuildVariablesSchema ({ disable = true } = {}) { rebuildVariablesSchema ({ disable = true } = {}) {
const parsed = {} const parsed = {}
const dynamicDocumentRef = this.documentRefs.find((e) => e.mergeSchemaProperties) const dynamicDocumentRef = this.documentRefs.find((e) => e.isDynamic)
this.documentRefs.forEach((ref) => { this.documentRefs.forEach((ref) => {
if (ref.updateVariablesSchema) { if (ref.isDynamic) {
ref.updateVariablesSchema() ref.updateVariablesSchema()
} }
}) })
@ -2964,70 +3025,8 @@ export default {
if (!this.template.variables_schema) { if (!this.template.variables_schema) {
this.template.variables_schema = parsed this.template.variables_schema = parsed
} else { } else {
this.syncVariablesSchema(this.template.variables_schema, parsed, { disable }) dynamicDocumentRef.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
}
},
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
for (const key of Object.keys(parsed)) {
if (!existing[key]) {
existing[key] = parsed[key]
}
} }
for (const key of Object.keys(existing)) {
if (!parsed[key]) {
if (disable) {
existing[key].disabled = true
} else {
delete existing[key]
}
} else {
delete existing[key].disabled
if (!existing[key].form_type) {
existing[key].type = parsed[key].type
}
if (parsed[key].items) {
if (!existing[key].items) {
existing[key].items = parsed[key].items
} else if (existing[key].items.properties && parsed[key].items.properties) {
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
existing[key].items.type = parsed[key].items.type
}
}
if (existing[key].properties && parsed[key].properties) {
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
}
}
}
},
reconcileDynamicFields () {
const dynamicFieldUuids = new Set()
this.dynamicDocuments.forEach((doc) => {
const body = doc.body || ''
const uuidRegex = /uuid="([^"]+)"/g
let match
while ((match = uuidRegex.exec(body)) !== null) {
dynamicFieldUuids.add(match[1])
}
})
const toRemove = this.template.fields.filter((field) => {
if (field.areas && field.areas.length > 0) return false
return field.uuid && !dynamicFieldUuids.has(field.uuid)
})
toRemove.forEach((field) => {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
})
this.save()
} }
} }
} }

@ -5,7 +5,6 @@
:draggable="editable" :draggable="editable"
:style="[nodeStyle]" :style="[nodeStyle]"
@mousedown="selectArea" @mousedown="selectArea"
@click.stop
@dragstart="onDragStart" @dragstart="onDragStart"
@contextmenu.prevent.stop="onContextMenu" @contextmenu.prevent.stop="onContextMenu"
> >
@ -25,7 +24,14 @@
<span <span
v-if="field?.default_value" v-if="field?.default_value"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 font-normal pl-0.5" class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 font-normal pl-0.5"
>{{ field.default_value }}</span> >
<template v-if="field.default_value === '{{date}}'">
{{ t('signing_date') }}
</template>
<template v-else>
{{ field.default_value }}
</template>
</span>
<span <span
v-else-if="field && !iconOnlyField" v-else-if="field && !iconOnlyField"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5" class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5"
@ -227,6 +233,7 @@ export default {
this.onAreaDragStart() this.onAreaDragStart()
}, },
onContextMenu (e) { onContextMenu (e) {
this.selectArea()
this.onAreaContextMenu(this.area, e) this.onAreaContextMenu(this.area, e)
}, },
onResizeStart (e) { onResizeStart (e) {

@ -29,18 +29,28 @@
:container="$refs.container" :container="$refs.container"
:editable="editable" :editable="editable"
:section="section" :section="section"
:section-refs="sectionRefs"
:container-width="containerWidth" :container-width="containerWidth"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:drag-field="dragField" :drag-field="dragField"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:render-html-for-save-ref="renderHtmlForSaveRef"
:draw-option="drawOption"
:attachment-uuid="document.uuid" :attachment-uuid="document.uuid"
@update="onSectionUpdate(section, $event)" @update="onSectionUpdate(section, $event)"
@draw="$emit('draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
/> />
</Teleport> </Teleport>
</div> </div>
</template> </template>
<script> <script>
import { ref } from 'vue'
import DynamicSection from './dynamic_section.vue' import DynamicSection from './dynamic_section.vue'
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js' import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js' import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
@ -70,9 +80,29 @@ export default {
type: Object, type: Object,
required: false, required: false,
default: null default: null
},
drawField: {
type: Object,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
default: ''
},
drawCustomField: {
type: Object,
required: false,
default: null
},
drawOption: {
type: Object,
required: false,
default: null
} }
}, },
emits: ['update'], emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
data () { data () {
return { return {
containerWidth: 1040, containerWidth: 1040,
@ -81,6 +111,10 @@ export default {
} }
}, },
computed: { computed: {
renderHtmlForSaveRef: () => ref(false),
isDynamic () {
return true
},
attachmentsIndex () { attachmentsIndex () {
return (this.document.attachments || []).reduce((acc, att) => { return (this.document.attachments || []).reduce((acc, att) => {
acc[att.uuid] = att.url acc[att.uuid] = att.url
@ -135,6 +169,15 @@ export default {
}, },
methods: { methods: {
mergeSchemaProperties, mergeSchemaProperties,
removeArea (area) {
this.sectionRefs.forEach((sectionRef) => {
const pos = sectionRef.findAreaNodePos(area.uuid)
if (pos !== -1) {
sectionRef.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
}
})
},
setSectionRefs (ref) { setSectionRefs (ref) {
if (ref) { if (ref) {
this.sectionRefs.push(ref) this.sectionRefs.push(ref)
@ -150,6 +193,8 @@ export default {
} }
}, },
scrollToArea (area) { scrollToArea (area) {
this.sectionRefs.forEach(({ editor }) => editor.commands.setNodeSelection(0))
this.sectionRefs.forEach(({ editor }) => { this.sectionRefs.forEach(({ editor }) => {
const el = editor.view.dom.querySelector(`[data-area-uuid="${area.uuid}"]`) const el = editor.view.dom.querySelector(`[data-area-uuid="${area.uuid}"]`)
@ -176,7 +221,7 @@ export default {
const target = this.bodyDom.getElementById(section.id) const target = this.bodyDom.getElementById(section.id)
if (target) { if (target) {
target.innerHTML = editor.getHTML() target.innerHTML = this.getHtmlForSave(editor)
} }
this.document.body = this.bodyDom.body.innerHTML this.document.body = this.bodyDom.body.innerHTML
@ -200,7 +245,7 @@ export default {
this.sectionRefs.forEach(({ section, editor }) => { this.sectionRefs.forEach(({ section, editor }) => {
const target = this.bodyDom.getElementById(section.id) const target = this.bodyDom.getElementById(section.id)
target.innerHTML = editor.getHTML() target.innerHTML = this.getHtmlForSave(editor)
}) })
this.document.body = this.bodyDom.body.innerHTML this.document.body = this.bodyDom.body.innerHTML
@ -209,6 +254,15 @@ export default {
this.$emit('update', this.document) this.$emit('update', this.document)
}, },
getHtmlForSave (editor) {
this.renderHtmlForSaveRef.value = true
const result = editor.getHTML()
this.renderHtmlForSaveRef.value = false
return result
},
saveBody () { saveBody () {
clearTimeout(this.saveTimer) clearTimeout(this.saveTimer)
@ -219,6 +273,43 @@ export default {
body: JSON.stringify({ body: this.bodyDom.body.innerHTML }), body: JSON.stringify({ body: this.bodyDom.body.innerHTML }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}) })
},
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
for (const key of Object.keys(parsed)) {
if (!existing[key]) {
existing[key] = parsed[key]
}
}
for (const key of Object.keys(existing)) {
if (!parsed[key]) {
if (disable) {
existing[key].disabled = true
} else {
delete existing[key]
}
} else {
delete existing[key].disabled
if (!existing[key].form_type) {
existing[key].type = parsed[key].type
}
if (parsed[key].items) {
if (!existing[key].items) {
existing[key].items = parsed[key].items
} else if (existing[key].items.properties && parsed[key].items.properties) {
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
existing[key].items.type = parsed[key].items.type
}
}
if (existing[key].properties && parsed[key].properties) {
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
}
}
}
} }
} }
} }

@ -584,7 +584,7 @@ const VariableHighlight = Extension.create({
} }
}) })
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop, onFieldDestroy, editorOptions }) { export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlForSaveRef, onFieldDrop, onFieldDestroy, editorOptions }) {
const FieldNode = Node.create({ const FieldNode = Node.create({
name: 'fieldNode', name: 'fieldNode',
inline: true, inline: true,
@ -617,11 +617,32 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop,
}] }]
}, },
renderHTML ({ node }) { renderHTML ({ node }) {
return ['dynamic-field', { const attrs = {
uuid: node.attrs.uuid, uuid: node.attrs.uuid,
'area-uuid': node.attrs.areaUuid, 'area-uuid': node.attrs.areaUuid,
style: `width: ${node.attrs.width}; height: ${node.attrs.height}; display: ${node.attrs.display}; vertical-align: ${node.attrs.verticalAlign};` style: `width: ${node.attrs.width}; height: ${node.attrs.height}; display: ${node.attrs.display}; vertical-align: ${node.attrs.verticalAlign};`
}] }
if (!renderHtmlForSaveRef.value) {
const fieldArea = dynamicAreaProps.findFieldArea(node.attrs.areaUuid)
if (fieldArea?.field && fieldArea?.area) {
const field = JSON.parse(JSON.stringify(fieldArea.field))
const area = JSON.parse(JSON.stringify(fieldArea.area))
delete field.areas
delete field.uuid
delete field.submitter_uuid
delete area.uuid
delete area.attachment_uuid
attrs['data-field'] = JSON.stringify(field)
attrs['data-area'] = JSON.stringify(area)
attrs['data-template-id'] = dynamicAreaProps.template.id
}
}
return ['dynamic-field', attrs]
}, },
addNodeView () { addNodeView () {
return ({ node, getPos, editor }) => { return ({ node, getPos, editor }) => {

@ -1,7 +1,13 @@
<template> <template>
<div <div
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute" class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': isDrawMode && editable }"
> >
<div
v-if="isDrawMode && editable && cursorHighlightCoords"
class="absolute pointer-events-none z-10 bg-black"
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
/>
<div :style="{ zoom: containerWidth / sectionWidthPx }"> <div :style="{ zoom: containerWidth / sectionWidthPx }">
<section <section
:id="section.id" :id="section.id"
@ -45,7 +51,10 @@
:field="contextMenuField" :field="contextMenuField"
:with-copy-to-all-pages="false" :with-copy-to-all-pages="false"
@close="closeContextMenu" @close="closeContextMenu"
@copy="onContextMenuCopy"
@delete="onContextMenuDelete" @delete="onContextMenuDelete"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
@save="save" @save="save"
/> />
</Teleport> </Teleport>
@ -54,6 +63,7 @@
<script> <script>
import { shallowRef } from 'vue' import { shallowRef } from 'vue'
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import FieldContextMenu from './field_context_menu.vue' import FieldContextMenu from './field_context_menu.vue'
import AreaTitle from './area_title.vue' import AreaTitle from './area_title.vue'
@ -73,6 +83,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
sectionRefs: {
type: Array,
required: false,
default: () => []
},
editable: { editable: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -101,19 +116,45 @@ export default {
required: false, required: false,
default: null default: null
}, },
drawField: {
type: Object,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
default: ''
},
drawCustomField: {
type: Object,
required: false,
default: null
},
renderHtmlForSaveRef: {
type: Object,
required: false,
default: null
},
drawOption: {
type: Object,
required: false,
default: null
},
attachmentUuid: { attachmentUuid: {
type: String, type: String,
required: false, required: false,
default: null default: null
} }
}, },
emits: ['update'], emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
data () { data () {
return { return {
isAreaDrag: false, isAreaDrag: false,
areaToolbarCoords: null, areaToolbarCoords: null,
dynamicMenuCoords: null, dynamicMenuCoords: null,
contextMenu: null contextMenu: null,
cursorHighlightCoords: null
} }
}, },
computed: { computed: {
@ -165,6 +206,9 @@ export default {
isDraggingField () { isDraggingField () {
return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField) return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField)
}, },
isDrawMode () {
return !!(this.drawField || this.drawCustomField || this.drawFieldType)
},
selectedArea () { selectedArea () {
return this.selectedAreasRef.value[0] return this.selectedAreasRef.value[0]
}, },
@ -193,45 +237,121 @@ export default {
} }
}, },
mounted () { mounted () {
this.initEditor() this.editorRef.value = buildEditor({
dynamicAreaProps: {
template: this.template,
t: this.t,
selectedAreasRef: this.selectedAreasRef,
getFieldTypeIndex: this.getFieldTypeIndex,
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
getZoom: () => this.zoom,
onAreaContextMenu: this.onAreaContextMenu,
onAreaResize: this.onAreaResize,
onAreaDragStart: this.onAreaDragStart
},
attachmentsIndex: this.attachmentsIndex,
onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy,
renderHtmlForSaveRef: this.renderHtmlForSaveRef,
editorOptions: {
element: this.$refs.editorElement,
editable: this.editable,
content: this.section.innerHTML,
onUpdate: (event) => this.$emit('update', event),
onSelectionUpdate: this.onSelectionUpdate,
onBlur: () => { this.dynamicMenuCoords = null }
}
})
this.editor.view.dom.addEventListener('paste', this.onEditorPaste, true)
this.editor.view.dom.addEventListener('pointerdown', this.onEditorPointerDown, true)
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
}, },
beforeUnmount () { beforeUnmount () {
if (this.editor) { if (this.editor) {
this.editor.view.dom.removeEventListener('paste', this.onEditorPaste, true)
this.editor.view.dom.removeEventListener('pointerdown', this.onEditorPointerDown, true)
this.editor.view.dom.removeEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.removeEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.removeEventListener('keydown', this.onEditorKeyDown)
this.editor.destroy() this.editor.destroy()
} }
}, },
methods: { methods: {
async initEditor () {
this.editorRef.value = buildEditor({
dynamicAreaProps: {
template: this.template,
t: this.t,
selectedAreasRef: this.selectedAreasRef,
getFieldTypeIndex: this.getFieldTypeIndex,
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
getZoom: () => this.zoom,
onAreaContextMenu: this.onAreaContextMenu,
onAreaResize: this.onAreaResize,
onAreaDragStart: this.onAreaDragStart
},
attachmentsIndex: this.attachmentsIndex,
onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy,
editorOptions: {
element: this.$refs.editorElement,
editable: this.editable,
content: this.section.innerHTML,
onUpdate: (event) => this.$emit('update', event),
onSelectionUpdate: this.onSelectionUpdate,
onBlur: () => { this.dynamicMenuCoords = null }
}
})
},
findAreaNodePos (areaUuid) { findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`) const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
return this.editor.view.posAtDOM(el, 0) return this.editor.view.posAtDOM(el, 0)
}, },
getFieldInsertIndex (pos) {
const view = this.editor.view
const fields = this.template.fields || []
if (!fields.length) {
return 0
}
let previousField = null
view.state.doc.nodesBetween(0, pos, (node, nodePos) => {
if (node.type.name !== 'fieldNode') {
return
}
if (nodePos + node.nodeSize <= pos) {
previousField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || previousField
}
})
if (!previousField) {
previousField = this.getPreviousSectionField()
}
if (!previousField) {
return 0
}
const previousFieldIndex = fields.indexOf(previousField)
return previousFieldIndex === -1 ? fields.length : previousFieldIndex + 1
},
insertFieldInTemplate (field, index) {
const currentFieldIndex = this.template.fields.indexOf(field)
if (currentFieldIndex !== -1) {
return currentFieldIndex
}
this.template.fields.splice(index, 0, field)
return index
},
getLastFieldInSection (sectionRef) {
let lastField = null
sectionRef.editor.state.doc.descendants((node) => {
if (node.type.name === 'fieldNode') {
lastField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || lastField
}
})
return lastField
},
getPreviousSectionField () {
const sectionIndex = this.sectionRefs.indexOf(this)
for (let index = sectionIndex - 1; index >= 0; index -= 1) {
const previousField = this.getLastFieldInSection(this.sectionRefs[index])
if (previousField) {
return previousField
}
}
return null
},
removeArea (area) { removeArea (area) {
const { field } = this.fieldAreaIndex[area.uuid] const { field } = this.fieldAreaIndex[area.uuid]
const areaIndex = field.areas.indexOf(area) const areaIndex = field.areas.indexOf(area)
@ -362,6 +482,220 @@ export default {
closeContextMenu () { closeContextMenu () {
this.contextMenu = null this.contextMenu = null
}, },
onEditorMouseLeave () {
this.cursorHighlightCoords = null
},
onEditorKeyDown (event) {
if (event.key === 'Escape') {
this.editor.chain().setNodeSelection(0).blur().run()
this.deselectArea()
}
},
onEditorMouseMove (event) {
if (!this.isDrawMode || !this.editable) {
this.cursorHighlightCoords = null
return
}
const view = this.editor?.view
if (!view) return
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) {
this.cursorHighlightCoords = null
return
}
const coords = view.coordsAtPos(pos.pos)
const outerRect = this.$el.getBoundingClientRect()
const lineHeight = coords.bottom - coords.top
this.cursorHighlightCoords = {
x: coords.left - outerRect.left,
y: coords.top - outerRect.top,
height: lineHeight
}
},
getFieldNode (areaUuid) {
const pos = this.findAreaNodePos(areaUuid)
return this.editor.state.doc.nodeAt(pos)
},
serializeFieldNodeHtml (areaUuid) {
const node = this.getFieldNode(areaUuid)
if (!node) {
return null
}
const serializer = DOMSerializer.fromSchema(this.editor.state.schema)
const container = document.createElement('div')
container.appendChild(serializer.serializeFragment(Fragment.from(node)))
return container.innerHTML
},
async writeHtmlToClipboard ({ html, text }, clipboardData = null) {
if (clipboardData) {
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', text || html)
return true
}
if (navigator.clipboard?.write && window.ClipboardItem) {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text || html], { type: 'text/plain' })
})
])
return true
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text || html)
return true
}
return false
},
async copyFieldAreaToClipboard (areaUuid, clipboardData = null) {
const html = this.serializeFieldNodeHtml(areaUuid)
if (!html) {
return false
}
try {
await this.writeHtmlToClipboard({ html, text: html }, clipboardData)
return true
} catch (e) {
console.error('Failed to copy dynamic field:', e)
return false
}
},
async onContextMenuCopy () {
await this.copyFieldAreaToClipboard(this.contextMenu.areaUuid)
this.closeContextMenu()
},
buildCopiedField (payload) {
const field = JSON.parse(JSON.stringify(payload.field))
const area = {
...JSON.parse(JSON.stringify(payload.area)),
uuid: v4(),
attachment_uuid: this.attachmentUuid
}
if (payload.templateId !== this.template.id) {
delete field.conditions
if (field.preferences) {
delete field.preferences.formula
}
}
const newField = {
...field,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid
const optionsMap = {}
newField.options = field.options.map((opt) => {
const newUuid = v4()
optionsMap[opt.uuid] = newUuid
return { ...opt, uuid: newUuid }
})
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
}
return { field: newField, area }
},
onEditorPaste (event) {
const clipboardData = event.clipboardData
if (!clipboardData) {
return
}
const html = clipboardData.getData('text/html')
const text = clipboardData.getData('text/plain')
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
return
}
const container = document.createElement('div')
container.innerHTML = clipboardHtml
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
if (!fieldNodes.length) {
return
}
event.preventDefault()
const { selection } = this.editor.state
const { from, to } = selection
let insertIndex = this.getFieldInsertIndex(selection.node?.type.name === 'fieldNode' ? to : from)
let lastArea = null
fieldNodes.forEach((fieldNode) => {
const fieldValue = fieldNode.dataset.field
const areaValue = fieldNode.dataset.area
const templateId = fieldNode.dataset.templateId
if (!fieldValue || !areaValue) {
return
}
const { field, area } = this.buildCopiedField({
field: JSON.parse(fieldValue),
area: JSON.parse(areaValue),
templateId: Number(templateId)
})
this.insertFieldInTemplate(field, insertIndex)
insertIndex += 1
fieldNode.setAttribute('uuid', field.uuid)
fieldNode.setAttribute('area-uuid', area.uuid)
fieldNode.removeAttribute('data-field')
fieldNode.removeAttribute('data-area')
fieldNode.removeAttribute('data-template-id')
lastArea = area
})
this.editor.chain().focus().insertContentAt({ from, to }, container.innerHTML).run()
if (lastArea) {
this.editor.commands.setNodeSelection(this.findAreaNodePos(lastArea.uuid))
}
this.closeContextMenu()
this.save()
},
onContextMenuDelete () { onContextMenuDelete () {
const menu = this.contextMenu const menu = this.contextMenu
const fieldArea = this.fieldAreaIndex[menu.areaUuid] const fieldArea = this.fieldAreaIndex[menu.areaUuid]
@ -399,89 +733,167 @@ export default {
if (!pos) return false if (!pos) return false
const fieldType = draggedField.type || 'text' this.insertFieldAtRange({
const dims = this.defaultSizes[fieldType] || this.defaultSizes.text sourceField: draggedField,
const areaUuid = v4() existingField: this.fieldsDragFieldRef?.value,
from: pos.pos
})
const existingField = this.fieldsDragFieldRef?.value this.fieldsDragFieldRef.value = null
this.customDragFieldRef.value = null
if (existingField) { return true
if (!this.template.fields.includes(existingField)) { },
this.template.fields.push(existingField) onEditorPointerDown (event) {
} if (!this.isDrawMode || !this.editable || this.isDraggingField) {
return
}
existingField.areas = existingField.areas || [] if (event.button === 2) {
existingField.areas.push({ uuid: areaUuid, attachment_uuid: this.attachmentUuid }) return
}
const nodeType = view.state.schema.nodes.fieldNode const view = this.editor?.view
const fieldNode = nodeType.create({
uuid: existingField.uuid, if (!view) {
areaUuid, return
width: dims.width, }
height: dims.height
})
const tr = view.state.tr.insert(pos.pos, fieldNode) const selection = view.state.selection
const isTextRangeSelection = !selection.empty && !selection.node
view.dispatch(tr) let from = selection.from
} else { let to = selection.to
const newField = {
name: draggedField.name || '', if (!isTextRangeSelection) {
uuid: v4(), const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
required: fieldType !== 'checkbox',
submitter_uuid: this.selectedSubmitter.uuid, if (!pos) {
type: fieldType, return
areas: [{ uuid: areaUuid, attachment_uuid: this.attachmentUuid }]
} }
if (['select', 'multiple', 'radio'].includes(fieldType)) { from = pos.pos
if (draggedField.options?.length) { to = pos.pos
newField.options = draggedField.options.map((opt) => ({ }
value: typeof opt === 'string' ? opt : opt.value,
uuid: v4() const sourceField = this.drawField || this.drawCustomField || { type: this.drawFieldType }
}))
} else { event.preventDefault()
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }] event.stopPropagation()
}
if (this.drawOption && this.drawField) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
if (areaWithoutOption && !this.drawField.areas.find((a) => a.option_uuid === this.drawField.options[0].uuid)) {
areaWithoutOption.option_uuid = this.drawField.options[0].uuid
} }
}
const inserted = this.insertFieldAtRange({
sourceField,
existingField: this.drawField,
optionUuid: this.drawOption?.uuid,
from,
to
})
if (fieldType === 'datenow') { if (inserted) {
newField.type = 'date' this.$emit('draw', inserted)
newField.readonly = true }
newField.default_value = '{{date}}' },
buildFieldArea ({ optionUuid = null } = {}) {
const area = {
uuid: v4(),
attachment_uuid: this.attachmentUuid
}
if (optionUuid) {
area.option_uuid = optionUuid
}
return area
},
buildNewField (sourceField, area) {
const fieldType = sourceField?.type || 'text'
const newField = {
name: sourceField?.name || '',
uuid: v4(),
required: fieldType !== 'checkbox',
submitter_uuid: this.selectedSubmitter.uuid,
type: fieldType,
areas: [area]
}
if (['select', 'multiple', 'radio'].includes(fieldType)) {
if (sourceField?.options?.length) {
newField.options = sourceField.options.map((opt) => ({
value: typeof opt === 'string' ? opt : opt.value,
uuid: v4()
}))
} else {
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
} }
}
if (fieldType === 'datenow') {
newField.type = 'date'
newField.readonly = true
newField.default_value = '{{date}}'
}
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) { if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
newField.readonly = true newField.readonly = true
if (fieldType === 'strikethrough') { if (fieldType === 'strikethrough') {
newField.default_value = true newField.default_value = true
}
} }
}
this.template.fields.push(newField) return newField
},
insertFieldAtRange ({ sourceField, existingField = null, optionUuid = null, from, to = from }) {
if (!sourceField) {
return null
}
const nodeType = view.state.schema.nodes.fieldNode const fieldType = sourceField.type || existingField?.type || 'text'
const fieldNode = nodeType.create({ const dims = this.defaultSizes[fieldType]
uuid: newField.uuid, const area = this.buildFieldArea({ optionUuid })
areaUuid, const insertIndex = this.getFieldInsertIndex(from)
width: dims.width,
height: dims.height let field = existingField
}) const view = this.editor.view
const tr = view.state.tr.insert(pos.pos, fieldNode) if (field) {
if (!this.template.fields.includes(field)) {
this.insertFieldInTemplate(field, insertIndex)
}
view.dispatch(tr) field.areas = field.areas || []
field.areas.push(area)
} else {
field = this.buildNewField(sourceField, area)
this.insertFieldInTemplate(field, insertIndex)
} }
this.fieldsDragFieldRef.value = null const nodeType = view.state.schema.nodes.fieldNode
this.customDragFieldRef.value = null const fieldNode = nodeType.create({
uuid: field.uuid,
areaUuid: area.uuid,
width: dims.width,
height: dims.height
})
const tr = from !== to
? view.state.tr.replaceWith(from, to, fieldNode)
: view.state.tr.insert(from, fieldNode)
this.editor.chain().focus().setNodeSelection(pos.pos).run() view.dispatch(tr)
this.editor.chain().focus().setNodeSelection(from).run()
this.save() this.save()
return true return { area, field, pos: from }
} }
} }
} }

@ -501,7 +501,7 @@ export default {
default: false default: false
} }
}, },
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema'], emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema', 'remove-field', 'remove-submitter'],
data () { data () {
return { return {
fieldPagesLoaded: null, fieldPagesLoaded: null,
@ -805,6 +805,8 @@ export default {
this.$emit('change-submitter', this.submitters[0]) this.$emit('change-submitter', this.submitters[0])
} }
this.$emit('remove-submitter', submitter)
this.save() this.save()
}, },
removeField (field, save = true) { removeField (field, save = true) {
@ -830,6 +832,8 @@ export default {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1) this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
}) })
this.$emit('remove-field', field)
if (save) { if (save) {
this.save() this.save()
} }

Loading…
Cancel
Save