improve dynamic editor

This commit is contained in:
Alex Turchyn authored and Pete Matsyburka committed 2026-10-03 14:05:38 +03:00
1 parent 5896c9a2aa
commit d793f00b37
9 files changed
+2473 -222

No files matched your search

@@ -0,0 +1,164 @@
<template>
<div
class="absolute z-[5] pointer-events-none select-none"
:style="{ top: coords.top + 'px', left: coords.left + 'px', width: coords.width + 'px', height: coords.height + 'px' }"
>
<div
class="absolute left-0 flex items-center gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg pointer-events-auto"
:style="{ top: Math.max(coords.top - 40, 0) - coords.top + 'px' }"
@mousedown.prevent
>
<button
v-for="(icon, align) in alignIcons"
:key="align"
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
:class="{ 'bg-neutral-200': coords.align === align }"
:title="t(`align_${align}`)"
@click="$emit('command', 'setBlockAlign', align)"
>
<component
:is="icon"
:width="16"
:height="16"
:stroke-width="1.6"
/>
</button>
<div class="w-px h-5 bg-neutral-200 mx-1" />
<template v-if="coords.type === 'table'">
<button
v-for="action in tableActions"
:key="action.title"
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="[{ 'text-red-600': action.danger }, isDisabled(action) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100']"
:title="action.structural && !coords.isRegular ? t('irregular_table_structure') : t(action.title)"
:disabled="isDisabled(action)"
@click="$emit('command', action.command, action.arg)"
>
<component
:is="action.icon"
:width="16"
:height="16"
:stroke-width="1.6"
/>
</button>
</template>
<button
v-else
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100 text-red-600"
:title="t('delete_image')"
@click="$emit('command', 'deleteSelection')"
>
<IconTrash
:width="16"
:height="16"
:stroke-width="1.6"
/>
</button>
</div>
<div
v-if="size && coords.type === 'table'"
class="absolute top-0 left-0 border border-dashed border-blue-500"
:style="{ width: size.width + 'px', height: size.height + 'px' }"
/>
<div
v-if="coords.type === 'table'"
class="absolute w-3 h-3 bg-white border-2 border-blue-500 rounded-sm pointer-events-auto cursor-nwse-resize"
:style="{ left: (size?.width ?? coords.width) - 6 + 'px', top: (size?.height ?? coords.height) - 6 + 'px' }"
:title="t('resize')"
@mousedown.prevent.stop="startResize"
/>
</div>
</template>
<script>
import { h } from 'vue'
import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconRowInsertTop, IconRowInsertBottom, IconColumnInsertLeft, IconColumnInsertRight, IconRowRemove, IconColumnRemove, IconTrash } from '@tabler/icons-vue'
const IconMergeCells = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
h('path', { d: 'M6 12h4m-2 -2l2 2l-2 2' }),
h('path', { d: 'M18 12h-4m2 -2l-2 2l2 2' })
])
const IconSplitCell = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
h('path', { d: 'M12 4v16' }),
h('path', { d: 'M10 12h-4m2 -2l-2 2l2 2' }),
h('path', { d: 'M14 12h4m-2 -2l2 2l-2 2' })
])
export default {
name: 'DynamicBlockMenu',
components: {
IconTrash
},
inject: ['t'],
props: {
coords: {
type: Object,
required: true
}
},
emits: ['command', 'resize'],
data () {
return {
size: null
}
},
computed: {
alignIcons () {
return {
left: IconAlignLeft,
center: IconAlignCenter,
right: IconAlignRight
}
},
tableActions () {
return [
{ command: 'addTableRow', arg: false, icon: IconRowInsertTop, title: 'insert_row_above', structural: true },
{ command: 'addTableRow', arg: true, icon: IconRowInsertBottom, title: 'insert_row_below', structural: true },
{ command: 'addTableColumn', arg: false, icon: IconColumnInsertLeft, title: 'insert_column_left', structural: true },
{ command: 'addTableColumn', arg: true, icon: IconColumnInsertRight, title: 'insert_column_right', structural: true },
{ command: 'mergeTableCells', icon: IconMergeCells, title: 'merge_cells', structural: true, enabled: 'canMerge' },
{ command: 'splitTableCell', icon: IconSplitCell, title: 'split_cell', structural: true, enabled: 'canSplit' },
{ command: 'deleteTableRow', icon: IconRowRemove, title: 'delete_row', danger: true, structural: true },
{ command: 'deleteTableColumn', icon: IconColumnRemove, title: 'delete_column', danger: true, structural: true },
{ command: 'deleteTable', icon: IconTrash, title: 'delete_table', danger: true }
]
}
},
beforeUnmount () {
window.removeEventListener('mousemove', this.onResizeMove)
window.removeEventListener('mouseup', this.onResizeEnd)
},
methods: {
isDisabled (action) {
return (action.structural && !this.coords.isRegular) || (action.enabled && !this.coords[action.enabled])
},
startResize (event) {
this.resizeStart = { x: event.clientX, y: event.clientY, width: this.coords.width, height: this.coords.height }
this.size = { width: this.coords.width, height: this.coords.height }
window.addEventListener('mousemove', this.onResizeMove)
window.addEventListener('mouseup', this.onResizeEnd)
},
onResizeMove (event) {
const { x, y, width, height } = this.resizeStart
this.size = {
width: Math.max(width + event.clientX - x, 20),
height: Math.max(height + event.clientY - y, this.coords.minHeight)
}
},
onResizeEnd () {
window.removeEventListener('mousemove', this.onResizeMove)
window.removeEventListener('mouseup', this.onResizeEnd)
if (this.size.width !== this.coords.width || this.size.height !== this.coords.height) {
this.$emit('resize', this.size)
}
this.size = null
}
}
}
</script>
@@ -50,7 +50,7 @@
</template> </template>
<script> <script>
import { ref } from 'vue' import { ref, computed } 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'
@@ -61,6 +61,11 @@ export default {
DynamicSection DynamicSection
}, },
inject: ['baseFetch', 'template'], inject: ['baseFetch', 'template'],
provide () {
return {
documentFonts: computed(() => this.documentFonts)
}
},
props: { props: {
document: { document: {
type: Object, type: Object,
@@ -134,6 +139,40 @@ export default {
styles () { styles () {
return this.headDom.querySelectorAll('style') return this.headDom.querySelectorAll('style')
}, },
documentFonts () {
const fonts = []
const firstFamily = (value) => value.split(',')[0].trim().replace(/^["']|["']$/g, '')
const add = (label, value) => {
if (label && !fonts.some((font) => font.label === label)) fonts.push({ label, value })
}
this.styles.forEach((style) => {
const css = style.textContent
for (const type of ['minor', 'major']) {
for (const [, name, value] of css.matchAll(new RegExp(`(--[\\w-]+-${type}HAnsi-font):\\s*([^;}]+)`, 'g'))) {
add(firstFamily(value), `var(${name})`)
}
}
for (const [, value] of css.matchAll(/@font-face\s*\{[^}]*?font-family:\s*([^;}]+)/g)) {
add(firstFamily(value), value.trim())
}
})
const skipFamilies = ['symbol', 'wingdings', 'wingdings 2', 'wingdings 3', 'webdings', 'serif', 'sans-serif', 'monospace', 'inherit', 'initial']
const addUsed = (value) => {
if (value && !value.startsWith('var(') && !skipFamilies.includes(firstFamily(value).toLowerCase())) add(firstFamily(value), value.trim())
}
this.styles.forEach((style) => {
for (const [, value] of style.textContent.replace(/@font-face\s*\{[^}]*\}/g, '').matchAll(/font-family:\s*([^;}]+)/g)) addUsed(value)
})
this.bodyDom.querySelectorAll('[style*="font-family"]').forEach((el) => addUsed(el.style.fontFamily))
return fonts
},
shadow () { shadow () {
if (this.isMounted) { if (this.isMounted) {
return this.$refs.shadow.attachShadow({ mode: 'open' }) return this.$refs.shadow.attachShadow({ mode: 'open' })
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,204 @@
<template>
<div
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
v-if="isOpen"
class="absolute top-7 left-0 min-w-36 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<div
class="relative"
@mouseenter="submenu = 'field'"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === '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"
@mouseenter="submenu = 'table'"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === '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"
@mouseleave="tableSize = { rows: 0, cols: 0 }"
>
<div class="grid grid-cols-8 gap-0.5">
<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'"
@mouseenter="tableSize = { rows: row, cols: col }"
@click="[$emit('insert-table', { rows: row, cols: col }), isOpen = false]"
/>
</template>
</div>
<div class="mt-1 text-xs text-center text-base-content/60">
{{ tableSize.rows ? `${tableSize.rows} × ${tableSize.cols}` : t('table_size') }}
</div>
</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'), isOpen = false]"
>
<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'), isOpen = false]"
>
<IconPhoto
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('image') }}
</button>
</div>
</div>
</template>
<script>
import { IconPlus, IconX, IconForms, IconTable, IconPhoto, IconChevronRight, IconBracketsContain } from '@tabler/icons-vue'
import FieldType from './field_type.vue'
export default {
name: 'DynamicInsertMenu',
components: {
IconPlus,
IconX,
IconForms,
IconTable,
IconPhoto,
IconChevronRight,
IconBracketsContain
},
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 't', 'fieldTypes'],
props: {
coords: {
type: Object,
required: true
}
},
emits: ['insert-field', 'insert-table', 'insert-variable', 'insert-image'],
data () {
return {
isOpen: false,
submenu: null,
tableSize: { rows: 0, cols: 0 }
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
skipTypes: FieldType.computed.skipTypes,
fieldIconsSorted: FieldType.computed.fieldIconsSorted,
fieldTypesList () {
return Object.fromEntries(Object.entries(this.fieldIconsSorted).filter(([type]) => {
return this.fieldTypes.includes(type) || ((this.withPhone || type !== 'phone') && (this.withPayment || type !== 'payment') && (this.withVerification || type !== 'verification') && (this.withKba || type !== 'kba'))
}))
}
},
watch: {
isOpen (value) {
this.submenu = null
if (value) {
document.addEventListener('mousedown', this.onDocumentMouseDown)
} else {
document.removeEventListener('mousedown', this.onDocumentMouseDown)
}
}
},
beforeUnmount () {
document.removeEventListener('mousedown', this.onDocumentMouseDown)
},
methods: {
onDocumentMouseDown (event) {
if (!event.composedPath().includes(this.$el)) {
this.isOpen = false
}
}
}
}
</script>
@@ -0,0 +1,196 @@
[class*="doc-list-"]::before {
display: inline-block;
min-width: 0.25in;
text-indent: 0;
text-align: left;
}
[class*="doc-list-dash-"]::before {
content: "–";
}
.doc-list-disc-0,
.doc-list-dash-0,
.doc-list-decimal-0 {
margin-left: 0.5in !important;
text-indent: -0.25in !important;
counter-set: doc-list-1 0;
}
.doc-list-disc-0::before {
content: "•";
}
.doc-list-decimal-0 {
counter-increment: doc-list-0;
}
.doc-list-decimal-0::before {
content: counter(doc-list-0) ".";
}
.doc-list-disc-1,
.doc-list-dash-1,
.doc-list-decimal-1 {
margin-left: 0.75in !important;
text-indent: -0.25in !important;
counter-set: doc-list-2 0;
}
.doc-list-disc-1::before {
content: "◦";
}
.doc-list-decimal-1 {
counter-increment: doc-list-1;
}
.doc-list-decimal-1::before {
content: counter(doc-list-1, lower-alpha) ".";
}
.doc-list-disc-2,
.doc-list-dash-2,
.doc-list-decimal-2 {
margin-left: 1in !important;
text-indent: -0.25in !important;
counter-set: doc-list-3 0;
}
.doc-list-disc-2::before {
content: "▪";
}
.doc-list-decimal-2 {
counter-increment: doc-list-2;
}
.doc-list-decimal-2::before {
content: counter(doc-list-2, lower-roman) ".";
}
.doc-list-disc-3,
.doc-list-dash-3,
.doc-list-decimal-3 {
margin-left: 1.25in !important;
text-indent: -0.25in !important;
counter-set: doc-list-4 0;
}
.doc-list-disc-3::before {
content: "•";
}
.doc-list-decimal-3 {
counter-increment: doc-list-3;
}
.doc-list-decimal-3::before {
content: counter(doc-list-3) ".";
}
.doc-list-disc-4,
.doc-list-dash-4,
.doc-list-decimal-4 {
margin-left: 1.5in !important;
text-indent: -0.25in !important;
counter-set: doc-list-5 0;
}
.doc-list-disc-4::before {
content: "◦";
}
.doc-list-decimal-4 {
counter-increment: doc-list-4;
}
.doc-list-decimal-4::before {
content: counter(doc-list-4, lower-alpha) ".";
}
.doc-list-disc-5,
.doc-list-dash-5,
.doc-list-decimal-5 {
margin-left: 1.75in !important;
text-indent: -0.25in !important;
counter-set: doc-list-6 0;
}
.doc-list-disc-5::before {
content: "▪";
}
.doc-list-decimal-5 {
counter-increment: doc-list-5;
}
.doc-list-decimal-5::before {
content: counter(doc-list-5, lower-roman) ".";
}
.doc-list-disc-6,
.doc-list-dash-6,
.doc-list-decimal-6 {
margin-left: 2in !important;
text-indent: -0.25in !important;
counter-set: doc-list-7 0;
}
.doc-list-disc-6::before {
content: "•";
}
.doc-list-decimal-6 {
counter-increment: doc-list-6;
}
.doc-list-decimal-6::before {
content: counter(doc-list-6) ".";
}
.doc-list-disc-7,
.doc-list-dash-7,
.doc-list-decimal-7 {
margin-left: 2.25in !important;
text-indent: -0.25in !important;
counter-set: doc-list-8 0;
}
.doc-list-disc-7::before {
content: "◦";
}
.doc-list-decimal-7 {
counter-increment: doc-list-7;
}
.doc-list-decimal-7::before {
content: counter(doc-list-7, lower-alpha) ".";
}
.doc-list-disc-8,
.doc-list-dash-8,
.doc-list-decimal-8 {
margin-left: 2.5in !important;
text-indent: -0.25in !important;
}
.doc-list-disc-8::before {
content: "▪";
}
.doc-list-decimal-8 {
counter-increment: doc-list-8;
}
.doc-list-decimal-8::before {
content: counter(doc-list-8, lower-roman) ".";
}
:not([class*="doc-list-"]) + .doc-list-decimal-0,
.doc-list-decimal-0:first-child,
.doc-list-disc-0 + .doc-list-decimal-0,
.doc-list-dash-0 + .doc-list-decimal-0 {
counter-set: doc-list-0 1 doc-list-1 0;
}
+580 -106
View File
@@ -1,107 +1,366 @@
<template> <template>
<div <div
v-if="visible" v-if="visible"
class="absolute z-10 flex items-center gap-0.5 px-1.5 py-1 bg-white border border-base-300 rounded-lg shadow select-none" ref="menu"
:style="{ top: (coords.top - 42) + 'px', left: coords.left + 'px', transform: 'translateX(-50%)' }" class="absolute z-10 flex flex-col gap-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg select-none text-sm"
:style="{ top: coords.top + 'px', left: menuLeft + 'px' }"
@mousedown.prevent @mousedown.prevent
> >
<button <div class="flex items-center gap-0.5">
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" <div class="relative">
:class="isBold ? 'bg-base-200' : 'bg-transparent'" <button
title="Bold" class="flex items-center justify-between gap-1 w-36 h-7 px-2 border border-neutral-200 rounded-md hover:bg-neutral-100"
@click="toggleBold" :title="t('font')"
> @click="toggleDropdown('font')"
<IconBold >
:width="16" <span
:height="16" class="truncate"
/> :style="{ fontFamily }"
</button> >{{ fontFamily }}</span>
<button <IconChevronDown
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" :width="14"
:class="isItalic ? 'bg-base-200' : 'bg-transparent'" :height="14"
title="Italic" />
@click="toggleItalic" </button>
> <div
<IconItalic v-if="dropdown === 'font'"
:width="16" ref="dropdown"
:height="16" :class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
/> class="absolute z-10 top-full mt-1 w-48 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-64 overflow-y-auto"
</button> >
<button <template
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" v-for="(group, index) in [documentFonts, standardFonts]"
:class="isUnderline ? 'bg-base-200' : 'bg-transparent'" :key="index"
title="Underline" >
@click="toggleUnderline" <div
> v-if="index && group.length && documentFonts.length"
<IconUnderline class="my-1 border-t border-neutral-200"
:width="16" />
:height="16" <button
/> v-for="font in group"
</button> :key="font.label"
<button class="block w-full text-left px-2 py-1 rounded-md hover:bg-neutral-100 truncate"
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700" :class="{ 'bg-neutral-200': font.label === fontFamily }"
:class="isStrike ? 'bg-base-200' : 'bg-transparent'" :style="{ fontFamily: font.value }"
title="Strikethrough" @click="setSpanStyle('font-family', font.value)"
@click="toggleStrike" >
> {{ font.label }}
<IconStrikethrough </button>
:width="16" </template>
:height="16" </div>
/> </div>
</button> <div class="relative">
<div class="w-px h-5 bg-base-300 mx-1" /> <button
<button class="flex items-center justify-between gap-1 w-16 h-7 px-2 border border-neutral-200 rounded-md hover:bg-neutral-100"
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent" :title="t('font_size')"
title="Wrap in variable" @click="toggleDropdown('size')"
@click="wrapVariable" >
> <span>{{ fontSize }}</span>
<IconBracketsContain <IconChevronDown
:width="16" :width="14"
:height="16" :height="14"
:stroke-width="1.6" />
/> </button>
<span class="px-0.5"> <div
Variable v-if="dropdown === 'size'"
</span> ref="dropdown"
</button> :class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
<div class="w-px h-5 bg-base-300 mx-1" /> class="absolute z-10 top-full mt-1 w-16 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-64 overflow-y-auto"
<button >
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent" <button
title="Wrap in condition" v-for="size in fontSizes"
@click="wrapCondition" :key="size"
> class="block w-full text-left px-2 py-1 rounded-md hover:bg-neutral-100"
<svg :class="{ 'bg-neutral-200': size === fontSize }"
xmlns="http://www.w3.org/2000/svg" @click="setFontSize(size)"
width="16" >
height="16" {{ size }}
viewBox="0 0 24 24" </button>
fill="none" </div>
stroke="currentColor" </div>
stroke-width="1.6" <button
stroke-linecap="round" class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
stroke-linejoin="round" :title="t('increase_font_size')"
class="tabler-icon tabler-icon-brackets-contain" @click="setFontSize(fontSizes.find((size) => size > fontSize) || fontSize)"
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" /> >
<text <IconTextIncrease
x="12" :width="16"
y="16.5" :height="16"
text-anchor="middle" />
fill="currentColor" </button>
stroke="none" <button
font-size="14" class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
font-weight="600" :title="t('decrease_font_size')"
font-family="ui-sans-serif, system-ui, sans-serif" @click="setFontSize([...fontSizes].reverse().find((size) => size < fontSize) || fontSize)"
>if</text> >
</svg> <IconTextDecrease
<span class="px-0.5"> :width="16"
Condition :height="16"
</span> />
</button> </button>
<div class="w-px h-5 bg-neutral-200 mx-1" />
<div class="relative">
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:title="t('line_spacing')"
@click="toggleDropdown('lineHeight')"
>
<IconLineHeight
:width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'lineHeight'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-20 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="value in lineHeights"
:key="value"
class="block w-full text-left px-2 py-1 rounded-md hover:bg-neutral-100"
:class="{ 'bg-neutral-200': value === lineHeight }"
@click="setBlockStyle('line-height', value)"
>
{{ value }}
</button>
</div>
</div>
<div class="relative">
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:title="t('align')"
@click="toggleDropdown('align')"
>
<component
:is="alignIcons[textAlign]"
:width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'align'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-max flex gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="(icon, align) in alignIcons"
:key="align"
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
:class="{ 'bg-neutral-200': align === textAlign }"
:title="t(align)"
@click="setBlockStyle('text-align', align)"
>
<component
:is="icon"
:width="16"
:height="16"
/>
</button>
</div>
</div>
<div class="relative">
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:class="{ 'bg-neutral-200': listStyle }"
:title="t('bullet_list')"
@click="toggleDropdown('list')"
>
<IconList
:width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'list'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-28 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="(label, style) in listStyles"
:key="style"
class="flex items-center gap-2 w-full text-left px-2 py-1 rounded-md hover:bg-neutral-100"
:class="{ 'bg-neutral-200': style === listStyle }"
@click="toggleList(style)"
>
<span class="w-4 text-center">{{ listMarkers[style] }}</span>
{{ label }}
</button>
</div>
</div>
</div>
<div class="flex items-center gap-0.5">
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
:class="{ 'bg-neutral-200': isBold }"
:title="t('bold')"
@click="toggleBold"
>
<IconBold
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
:class="{ 'bg-neutral-200': isItalic }"
:title="t('italic')"
@click="toggleItalic"
>
<IconItalic
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
:class="{ 'bg-neutral-200': isUnderline }"
:title="t('underline')"
@click="toggleUnderline"
>
<IconUnderline
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
:class="{ 'bg-neutral-200': isStrike }"
:title="t('strikethrough')"
@click="toggleStrike"
>
<IconStrikethrough
:width="16"
:height="16"
/>
</button>
<div class="w-px h-5 bg-neutral-200 mx-1" />
<div
v-for="colorProperty in ['color', 'background-color']"
:key="colorProperty"
class="relative"
>
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:title="colorProperty === 'color' ? t('text_color') : t('background_color')"
@click="toggleDropdown(colorProperty)"
>
<span class="flex flex-col items-center">
<component
:is="colorProperty === 'color' ? 'IconLetterA' : 'IconHighlight'"
:width="16"
:height="14"
/>
<span
class="w-4 h-1 rounded-sm border border-neutral-200"
:style="{ backgroundColor: colorProperty === 'color' ? color : backgroundColor }"
/>
</span>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === colorProperty"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
class="flex items-center gap-1 w-full mb-2 px-2 py-1 rounded-md hover:bg-neutral-100"
@click="setSpanStyle(colorProperty, null)"
>
<IconX
:width="14"
:height="14"
/>
{{ colorProperty === 'color' ? t('default') : t('none') }}
</button>
<div class="grid grid-cols-10 gap-1">
<button
v-for="value in colors"
:key="value"
class="w-4 h-4 rounded-sm border border-neutral-200 hover:scale-125 transition-transform"
:style="{ backgroundColor: value }"
:title="value"
@click="setSpanStyle(colorProperty, value)"
/>
</div>
</div>
</div>
<template v-if="!isCellSelection">
<div class="w-px h-5 bg-neutral-200 mx-1" />
<button
class="inline-flex items-center justify-center text-xs h-7 rounded-md hover:bg-neutral-100"
:title="t('create_variable')"
@click="wrapVariable"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
<span class="px-0.5">
{{ t('variable') }}
</span>
</button>
<button
class="inline-flex items-center justify-center text-xs h-7 rounded-md hover:bg-neutral-100"
:title="t('create_condition')"
@click="wrapCondition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
class="tabler-icon tabler-icon-brackets-contain"
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" />
<text
x="12"
y="16.5"
text-anchor="middle"
fill="currentColor"
stroke="none"
font-size="14"
font-weight="600"
font-family="ui-sans-serif, system-ui, sans-serif"
>if</text>
</svg>
<span class="px-0.5">
{{ t('condition') }}
</span>
</button>
</template>
</div>
</div> </div>
</template> </template>
<script> <script>
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain } from '@tabler/icons-vue' import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain, IconChevronDown, IconTextIncrease, IconTextDecrease, IconLineHeight, IconAlignLeft, IconAlignCenter, IconAlignRight, IconAlignJustified, IconLetterA, IconHighlight, IconList, IconX } from '@tabler/icons-vue'
import { CellSelection } from '@tiptap/pm/tables'
import { findNumbering, numberingListStyle } from './dynamic_editor.js'
export default { export default {
name: 'DynamicMenu', name: 'DynamicMenu',
@@ -110,7 +369,19 @@ export default {
IconItalic, IconItalic,
IconUnderline, IconUnderline,
IconStrikethrough, IconStrikethrough,
IconBracketsContain IconBracketsContain,
IconChevronDown,
IconTextIncrease,
IconTextDecrease,
IconLineHeight,
IconLetterA,
IconHighlight,
IconList,
IconX
},
inject: {
t: 't',
documentFonts: { default: () => [] }
}, },
props: { props: {
editor: { editor: {
@@ -127,15 +398,83 @@ export default {
data () { data () {
return { return {
isMouseDown: false, isMouseDown: false,
isBold: this.editor.isActive('bold'), dropdown: null,
isItalic: this.editor.isActive('italic'), isDropdownAlignedRight: false,
isUnderline: this.editor.isActive('underline'), menuWidth: 0,
isStrike: this.editor.isActive('strike') isBold: false,
isItalic: false,
isUnderline: false,
isStrike: false,
fontFamily: '',
fontSize: 12,
color: '',
backgroundColor: '',
lineHeight: '',
textAlign: 'left',
listStyle: null,
isCellSelection: false
} }
}, },
computed: { computed: {
visible () { visible () {
return !!this.coords && !this.isMouseDown return !!this.coords && !this.isMouseDown
},
menuLeft () {
return Math.max(4, Math.min(this.coords.left - this.menuWidth / 2, this.coords.width - this.menuWidth - 4))
},
standardFonts () {
return [
{ label: 'Arial', value: 'Arial' },
{ label: 'Times New Roman', value: "'Times New Roman'" },
{ label: 'Courier New', value: "'Courier New'" }
].filter((font) => !this.documentFonts.some((documentFont) => documentFont.label === font.label))
},
fontSizes () {
return [8, 9, 10, 10.5, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72]
},
lineHeights () {
return ['1', '1.15', '1.5', '2', '2.5', '3']
},
alignIcons () {
return {
left: IconAlignLeft,
center: IconAlignCenter,
right: IconAlignRight,
justify: IconAlignJustified
}
},
listStyles () {
return {
disc: this.t('dots'),
dash: this.t('dash'),
decimal: this.t('numbers')
}
},
listMarkers () {
return {
disc: '•',
dash: '–',
decimal: '1.'
}
},
colors () {
return [
'#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#efefef', '#f3f3f3', '#ffffff',
'#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff',
'#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc',
'#cc4125', '#e06666', '#f6b26b', '#ffd966', '#93c47d', '#76a5af', '#6d9eeb', '#6fa8dc', '#8e7cc3', '#c27ba0',
'#85200c', '#990000', '#b45f06', '#bf9000', '#38761d', '#134f5c', '#1155cc', '#0b5394', '#351c75', '#741b47'
]
}
},
watch: {
visible (value) {
this.dropdown = null
if (value) {
this.readState()
this.$nextTick(this.measure)
}
} }
}, },
mounted () { mounted () {
@@ -154,11 +493,72 @@ export default {
document.removeEventListener('mouseup', this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp)
}, },
methods: { methods: {
measure () {
if (this.$refs.menu) {
this.menuWidth = this.$refs.menu.offsetWidth
}
},
toggleDropdown (name) {
this.dropdown = this.dropdown === name ? null : name
this.isDropdownAlignedRight = false
this.$nextTick(() => {
const dropdownEl = [this.$refs.dropdown].flat()[0]
if (!dropdownEl) return
const dropdownRect = dropdownEl.getBoundingClientRect()
const containerRect = this.$el.parentElement.getBoundingClientRect()
this.isDropdownAlignedRight = dropdownRect.right > containerRect.right
})
},
toggleBold () { toggleBold () {
this.editor.chain().focus().toggleBold().run() this.toggleStyledMark('bold', 'font-weight', 'bold', this.isBold, (style) => parseInt(style.fontWeight) >= 600)
}, },
toggleItalic () { toggleItalic () {
this.editor.chain().focus().toggleItalic().run() this.toggleStyledMark('italic', 'font-style', 'italic', this.isItalic, (style) => style.fontStyle === 'italic')
},
toggleStyledMark (markName, property, value, isActive, isStyled) {
const isSegmentStyled = ({ from }) => isStyled(getComputedStyle(this.textElementAt(from)))
if (isActive) {
this.editor.chain().focus().unsetMark(markName).setListMarkerStyle(property, null).run()
if (this.selectedTextSegments().some(isSegmentStyled)) {
this.editor.chain().setSpanStyle(property, 'normal').run()
}
} else {
this.editor.chain().focus().setSpanStyle(property, null).run()
const { state, view } = this.editor
const tr = state.tr
this.selectedTextSegments().forEach((segment) => {
if (!isSegmentStyled(segment)) tr.addMark(segment.from, segment.to, state.schema.marks[markName].create())
})
if (tr.docChanged) view.dispatch(tr)
this.editor.commands.setListMarkerStyle(property, value)
}
},
selectedTextSegments () {
const { state } = this.editor
const segments = []
state.selection.ranges.forEach(({ $from, $to }) => state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.isText) segments.push({ from: Math.max(pos, $from.pos), to: Math.min(pos + node.nodeSize, $to.pos) })
}))
return segments
},
textElementAt (pos) {
const { node, offset } = this.editor.view.domAtPos(pos, 1)
if (node.nodeType === Node.TEXT_NODE) return node.parentElement
return node.childNodes[offset]?.nodeType === Node.ELEMENT_NODE ? node.childNodes[offset] : node
}, },
toggleUnderline () { toggleUnderline () {
this.editor.chain().focus().toggleUnderline().run() this.editor.chain().focus().toggleUnderline().run()
@@ -166,6 +566,28 @@ export default {
toggleStrike () { toggleStrike () {
this.editor.chain().focus().toggleStrike().run() this.editor.chain().focus().toggleStrike().run()
}, },
setSpanStyle (property, value) {
this.dropdown = null
const chain = this.editor.chain().focus().setSpanStyle(property, value)
if (property !== 'background-color') chain.setListMarkerStyle(property, value)
chain.run()
},
setFontSize (size) {
this.setSpanStyle('font-size', `${size}pt`)
},
setBlockStyle (property, value) {
this.dropdown = null
this.editor.chain().focus().setBlockStyle(property, value).run()
},
toggleList (style) {
this.dropdown = null
this.editor.chain().focus().toggleList(style).run()
},
wrapVariable () { wrapVariable () {
const { from, to } = this.editor.state.selection const { from, to } = this.editor.state.selection
const replacement = '[[variable]]' const replacement = '[[variable]]'
@@ -201,10 +623,62 @@ export default {
}, 1) }, 1)
}, },
onTransaction () { onTransaction () {
this.isBold = this.editor.isActive('bold') if (this.visible) {
this.isItalic = this.editor.isActive('italic') this.readState()
this.isUnderline = this.editor.isActive('underline') }
this.isStrike = this.editor.isActive('strike') },
readState () {
const { view, state } = this.editor
const { ranges } = state.selection
const selectionFrom = Math.min(...ranges.map((range) => range.$from.pos))
const selectionTo = Math.max(...ranges.map((range) => range.$to.pos))
let from = null
state.doc.nodesBetween(selectionFrom, selectionTo, (node, pos) => {
if (from === null && node.isText) from = Math.max(pos, selectionFrom)
})
if (from === null) from = selectionFrom
const $from = state.doc.resolve(from)
const el = this.textElementAt(from)
const style = getComputedStyle(el)
const blockDom = $from.parent.isTextblock ? view.nodeDOM($from.before()) : view.dom
const decorations = []
this.isCellSelection = state.selection instanceof CellSelection
this.isBold = parseInt(style.fontWeight) >= 600
this.isItalic = style.fontStyle === 'italic'
this.fontFamily = style.fontFamily.split(',')[0].replace(/["']/g, '').trim()
this.fontSize = Math.round(parseFloat(style.fontSize) * 0.75 * 2) / 2
this.color = style.color
this.backgroundColor = 'transparent'
for (let inlineEl = el; inlineEl && view.dom.contains(inlineEl); inlineEl = inlineEl.parentElement) {
const { textDecorationLine, backgroundColor } = getComputedStyle(inlineEl)
decorations.push(textDecorationLine)
if (inlineEl === blockDom) break
if (this.backgroundColor === 'transparent' && backgroundColor !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'transparent') {
this.backgroundColor = backgroundColor
}
}
this.isUnderline = this.editor.isActive('underline') || decorations.some((line) => line.includes('underline'))
this.isStrike = this.editor.isActive('strike') || decorations.some((line) => line.includes('line-through'))
if ($from.parent.isTextblock) {
const blockStyle = getComputedStyle(blockDom)
const align = blockStyle.textAlign.replace('start', 'left').replace('end', 'right')
this.textAlign = this.alignIcons[align] ? align : 'left'
this.lineHeight = String(Math.round(parseFloat(blockStyle.lineHeight) / parseFloat(blockStyle.fontSize) * 100) / 100)
}
this.listStyle = findNumbering($from.parent) ? numberingListStyle(view, $from.before()) : null
} }
} }
} }
@@ -17,6 +17,13 @@
:style="section.style.cssText" :style="section.style.cssText"
/> />
</div> </div>
<input
ref="imageInput"
type="file"
accept="image/*"
class="hidden"
@change="onImageSelected"
>
<Teleport <Teleport
v-if="editor" v-if="editor"
:to="container" :to="container"
@@ -45,6 +52,21 @@
@add-variable="dynamicMenuCoords = null" @add-variable="dynamicMenuCoords = null"
@add-condition="dynamicMenuCoords = null" @add-condition="dynamicMenuCoords = null"
/> />
<DynamicInsertMenu
v-if="editable && insertMenuCoords && !isDrawMode && !selectedAreasRef.value.length"
:key="insertMenuCoords.top"
:coords="insertMenuCoords"
@insert-field="onInsertField"
@insert-table="onInsertTable"
@insert-variable="editor.chain().focus().insertContent('[[variable]]').run()"
@insert-image="$refs.imageInput.click()"
/>
<DynamicBlockMenu
v-if="editable && blockMenuCoords && !isDrawMode"
:coords="blockMenuCoords"
@command="onBlockCommand"
@resize="onBlockResize"
/>
<FieldContextMenu <FieldContextMenu
v-if="contextMenu && contextMenuField" v-if="contextMenu && contextMenuField"
:context-menu="contextMenu" :context-menu="contextMenu"
@@ -64,16 +86,21 @@
<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 { 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'
import DynamicMenu from './dynamic_menu.vue' import DynamicMenu from './dynamic_menu.vue'
import { buildEditor } from './dynamic_editor.js' import DynamicInsertMenu from './dynamic_insert_menu.vue'
import DynamicBlockMenu from './dynamic_block_menu.vue'
import { buildEditor, isRegularTable, resizeTable, tableRowMinHeights } from './dynamic_editor.js'
export default { export default {
name: 'DynamicSection', name: 'DynamicSection',
components: { components: {
DynamicMenu, DynamicMenu,
DynamicInsertMenu,
DynamicBlockMenu,
FieldContextMenu, FieldContextMenu,
AreaTitle AreaTitle
}, },
@@ -153,6 +180,8 @@ export default {
isAreaDrag: false, isAreaDrag: false,
areaToolbarCoords: null, areaToolbarCoords: null,
dynamicMenuCoords: null, dynamicMenuCoords: null,
insertMenuCoords: null,
blockMenuCoords: null,
contextMenu: null, contextMenu: null,
cursorHighlightCoords: null cursorHighlightCoords: null
} }
@@ -235,6 +264,14 @@ export default {
if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) { if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) {
this.$nextTick(() => this.setDynamicMenuCoords(this.editor)) this.$nextTick(() => this.setDynamicMenuCoords(this.editor))
} }
if (this.insertMenuCoords && this.editor) {
this.$nextTick(() => this.setInsertMenuCoords(this.editor))
}
if (this.blockMenuCoords && this.editor) {
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
}
} }
}, },
mounted () { mounted () {
@@ -258,9 +295,20 @@ export default {
element: this.$refs.editorElement, element: this.$refs.editorElement,
editable: this.editable, editable: this.editable,
content: this.section.innerHTML, content: this.section.innerHTML,
onUpdate: (event) => this.$emit('update', event), onUpdate: (event) => {
this.$emit('update', event)
if (this.blockMenuCoords) {
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
}
},
onSelectionUpdate: this.onSelectionUpdate, onSelectionUpdate: this.onSelectionUpdate,
onBlur: () => { this.dynamicMenuCoords = null } onFocus: this.onSelectionUpdate,
onBlur: () => {
this.dynamicMenuCoords = null
this.insertMenuCoords = null
this.blockMenuCoords = null
}
} }
}) })
@@ -402,28 +450,150 @@ export default {
this.selectedAreasRef.value = [area] this.selectedAreasRef.value = [area]
} }
} }
this.insertMenuCoords = null
this.blockMenuCoords = null
} else { } else {
this.areaToolbarCoords = null this.areaToolbarCoords = null
this.selectedAreasRef.value = [] this.selectedAreasRef.value = []
if (editor.state.selection.empty) { this.setBlockMenuCoords(editor)
if (selection.node) {
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.setDynamicMenuCoords(editor) this.insertMenuCoords = null
if (editor.isFocused) {
this.setDynamicMenuCoords(editor)
} else {
this.dynamicMenuCoords = null
}
} }
} }
}, },
setInsertMenuCoords (editor) {
const coords = editor.view.coordsAtPos(editor.state.selection.head)
const containerRect = this.container.getBoundingClientRect()
const sectionEl = this.$refs.editorElement
const sectionRect = sectionEl.getBoundingClientRect()
const paddingLeft = parseFloat(getComputedStyle(sectionEl).paddingLeft) * this.zoom
this.insertMenuCoords = {
top: (coords.top + coords.bottom) / 2 - containerRect.top,
left: sectionRect.left - containerRect.left + Math.max(paddingLeft / 2, 14)
}
},
setBlockMenuCoords (editor) {
const { selection } = editor.state
const isImage = selection.node?.type.name === 'image'
const table = isImage ? null : findTable(selection.$from)
if (!isImage && !table) {
this.blockMenuCoords = null
return
}
const pos = isImage ? selection.from : table.pos
const dom = editor.view.nodeDOM(pos)
const rect = dom.getBoundingClientRect()
const containerRect = this.container.getBoundingClientRect()
const coords = {
type: isImage ? 'image' : 'table',
pos,
align: 'left',
top: rect.top - containerRect.top,
left: rect.left - containerRect.left,
width: rect.width,
height: rect.height
}
if (isImage) {
coords.align = getComputedStyle(dom.parentElement).textAlign.replace('start', 'left').replace('end', 'right')
} else {
const { marginLeft, marginRight, float } = dom.style
if (float === 'left' || float === 'right') {
coords.align = float
} else if (marginLeft === 'auto') {
coords.align = marginRight === 'auto' ? 'center' : 'right'
} else if (dom.getAttribute('align')) {
coords.align = dom.getAttribute('align')
}
coords.isRegular = isRegularTable(table.node)
coords.canMerge = editor.can().mergeTableCells()
coords.canSplit = editor.can().splitTableCell()
coords.minHeight = tableRowMinHeights(dom, this.zoom, false).reduce((acc, value) => acc + value, 0)
}
this.blockMenuCoords = coords
},
onBlockResize ({ width, height }) {
resizeTable(this.editor.view, this.blockMenuCoords.pos, { width: width / this.zoom, height: height / this.zoom }, this.zoom)
this.editor.commands.focus()
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
},
onBlockCommand (command, arg) {
this.editor.chain().focus()[command](arg).run()
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
},
onInsertField (type) {
this.insertFieldAtRange({ sourceField: { type }, from: this.editor.state.selection.head })
},
onInsertTable ({ rows, cols }) {
const $pos = this.editor.state.selection.$head
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 isEmpty = $pos.parent.isTextblock && !$pos.parent.content.size
const from = isEmpty ? $pos.before() : $pos.after()
const to = isEmpty ? $pos.after() : from
this.editor.chain().focus()
.insertContentAt({ from, to }, html)
.setTextSelection(from + 5)
.run()
},
onImageSelected (event) {
const file = event.target.files[0]
const pos = this.editor.state.selection.head
event.target.value = ''
if (!file) return
const reader = new FileReader()
reader.onload = () => {
this.editor.chain().focus()
.insertContentAt(pos, { type: 'image', attrs: { htmlAttrs: { src: reader.result, style: 'max-width: 100%' } } })
.run()
}
reader.readAsDataURL(file)
},
setDynamicMenuCoords (editor) { setDynamicMenuCoords (editor) {
const { from, to } = editor.state.selection const { from, to } = editor.state.selection
const view = editor.view const view = editor.view
const start = view.coordsAtPos(from) const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to) const end = view.coordsAtPos(to)
const containerRect = this.container.getBoundingClientRect() const containerRect = this.container.getBoundingClientRect()
const left = (start.left + end.right) / 2 - containerRect.left
this.dynamicMenuCoords = { this.dynamicMenuCoords = {
top: Math.min(start.top, end.top) - containerRect.top, top: Math.max(start.bottom, end.bottom) - containerRect.top + 8,
left: Math.max(80, Math.min(left, containerRect.width - 80)) left: (start.left + end.right) / 2 - containerRect.left,
width: containerRect.width
} }
}, },
onFieldDestroy (node) { onFieldDestroy (node) {
@@ -2,6 +2,7 @@
@import "tailwindcss/components"; @import "tailwindcss/components";
@import "tailwindcss/utilities"; @import "tailwindcss/utilities";
@import "dynamic_lists";
*, *,
::before, ::before,
+245 -7
View File
@@ -249,7 +249,41 @@ const en = {
no_revisions_yet: 'No revisions yet', no_revisions_yet: 'No revisions yet',
viewing_revision_from: 'Viewing revision from {date}', viewing_revision_from: 'Viewing revision from {date}',
connect_google_drive: 'Connect Google Drive', connect_google_drive: 'Connect Google Drive',
submitting: 'Submitting' submitting: 'Submitting',
font_size: 'Font size',
increase_font_size: 'Increase font size',
decrease_font_size: 'Decrease font size',
line_spacing: 'Line spacing',
justify: 'Justify',
bullet_list: 'List',
dots: 'Dots',
dash: 'Dash',
numbers: 'Numbers',
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
strikethrough: 'Strikethrough',
text_color: 'Text color',
background_color: 'Background color',
variable: 'Variable',
create_variable: 'Wrap in variable',
create_condition: 'Wrap in condition',
insert: 'Insert',
field: 'Field',
table: 'Table',
table_size: 'Table size',
align_center: 'Align Center',
insert_row_above: 'Insert row above',
insert_row_below: 'Insert row below',
insert_column_left: 'Insert column left',
insert_column_right: 'Insert column right',
merge_cells: 'Merge cells',
split_cell: 'Split cell',
delete_row: 'Delete row',
delete_column: 'Delete column',
delete_table: 'Delete table',
delete_image: 'Delete image',
irregular_table_structure: 'Rows and columns can\'t be edited: irregular table layout'
} }
const es = { const es = {
@@ -503,7 +537,41 @@ const es = {
no_revisions_yet: 'Aún no hay revisiones', no_revisions_yet: 'Aún no hay revisiones',
viewing_revision_from: 'Viendo revisión del {date}', viewing_revision_from: 'Viendo revisión del {date}',
connect_google_drive: 'Conectar Google Drive', connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando' submitting: 'Enviando',
font_size: 'Tamaño',
increase_font_size: 'Aumentar tamaño',
decrease_font_size: 'Reducir tamaño',
line_spacing: 'Interlineado',
justify: 'Justificar',
bullet_list: 'Lista',
dots: 'Puntos',
dash: 'Guiones',
numbers: 'Números',
bold: 'Negrita',
italic: 'Cursiva',
underline: 'Subrayado',
strikethrough: 'Tachado',
text_color: 'Color de texto',
background_color: 'Color de fondo',
variable: 'Variable',
create_variable: 'Crear variable',
create_condition: 'Crear condición',
insert: 'Insertar',
field: 'Campo',
table: 'Tabla',
table_size: 'Tamaño de tabla',
align_center: 'Centrar',
insert_row_above: 'Fila arriba',
insert_row_below: 'Fila abajo',
insert_column_left: 'Columna izquierda',
insert_column_right: 'Columna derecha',
merge_cells: 'Combinar celdas',
split_cell: 'Dividir celda',
delete_row: 'Eliminar fila',
delete_column: 'Eliminar columna',
delete_table: 'Eliminar tabla',
delete_image: 'Eliminar imagen',
irregular_table_structure: 'No se pueden editar filas ni columnas: tabla irregular'
} }
const it = { const it = {
@@ -757,7 +825,41 @@ const it = {
no_revisions_yet: 'Nessuna revisione ancora', no_revisions_yet: 'Nessuna revisione ancora',
viewing_revision_from: 'Visualizzazione revisione del {date}', viewing_revision_from: 'Visualizzazione revisione del {date}',
connect_google_drive: 'Connetti Google Drive', connect_google_drive: 'Connetti Google Drive',
submitting: 'Invio' submitting: 'Invio',
font_size: 'Dimensione',
increase_font_size: 'Aumenta dimensione',
decrease_font_size: 'Riduci dimensione',
line_spacing: 'Interlinea',
justify: 'Giustifica',
bullet_list: 'Elenco',
dots: 'Punti',
dash: 'Trattini',
numbers: 'Numeri',
bold: 'Grassetto',
italic: 'Corsivo',
underline: 'Sottolineato',
strikethrough: 'Barrato',
text_color: 'Colore testo',
background_color: 'Colore sfondo',
variable: 'Variabile',
create_variable: 'Crea variabile',
create_condition: 'Crea condizione',
insert: 'Inserisci',
field: 'Campo',
table: 'Tabella',
table_size: 'Dimensione tabella',
align_center: 'Centra',
insert_row_above: 'Riga sopra',
insert_row_below: 'Riga sotto',
insert_column_left: 'Colonna a sinistra',
insert_column_right: 'Colonna a destra',
merge_cells: 'Unisci celle',
split_cell: 'Dividi cella',
delete_row: 'Elimina riga',
delete_column: 'Elimina colonna',
delete_table: 'Elimina tabella',
delete_image: 'Elimina immagine',
irregular_table_structure: 'Righe e colonne non modificabili: tabella irregolare'
} }
const pt = { const pt = {
@@ -1011,7 +1113,41 @@ const pt = {
no_revisions_yet: 'Nenhuma revisão ainda', no_revisions_yet: 'Nenhuma revisão ainda',
viewing_revision_from: 'Visualizando revisão de {date}', viewing_revision_from: 'Visualizando revisão de {date}',
connect_google_drive: 'Conectar Google Drive', connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando' submitting: 'Enviando',
font_size: 'Tamanho',
increase_font_size: 'Aumentar tamanho',
decrease_font_size: 'Diminuir tamanho',
line_spacing: 'Espaçamento',
justify: 'Justificar',
bullet_list: 'Lista',
dots: 'Pontos',
dash: 'Traços',
numbers: 'Números',
bold: 'Negrito',
italic: 'Itálico',
underline: 'Sublinhado',
strikethrough: 'Tachado',
text_color: 'Cor do texto',
background_color: 'Cor de fundo',
variable: 'Variável',
create_variable: 'Criar variável',
create_condition: 'Criar condição',
insert: 'Inserir',
field: 'Campo',
table: 'Tabela',
table_size: 'Tamanho da tabela',
align_center: 'Centralizar',
insert_row_above: 'Linha acima',
insert_row_below: 'Linha abaixo',
insert_column_left: 'Coluna à esquerda',
insert_column_right: 'Coluna à direita',
merge_cells: 'Mesclar células',
split_cell: 'Dividir célula',
delete_row: 'Excluir linha',
delete_column: 'Excluir coluna',
delete_table: 'Excluir tabela',
delete_image: 'Excluir imagem',
irregular_table_structure: 'Linhas e colunas não editáveis: tabela irregular'
} }
const fr = { const fr = {
@@ -1265,7 +1401,41 @@ const fr = {
no_revisions_yet: 'Aucune révision pour le moment', no_revisions_yet: 'Aucune révision pour le moment',
viewing_revision_from: 'Affichage de la révision du {date}', viewing_revision_from: 'Affichage de la révision du {date}',
connect_google_drive: 'Connecter Google Drive', connect_google_drive: 'Connecter Google Drive',
submitting: 'Soumission' submitting: 'Soumission',
font_size: 'Taille',
increase_font_size: 'Augmenter la taille',
decrease_font_size: 'Réduire la taille',
line_spacing: 'Interligne',
justify: 'Justifier',
bullet_list: 'Liste',
dots: 'Points',
dash: 'Tirets',
numbers: 'Numéros',
bold: 'Gras',
italic: 'Italique',
underline: 'Souligné',
strikethrough: 'Barré',
text_color: 'Couleur du texte',
background_color: 'Couleur de fond',
variable: 'Variable',
create_variable: 'Créer une variable',
create_condition: 'Créer une condition',
insert: 'Insérer',
field: 'Champ',
table: 'Tableau',
table_size: 'Taille du tableau',
align_center: 'Centrer',
insert_row_above: 'Ligne au-dessus',
insert_row_below: 'Ligne en dessous',
insert_column_left: 'Colonne à gauche',
insert_column_right: 'Colonne à droite',
merge_cells: 'Fusionner',
split_cell: 'Scinder',
delete_row: 'Supprimer la ligne',
delete_column: 'Supprimer la colonne',
delete_table: 'Supprimer le tableau',
delete_image: 'Supprimer l\'image',
irregular_table_structure: 'Lignes et colonnes non modifiables : tableau irrégulier'
} }
const de = { const de = {
@@ -1519,7 +1689,41 @@ const de = {
no_revisions_yet: 'Noch keine Revisionen', no_revisions_yet: 'Noch keine Revisionen',
viewing_revision_from: 'Ansicht der Revision vom {date}', viewing_revision_from: 'Ansicht der Revision vom {date}',
connect_google_drive: 'Google Drive verbinden', connect_google_drive: 'Google Drive verbinden',
submitting: 'Übermittlung' submitting: 'Übermittlung',
font_size: 'Schriftgröße',
increase_font_size: 'Schrift vergrößern',
decrease_font_size: 'Schrift verkleinern',
line_spacing: 'Zeilenabstand',
justify: 'Blocksatz',
bullet_list: 'Liste',
dots: 'Punkte',
dash: 'Striche',
numbers: 'Zahlen',
bold: 'Fett',
italic: 'Kursiv',
underline: 'Unterstrichen',
strikethrough: 'Durchgestrichen',
text_color: 'Textfarbe',
background_color: 'Hintergrundfarbe',
variable: 'Variable',
create_variable: 'Variable erstellen',
create_condition: 'Bedingung erstellen',
insert: 'Einfügen',
field: 'Feld',
table: 'Tabelle',
table_size: 'Tabellengröße',
align_center: 'Zentrieren',
insert_row_above: 'Zeile darüber',
insert_row_below: 'Zeile darunter',
insert_column_left: 'Spalte links',
insert_column_right: 'Spalte rechts',
merge_cells: 'Zellen verbinden',
split_cell: 'Zelle teilen',
delete_row: 'Zeile löschen',
delete_column: 'Spalte löschen',
delete_table: 'Tabelle löschen',
delete_image: 'Bild löschen',
irregular_table_structure: 'Zeilen und Spalten nicht bearbeitbar: unregelmäßige Tabelle'
} }
const nl = { const nl = {
@@ -1773,7 +1977,41 @@ const nl = {
no_revisions_yet: 'Nog geen revisies', no_revisions_yet: 'Nog geen revisies',
viewing_revision_from: 'Revisie van {date} bekijken', viewing_revision_from: 'Revisie van {date} bekijken',
connect_google_drive: 'Verbind Google Drive', connect_google_drive: 'Verbind Google Drive',
submitting: 'Indienen' submitting: 'Indienen',
font_size: 'Tekstgrootte',
increase_font_size: 'Tekst vergroten',
decrease_font_size: 'Tekst verkleinen',
line_spacing: 'Regelafstand',
justify: 'Uitvullen',
bullet_list: 'Lijst',
dots: 'Punten',
dash: 'Streepjes',
numbers: 'Nummers',
bold: 'Vet',
italic: 'Cursief',
underline: 'Onderstrepen',
strikethrough: 'Doorhalen',
text_color: 'Tekstkleur',
background_color: 'Achtergrondkleur',
variable: 'Variabele',
create_variable: 'Maak variabele',
create_condition: 'Maak voorwaarde',
insert: 'Invoegen',
field: 'Veld',
table: 'Tabel',
table_size: 'Tabelgrootte',
align_center: 'Centreren',
insert_row_above: 'Rij erboven',
insert_row_below: 'Rij eronder',
insert_column_left: 'Kolom links',
insert_column_right: 'Kolom rechts',
merge_cells: 'Cellen samenvoegen',
split_cell: 'Cel splitsen',
delete_row: 'Rij verwijderen',
delete_column: 'Kolom verwijderen',
delete_table: 'Tabel verwijderen',
delete_image: 'Afbeelding verwijderen',
irregular_table_structure: 'Rijen en kolommen niet bewerkbaar: onregelmatige tabel'
} }
export { en, es, it, pt, fr, de, nl } export { en, es, it, pt, fr, de, nl }