diff --git a/app/src/components/v-field-template/v-field-template.vue b/app/src/components/v-field-template/v-field-template.vue index 415e4da5f8..d97f2587c5 100644 --- a/app/src/components/v-field-template/v-field-template.vue +++ b/app/src/components/v-field-template/v-field-template.vue @@ -31,6 +31,8 @@ import FieldListItem from './field-list-item.vue'; import { useFieldsStore } from '@/stores'; import { Field } from '@/types/'; import useFieldTree from '@/composables/use-field-tree'; +import { start } from '@popperjs/core'; +import { af } from 'date-fns/locale'; export default defineComponent({ components: { FieldListItem }, @@ -58,31 +60,34 @@ export default defineComponent({ const { tree } = useFieldTree(collection); watch(() => props.value, setContent, { immediate: true }); - onMounted(setContent); + onMounted(() => { + document.onselectionchange = onSelect; + setContent(); + }); - return { tree, addField, onInput, contentEl, onClick, onKeyDown, menuActive }; + return { tree, addField, onInput, contentEl, onClick, onKeyDown, menuActive, onSelect }; function onInput() { if (!contentEl.value) return; const valueString = getInputValue(); - emit('input', valueString); } function onClick(event: MouseEvent) { const target = event.target as HTMLElement; - if (target.tagName.toLowerCase() !== 'button') return; + if (target.tagName.toLowerCase() !== 'label') return; const field = target.dataset.field; emit('input', props.value.replace(`{{${field}}}`, '')); - // A button is wrapped in two empty `` elements - target.previousElementSibling?.remove(); - target.nextElementSibling?.remove(); + const before = target.previousElementSibling; + const after = target.nextElementSibling; + if (!before || !after || !(before instanceof HTMLElement) || !(after instanceof HTMLElement)) return; target.remove(); - + joinElements(before, after); + window.getSelection()?.removeAllRanges(); onInput(); } @@ -93,33 +98,98 @@ export default defineComponent({ } } + function onSelect() { + if (!contentEl.value) return; + const selection = window.getSelection(); + if (!selection) return; + const range = selection.getRangeAt(0); + if (!range) return; + const start = range.startContainer; + + if ( + !(start instanceof HTMLElement && start.classList.contains('text')) && + !start.parentElement?.classList.contains('text') + ) { + selection.removeAllRanges(); + const range = new Range(); + let textSpan = null; + + for (let i = 0; i < contentEl.value.childNodes.length || !textSpan; i++) { + const child = contentEl.value.children[i]; + if (child.classList.contains('text')) { + textSpan = child; + } + } + if (!textSpan) { + textSpan = document.createElement('span'); + textSpan.classList.add('text'); + contentEl.value.appendChild(textSpan); + } + + range.setStart(textSpan, 0); + selection.addRange(range); + } + } + function addField(fieldKey: string) { if (!contentEl.value) return; const field: Field | null = fieldsStore.getField(props.collection, fieldKey); if (!field) return; - const button = document.createElement('button'); - button.dataset.field = fieldKey; - button.setAttribute('contenteditable', 'false'); - button.innerText = String(field.name); + const label = document.createElement('label'); + label.dataset.field = fieldKey; + label.setAttribute('contenteditable', 'false'); + label.innerText = String(field.name); const range = window.getSelection()?.getRangeAt(0); - range?.deleteContents(); - range?.insertNode(button); - window.getSelection()?.removeAllRanges(); + if (!range) return; + range.deleteContents(); + const end = splitElements(); + if (!end) return; + contentEl.value.insertBefore(label, end); + window.getSelection()?.removeAllRanges(); onInput(); } + function joinElements(first: HTMLElement, second: HTMLElement) { + first.innerText += second.innerText; + second.remove(); + } + + function splitElements() { + const range = window.getSelection()?.getRangeAt(0); + if (!range) return; + + const textNode = range.startContainer; + if (textNode.nodeType != Node.TEXT_NODE) return; + const start = textNode.parentElement; + if (!(start instanceof HTMLSpanElement) || !start.classList.contains('text')) return; + + const startOffset = range.startOffset; + + const left = start.textContent?.substr(0, startOffset) || ''; + const right = start.textContent?.substr(startOffset) || ''; + + start.innerText = left; + + const nextSpan = document.createElement('span'); + nextSpan.classList.add('text'); + nextSpan.innerText = right; + contentEl.value?.insertBefore(nextSpan, start.nextSibling); + return nextSpan; + } + function getInputValue() { if (!contentEl.value) return null; return Array.from(contentEl.value.childNodes).reduce((acc, node) => { - if (node.nodeType === Node.TEXT_NODE) return (acc += node.textContent); - const el = node as HTMLElement; const tag = el.tagName; - if (tag.toLowerCase() === 'button') return (acc += `{{${el.dataset.field}}}`); + if (tag) { + if (tag.toLowerCase() === 'label') return (acc += `{{${el.dataset.field}}}`); + if (tag.toLowerCase() === 'span') return (acc += el.textContent); + } return (acc += ''); }, ''); } @@ -127,30 +197,34 @@ export default defineComponent({ function setContent() { if (!contentEl.value) return; - if (props.value === null) { - contentEl.value.innerHTML = ''; + if (props.value === null || props.value === '') { + contentEl.value.innerHTML = ''; return; } if (props.value !== getInputValue()) { const regex = /({{.*?}})/g; + const before = null; + const after = null; + const newInnerHTML = props.value .split(regex) .map((part) => { - if (part.startsWith('{{') === false) return part; - - const fieldKey = part.replace(/{{/g, '').replace(/}}/g, '').trim(); + if (part.startsWith('{{') === false) { + return `${part}`; + } + const fieldKey = part.replaceAll(/({|})/g, '').trim(); const field: Field | null = fieldsStore.getField(props.collection, fieldKey); // Instead of crashing when the field doesn't exist, we'll render a couple question // marks to indicate it's absence - if (!field) return '???'; + // Not possible anymore because it would mess up the innerHTML + if (!field) return ''; - return ``; + return ``; }) .join(''); - contentEl.value.innerHTML = newInnerHTML; } } @@ -169,7 +243,7 @@ export default defineComponent({ ::v-deep { > * { - display: inline; + display: inline-block; white-space: nowrap; } @@ -177,8 +251,12 @@ export default defineComponent({ display: none; } - button { - margin: 0; + span { + min-height: 1em; + } + + label { + margin: 0 4px; padding: 0 4px; color: var(--primary); background-color: var(--primary-alt); @@ -195,3 +273,8 @@ export default defineComponent({ } } +