mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 06:42:32 -05:00
1849 lines
50 KiB
JavaScript
1849 lines
50 KiB
JavaScript
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;
|
||
}`
|
||
|
||
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) {
|
||
selectedTextblocks(state).forEach(({ node, pos, numbering }) => {
|
||
tr.setNodeMarkup(pos, null, { ...node.attrs, htmlAttrs: withNumberingClass(node.attrs.htmlAttrs, `doc-list-${listStyle}-${numbering?.level || 0}`) })
|
||
})
|
||
}
|
||
|
||
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
|
||
})
|
||
}
|