import {Fragment, Node as ProseMirrorNode} from "prosemirror-model" import {Plugin, PluginKey} from "prosemirror-state" import type {EditorState} from "prosemirror-state" import type {EditorView, NodeView} from "prosemirror-view" import type {Editor} from "../types.js" const key = new PluginKey("documentTemplate") interface DocumentTemplatePluginOptions { editor: Editor } interface ProtectedRange { from: number to: number } export function addDeletedPartWidget( dom: HTMLElement, view: EditorView, getPos: () => number | undefined ): void { dom.classList.add("doc-deleted") dom.insertAdjacentHTML( "beforeend", '
' ) const removeButton = dom.lastElementChild as HTMLElement removeButton.addEventListener("click", () => { const pos = getPos() if (pos === undefined) { return } const from = pos, to = from + view.state.doc.nodeAt(from)!.nodeSize, tr = view.state.tr tr.delete(from, to) tr.setMeta("deleteUnusedSection", true) view.dispatch(tr) }) } export function getProtectedRanges(state: EditorState): ProtectedRange[] { return (key.getState(state) as {protectedRanges: ProtectedRange[]}) .protectedRanges } export function getAllowedElementsAndMarks( state: EditorState ): {elements: string[] | false; marks: string[] | false} { // Get the allowed elements and marks at the current selection position const {$anchor} = state.selection const docPart = $anchor.node(1) // Get the part node (richtext_part, heading_part, etc.) if (!docPart) { return {elements: false, marks: false} } const allowedElements = docPart.attrs.elements ? (docPart.attrs.elements as string[]).concat( "table_caption", "table_body", "table_row", "table_cell", "table_header", "list_item", "text" ) : false const allowedMarks = docPart.attrs.marks ? (docPart.attrs.marks as string[]).concat( "insertion", "deletion", "comment", "anchor" ) : false return {elements: allowedElements, marks: allowedMarks} } export function checkProtectedInSelection(state: EditorState): boolean { // Checks whether there is a protected range // within a selection const anchorDocPart = state.selection.$anchor.node(1), headDocPart = state.selection.$head.node(1) // If the protection is of header/start check if selection falls within the // protected range if ( ["start", "header"].includes(anchorDocPart.attrs.locking) || ["start", "header"].includes(headDocPart.attrs.locking) ) { const protectedRanges = getProtectedRanges(state), start = state.selection.from, end = state.selection.to if ( protectedRanges.find( ({from, to}) => !( (start <= from && end <= from) || (start >= to && end >= to) ) ) ) { return true } } return ( anchorDocPart.attrs.locking === "fixed" || headDocPart.attrs.locking === "fixed" ) } export class PartView implements NodeView { node: ProseMirrorNode view: EditorView getPos: () => number | undefined dom: HTMLElement contentDOM: HTMLElement constructor( node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined ) { this.node = node this.view = view this.getPos = getPos this.dom = document.createElement("div") this.dom.classList.add("doc-part") this.dom.classList.add(`doc-${this.node.type.name}`) this.dom.classList.add(`doc-${this.node.attrs.id}`) if (node.attrs.hidden) { this.dom.dataset.hidden = "true" } if (node.attrs.deleted) { this.contentDOM = this.dom.appendChild( document.createElement("div") ) addDeletedPartWidget(this.dom, view, getPos) } else { this.contentDOM = this.dom } } stopEvent(): boolean { return false } } export const documentTemplatePlugin = ( options: DocumentTemplatePluginOptions ) => new Plugin({ key, state: { init(_config, state) { if (options.editor.docInfo.access_rights === "write") { ;((this as any).spec.props as any).nodeViews[ "richtext_part" ] = ( node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined ) => new PartView(node, view, getPos) ;((this as any).spec.props as any).nodeViews[ "heading_part" ] = ( node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined ) => new PartView(node, view, getPos) ;((this as any).spec.props as any).nodeViews[ "table_part" ] = ( node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined ) => new PartView(node, view, getPos) // Tags and Contributors have node views defined in tag_input and contributor_input. // TOCs have node views defined in toc_render. } const protectedRanges: ProtectedRange[] = [ {from: 0, to: 1} // article node ] state.doc.forEach((node, pos) => { const from = pos let to = from if (node.attrs.locking === "fixed") { to = from + node.nodeSize } else if (node.attrs.locking === "header") { // only relevant for tables to = from + 1 + 1 + 1 + ((node.firstChild as ProseMirrorNode).firstChild ?.nodeSize || 0) // + 1 for the part node + 1 for the table + 1 for the first row } else if (node.attrs.locking === "start") { let initialFragment = Fragment.fromJSON( options.editor.schema, node.attrs.initial ) let protectionSize = initialFragment.size if ( initialFragment.lastChild?.isTextblock ) { protectionSize -= 1 // We allow writing at the end of the last text block. if (initialFragment.lastChild?.nodeSize === 2) { // The last text block is empty, so we remove all protection from it, even node type protectionSize -= 1 } initialFragment = initialFragment.cut( 0, protectionSize ) } if ( node.content.size >= protectionSize && initialFragment.eq( node.slice(0, protectionSize).content ) ) { // We only add protection if the start of the current content corresponds to the // initial content. This may not be the case if the template has been changed. to = from + 1 + protectionSize // + 1 for inside the part node } } protectedRanges.push({from, to}) }) return { protectedRanges } }, apply(tr, _prev, oldState, _state) { let {protectedRanges} = key.getState(oldState) as { protectedRanges: ProtectedRange[] } protectedRanges = protectedRanges.map(marker => ({ from: tr.mapping.map(marker.from, 1), to: tr.mapping.map(marker.to, -1) })) return { protectedRanges } } }, props: { nodeViews: {} }, filterTransaction: (tr, state) => { if ( !tr.docChanged || tr.getMeta("fixIds") || tr.getMeta("remote") || tr.getMeta("track") || tr.getMeta("fromFootnote") || tr.getMeta("deleteUnusedSection") || tr.getMeta("settings") || ["historyUndo", "historyRedo"].includes(tr.getMeta("inputType")) ) { return true } if (state.doc.childCount !== tr.doc.childCount) { return false } const {protectedRanges} = key.getState(state) as { protectedRanges: ProtectedRange[] } let allowed = true let changingRanges: Array<{start: number; end: number}> = [] // We map all changes back to the document before changes have been applied. tr.steps .slice() .reverse() .forEach(step => { const map = step.getMap() if (changingRanges.length) { const mapInv = map.invert() changingRanges = changingRanges.map(range => ({ start: mapInv.map(range.start, -1), end: mapInv.map(range.end, 1) })) } const stepData = step as any if ( ["removeMark", "addMark"].includes(stepData.jsonID) ) { changingRanges.push({ start: stepData.from, end: stepData.to }) } map.forEach((start: number, end: number) => { changingRanges.push({start, end}) }) }) changingRanges.forEach(({start, end}) => { if ( protectedRanges.find( ({from, to}) => !( (start <= from && end <= from) || (start >= to && end >= to) ) ) ) { allowed = false } }) let allowedElements: string[] | false = false, allowedMarks: string[] | false = false changingRanges.forEach(range => state.doc.nodesBetween( range.start, range.end, (node, pos, parent, _index) => { const elements = allowedElements const marks = allowedMarks if (parent === tr.doc) { allowedElements = node.attrs.elements ? (node.attrs.elements as string[]).concat( "table_caption", "table_body", "table_row", "table_cell", "table_header", "list_item", "text" ) : false allowedMarks = node.attrs.marks ? (node.attrs.marks as string[]).concat( "insertion", "deletion", "comment", "anchor" ) : false return allowed } if (pos < range.start) { return true } if ( Array.isArray(elements) && !elements.includes(node.type.name) ) { allowed = false } else if (Array.isArray(marks)) { node.marks.forEach(mark => { if (!marks.includes(mark.type.name)) { allowed = false } }) } return undefined } ) ) return allowed } })