optimize add list style

This commit is contained in:
Pete Matsyburka committed 2026-10-03 14:05:38 +03:00
1 parent a990d22831
commit bef825c4e0
4 files changed
+218 -234

No files matched your search

@@ -51,6 +51,7 @@
@add-custom-field="$emit('add-custom-field', $event)" @add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
@add-attachment="document.attachments.push($event)" @add-attachment="document.attachments.push($event)"
@add-list="ensureListsStyle"
/> />
</Teleport> </Teleport>
</div> </div>
@@ -189,15 +190,8 @@ export default {
} }
} }
}, },
watch: { created () {
bodyDom: { this.loadListsStyle()
immediate: true,
handler () {
this.listsStyle = this.bodyDom.querySelector('body > style[data-lists]')?.textContent || null
this.ensureListsStyle(this.bodyDom)
}
}
}, },
mounted () { mounted () {
this.isMounted = true this.isMounted = true
@@ -244,6 +238,8 @@ export default {
} }
}, },
reloadContent () { reloadContent () {
this.loadListsStyle()
this.sectionRefs.forEach((ref) => ref.reloadContent()) this.sectionRefs.forEach((ref) => ref.reloadContent())
}, },
onBeforeUnload (event) { onBeforeUnload (event) {
@@ -269,8 +265,6 @@ export default {
}) })
}, },
onSectionUpdate (section, { editor }) { onSectionUpdate (section, { editor }) {
this.ensureListsStyle(editor.view.dom)
clearTimeout(this.saveTimer) clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(async () => { this.saveTimer = setTimeout(async () => {
@@ -279,8 +273,11 @@ export default {
delete this.saveTimer delete this.saveTimer
}, 1000) }, 1000)
}, },
ensureListsStyle (root) { loadListsStyle () {
if (this.listsStyle || !root.querySelector('[class*="doc-list-"]')) return this.listsStyle = this.bodyDom.querySelector('body > style[data-lists]')?.textContent || null
},
ensureListsStyle () {
if (this.listsStyle) return
const style = this.bodyDom.createElement('style') const style = this.bodyDom.createElement('style')
@@ -1,185 +1,159 @@
<template> <template>
<div <div class="absolute top-7 left-0 min-w-36 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg">
class="absolute z-10 select-none text-sm"
:style="{ top: coords.top - 12 + 'px', left: coords.left - 12 + 'px' }"
@mousedown.prevent
>
<button
class="flex items-center justify-center w-6 h-6 rounded-md border border-neutral-200"
:class="isOpen ? 'bg-neutral-100 text-base-content' : 'bg-white text-base-content/40 hover:bg-neutral-100 hover:text-base-content'"
:title="t('insert')"
@click="isOpen = !isOpen"
>
<IconX
v-if="isOpen"
:width="14"
:height="14"
/>
<IconPlus
v-else
:width="14"
:height="14"
/>
</button>
<div <div
v-if="isOpen" class="relative"
class="absolute top-7 left-0 min-w-36 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg" @pointerenter="$event.pointerType === 'mouse' ? submenu = 'field' : null"
> >
<div
class="relative"
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'field' : null"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === 'field' }"
@click="submenu = submenu === 'field' ? null : 'field'"
>
<IconForms
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('field') }}
<IconChevronRight
:width="14"
:height="14"
class="ml-auto"
/>
</button>
<div
v-if="submenu === 'field'"
class="absolute top-0 left-full min-w-40 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="(icon, type) in fieldTypesList"
:key="type"
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@click="[$emit('insert-field', type), isOpen = false]"
>
<component
:is="icon"
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ fieldNames[type] }}
</button>
</div>
</div>
<div
class="relative"
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'table' : null"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === 'table' }"
@click="submenu = submenu === 'table' ? null : 'table'"
>
<IconTable
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('table') }}
<IconChevronRight
:width="14"
:height="14"
class="ml-auto"
/>
</button>
<div
v-if="submenu === 'table'"
class="absolute top-0 left-full w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<div
v-if="!isMobile"
class="grid grid-cols-8 gap-0.5 mb-2"
>
<template
v-for="row in 8"
:key="row"
>
<button
v-for="col in 8"
:key="col"
class="w-4 h-4 border rounded-sm"
:class="row <= tableSize.rows && col <= tableSize.cols ? 'bg-neutral-200 border-neutral-400' : 'bg-white border-neutral-200'"
:aria-label="`${row} × ${col}`"
@pointerenter="$event.pointerType === 'mouse' ? tableSize = { rows: row, cols: col } : null"
@click="[$emit('insert-table', { rows: row, cols: col }), isOpen = false]"
/>
</template>
</div>
<div class="flex items-center justify-center gap-1 text-xs">
<template
v-for="key in ['rows', 'cols']"
:key="key"
>
<span v-if="key === 'cols'">×</span>
<div class="flex items-center border border-neutral-200 rounded-md">
<button
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
:aria-label="t(`decrease_${key}`)"
@click="tableSize[key] = Math.max(tableSize[key] - 1, 1)"
>
<IconMinus
:width="12"
:height="12"
/>
</button>
<span class="w-5 text-center">{{ tableSize[key] }}</span>
<button
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
:aria-label="t(`increase_${key}`)"
@click="tableSize[key] = Math.min(tableSize[key] + 1, 20)"
>
<IconPlus
:width="12"
:height="12"
/>
</button>
</div>
</template>
</div>
<button
v-if="isMobile || tableSize.rows > 8 || tableSize.cols > 8"
class="w-full mt-2 px-2 py-1 rounded-md bg-neutral-100 hover:bg-neutral-200"
@click="[$emit('insert-table', { ...tableSize }), isOpen = false]"
>
{{ t('insert') }}
</button>
</div>
</div>
<button <button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100" class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
@mouseenter="submenu = null" :class="{ 'bg-neutral-100': submenu === 'field' }"
@click="[$emit('insert-variable'), isOpen = false]" @click="submenu = submenu === 'field' ? null : 'field'"
> >
<IconBracketsContain <IconForms
:width="16" :width="16"
:height="16" :height="16"
:stroke-width="1.6" :stroke-width="1.6"
/> />
{{ t('variable') }} {{ t('field') }}
</button> <IconChevronRight
<button :width="14"
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100" :height="14"
@mouseenter="submenu = null" class="ml-auto"
@click="[$emit('insert-image'), isOpen = false]"
>
<IconPhoto
:width="16"
:height="16"
:stroke-width="1.6"
/> />
{{ t('image') }}
</button> </button>
<div
v-if="submenu === 'field'"
class="absolute top-0 left-full min-w-40 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="(icon, type) in fieldTypesList"
:key="type"
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@click="[$emit('insert-field', type), $emit('close')]"
>
<component
:is="icon"
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ fieldNames[type] }}
</button>
</div>
</div> </div>
<div
class="relative"
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'table' : null"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === 'table' }"
@click="submenu = submenu === 'table' ? null : 'table'"
>
<IconTable
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('table') }}
<IconChevronRight
:width="14"
:height="14"
class="ml-auto"
/>
</button>
<div
v-if="submenu === 'table'"
class="absolute top-0 left-full w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<div
v-if="!isMobile"
class="grid grid-cols-8 gap-0.5 mb-2"
>
<template
v-for="row in 8"
:key="row"
>
<button
v-for="col in 8"
:key="col"
class="w-4 h-4 border rounded-sm"
:class="row <= tableSize.rows && col <= tableSize.cols ? 'bg-neutral-200 border-neutral-400' : 'bg-white border-neutral-200'"
:aria-label="`${row} × ${col}`"
@pointerenter="$event.pointerType === 'mouse' ? tableSize = { rows: row, cols: col } : null"
@click="[$emit('insert-table', { rows: row, cols: col }), $emit('close')]"
/>
</template>
</div>
<div class="flex items-center justify-center gap-1 text-xs">
<template
v-for="key in ['rows', 'cols']"
:key="key"
>
<span v-if="key === 'cols'">×</span>
<div class="flex items-center border border-neutral-200 rounded-md">
<button
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
:aria-label="t(`decrease_${key}`)"
@click="tableSize[key] = Math.max(tableSize[key] - 1, 1)"
>
<IconMinus
:width="12"
:height="12"
/>
</button>
<span class="w-5 text-center">{{ tableSize[key] }}</span>
<button
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
:aria-label="t(`increase_${key}`)"
@click="tableSize[key] = Math.min(tableSize[key] + 1, 20)"
>
<IconPlus
:width="12"
:height="12"
/>
</button>
</div>
</template>
</div>
<button
v-if="isMobile || tableSize.rows > 8 || tableSize.cols > 8"
class="w-full mt-2 px-2 py-1 rounded-md bg-neutral-100 hover:bg-neutral-200"
@click="[$emit('insert-table', { ...tableSize }), $emit('close')]"
>
{{ t('insert') }}
</button>
</div>
</div>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@mouseenter="submenu = null"
@click="[$emit('insert-variable'), $emit('close')]"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('variable') }}
</button>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@mouseenter="submenu = null"
@click="[$emit('insert-image'), $emit('close')]"
>
<IconPhoto
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('image') }}
</button>
</div> </div>
</template> </template>
<script> <script>
import { IconPlus, IconMinus, IconX, IconForms, IconTable, IconPhoto, IconChevronRight, IconBracketsContain } from '@tabler/icons-vue' import { IconPlus, IconMinus, IconForms, IconTable, IconPhoto, IconChevronRight, IconBracketsContain } from '@tabler/icons-vue'
import FieldType from './field_type.vue' import FieldType from './field_type.vue'
export default { export default {
@@ -187,7 +161,6 @@ export default {
components: { components: {
IconPlus, IconPlus,
IconMinus, IconMinus,
IconX,
IconForms, IconForms,
IconTable, IconTable,
IconPhoto, IconPhoto,
@@ -195,16 +168,9 @@ export default {
IconBracketsContain IconBracketsContain
}, },
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 't', 'fieldTypes', 'isMobile'], inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 't', 'fieldTypes', 'isMobile'],
props: { emits: ['insert-field', 'insert-table', 'insert-variable', 'insert-image', 'close'],
coords: {
type: Object,
required: true
}
},
emits: ['insert-field', 'insert-table', 'insert-variable', 'insert-image'],
data () { data () {
return { return {
isOpen: false,
submenu: null, submenu: null,
tableSize: { rows: 3, cols: 3 } tableSize: { rows: 3, cols: 3 }
} }
@@ -220,24 +186,18 @@ export default {
})) }))
} }
}, },
watch: { mounted () {
isOpen (value) { document.addEventListener('mousedown', this.onDocumentMouseDown)
this.submenu = null
if (value) {
document.addEventListener('mousedown', this.onDocumentMouseDown)
} else {
document.removeEventListener('mousedown', this.onDocumentMouseDown)
}
}
}, },
beforeUnmount () { beforeUnmount () {
document.removeEventListener('mousedown', this.onDocumentMouseDown) document.removeEventListener('mousedown', this.onDocumentMouseDown)
this.$emit('close')
}, },
methods: { methods: {
onDocumentMouseDown (event) { onDocumentMouseDown (event) {
if (!event.composedPath().includes(this.$el)) { if (!event.composedPath().includes(this.$el.parentElement)) {
this.isOpen = false this.$emit('close')
} }
} }
} }
@@ -385,7 +385,7 @@ export default {
default: null default: null
} }
}, },
emits: ['add-variable', 'add-condition'], emits: ['add-variable', 'add-condition', 'add-list'],
data () { data () {
return { return {
isMouseDown: false, isMouseDown: false,
@@ -576,6 +576,8 @@ export default {
toggleList (style) { toggleList (style) {
this.dropdown = null this.dropdown = null
if (style !== this.listStyle) this.$emit('add-list')
this.editor.chain().focus().toggleList(style).run() this.editor.chain().focus().toggleList(style).run()
}, },
wrapVariable () { wrapVariable () {
@@ -51,19 +51,44 @@
v-if="editable" v-if="editable"
v-show="!selectedAreasRef.value.length" v-show="!selectedAreasRef.value.length"
:editor="editor" :editor="editor"
:coords="dynamicMenuCoords" :coords="isInsertMenuOpen ? null : dynamicMenuCoords"
@add-variable="dynamicMenuCoords = null" @add-variable="dynamicMenuCoords = null"
@add-condition="dynamicMenuCoords = null" @add-condition="dynamicMenuCoords = null"
@add-list="$emit('add-list')"
/> />
<DynamicInsertMenu <div
v-if="editable && insertMenuCoords && !isDrawMode && !selectedAreasRef.value.length" v-if="editable && insertMenuCoords && !isDrawMode"
:key="insertMenuCoords.top" class="absolute z-10 select-none text-sm"
:coords="insertMenuCoords" :style="{ top: insertMenuCoords.top - 12 + 'px', left: insertMenuCoords.left - 12 + 'px' }"
@insert-field="onInsertField" @mousedown.prevent
@insert-table="onInsertTable" >
@insert-variable="editor.chain().focus().insertContent('[[variable]]').run()" <button
@insert-image="$refs.imageInput.click()" class="flex items-center justify-center w-6 h-6 rounded-md border border-neutral-200"
/> :class="isInsertMenuOpen ? 'bg-neutral-100 text-base-content' : 'bg-white text-base-content/40 hover:bg-neutral-100 hover:text-base-content'"
:title="t('insert')"
@click="isInsertMenuOpen = !isInsertMenuOpen"
>
<IconX
v-if="isInsertMenuOpen"
:width="14"
:height="14"
/>
<IconPlus
v-else
:width="14"
:height="14"
/>
</button>
<DynamicInsertMenu
v-if="isInsertMenuOpen"
:key="insertMenuCoords.top"
@close="isInsertMenuOpen = false"
@insert-field="onInsertField"
@insert-table="onInsertTable"
@insert-variable="editor.chain().focus().insertContent('[[variable]]').run()"
@insert-image="$refs.imageInput.click()"
/>
</div>
<DynamicBlockMenu <DynamicBlockMenu
v-if="editable && blockMenuCoords && !isDrawMode" v-if="editable && blockMenuCoords && !isDrawMode"
:coords="blockMenuCoords" :coords="blockMenuCoords"
@@ -90,8 +115,9 @@
<script> <script>
import { shallowRef } from 'vue' import { shallowRef } from 'vue'
import { DOMSerializer, Fragment } from '@tiptap/pm/model' import { DOMSerializer, Fragment } from '@tiptap/pm/model'
import { findTable } from '@tiptap/pm/tables' import { findTable, CellSelection } from '@tiptap/pm/tables'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { IconPlus, IconX } from '@tabler/icons-vue'
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'
import DynamicMenu from './dynamic_menu.vue' import DynamicMenu from './dynamic_menu.vue'
@@ -106,7 +132,9 @@ export default {
DynamicInsertMenu, DynamicInsertMenu,
DynamicBlockMenu, DynamicBlockMenu,
FieldContextMenu, FieldContextMenu,
AreaTitle AreaTitle,
IconPlus,
IconX
}, },
inject: ['template', 'save', 'baseFetch', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'], inject: ['template', 'save', 'baseFetch', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'],
props: { props: {
@@ -178,13 +206,14 @@ export default {
default: null default: null
} }
}, },
emits: ['update', 'draw', 'set-draw', 'add-custom-field', 'add-attachment'], emits: ['update', 'draw', 'set-draw', 'add-custom-field', 'add-attachment', 'add-list'],
data () { data () {
return { return {
isAreaDrag: false, isAreaDrag: false,
areaToolbarCoords: null, areaToolbarCoords: null,
dynamicMenuCoords: null, dynamicMenuCoords: null,
insertMenuCoords: null, insertMenuCoords: null,
isInsertMenuOpen: false,
blockMenuCoords: null, blockMenuCoords: null,
contextMenu: null, contextMenu: null,
cursorHighlightCoords: null cursorHighlightCoords: null
@@ -455,7 +484,6 @@ export default {
} }
} }
this.insertMenuCoords = null
this.blockMenuCoords = null this.blockMenuCoords = null
} else { } else {
this.areaToolbarCoords = null this.areaToolbarCoords = null
@@ -463,25 +491,18 @@ export default {
this.setBlockMenuCoords(editor) this.setBlockMenuCoords(editor)
if (selection.node) { if (selection.node || selection.empty || !editor.isFocused) {
this.dynamicMenuCoords = null this.dynamicMenuCoords = null
this.insertMenuCoords = null
} else if (selection.empty) {
this.dynamicMenuCoords = null
if (editor.isFocused) {
this.setInsertMenuCoords(editor)
}
} else { } else {
this.insertMenuCoords = null this.setDynamicMenuCoords(editor)
if (editor.isFocused) {
this.setDynamicMenuCoords(editor)
} else {
this.dynamicMenuCoords = null
}
} }
} }
if (selection instanceof CellSelection) {
this.insertMenuCoords = null
} else if (editor.isFocused) {
this.setInsertMenuCoords(editor)
}
}, },
setInsertMenuCoords (editor) { setInsertMenuCoords (editor) {
const coords = editor.view.coordsAtPos(editor.state.selection.head) const coords = editor.view.coordsAtPos(editor.state.selection.head)
@@ -562,10 +583,12 @@ export default {
this.$nextTick(() => this.setBlockMenuCoords(this.editor)) this.$nextTick(() => this.setBlockMenuCoords(this.editor))
}, },
onInsertField (type) { onInsertField (type) {
this.insertFieldAtRange({ sourceField: { type }, from: this.editor.state.selection.head }) const { from, to } = this.editor.state.selection
this.insertFieldAtRange({ sourceField: { type }, from, to })
}, },
onInsertTable ({ rows, cols }) { onInsertTable ({ rows, cols }) {
const $pos = this.editor.state.selection.$head const $pos = this.editor.state.tr.deleteSelection().selection.$head
const cell = '<td style="border: 1px solid #000000; padding: 4pt; vertical-align: top"><p style="margin: 0"></p></td>' const cell = '<td style="border: 1px solid #000000; padding: 4pt; vertical-align: top"><p style="margin: 0"></p></td>'
const html = `<table style="width: 100%; border-collapse: collapse"><tbody>${`<tr>${cell.repeat(cols)}</tr>`.repeat(rows)}</tbody></table>` const html = `<table style="width: 100%; border-collapse: collapse"><tbody>${`<tr>${cell.repeat(cols)}</tr>`.repeat(rows)}</tbody></table>`
const isEmpty = $pos.parent.isTextblock && !$pos.parent.content.size const isEmpty = $pos.parent.isTextblock && !$pos.parent.content.size
@@ -573,13 +596,13 @@ export default {
const to = isEmpty ? $pos.after() : from const to = isEmpty ? $pos.after() : from
this.editor.chain().focus() this.editor.chain().focus()
.deleteSelection()
.insertContentAt({ from, to }, html) .insertContentAt({ from, to }, html)
.setTextSelection(from + 4) .setTextSelection(from + 4)
.run() .run()
}, },
async onImageSelected (event) { async onImageSelected (event) {
const file = event.target.files[0] const file = event.target.files[0]
const pos = this.editor.state.selection.head
event.target.value = '' event.target.value = ''
@@ -603,7 +626,7 @@ export default {
await this.$nextTick() await this.$nextTick()
this.editor.chain().focus() this.editor.chain().focus()
.insertContentAt(pos, { type: 'image', attrs: { htmlAttrs: { src: `blob:${data.uuid}`, style: 'max-width: 100%' } } }) .insertContent({ type: 'image', attrs: { htmlAttrs: { src: `blob:${data.uuid}`, style: 'max-width: 100%' } } })
.run() .run()
}, },
setDynamicMenuCoords (editor) { setDynamicMenuCoords (editor) {
@@ -835,6 +858,8 @@ export default {
const text = clipboardData.getData('text/plain') const text = clipboardData.getData('text/plain')
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '') const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
if (/class="[^"]*doc-list-/.test(html)) this.$emit('add-list')
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) { if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
return return
} }