Files
docuseal/app/javascript/template_builder/dynamic_editor.js
T

1866 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Editor, Extension, Node, Mark, ResizableNodeView } from '@tiptap/core'
import { Plugin, PluginKey, NodeSelection } from '@tiptap/pm/state'
import { TableMap, CellSelection, tableEditing, findTable, selectedRect, addRowAfter, addRowBefore, addColumnAfter, addColumnBefore, deleteRow, deleteColumn, mergeCells, splitCell } from '@tiptap/pm/tables'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import Document from '@tiptap/extension-document'
import Text from '@tiptap/extension-text'
import HardBreak from '@tiptap/extension-hard-break'
import History from '@tiptap/extension-history'
import Gapcursor from '@tiptap/extension-gapcursor'
import Dropcursor from '@tiptap/extension-dropcursor'
import { createApp, reactive } from 'vue'
import DynamicArea from './dynamic_area.vue'
import styles from './dynamic_styles.scss'
export const dynamicStylesheet = new CSSStyleSheet()
dynamicStylesheet.replaceSync(styles[0][1])
export const tiptapStylesheet = new CSSStyleSheet()
tiptapStylesheet.replaceSync(
`.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
display: flex;
flex-flow: column nowrap;
min-height: inherit;
}
.ProseMirror > article {
margin-bottom: auto;
}
.ProseMirror [contenteditable="false"] {
white-space: normal;
}
.ProseMirror [contenteditable="false"] [contenteditable="true"] {
white-space: pre-wrap;
}
.ProseMirror pre {
white-space: pre-wrap;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 0 !important;
height: 0 !important;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
[data-resize-container][data-node="image"] {
display: inline-flex !important;
max-width: 100%;
vertical-align: bottom;
}
[data-resize-container][data-node="image"] [data-resize-wrapper] {
max-width: 100%;
text-indent: 0;
}
[data-resize-handle] {
--handle-size: calc(10px / var(--zoom));
display: none;
width: var(--handle-size);
height: var(--handle-size);
background: #ffffff;
border: calc(2px / var(--zoom)) solid #3b82f6;
border-radius: 2px;
z-index: 1;
}
@media (pointer: coarse) {
[data-resize-handle]::after {
content: "";
position: absolute;
inset: calc(-8px / var(--zoom));
}
}
.ProseMirror-selectednode [data-resize-wrapper] {
outline: 2px solid #3b82f6;
}
.ProseMirror-selectednode [data-resize-handle] {
display: block;
}
[data-resize-handle="top-left"] { margin: calc(var(--handle-size) / -2) 0 0 calc(var(--handle-size) / -2); cursor: nwse-resize; }
[data-resize-handle="top-right"] { margin: calc(var(--handle-size) / -2) calc(var(--handle-size) / -2) 0 0; cursor: nesw-resize; }
[data-resize-handle="bottom-left"] { margin: 0 0 calc(var(--handle-size) / -2) calc(var(--handle-size) / -2); cursor: nesw-resize; }
[data-resize-handle="bottom-right"] { margin: 0 calc(var(--handle-size) / -2) calc(var(--handle-size) / -2) 0; cursor: nwse-resize; }
/* iOS 27 WebKit drops thin collapsed borders when zoomed out */
[data-zoomed-out] .ProseMirror table {
border-collapse: separate !important;
border-spacing: 0 !important;
}
.ProseMirror .selectedCell {
position: relative;
}
.ProseMirror .selectedCell::after {
content: "";
position: absolute;
inset: 0;
background: rgba(59, 130, 246, 0.15);
pointer-events: none;
}
dynamic-variable {
background-color: #fef3c7;
word-break: break-all;
overflow-wrap: anywhere;
}`)
const DROP_ATTRS = [
'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster',
'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile'
]
const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i
function isSafeAttr (name, value) {
const lowerName = name.toLowerCase()
if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false
if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false
return true
}
function collectDomAttrs (dom) {
const attrs = {}
for (let i = 0; i < dom.attributes.length; i++) {
const { name, value } = dom.attributes[i]
if (isSafeAttr(name, value)) attrs[name] = value
}
return { htmlAttrs: attrs }
}
function collectSpanDomAttrs (dom) {
const result = collectDomAttrs(dom)
if (result.htmlAttrs.style) {
const temp = document.createElement('span')
temp.style.cssText = result.htmlAttrs.style
if (['bold', '700'].includes(temp.style.fontWeight)) {
temp.style.removeProperty('font-weight')
}
if (temp.style.fontStyle === 'italic') {
temp.style.removeProperty('font-style')
}
if (['underline', 'line-through'].includes(temp.style.textDecoration)) {
temp.style.removeProperty('text-decoration')
}
if (temp.style.cssText) {
result.htmlAttrs.style = temp.style.cssText
} else {
delete result.htmlAttrs.style
}
}
return result
}
function createBlockNode (name, tag, content, extra = {}) {
return Node.create({
name,
group: 'block',
content: content || 'block+',
...extra,
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag, getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return [tag, node.attrs.htmlAttrs, 0]
}
})
}
const CustomParagraph = Node.create({
name: 'paragraph',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag: 'p', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['p', node.attrs.htmlAttrs, 0]
}
})
const CustomHeading = Node.create({
name: 'heading',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} },
level: { default: 1 }
}
},
parseHTML () {
return [1, 2, 3, 4, 5, 6].map((level) => ({
tag: `h${level}`,
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
}))
},
renderHTML ({ node }) {
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
}
})
const SectionNode = createBlockNode('section', 'section')
const ArticleNode = createBlockNode('article', 'article', null, { isolating: true })
const HeaderNode = createBlockNode('header', 'header', null, { isolating: true })
const FooterNode = createBlockNode('footer', 'footer', null, { isolating: true })
const DivNode = createBlockNode('div', 'div')
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
const PreNode = createBlockNode('pre', 'pre')
const OrderedListNode = createBlockNode('orderedList', 'ol', '(listItem | block)+')
const BulletListNode = createBlockNode('bulletList', 'ul', '(listItem | block)+')
const ListItemNode = Node.create({
name: 'listItem',
content: 'block+',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag: 'li', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['li', node.attrs.htmlAttrs, 0]
}
})
function collectCellDomAttrs (dom) {
const { htmlAttrs } = collectDomAttrs(dom)
const colspan = parseInt(htmlAttrs.colspan) || 1
const rowspan = parseInt(htmlAttrs.rowspan) || 1
delete htmlAttrs.colspan
delete htmlAttrs.rowspan
return { htmlAttrs, colspan, rowspan }
}
function renderCellAttrs (node) {
const attrs = { ...node.attrs.htmlAttrs }
if (node.attrs.colspan > 1) attrs.colspan = node.attrs.colspan
if (node.attrs.rowspan > 1) attrs.rowspan = node.attrs.rowspan
return attrs
}
function isRemovableHiddenCell (dom) {
const isHidden = (cell) => cell.style.display === 'none'
return isHidden(dom) && [...dom.parentElement.children].some((cell) => !isHidden(cell))
}
function createTableCellNode (name, tag, tableRole) {
return Node.create({
name,
tableRole,
content: 'block*',
isolating: true,
addAttributes () {
return {
htmlAttrs: { default: {} },
colspan: { default: 1 },
rowspan: { default: 1 },
colwidth: { default: null }
}
},
parseHTML () {
return [
{ tag, priority: 60, ignore: true, getAttrs: (dom) => isRemovableHiddenCell(dom) ? null : false },
{ tag, getAttrs: collectCellDomAttrs }
]
},
renderHTML ({ node }) {
return [tag, renderCellAttrs(node), 0]
}
})
}
const TableNode = Node.create({
name: 'table',
group: 'block',
tableRole: 'table',
content: 'tableRow+',
isolating: true,
addAttributes () {
return {
htmlAttrs: { default: {} },
colgroupAttrs: { default: null },
cols: { default: [] }
}
},
parseHTML () {
return [
{
tag: 'table',
getAttrs (dom) {
const colgroup = dom.querySelector(':scope > colgroup')
return {
...collectDomAttrs(dom),
colgroupAttrs: colgroup ? collectDomAttrs(colgroup).htmlAttrs : null,
cols: colgroup ? [...colgroup.querySelectorAll(':scope > col')].map((col) => collectDomAttrs(col).htmlAttrs) : []
}
}
},
{ tag: 'colgroup', ignore: true }
]
},
renderHTML ({ node }) {
const { htmlAttrs, colgroupAttrs, cols } = node.attrs
if (colgroupAttrs || cols.length) {
return ['table', htmlAttrs, ['colgroup', colgroupAttrs || {}, ...cols.map((col) => ['col', col])], ['tbody', 0]]
}
return ['table', htmlAttrs, ['tbody', 0]]
}
})
const TableRow = Node.create({
name: 'tableRow',
tableRole: 'row',
content: '(tableCell | tableHeader)+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'tr', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['tr', node.attrs.htmlAttrs, 0]
}
})
const TableCell = createTableCellNode('tableCell', 'td', 'cell')
const TableHeader = createTableCellNode('tableHeader', 'th', 'header_cell')
const ImageNode = Node.create({
name: 'image',
inline: true,
group: 'inline',
draggable: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'img', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['img', node.attrs.htmlAttrs]
}
})
const CustomBold = Mark.create({
name: 'bold',
parseHTML () {
return [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }]
},
renderHTML () {
return ['strong', 0]
},
addCommands () {
return {
toggleBold: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-b': () => this.editor.commands.toggleBold()
}
}
})
const CustomItalic = Mark.create({
name: 'italic',
parseHTML () {
return [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }]
},
renderHTML () {
return ['em', 0]
},
addCommands () {
return {
toggleItalic: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-i': () => this.editor.commands.toggleItalic()
}
}
})
const CustomUnderline = Mark.create({
name: 'underline',
parseHTML () {
return [{ tag: 'u' }, { style: 'text-decoration=underline' }]
},
renderHTML () {
return ['u', 0]
},
addCommands () {
return {
toggleUnderline: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-u': () => this.editor.commands.toggleUnderline()
}
}
})
const CustomStrike = Mark.create({
name: 'strike',
parseHTML () {
return [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }]
},
renderHTML () {
return ['s', 0]
},
addCommands () {
return {
toggleStrike: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-Shift-s': () => this.editor.commands.toggleStrike()
}
}
})
const EmptySpanNode = Node.create({
name: 'emptySpan',
inline: true,
group: 'inline',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{
tag: 'span',
priority: 60,
getAttrs (dom) {
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
return collectDomAttrs(dom)
}
return false
}
}]
},
renderHTML ({ node }) {
return ['span', node.attrs.htmlAttrs]
}
})
const SpanMark = Mark.create({
name: 'span',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
},
renderHTML ({ mark }) {
return ['span', mark.attrs.htmlAttrs, 0]
}
})
const LayoutSpanMark = SpanMark.extend({
name: 'layoutSpan',
parseHTML () {
return [{
tag: 'span',
priority: 55,
getAttrs (dom) {
if (['left', 'right'].includes(dom.style.float) || ['inline-block', 'block'].includes(dom.style.display)) {
return collectSpanDomAttrs(dom)
}
return false
}
}]
}
})
const LinkMark = Mark.create({
name: 'link',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'a', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['a', mark.attrs.htmlAttrs, 0]
}
})
const SubscriptMark = Mark.create({
name: 'subscript',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'sub', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['sub', mark.attrs.htmlAttrs, 0]
}
})
const SuperscriptMark = Mark.create({
name: 'superscript',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'sup', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['sup', mark.attrs.htmlAttrs, 0]
}
})
const TabHandler = Extension.create({
name: 'tabHandler',
addKeyboardShortcuts () {
return {
Tab: () => {
this.editor.commands.insertContent('\t')
return true
}
}
}
})
const NumberingKeymap = Extension.create({
name: 'numberingKeymap',
priority: 110,
addKeyboardShortcuts () {
const numberingAtCursor = () => {
const { empty, $from } = this.editor.state.selection
return empty && $from.parentOffset === 0 && !!findNumbering($from.parent)
}
return {
Tab: () => {
const { empty, $from } = this.editor.state.selection
return (!empty || $from.parentOffset === 0) && this.editor.commands.shiftNumberingLevel(1)
},
'Shift-Tab': () => this.editor.commands.shiftNumberingLevel(-1),
Enter: () => numberingAtCursor() && !this.editor.state.selection.$from.parent.content.size && this.editor.commands.unsetNumbering(),
Backspace: () => numberingAtCursor() && this.editor.commands.unsetNumbering()
}
}
})
export const listsCss = `[class*="doc-list-"]::before {
display: inline-block;
min-width: 0.25in;
text-indent: 0;
text-align: left;
}
.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-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-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-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-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-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-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-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-8,
.doc-list-dash-8,
.doc-list-decimal-8 {
margin-left: 2.5in !important;
text-indent: -0.25in !important;
}
.doc-list-disc-0::before,
.doc-list-disc-3::before,
.doc-list-disc-6::before {
content: "•";
}
.doc-list-disc-1::before,
.doc-list-disc-4::before,
.doc-list-disc-7::before {
content: "◦";
}
.doc-list-disc-2::before,
.doc-list-disc-5::before,
.doc-list-disc-8::before {
content: "▪";
}
[class*="doc-list-dash-"]::before {
content: "–";
}
.doc-list-decimal-0 {
counter-increment: doc-list-0;
}
.doc-list-decimal-1 {
counter-increment: doc-list-1;
}
.doc-list-decimal-2 {
counter-increment: doc-list-2;
}
.doc-list-decimal-3 {
counter-increment: doc-list-3;
}
.doc-list-decimal-4 {
counter-increment: doc-list-4;
}
.doc-list-decimal-5 {
counter-increment: doc-list-5;
}
.doc-list-decimal-6 {
counter-increment: doc-list-6;
}
.doc-list-decimal-7 {
counter-increment: doc-list-7;
}
.doc-list-decimal-8 {
counter-increment: doc-list-8;
}
.doc-list-decimal-0::before {
content: counter(doc-list-0) ".";
}
.doc-list-decimal-1::before {
content: counter(doc-list-1, lower-alpha) ".";
}
.doc-list-decimal-2::before {
content: counter(doc-list-2, lower-roman) ".";
}
.doc-list-decimal-3::before {
content: counter(doc-list-3) ".";
}
.doc-list-decimal-4::before {
content: counter(doc-list-4, lower-alpha) ".";
}
.doc-list-decimal-5::before {
content: counter(doc-list-5, lower-roman) ".";
}
.doc-list-decimal-6::before {
content: counter(doc-list-6) ".";
}
.doc-list-decimal-7::before {
content: counter(doc-list-7, lower-alpha) ".";
}
.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;
}
[class*="doc-list-"]:has(+ [class*="doc-list-"]) {
margin-bottom: 0 !important;
}
[class*="doc-list-"] + [class*="doc-list-"] {
margin-top: 0 !important;
}`
const NUMBERING_CLASS_REGEXP = /^(doc-num-\d+|doc-list-(?:disc|dash|decimal))-(\d+)$/
export function findNumbering (node) {
for (const className of (node.attrs.htmlAttrs?.class || '').split(' ')) {
const match = className.match(NUMBERING_CLASS_REGEXP)
if (match) return { prefix: match[1], level: parseInt(match[2]) }
}
return null
}
function withNumberingClass (htmlAttrs, className) {
const classNames = (htmlAttrs.class || '').split(' ').filter(Boolean)
const index = classNames.findIndex((name) => NUMBERING_CLASS_REGEXP.test(name))
if (index === -1) {
classNames.push(className)
} else {
classNames[index] = className
}
const attrs = { ...htmlAttrs, class: classNames.filter(Boolean).join(' ') }
if (!attrs.class) delete attrs.class
return attrs
}
function selectedTextblocks (state) {
const blocks = []
state.selection.ranges.forEach(({ $from, $to }) => state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (!node.isTextblock) return
blocks.push({ node, pos, numbering: findNumbering(node) })
return false
}))
return blocks
}
export function numberingListStyle (view, pos) {
const { content } = getComputedStyle(view.nodeDOM(pos), '::before')
if (content.includes('counter(')) return 'decimal'
return /^"[–-]/.test(content) ? 'dash' : 'disc'
}
function hasNumberingLevel (view, { prefix }, level) {
if (prefix.startsWith('doc-list-')) return level >= 0 && level <= 8
const selector = `p.${prefix}-${level}`
return [...view.dom.getRootNode().styleSheets].some((sheet) => [...sheet.cssRules].some((rule) => rule.selectorText === selector))
}
function updateStyle (style, property, value) {
const el = document.createElement('span')
el.style.cssText = style || ''
if (value) {
el.style.setProperty(property, value)
} else {
el.style.removeProperty(property)
}
return el.style.cssText
}
function withStyle (htmlAttrs, property, value) {
const style = updateStyle(htmlAttrs.style, property, value)
const attrs = { ...htmlAttrs, style }
if (!style) delete attrs.style
return attrs
}
const Formatting = Extension.create({
name: 'formatting',
addCommands () {
return {
setSpanStyle: (property, value) => ({ state, tr, dispatch }) => {
const { empty, ranges } = state.selection
const type = state.schema.marks.span
if (empty) return false
if (dispatch) {
ranges.forEach(({ $from: { pos: from }, $to: { pos: to } }) => state.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isInline) return
const start = Math.max(pos, from)
const end = Math.min(pos + node.nodeSize, to)
const spans = node.marks.filter((mark) => mark.type === type)
const hasProperty = (mark) => {
const el = document.createElement('span')
el.style.cssText = mark.attrs.htmlAttrs.style || ''
return !!el.style.getPropertyValue(property)
}
const updated = spans.map((mark, index) => {
let htmlAttrs = hasProperty(mark) ? withStyle(mark.attrs.htmlAttrs, property, null) : mark.attrs.htmlAttrs
if (value && index === spans.length - 1) {
htmlAttrs = withStyle(htmlAttrs, property, value)
}
return htmlAttrs
})
if (value && !spans.length) {
updated.push({ style: `${property}: ${value}` })
}
if (updated.length === spans.length && updated.every((htmlAttrs, index) => htmlAttrs === spans[index].attrs.htmlAttrs)) return
tr.removeMark(start, end, type)
updated.forEach((htmlAttrs) => tr.addMark(start, end, type.create({ htmlAttrs })))
}))
}
return true
},
setBlockStyle: (property, value) => ({ state, tr, dispatch }) => {
if (dispatch) {
state.selection.ranges.forEach(({ $from, $to }) => state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (!node.isTextblock) return
tr.setNodeMarkup(pos, null, { ...node.attrs, htmlAttrs: withStyle(node.attrs.htmlAttrs, property, value) })
return false
}))
}
return true
},
shiftNumberingLevel: (delta) => ({ state, tr, dispatch, view }) => {
if (!findNumbering(state.selection.$from.parent)) return false
const blocks = selectedTextblocks(state).filter(({ numbering }) => numbering)
if (dispatch && blocks.every(({ numbering }) => hasNumberingLevel(view, numbering, numbering.level + delta))) {
blocks.forEach(({ node, pos, numbering }) => {
tr.setNodeMarkup(pos, null, { ...node.attrs, htmlAttrs: withNumberingClass(node.attrs.htmlAttrs, `${numbering.prefix}-${numbering.level + delta}`) })
})
}
return true
},
setListMarkerStyle: (property, value) => ({ state, tr, dispatch }) => {
const from = Math.min(...state.selection.ranges.map(({ $from }) => $from.pos))
const to = Math.max(...state.selection.ranges.map(({ $to }) => $to.pos))
const blocks = selectedTextblocks(state).filter(({ node, pos, numbering }) => numbering && from <= pos + 1 && to >= pos + node.nodeSize - 1)
if (!blocks.length) return false
if (dispatch) {
blocks.forEach(({ node, pos }) => tr.setNodeMarkup(pos, null, { ...node.attrs, htmlAttrs: withStyle(node.attrs.htmlAttrs, property, value) }))
}
return true
},
unsetNumbering: () => ({ state, tr, dispatch }) => {
const blocks = selectedTextblocks(state).filter(({ numbering }) => numbering)
if (!blocks.length) return false
if (dispatch) {
blocks.forEach(({ node, pos }) => tr.setNodeMarkup(pos, null, { ...node.attrs, htmlAttrs: withNumberingClass(node.attrs.htmlAttrs, null) }))
}
return true
},
toggleList: (listStyle) => ({ state, tr, dispatch, view, commands }) => {
const { $from } = state.selection
if (findNumbering($from.parent) && numberingListStyle(view, $from.before()) === listStyle) {
return commands.unsetNumbering()
}
if (dispatch) {
const blocks = selectedTextblocks(state)
const $before = state.doc.resolve(blocks[0].pos)
const $after = state.doc.resolve(blocks.at(-1).pos + blocks.at(-1).node.nodeSize)
const adjacent = [[$before.nodeBefore, $before.pos - $before.nodeBefore?.nodeSize], [$after.nodeAfter, $after.pos]]
.map(([node, pos]) => node && findNumbering(node) && numberingListStyle(view, pos) === listStyle && findNumbering(node).prefix)
.find(Boolean)
blocks.forEach(({ node, pos, numbering }) => {
const className = numbering ? `doc-list-${listStyle}-${numbering.level}` : `${adjacent || `doc-list-${listStyle}`}-0`
tr.setNodeMarkup(pos, null, { ...node.attrs, htmlAttrs: withNumberingClass(node.attrs.htmlAttrs, className) })
})
}
return true
}
}
}
})
function deleteTableNode (tr, table) {
const $table = tr.doc.resolve(table.pos)
if ($table.parent.childCount === 1) {
tr.replaceWith(table.pos, table.pos + table.node.nodeSize, tr.doc.type.schema.nodes.paragraph.create())
} else {
tr.delete(table.pos, table.pos + table.node.nodeSize)
}
}
export function isRegularTable (table) {
return !TableMap.get(table).problems
}
function findEditableTable (state) {
const table = findTable(state.selection.$head)
return table && isRegularTable(table.node) ? table : null
}
function tableCellNodes (table) {
const cells = new Set()
table.descendants((node) => {
if (node.type.spec.tableRole === 'cell' || node.type.spec.tableRole === 'header_cell') {
cells.add(node)
return false
}
})
return cells
}
function styleNewCells (tr, existingCells) {
const table = findTable(tr.selection.$head)
const map = TableMap.get(table.node)
const cellPositions = [...new Set(map.map)]
const isNew = (pos) => !existingCells.has(table.node.nodeAt(pos))
const findReference = (pos) => {
const { left, top } = map.findCell(pos)
for (let distance = 1; distance < Math.max(map.width, map.height); distance++) {
const candidates = [
[top, left - distance], [top, left + distance],
[top - distance, left], [top + distance, left]
]
for (const [row, col] of candidates) {
if (row < 0 || col < 0 || row >= map.height || col >= map.width) continue
const candidatePos = map.map[row * map.width + col]
if (!isNew(candidatePos)) return table.node.nodeAt(candidatePos)
}
}
return null
}
cellPositions.filter((pos) => isNew(pos) && !table.node.nodeAt(pos).childCount).sort((a, b) => b - a).forEach((pos) => {
const cell = table.node.nodeAt(pos)
const reference = findReference(pos)
const cellPos = table.start + pos
if (!reference) return
const paragraph = reference.firstChild?.type.name === 'paragraph' ? reference.firstChild : null
tr.insert(cellPos + 1, tr.doc.type.schema.nodes.paragraph.create(paragraph?.attrs))
tr.setNodeMarkup(cellPos, null, { ...cell.attrs, htmlAttrs: reference.attrs.htmlAttrs })
})
}
function runTableCommand (command, { state, tr, dispatch }, colsUpdate) {
const table = findEditableTable(state)
if (!table) return false
if (!dispatch) return command(state)
const rect = selectedRect(state)
const colsMatch = table.node.attrs.cols.length === rect.map.width
const existingCells = tableCellNodes(table.node)
if (!command(state, () => {})) return false
styleNewCells(tr, existingCells)
if (colsUpdate && colsMatch) {
const tablePos = tr.mapping.map(table.pos)
const { attrs } = tr.doc.nodeAt(tablePos)
const cols = [...attrs.cols]
colsUpdate(cols, rect)
tr.setNodeMarkup(tablePos, null, { ...attrs, cols })
}
return true
}
const TableCommands = Extension.create({
name: 'tableCommands',
extendNodeSchema (extension) {
return extension.config.tableRole ? { tableRole: extension.config.tableRole } : {}
},
addProseMirrorPlugins () {
const plugin = tableEditing()
const { handlePaste } = plugin.props
delete plugin.spec.appendTransaction
plugin.props.handlePaste = (view, event, slice) => {
const table = findTable(view.state.selection.$head)
if (!table) return false
if (!isRegularTable(table.node)) return view.state.selection instanceof CellSelection
const existingCells = tableCellNodes(table.node)
const dispatch = (tr) => {
styleNewCells(tr, existingCells)
view.dispatch(tr)
}
return handlePaste({ state: view.state, dispatch }, event, slice)
}
return [plugin]
},
addKeyboardShortcuts () {
const clearCells = () => {
const { state, view } = this.editor
if (!(state.selection instanceof CellSelection)) return false
const tr = state.tr
state.selection.forEachCell((cell, pos) => {
const paragraph = cell.firstChild?.type.name === 'paragraph' ? cell.firstChild : null
tr.replaceWith(tr.mapping.map(pos + 1), tr.mapping.map(pos + cell.nodeSize - 1), state.schema.nodes.paragraph.create(paragraph?.attrs))
})
view.dispatch(tr)
return true
}
return { Backspace: clearCells, Delete: clearCells, 'Mod-Backspace': clearCells, 'Mod-Delete': clearCells }
},
addCommands () {
return {
addTableRow: (after) => (props) => runTableCommand(after ? addRowAfter : addRowBefore, props),
addTableColumn: (after) => (props) => runTableCommand(after ? addColumnAfter : addColumnBefore, props, (cols, rect) => {
const index = after ? rect.right : rect.left
cols.splice(index, 0, { ...cols[after ? index - 1 : index] })
}),
deleteTableRow: () => (props) => {
const table = findEditableTable(props.state)
if (!table) return false
const rect = selectedRect(props.state)
if (rect.top === 0 && rect.bottom === rect.map.height) {
if (props.dispatch) deleteTableNode(props.tr, table)
return true
}
return runTableCommand(deleteRow, props)
},
deleteTableColumn: () => (props) => {
const table = findEditableTable(props.state)
if (!table) return false
const rect = selectedRect(props.state)
if (rect.left === 0 && rect.right === rect.map.width) {
if (props.dispatch) deleteTableNode(props.tr, table)
return true
}
return runTableCommand(deleteColumn, props, (cols, { left, right }) => cols.splice(left, right - left))
},
mergeTableCells: () => (props) => runTableCommand(mergeCells, props),
splitTableCell: () => (props) => runTableCommand(splitCell, props),
setBlockAlign: (align) => ({ state, tr, dispatch, commands }) => {
if (state.selection.node?.type.name === 'image') {
return commands.setBlockStyle('text-align', align)
}
const table = findTable(state.selection.$from)
if (!table) return false
if (dispatch) {
const el = document.createElement('table')
el.style.cssText = table.node.attrs.htmlAttrs.style || ''
const isFloating = ['left', 'right'].includes(el.style.float)
let margins = { left: ['0', 'auto'], center: ['auto', 'auto'], right: ['auto', '0'] }[align]
let htmlAttrs = table.node.attrs.htmlAttrs
if (isFloating && align !== 'center') {
const gap = [el.style.marginLeft, el.style.marginRight].find((value) => parseFloat(value)) || '0'
margins = align === 'left' ? ['0', gap] : [gap, '0']
htmlAttrs = withStyle(htmlAttrs, 'float', align)
} else {
htmlAttrs = withStyle(htmlAttrs, 'float', null)
}
htmlAttrs = withStyle(withStyle(htmlAttrs, 'margin-left', margins[0]), 'margin-right', margins[1])
delete htmlAttrs.align
tr.setNodeMarkup(table.pos, null, { ...table.node.attrs, htmlAttrs })
}
return true
},
deleteTable: () => ({ state, tr, dispatch }) => {
const table = findTable(state.selection.$from)
if (!table) return false
if (dispatch) deleteTableNode(tr, table)
return true
}
}
}
})
function pxToPt (px) {
return `${Math.round(px * 0.75 * 10) / 10}pt`
}
function tableColumnsRow (tableDom) {
const rows = [...tableDom.rows]
const columnsCount = Math.max(...rows.map((row) => [...row.cells].reduce((acc, cell) => acc + cell.colSpan, 0)))
return rows.find((row) => row.cells.length === columnsCount)
}
function tablePosFromDom (view, tableDom) {
return view.posAtDOM(tableDom.rows[0], 0) - 2
}
function setTableColumnWidths (view, tableDom, widths) {
const tablePos = tablePosFromDom(view, tableDom)
const table = view.state.doc.nodeAt(tablePos)
const cols = widths.map((width) => ({ style: `width: ${pxToPt(width)}` }))
const tableStyle = updateStyle(updateStyle(table.attrs.htmlAttrs.style, 'table-layout', 'fixed'), 'width', pxToPt(widths.reduce((acc, width) => acc + width, 0)))
view.dispatch(view.state.tr.setNodeMarkup(tablePos, null, { ...table.attrs, cols, htmlAttrs: { ...table.attrs.htmlAttrs, style: tableStyle } }))
}
function cellMinHeight (cellDom, zoom, withMargins) {
const style = getComputedStyle(cellDom)
const first = cellDom.firstElementChild
const last = cellDom.lastElementChild
const chrome = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) + (parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth)) / 2
if (!first) return chrome * zoom
const margins = withMargins ? parseFloat(getComputedStyle(first).marginTop) + parseFloat(getComputedStyle(last).marginBottom) : 0
return last.getBoundingClientRect().bottom - first.getBoundingClientRect().top + (margins + chrome) * zoom
}
export function tableRowMinHeights (tableDom, zoom, withMargins = true) {
return [...tableDom.rows].map((row) => Math.max(...[...row.cells].map((cell) => cellMinHeight(cell, zoom, withMargins))))
}
function scaleTableRows (view, tablePos, height, zoom) {
const tableDom = view.nodeDOM(tablePos)
const rows = [...tableDom.rows]
const heights = rows.map((row) => row.getBoundingClientRect().height / zoom)
const total = heights.reduce((acc, value) => acc + value, 0)
const tr = view.state.tr
let newHeights
if (height >= total) {
newHeights = heights.map((value) => value * height / total)
} else {
let mins = tableRowMinHeights(tableDom, zoom).map((value, index) => Math.min(value / zoom, heights[index]))
let minTotal = mins.reduce((acc, value) => acc + value, 0)
if (height < minTotal) {
mins = tableRowMinHeights(tableDom, zoom, false).map((value, index) => Math.min(value / zoom, heights[index]))
minTotal = mins.reduce((acc, value) => acc + value, 0)
tr.doc.nodeAt(tablePos).descendants((node, pos) => {
if (!node.isTextblock) return
const htmlAttrs = withStyle(withStyle(node.attrs.htmlAttrs, 'margin-top', '0'), 'margin-bottom', '0')
tr.setNodeMarkup(tablePos + 1 + pos, null, { ...node.attrs, htmlAttrs })
return false
})
}
const ratio = total > minTotal ? Math.max(height - minTotal, 0) / (total - minTotal) : 0
newHeights = heights.map((value, index) => mins[index] + (value - mins[index]) * ratio)
}
rows.forEach((rowDom, index) => {
const rowPos = view.posAtDOM(rowDom, 0) - 1
const row = tr.doc.nodeAt(rowPos)
tr.setNodeMarkup(rowPos, null, { ...row.attrs, htmlAttrs: withStyle(row.attrs.htmlAttrs, 'height', pxToPt(newHeights[index])) })
})
view.dispatch(tr)
}
export function resizeTable (view, tablePos, { width, height }, zoom) {
const tableDom = view.nodeDOM(tablePos)
const tableRect = tableDom.getBoundingClientRect()
if (Math.round(height) !== Math.round(tableRect.height / zoom)) {
scaleTableRows(view, tablePos, height, zoom)
}
if (Math.round(width) === Math.round(tableRect.width / zoom)) return
const columnsRow = tableColumnsRow(tableDom)
if (columnsRow) {
const widths = [...columnsRow.cells].map((cell) => cell.getBoundingClientRect().width / zoom)
const scale = width / widths.reduce((acc, value) => acc + value, 0)
setTableColumnWidths(view, tableDom, widths.map((value) => value * scale))
} else {
const table = view.state.doc.nodeAt(tablePos)
view.dispatch(view.state.tr.setNodeMarkup(tablePos, null, { ...table.attrs, htmlAttrs: withStyle(table.attrs.htmlAttrs, 'width', pxToPt(width)) }))
}
}
function resizeImage (view, pos, width) {
const image = view.state.doc.nodeAt(pos)
const htmlAttrs = { ...image.attrs.htmlAttrs }
delete htmlAttrs.width
delete htmlAttrs.height
htmlAttrs.style = updateStyle(updateStyle(htmlAttrs.style, 'width', pxToPt(width)), 'height', 'auto')
const tr = view.state.tr.setNodeMarkup(pos, null, { ...image.attrs, htmlAttrs })
view.dispatch(tr.setSelection(NodeSelection.create(tr.doc, pos)))
}
const variableHighlightKey = new PluginKey('variableHighlight')
function buildDecorations (doc) {
const decorations = []
const regex = /\[\[[^\]]*\]\]/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
const from = pos + match.index
const to = from + match[0].length
decorations.push(Decoration.inline(from, to, { nodeName: 'dynamic-variable' }))
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [
new Plugin({
key: variableHighlightKey,
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
if (tr.docChanged) {
return buildDecorations(tr.doc)
}
return oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
},
handleTextInput (view, from, to, text) {
if (text !== '[') return false
const { state } = view
const charBefore = state.doc.textBetween(Math.max(from - 1, 0), from)
if (charBefore !== '[') return false
const tr = state.tr.insertText('[]]', from, to)
tr.setSelection(state.selection.constructor.create(tr.doc, from + 1))
view.dispatch(tr)
return true
}
}
})
]
}
})
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlForSaveRef, onFieldDrop, onFieldDestroy, editorOptions }) {
const FieldNode = Node.create({
name: 'fieldNode',
inline: true,
group: 'inline',
atom: true,
draggable: true,
addAttributes () {
return {
uuid: { default: null },
areaUuid: { default: null },
width: { default: '124px' },
height: { default: null },
verticalAlign: { default: 'text-bottom' },
display: { default: 'inline-flex' }
}
},
parseHTML () {
return [{
tag: 'dynamic-field',
getAttrs (dom) {
return {
uuid: dom.getAttribute('uuid'),
areaUuid: dom.getAttribute('area-uuid'),
width: dom.style.width,
height: dom.style.height,
display: dom.style.display,
verticalAlign: dom.style.verticalAlign
}
}
}]
},
renderHTML ({ node }) {
const attrs = {
uuid: node.attrs.uuid,
'area-uuid': node.attrs.areaUuid,
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 () {
return ({ node, getPos, editor }) => {
const dom = document.createElement('span')
const nodeStyle = reactive({
width: node.attrs.width,
height: node.attrs.height,
verticalAlign: node.attrs.verticalAlign,
display: node.attrs.display
})
dom.dataset.areaUuid = node.attrs.areaUuid
const shadow = dom.attachShadow({ mode: 'open' })
shadow.adoptedStyleSheets = [dynamicStylesheet]
const app = createApp(DynamicArea, {
fieldUuid: node.attrs.uuid,
areaUuid: node.attrs.areaUuid,
nodeStyle,
getPos,
editor,
editable: editorOptions.editable,
...dynamicAreaProps
})
app.mount(shadow)
return {
dom,
update (updatedNode) {
if (updatedNode.attrs.areaUuid === node.attrs.areaUuid) {
nodeStyle.width = updatedNode.attrs.width
nodeStyle.height = updatedNode.attrs.height
nodeStyle.verticalAlign = updatedNode.attrs.verticalAlign
nodeStyle.display = updatedNode.attrs.display
}
},
destroy () {
onFieldDestroy(node)
app.unmount()
}
}
}
}
})
const FieldDropPlugin = Extension.create({
name: 'fieldDrop',
addProseMirrorPlugins () {
return [
new Plugin({
key: new PluginKey('fieldDrop'),
props: {
handleDrop: onFieldDrop
}
})
]
}
})
const RESIZE_HANDLE_PX = 4
const MIN_SIZE_PX = 16
function findResizeTarget (view, event) {
const cellDom = event.target.closest('td, th')
if (!cellDom) return null
const rect = cellDom.getBoundingClientRect()
const isRow = Math.abs(event.clientY - rect.bottom) <= RESIZE_HANDLE_PX
const isRight = Math.abs(event.clientX - rect.right) <= RESIZE_HANDLE_PX
const isLeft = Math.abs(event.clientX - rect.left) <= RESIZE_HANDLE_PX
if (!isRow && !isRight && !isLeft) return null
const tableDom = cellDom.closest('table')
const tablePos = tablePosFromDom(view, tableDom)
const cell = TableMap.get(view.state.doc.nodeAt(tablePos)).findCell(view.posAtDOM(cellDom, 0) - tablePos - 2)
if (isRow) {
return { type: 'row', rowDom: tableDom.rows[cell.bottom - 1], tableDom }
}
const columnIndex = isRight ? cell.right - 1 : cell.left - 1
if (columnIndex < 0 || !tableColumnsRow(tableDom)) return null
return { type: 'column', columnIndex, tableDom }
}
function applyColumnResize (view, { tableDom, columnIndex }, delta) {
const zoom = dynamicAreaProps.getZoom()
const widths = [...tableColumnsRow(tableDom).cells].map((cell) => cell.getBoundingClientRect().width / zoom)
if (columnIndex + 1 < widths.length) {
const change = Math.max(Math.min(delta, widths[columnIndex + 1] - MIN_SIZE_PX), MIN_SIZE_PX - widths[columnIndex])
widths[columnIndex] += change
widths[columnIndex + 1] -= change
} else {
const maxDelta = (view.dom.parentElement.getBoundingClientRect().right - tableDom.getBoundingClientRect().right) / zoom
widths[columnIndex] = Math.max(widths[columnIndex] + Math.min(delta, Math.max(maxDelta, 0)), MIN_SIZE_PX)
}
setTableColumnWidths(view, tableDom, widths)
}
function applyRowResize (view, { rowDom }, delta) {
const height = Math.max(rowDom.getBoundingClientRect().height / dynamicAreaProps.getZoom() + delta, MIN_SIZE_PX)
const rowPos = view.posAtDOM(rowDom, 0) - 1
const row = view.state.doc.nodeAt(rowPos)
view.dispatch(view.state.tr.setNodeMarkup(rowPos, null, { ...row.attrs, htmlAttrs: withStyle(row.attrs.htmlAttrs, 'height', pxToPt(height)) }))
}
function startTableResize (view, event, target) {
const tableRect = target.tableDom.getBoundingClientRect()
const isColumn = target.type === 'column'
const guide = document.createElement('div')
guide.style.cssText = `position: fixed; z-index: 9999; pointer-events: none; background: #3b82f6; ${isColumn ? `top: ${tableRect.top}px; height: ${tableRect.height}px; width: 1px;` : `left: ${tableRect.left}px; width: ${tableRect.width}px; height: 1px;`}`
const moveGuide = (e) => {
if (isColumn) {
guide.style.left = `${e.clientX}px`
} else {
guide.style.top = `${e.clientY}px`
}
}
moveGuide(event)
document.body.appendChild(guide)
document.body.style.cursor = isColumn ? 'col-resize' : 'row-resize'
const onMouseUp = (e) => {
window.removeEventListener('mousemove', moveGuide)
window.removeEventListener('mouseup', onMouseUp)
guide.remove()
document.body.style.cursor = ''
const delta = (isColumn ? e.clientX - event.clientX : e.clientY - event.clientY) / dynamicAreaProps.getZoom()
if (!delta) return
if (isColumn) {
applyColumnResize(view, target, delta)
} else {
applyRowResize(view, target, delta)
}
}
window.addEventListener('mousemove', moveGuide)
window.addEventListener('mouseup', onMouseUp)
}
const TableResize = Extension.create({
name: 'tableResize',
addProseMirrorPlugins () {
return [
new Plugin({
key: new PluginKey('tableResize'),
props: {
handleDOMEvents: {
mousemove (view, event) {
const target = view.editable && findResizeTarget(view, event)
view.dom.style.cursor = target ? (target.type === 'column' ? 'col-resize' : 'row-resize') : ''
return false
},
mouseleave (view) {
view.dom.style.cursor = ''
return false
},
mousedown (view, event) {
const target = view.editable && event.button === 0 && findResizeTarget(view, event)
if (!target) return false
event.preventDefault()
startTableResize(view, event, target)
return true
}
}
}
})
]
}
})
class ZoomedResizableNodeView extends ResizableNodeView {
constructor (options) {
super(options)
this.wrapper.addEventListener('touchstart', (event) => {
if (event.target.closest('[data-resize-handle]')) {
document.addEventListener('touchend', this.handleTouchEnd)
document.addEventListener('touchcancel', this.handleTouchEnd)
}
}, true)
}
handleTouchEnd = () => {
document.removeEventListener('touchend', this.handleTouchEnd)
document.removeEventListener('touchcancel', this.handleTouchEnd)
document.removeEventListener('touchmove', this.handleTouchMove)
this.handleMouseUp()
}
handleResize (deltaX, deltaY) {
const zoom = dynamicAreaProps.getZoom()
super.handleResize(deltaX / zoom, deltaY / zoom)
}
}
const DynamicImageNode = ImageNode.extend({
addProseMirrorPlugins () {
return [
new Plugin({
props: {
attributes: (state) => state.selection.node?.type.name === this.name ? { inputmode: 'none', style: 'caret-color: transparent' } : {}
}
})
]
},
renderHTML ({ node }) {
const { loading, ...attrs } = node.attrs.htmlAttrs
return ['img', attrs]
},
addNodeView () {
return ({ node, getPos, editor }) => {
const img = document.createElement('img')
const attrs = { ...node.attrs.htmlAttrs }
const blobUuid = attrs.src?.startsWith('blob:') && attrs.src.slice(5)
if (blobUuid && attachmentsIndex[blobUuid]) {
attrs.src = attachmentsIndex[blobUuid]
}
img.setAttribute('loading', 'lazy')
Object.entries(attrs).forEach(([k, v]) => img.setAttribute(k, v))
if (!editorOptions.editable) {
return { dom: img }
}
return new ZoomedResizableNodeView({
element: img,
node,
editor,
getPos,
onCommit: (width) => resizeImage(editor.view, getPos(), width),
onUpdate: (updatedNode) => updatedNode.attrs.htmlAttrs === node.attrs.htmlAttrs,
options: {
directions: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
min: { width: MIN_SIZE_PX, height: MIN_SIZE_PX },
preserveAspectRatio: true
}
})
}
}
})
return new Editor({
extensions: [
Document,
Text,
HardBreak,
History,
Gapcursor,
Dropcursor,
CustomParagraph,
CustomHeading,
SectionNode,
ArticleNode,
HeaderNode,
FooterNode,
DivNode,
BlockquoteNode,
PreNode,
OrderedListNode,
BulletListNode,
ListItemNode,
TableNode,
TableRow,
TableCell,
TableHeader,
DynamicImageNode,
EmptySpanNode,
LayoutSpanMark,
LinkMark,
SpanMark,
CustomBold,
CustomItalic,
CustomUnderline,
CustomStrike,
SubscriptMark,
SuperscriptMark,
VariableHighlight,
TabHandler,
NumberingKeymap,
Formatting,
TableCommands,
FieldNode,
FieldDropPlugin,
TableResize
],
editorProps: {
attributes: {
style: 'outline: none'
}
},
parseOptions: {
preserveWhitespace: true
},
injectCSS: false,
...editorOptions
})
}