diff --git a/client/package-lock.json b/client/package-lock.json index 4d8c357616..68b3cc171d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -21,6 +21,7 @@ "@angular/platform-browser-dynamic": "^18.2.13", "@angular/router": "^18.2.13", "@angular/service-worker": "^18.2.13", + "@intevation/tiptap-extension-office-paste": "^0.0.3", "@material/typography": "^14.0.0", "@ngx-pwa/local-storage": "^18.0.0", "@ngx-translate/core": "^16.0.3", @@ -3595,6 +3596,15 @@ "node": ">=18" } }, + "node_modules/@intevation/tiptap-extension-office-paste": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@intevation/tiptap-extension-office-paste/-/tiptap-extension-office-paste-0.0.3.tgz", + "integrity": "sha512-j4sE2D6Txa+ZhNLPGFOEIM8zjLqjDEHu6tu0amduMLdb4xQGkdfa1fX8zyErs9RT1bjHK9vLQ3ewBFra5kPPpQ==", + "peerDependencies": { + "@tiptap/core": "^2.10.3", + "@tiptap/pm": "^2.10.3" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/client/package.json b/client/package.json index 9e3c4adb3f..b84a7c7408 100644 --- a/client/package.json +++ b/client/package.json @@ -48,6 +48,7 @@ "@angular/platform-browser-dynamic": "^18.2.13", "@angular/router": "^18.2.13", "@angular/service-worker": "^18.2.13", + "@intevation/tiptap-extension-office-paste": "^0.0.3", "@material/typography": "^14.0.0", "@ngx-pwa/local-storage": "^18.0.0", "@ngx-translate/core": "^16.0.3", diff --git a/client/src/app/ui/modules/editor/components/editor/editor.component.ts b/client/src/app/ui/modules/editor/components/editor/editor.component.ts index 65b24776cf..1fc29f3714 100644 --- a/client/src/app/ui/modules/editor/components/editor/editor.component.ts +++ b/client/src/app/ui/modules/editor/components/editor/editor.component.ts @@ -13,6 +13,7 @@ import { } from '@angular/core'; import { NG_VALUE_ACCESSOR, UntypedFormControl } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; +import OfficePaste from '@intevation/tiptap-extension-office-paste'; import { TranslateService } from '@ngx-translate/core'; import { Editor, Extension } from '@tiptap/core'; import Blockquote from '@tiptap/extension-blockquote'; @@ -58,7 +59,6 @@ import { ClearTextcolorPaste } from './extensions/clear-textcolor'; import { Highlight } from './extensions/highlight'; import IFrame from './extensions/iframe'; import { ImageResize } from './extensions/image-resize'; -import { MSOfficePaste } from './extensions/office'; const DEFAULT_COLOR_PALETE = [ `#BFEDD2`, @@ -163,7 +163,7 @@ export class EditorComponent extends BaseFormControlComponent implements const editorConfig = { element: this.editorEl.nativeElement, extensions: [ - MSOfficePaste, + OfficePaste, ClearTextcolorPaste, // Nodes Document, diff --git a/client/src/app/ui/modules/editor/components/editor/extensions/office.ts b/client/src/app/ui/modules/editor/components/editor/extensions/office.ts deleted file mode 100644 index 28be4d464a..0000000000 --- a/client/src/app/ui/modules/editor/components/editor/extensions/office.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Extension } from '@tiptap/core'; -import { Plugin } from '@tiptap/pm/state'; -import { parseLetterNumber, parseRomanNumber } from 'src/app/infrastructure/utils'; -import { unwrapNode } from 'src/app/infrastructure/utils/dom-helpers'; - -export const MSOfficePaste = Extension.create({ - priority: 99999, - name: `ms-office-paste`, - - addProseMirrorPlugins() { - return [OfficePastePlugin]; - } -}); - -const OfficePastePlugin = new Plugin({ - props: { - transformPastedHTML(html: string): string { - console.debug([html]); - if (html.indexOf(`microsoft-com`) !== -1 && html.indexOf(`office`) !== -1) { - html = transformLists(html); - html = transformRemoveBookmarks(html); - html = transformMsoStyles(html); - } - console.debug([html]); - return html; - } - } -}); - -function transformMsoStyles(html: string): string { - html = html.replace(/(.*)<\/o:p>/g, ``); - - const parser = new DOMParser(); - const doc = parser.parseFromString(html, `text/html`); - doc.querySelectorAll(`[style*="mso-"]`).forEach(node => { - const styles = parseStyleAttribute(node); - const newStyles = []; - for (const prop of Object.keys(styles)) { - if (prop && !prop.startsWith(`mso-`)) { - newStyles.push(`${prop}: ${styles[prop]}`); - } - } - node.setAttribute(`style`, newStyles.join(`;`)); - }); - - doc.querySelectorAll(`[style*="color: black"]`).forEach(node => { - (node as HTMLElement).style.removeProperty(`color`); - }); - - return doc.documentElement.outerHTML; -} - -function transformRemoveBookmarks(html: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, `text/html`); - const bookmarks = doc.querySelectorAll(`[style*="mso-bookmark:"]`); - bookmarks.forEach(node => { - const bookmark = parseStyleAttribute(node)[`mso-bookmark`]; - const bookmarkLink = doc.querySelector(`a[name="${bookmark}"]`); - if (bookmarkLink) { - bookmarkLink.parentNode.removeChild(bookmarkLink); - } - unwrapNode(node as HTMLElement); - }); - - return doc.documentElement.outerHTML; -} - -function transformLists(html: string): string { - if (html.indexOf(`mso-list:`) === -1) { - return html; - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(html, `text/html`); - - let listStack: HTMLElement[] = []; - let currentListId: string; - const listElements = doc.querySelectorAll(`p[style*="mso-list:"]`); - listElements.forEach(node => { - const el = node; - const [msoListId, msoListLevel] = parseMsoListAttribute(parseStyleAttribute(el)[`mso-list`]); - - // Check for start of a new list - if (currentListId !== msoListId && (hasNonListItemSibling(el) || msoListLevel === 1)) { - currentListId = msoListId; - listStack = []; - } - - while (msoListLevel > listStack.length) { - const newList = createListElement(el); - - if (listStack.length > 0) { - listStack[listStack.length - 1].appendChild(newList); - } else { - el.before(newList); - } - listStack.push(newList); - } - - while (msoListLevel < listStack.length) { - listStack.pop(); - } - - // Remove list item numbers and create li - listStack[listStack.length - 1].appendChild(getListItemFromParagraph(el)); - el.remove(); - }); - - return doc.documentElement.outerHTML; -} - -function hasNonListItemSibling(el: HTMLElement): boolean { - return ( - !el.previousElementSibling || - !(el.previousElementSibling.nodeName === `OL` || el.previousElementSibling.nodeName === `UL`) - ); -} - -function getListItemFromParagraph(el: HTMLElement): HTMLElement { - const li = document.createElement(`li`); - li.innerHTML = el.innerHTML.replace(listTypeRegex, ``); - - return li; -} - -// Parses `mso-list` style attribute -function parseMsoListAttribute(attr: string): [id: string, level: number] { - const msoListValue: string = attr; - const msoListInfos = msoListValue.split(` `); - const msoListId = msoListInfos.find(e => /l[0-9]+/.test(e)); - const msoListLevel = +msoListInfos.find((e: string) => e.startsWith(`level`))?.substring(5) || 1; - - return [msoListId, msoListLevel]; -} - -const listTypeRegex = /((.|\n)*)/m; -function getListPrefix(el: HTMLElement): string { - const matches = el.innerHTML.match(listTypeRegex); - if (matches?.length) { - const parser = new DOMParser(); - const doc = parser.parseFromString(matches[0], `text/html`); - return doc.body.querySelector(`span`).textContent; - } - - return ``; -} - -function parseStyleAttribute(el: Element): { [prop: string]: string } { - const styleRaw: string = el?.attributes[`style`]?.value || ``; - return Object.fromEntries(styleRaw.split(`;`).map(line => line.split(`:`).map(v => v.trim()))); -} - -function createListElement(el: HTMLElement): HTMLElement { - const listInfo = getListInfo(getListPrefix(el)); - const list = document.createElement(listInfo.type); - if (listInfo.countType) { - list.setAttribute(`type`, listInfo.countType); - } - if (listInfo.start > 1) { - list.setAttribute(`start`, listInfo.start.toString()); - } - return list; -} - -const listOrderRegex = { - number: /[0-9]+\./, - romanLower: /(?=[mdclxvi])m*(c[md]|d?c*)(x[cl]|l?x*)(i[xv]|v?i*)\./, - romanUpper: /(?=[MDCLXVI])M*(C[MD]|D?C*)(X[CL]|L?X*)(I[XV]|V?I*)\./, - letterLower: /[a-z]+\./, - letterUpper: /[A-Z]+\./ -}; - -function getListInfo(prefix: string): { type: string; start: number; countType: string } { - let type = `ul`; - let countType: string | null = null; - let start = 1; - if (listOrderRegex.number.test(prefix)) { - type = `ol`; - start = +prefix.match(listOrderRegex.number)[0].replace(`.`, ``); - } else if (listOrderRegex.romanLower.test(prefix)) { - type = `ol`; - countType = `i`; - start = +parseRomanNumber(prefix.match(listOrderRegex.romanLower)[0].replace(`.`, ``)); - } else if (listOrderRegex.romanUpper.test(prefix)) { - type = `ol`; - countType = `I`; - start = +parseRomanNumber(prefix.match(listOrderRegex.romanUpper)[0].replace(`.`, ``)); - } else if (listOrderRegex.letterLower.test(prefix)) { - type = `ol`; - countType = `a`; - start = +parseLetterNumber(prefix.match(listOrderRegex.letterLower)[0].replace(`.`, ``)); - } else if (listOrderRegex.letterUpper.test(prefix)) { - type = `ol`; - countType = `A`; - start = +parseLetterNumber(prefix.match(listOrderRegex.letterUpper)[0].replace(`.`, ``)); - } - - return { - type, - start, - countType - }; -}