diff --git a/package-lock.json b/package-lock.json index 7ce77f6e17..676b153214 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,7 +101,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=12" } @@ -118,7 +117,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -135,7 +133,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -152,7 +149,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -169,7 +165,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -186,7 +181,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -203,7 +197,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -220,7 +213,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -237,7 +229,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -254,7 +245,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -271,7 +261,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -288,7 +277,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -305,7 +293,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -322,7 +309,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -339,7 +325,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -356,7 +341,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -373,7 +357,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -390,7 +373,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -407,7 +389,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -424,7 +405,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=12" } @@ -441,7 +421,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -458,7 +437,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -475,7 +453,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -892,8 +869,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.21.1", @@ -906,8 +882,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.21.1", @@ -920,8 +895,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.21.1", @@ -934,8 +908,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.21.1", @@ -948,8 +921,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.21.1", @@ -962,8 +934,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.21.1", @@ -976,8 +947,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.21.1", @@ -990,8 +960,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "version": "4.21.1", @@ -1004,8 +973,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.21.1", @@ -1018,8 +986,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.21.1", @@ -1032,8 +999,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.21.1", @@ -1046,8 +1012,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.21.1", @@ -1060,8 +1025,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.21.1", @@ -1074,8 +1038,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.21.1", @@ -1088,8 +1051,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.21.1", @@ -1102,8 +1064,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@sinclair/typebox": { "version": "0.27.8", @@ -6609,7 +6570,7 @@ }, "packages/superdoc": { "name": "@harbour-enterprises/superdoc", - "version": "1.0.0-alpha.49", + "version": "1.0.0-alpha.50", "dependencies": { "@harbour-enterprises/super-editor": "0.0.1-alpha.0", "eventemitter3": "^5.0.1", @@ -6625,6 +6586,7 @@ "postcss-nested": "^6.0.1", "postcss-nested-import": "^1.3.0", "vite": "^5.2.12", + "vite-plugin-node-polyfills": "^0.22.0", "vue-draggable-next": "^2.2.1" } }, diff --git a/packages/super-editor/src/components/toolbar/defaultItems.js b/packages/super-editor/src/components/toolbar/defaultItems.js index 0ee019e119..8844515980 100644 --- a/packages/super-editor/src/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/components/toolbar/defaultItems.js @@ -9,7 +9,7 @@ import LinkInput from "./LinkInput.vue"; import DocumentMode from "./DocumentMode.vue"; -export const makeDefaultItems = (superToolbar) => { +export const makeDefaultItems = (superToolbar, isDev = false) => { // bold const bold = useToolbarItem({ type: "button", @@ -525,7 +525,6 @@ export const makeDefaultItems = (superToolbar) => { type: "button", disabled: true, name: "redo", - disabled: true, tooltip: "Redo", command: "redo", icon: "fa fa-rotate-right", @@ -536,6 +535,66 @@ export const makeDefaultItems = (superToolbar) => { } }); + const test = useToolbarItem({ + type: "button", + disabled: false, + name: "test", + tooltip: "Test", + command: "insertTestNodes", + icon: "fas fa-vial", + group: "left", + }); + + const trackChanges = useToolbarItem({ + type: "button", + disabled: false, + name: "trackChanges", + tooltip: "Track Changes", + command: "toggleTrackChanges", + icon: "fa-solid fa-list-check", + group: "left", + }); + + const acceptChangesOnCursorPositions = useToolbarItem({ + type: "button", + disabled: false, + name: "acceptChangesOnCursorPositions", + tooltip: "Accept Changes under selection", + command: "acceptChangesOnCursorPositions", + icon: "fa fa-calendar-check", + group: "left", + }); + + const revertChangesOnCursorPositions = useToolbarItem({ + type: "button", + disabled: false, + name: "revertChangesOnCursorPositions", + tooltip: "Revert Changes under selection", + command: "revertChangesOnCursorPositions", + icon: "fa fa-calendar-xmark", + group: "left", + }); + + const toggleTrackChangesOriginal = useToolbarItem({ + type: "button", + disabled: false, + name: "toggleTrackChangesShowOriginal", + tooltip: "Toggle Show Original", + command: "toggleTrackChangesShowOriginal", + icon: "fa fa-eye", + group: "left", + }); + + const toggleTrackChangesFinal = useToolbarItem({ + type: "button", + disabled: false, + name: "toggleTrackChangesShowFinal", + tooltip: "Toggle Show Final", + command: "toggleTrackChangesShowFinal", + icon: "fa-solid fa-file", + group: "left", + }); + // search const search = useToolbarItem({ type: "button", @@ -681,9 +740,15 @@ export const makeDefaultItems = (superToolbar) => { } - const toolbarItems = [ + let toolbarItems = [ undo, redo, + test, + trackChanges, + acceptChangesOnCursorPositions, + revertChangesOnCursorPositions, + toggleTrackChangesOriginal, + toggleTrackChangesFinal, zoom, separator, fontButton, @@ -712,6 +777,9 @@ export const makeDefaultItems = (superToolbar) => { search, ]; + const devItems = [test, trackChanges, acceptChangesOnCursorPositions, revertChangesOnCursorPositions, toggleTrackChangesOriginal, toggleTrackChangesFinal]; + if (!isDev) toolbarItems = toolbarItems.filter((item) => !devItems.includes(item)); + const desktopExclude = ["overflow"]; const toolbarItemsDesktop = toolbarItems .map((item) => item.name) diff --git a/packages/super-editor/src/components/toolbar/super-toolbar.js b/packages/super-editor/src/components/toolbar/super-toolbar.js index 889062d651..4d27b818d7 100644 --- a/packages/super-editor/src/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/components/toolbar/super-toolbar.js @@ -37,8 +37,9 @@ export class SuperToolbar extends EventEmitter { this.config = { ...this.config, ...config }; this.toolbarItems = []; this.documentMode = 'editing'; + this.isDev = config.isDev || false; - this.#makeToolbarItems(this); + this.#makeToolbarItems(this, config.isDev); let el = null; if (this.config.element) { @@ -74,8 +75,8 @@ export class SuperToolbar extends EventEmitter { this.#updateToolbarState(); } - #makeToolbarItems(superToolbar) { - const defaultItems = makeDefaultItems(superToolbar); + #makeToolbarItems(superToolbar, isDev = false) { + const defaultItems = makeDefaultItems(superToolbar, isDev); this.toolbarItems = defaultItems; this.#updateToolbarState(); } diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index df1eeca8e5..d41c12a7c1 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -14,6 +14,7 @@ import { createStyleTag } from './utilities/createStyleTag.js'; import { initComments } from '@features/index.js'; import { style } from './config/style.js'; import DocxZipper from '@core/DocxZipper.js'; +import {amendTransaction} from "../extensions/track-changes/track-changes-tr-modifier.js"; /** * Editor main class. @@ -95,7 +96,7 @@ export class Editor extends EventEmitter { this.#createView(); this.#initDefaultStyles(); this.#injectCSS() - + this.on('create', this.options.onCreate); this.on('update', this.options.onUpdate); this.on('selectionUpdate', this.options.onSelectionUpdate); @@ -223,23 +224,24 @@ export class Editor extends EventEmitter { // Viewing mode: Not editable, no tracked changes, no comments if (this.documentMode === 'viewing') { this.unregisterPlugin('comments'); - // this.unregisterPlugin('TrackChangesBase'); + this.commands.toggleTrackChangesShowOriginal(); this.setEditable(false, false); } // Suggesting: Editable, tracked changes plugin enabled, comments else if (this.documentMode === 'suggesting') { this.#registerPluginByNameIfNotExists('comments') - // this.#registerPluginByNameIfNotExists('TrackChangesBase'); - // this.commands.enableTrackChanges(); + this.#registerPluginByNameIfNotExists('TrackChangesBase'); + this.commands.disableTrackChangesShowOriginal(); + this.commands.enableTrackChanges(); this.setEditable(true, false); } // Editing: Editable, tracked changes plguin disabled, comments else if (this.documentMode === 'editing') { - // this.#registerPluginByNameIfNotExists('TrackChangesBase'); + this.#registerPluginByNameIfNotExists('TrackChangesBase'); this.#registerPluginByNameIfNotExists('comments'); - // this.commands.disableTrackChanges(); + this.commands.disableTrackChanges(); this.setEditable(true, false); } } @@ -498,8 +500,17 @@ export class Editor extends EventEmitter { if (this.view.isDestroyed) { return; } - - const state = this.state.apply(transaction); + + let state; + try { + const trackedTr = amendTransaction(transaction, this.view, "AuthorUser") + const {state: newState} = this.view.state.applyTransaction(trackedTr) + state = newState + } catch (e) { + console.log(e) + //just in case + state = this.state.apply(transaction); + } const selectionHasChanged = !this.state.selection.eq(state.selection); this.view.updateState(state); diff --git a/packages/super-editor/src/core/config/style.js b/packages/super-editor/src/core/config/style.js index 7e45d1b2cb..c7f74d5636 100644 --- a/packages/super-editor/src/core/config/style.js +++ b/packages/super-editor/src/core/config/style.js @@ -68,6 +68,36 @@ li.ProseMirror-selectednode:after { pointer-events: none; } +.insertionMark { + &.inline { + &.hidden { + display: none; + } + &.normal { + } + &.highlighted { + border: 1px dashed #00853D; + background-color: #399C7222; + pointer-events: none; + } + } +} + +.deletionMark { + &.inline { + &.hidden { + display: none; + } + &.normal { + } + } + &.widget { + border: 1px dashed #CB0E47; + background-color: #CB0E4722; + pointer-events: none; + } +} + /* Protect against generic img rules */ img.ProseMirror-separator { display: inline !important; diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index c7c294c2b3..fa71646679 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -14,6 +14,7 @@ class SuperConverter { 'w:p': 'paragraph', 'w:r': 'run', 'w:t': 'text', + 'w:delText': 'deletedText', 'w:br': 'lineBreak', 'w:tbl': 'table', 'w:tr': 'tableRow', @@ -63,6 +64,7 @@ class SuperConverter { 'w:p', 'w:r', 'w:t', + 'w:delText', ]) constructor(params = null) { diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 4e0886932d..0398f5ed8d 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -2,6 +2,11 @@ import { SuperConverter } from './SuperConverter.js'; import { toKebabCase } from '@harbour-enterprises/common'; import { inchesToTwips } from './helpers.js'; import { generateRandomId } from '@helpers/docxIdGenerator.js'; +import { + TrackDeleteMarkName, + TrackInsertMarkName, + TrackMarksMarkName +} from "../../extensions/track-changes/constants.js"; export class DocxExporter { @@ -32,6 +37,30 @@ export class DocxExporter { }); } + /** + * + * @param marks + * @returns {undefined|{elements: *[], name: string, attributes: {}, type: string}} + */ + #createTrackStyleMark(marks) { + const trackStyleMark = marks.find(mark => mark.type === TrackMarksMarkName); + if (trackStyleMark) { + const markElement = { + type: 'element', + name: 'w:rPrChange', attributes: { + 'w:id': trackStyleMark.attrs.wid, + 'w:author': trackStyleMark.attrs.author, + 'w:date': trackStyleMark.attrs.date, + }, + elements: trackStyleMark.attrs.before + .map(mark => this.#mapOutputMarkToElement(mark)) + .filter(r => r !== undefined) + }; + return markElement; + } + return undefined; + } + #outputProcessNodes(nodes, parent = null) { const resultingElements = []; let index = 0; @@ -47,7 +76,10 @@ export class DocxExporter { resultingNode = this.#outputHandleDocumentNode(node); break; case 'text': - if (node.marks?.some((m) => m.type === 'link')) { + if(node.marks?.some((m) => m.type === TrackInsertMarkName || m.type === TrackDeleteMarkName)) { + resultingNode = this.#outputHandleTrackedNode(node); + skip = true; + } else if (node.marks?.some((m) => m.type === 'link')) { index++; // const linkNodes = this.#outputHandleLinkNode(node); // resultingElements.push(...linkNodes); @@ -98,7 +130,7 @@ export class DocxExporter { marks.forEach((mark) => { const markElement = this.#mapOutputMarkToElement(mark); - if (mark.type === 'textIndent') { + if (mark.type === 'textIndent' && markElement) { markElements.unshift(markElement); } else markElements.push(markElement); }); @@ -125,8 +157,14 @@ export class DocxExporter { return resultingElements; } + /** + * + * @param mark + * @returns {{type: string | *, name: string | *, attributes?: {}} | undefined} + */ #mapOutputMarkToElement(mark) { const xmlMark = SuperConverter.markTypes.find((m) => m.type === mark.type); + if(!xmlMark) return undefined; const markElement = { name: xmlMark.name, attributes: {} }; let value; @@ -352,6 +390,60 @@ export class DocxExporter { return newId; } + + #outputHandleTrackedNode(node) { + const text = node.text; + const marks = node.marks; + const trackedMark = marks.find((m) => m.type === TrackInsertMarkName || m.type === TrackDeleteMarkName); + const isInsert = trackedMark.type === TrackInsertMarkName; + + //TODO this should use #outputHandleTextNode in the long run + let runProperties = null; + if (marks) { + const elements = []; + + // Some marks have special handling - we process them here + elements.push(...this.#outputHandleMarks(marks)); + + const trackStyleMark = this.#createTrackStyleMark(marks) + if (trackStyleMark) { + elements.push(trackStyleMark); + } + + runProperties = { + name: 'w:rPr', + type: 'element', + elements + } + } + + const trackedNode = { + name: isInsert ? 'w:ins' : 'w:del', + type: 'element', + attributes: { + 'w:id': trackedMark.attrs.wid, + 'w:author': trackedMark.attrs.author, + 'w:date': trackedMark.attrs.date, + }, + elements: [ + { + name: 'w:r', + type: 'element', + elements: [ + runProperties, + { + name: isInsert ? 'w:t' : 'w:delText', + type: 'element', + attributes: { 'xml:space': 'preserve' }, + elements: [{ text, type: 'text' }] + } + ] + } + ] + } + return trackedNode; + } + #outputHandleLinkNode(node) { const linkMark = node.marks.find((m) => m.type === 'link'); const link = linkMark.attrs.href; @@ -384,14 +476,18 @@ export class DocxExporter { attrs: mark.attrs, } const markElement = this.#mapOutputMarkToElement(unwrappedMark); - elements.push(markElement); + if(markElement){ + elements.push(markElement); + } }); } // All other marks else { const markElement = this.#mapOutputMarkToElement(mark); - elements.push(markElement); + if(markElement) { + elements.push(markElement); + } } }); return elements; @@ -411,6 +507,11 @@ export class DocxExporter { // Some marks have special handling - we process them here elements.push(...this.#outputHandleMarks(marks)); + const trackStyleMark = this.#createTrackStyleMark(marks) + if (trackStyleMark) { + elements.push(trackStyleMark); + } + runProperties = { name: 'w:rPr', type: 'element', @@ -489,7 +590,7 @@ export class DocxExporter { if (name === 'w:instrText') { tags.push(elements[0].text); - } else if (name === 'w:t') { + } else if (name === 'w:t' || name === 'w:delText') { const text = this.#replaceSpecialCharacters(elements[0].text); tags.push(text); } else { diff --git a/packages/super-editor/src/core/super-converter/importer.js b/packages/super-editor/src/core/super-converter/importer.js index f226a78730..83c62f616f 100644 --- a/packages/super-editor/src/core/super-converter/importer.js +++ b/packages/super-editor/src/core/super-converter/importer.js @@ -1,12 +1,16 @@ import { SuperConverter } from './SuperConverter'; import { twipsToPixels, twipsToInches, halfPointToPixels, emuToPixels } from './helpers.js'; -import { toKebabCase } from '@harbour-enterprises/common'; +import { + TrackDeleteMarkName, + TrackInsertMarkName, + TrackMarksMarkName +} from "../../extensions/track-changes/constants.js"; /** * The DocxImporter class is responsible for converting a JSON representation of a DOCX file. * It depends on SuperConverter and its xml converted data to build the schema. - * + * * Calling getSchema() will return a ProseMirror schema object. */ export class DocxImporter { @@ -36,11 +40,12 @@ export class DocxImporter { /** * Process a list of JSON elements and convert them to ProseMirror nodes. - * - * @param {list} elements - * @returns + * + * @param {list} elements + * @param {boolean} insideTrackChange - If we are inside a track change node. + * @returns */ - #convertToSchema(elements) { + #convertToSchema(elements, insideTrackChange = false) { // console.debug('\nConvert to schema:', elements,'\n') if (!elements || !elements.length) return; @@ -49,13 +54,13 @@ export class DocxImporter { const node = elements[index]; if (node.seen) continue; - // We will build a prose mirror ready schema node from XML node + // We will build a prose mirror ready schema node from XML node let schemaNode; switch (node.name) { case 'w:body': return this.#handleBodyNode(node); case 'w:r': - processedElements.push(...this.#handleRunNode(node)); + processedElements.push(...this.#handleRunNode(node, insideTrackChange)); continue; case 'w:p': schemaNode = this.#handleParagraphNode(node, elements, index); @@ -94,18 +99,31 @@ export class DocxImporter { case 'w:br': schemaNode = this.#handleLineBreakNode(node); break; + case 'w:del': + case 'w:ins': + schemaNode = this.#handleTrackChangeNode(node); + break; + case 'w:delText': + schemaNode = this.#handleDelText(node, insideTrackChange); + break; default: schemaNode = this.#handleStandardNode(node); } - if (schemaNode?.type) { - const ignore = ['runProperties']; - if (!ignore.includes(schemaNode.type)) processedElements.push(schemaNode); + if(!Array.isArray(schemaNode)) { + schemaNode = [schemaNode] + } + + for(let node of schemaNode) { + if (node?.type) { + const ignore = ['runProperties']; + if (!ignore.includes(node.type)) processedElements.push(node); + } } } return processedElements; } - + #handleLineBreakNode(node) { return { type: 'lineBreak', @@ -122,7 +140,7 @@ export class DocxImporter { #handleDrawingNode(node) { let result; const { elements } = node; - + // Inline images const inlineImage = elements.find((el) => el.name === 'wp:inline'); if (inlineImage) result = this.#handleInlineImageNode(inlineImage); @@ -149,7 +167,7 @@ export class DocxImporter { const graphic = node.elements.find((el) => el.name === 'a:graphic'); const graphicData = graphic.elements.find((el) => el.name === 'a:graphicData'); - + const picture = graphicData.elements.find((el) => el.name === 'pic:pic'); const blipFill = picture.elements.find((el) => el.name === 'pic:blipFill'); const blip = blipFill.elements.find((el) => el.name === 'a:blip'); @@ -167,7 +185,7 @@ export class DocxImporter { const path = `word/${relAttributes['Target']}`; return { - type: 'image', + type: 'image', attrs: { src: media[path], alt: 'Image', @@ -198,7 +216,7 @@ export class DocxImporter { const marginTag = tcPr?.elements?.find((el) => el.name === 'w:tcMar'); const marginLeft = marginTag?.elements?.find((el) => el.name === 'w:left'); const marginRight = marginTag?.elements?.find((el) => el.name === 'w:right'); - const marginTop = marginTag?.elements?.find((el) => el.name === 'w:top'); + const marginTop = marginTag?.elements?.find((el) => el.name === 'w:top'); const marginBottom = marginTag?.elements?.find((el) => el.name === 'w:bottom'); const verticalAlignTag = tcPr?.elements?.find((el) => el.name === 'w:vAlign'); @@ -239,7 +257,7 @@ export class DocxImporter { const tableBorders = tblPr?.elements.find((el) => el.name === 'w:tblBorders'); const { elements: borderElements = [] } = tableBorders || {}; const { borders, rowBorders } = this.#processTableBorders(borderElements); - + return { name, basedOn, @@ -348,7 +366,7 @@ export class DocxImporter { const rel = elements.find((el) => el.attributes['Id'] === rId) || {}; const { attributes: relAttributes = {} } = rel; let href = relAttributes['Target']; - + if (anchor && !href) href = `#${anchor}`; // Add marks to the run node and process it @@ -376,6 +394,11 @@ export class DocxImporter { return updatedNode } + /** + * + * @param {{type: string, attrs: {}}[]} marks + * @returns {{type: string, attrs: {}}[]} + */ #createImportMarks(marks) { const textStyleMarksToCombine = marks.filter((mark) => mark.type === 'textStyle'); const remainingMarks = marks.filter((mark) => mark.type !== 'textStyle'); @@ -386,12 +409,12 @@ export class DocxImporter { textStyleMarksToCombine.forEach((mark) => { const { attrs } = mark; - Object.keys(attrs).forEach((attr) => { + Object.keys(attrs).forEach((attr) => { combinedTextAttrs[attr] = attrs[attr]; }); }); }; - + const result = [...remainingMarks, { type: 'textStyle', attrs: combinedTextAttrs }]; return result; } @@ -429,8 +452,8 @@ export class DocxImporter { return this.#convertToSchema(content); } - #handleRunNode(node) { - let processedRun = this.#convertToSchema(node.elements)?.filter(n => n) || []; + #handleRunNode(node, insideTrackChange = false) { + let processedRun = this.#convertToSchema(node.elements, insideTrackChange)?.filter(n => n) || []; const hasRunProperties = node.elements.some(el => el.name === 'w:rPr'); if (hasRunProperties) { const { marks = [], attributes = {} } = this.#parseProperties(node); @@ -442,7 +465,7 @@ export class DocxImporter { /** * Special cases of w:p based on paragraph properties - * + * * If we detect a list node, we need to get all nodes that are also lists and process them together * in order to combine list item nodes into list nodes. */ @@ -454,7 +477,7 @@ export class DocxImporter { node.elements = processedElements; // Check if this paragraph node is a list - if (this.#testForList(node)) { + if (this.#testForList(node)) { // Get all siblings that are list items and haven't been processed yet. const siblings = [...elements.slice(index)]; const listItems = []; @@ -473,7 +496,7 @@ export class DocxImporter { // TODO - Check that this change is OK return this.#handleListNodes(listItems, 0, node); - } + } // If it is a standard paragraph node, process normally schemaNode = this.#handleStandardNode(node); @@ -488,12 +511,45 @@ export class DocxImporter { return schemaNode; } + #handleTrackChangeNode(node) { + const { name } = node; + const { attributes, elements } = this.#parseProperties(node); + const schemaNode = this.#handleStandardNode(node); + + const subs = this.#convertToSchema(elements, true) + const changeType = name === 'w:del' ? TrackDeleteMarkName : TrackInsertMarkName; + const mappedAttributes = { + wid: attributes['w:id'], + date: attributes['w:date'], + author: attributes['w:author'], + } + + subs.forEach(subElement => { + return subElement.marks.push({ type: changeType, attrs: mappedAttributes }); + }); + + console.log("HERE!!!!", name, schemaNode, attributes, subs) + + return subs; + } + + #handleDelText(node, insideTrackChange) { + console.log(node, insideTrackChange) + if(!insideTrackChange) return undefined; + + const nodeAsTextNode = this.#handleTextNode(node) + + nodeAsTextNode.type = 'text'; + console.log("2", nodeAsTextNode); + return nodeAsTextNode; + } + /** * We need to pre-process nodes in a paragraph to combine nodes together where necessary ie: links * TODO: Likely will find more w:fldChar to deal with. - * - * @param {*} nodes - * @returns + * + * @param {*} nodes + * @returns */ #preProcessNodesForFldChar(nodes) { const processedNodes = []; @@ -578,9 +634,9 @@ export class DocxImporter { /** * List processing - * + * * This recursive function takes a list of known list items and combines them into nested lists. - * + * * It begins with listLevel = 0, and if we find an indented node, we call this function again and increase the level. * with the same set of list items (as we do not know the node levels until we process them). * @@ -597,7 +653,7 @@ export class DocxImporter { // Skip items we've already processed if (item.seen) continue; - // Sometimes there are paragraph nodes that only have pPr element and no text node - these are + // Sometimes there are paragraph nodes that only have pPr element and no text node - these are // Spacers in the XML and need to be appended to the last item. if (item.elements && !this.#hasTextNode(item.elements)) { const n = this.#handleStandardNode(item, listItems, index); @@ -642,7 +698,7 @@ export class DocxImporter { parentAttributes: item?.attributes || null, } parsedListItems.push(this.#createListItem(schemaElements, nodeAttributes, [])); - } + } // If this item belongs in a deeper list level, we need to process it by calling this function again // But going one level deeper. @@ -702,10 +758,10 @@ export class DocxImporter { else if (!elements.length && 'attributes' in node && node.attributes['xml:space'] === 'preserve') { text = ' '; } - + // Ignore others - can catch other special cases here if necessary else return null; - + return { type: this.#getElementName(node), text: text, @@ -715,6 +771,11 @@ export class DocxImporter { } + /** + * + * @param property + * @returns {{type: string, attrs: {}}[]} + */ #parseMarks(property) { const marks = []; const seen = new Set(); @@ -768,6 +829,28 @@ export class DocxImporter { return this.#createImportMarks(marks); } + /** + * + * @param rPr + * @param {{type: string, attrs: {}}[]} currentMarks + * @returns {{type: string, attrs: {}}[]} a trackMarksMark, or an empty array + */ + #handleStyleChangeMarks(rPr, currentMarks) { + const styleChangeMark = rPr.elements?.find((el) => el.name === 'w:rPrChange') + if(!styleChangeMark) { + return [] + } + + const { attributes } = styleChangeMark; + const mappedAttributes = { + wid: attributes['w:id'], + date: attributes['w:date'], + author: attributes['w:author'], + } + const submarks = this.#parseMarks(styleChangeMark); + return [{type: TrackMarksMarkName, attrs: {...mappedAttributes, before: submarks, after: [...currentMarks]}}] + } + #getIndentValue(attributes) { let value = attributes['w:left']; if (!value) value = attributes['w:firstLine']; @@ -852,6 +935,8 @@ export class DocxImporter { if (paragraphProperties && paragraphProperties.elements?.length) { marks.push(...this.#parseMarks(paragraphProperties)); } + //add style change marks + marks.push(...this.#handleStyleChangeMarks(runProperties, marks)); // Maintain any extra properties if (paragraphProperties && paragraphProperties.elements?.length) { diff --git a/packages/super-editor/src/core/super-converter/numbering.js b/packages/super-editor/src/core/super-converter/numbering.js index 1bd688ffaa..71606d8d11 100644 --- a/packages/super-editor/src/core/super-converter/numbering.js +++ b/packages/super-editor/src/core/super-converter/numbering.js @@ -110,7 +110,7 @@ function _processListParagraphProperties(data) { function _processListRunProperties(data) { const { elements } = data; - const expectedTypes = ['w:rFonts', 'w:b', 'w:bCs', 'w:i', 'w:iCs', 'w:strike', 'w:dstrike', 'w:color', 'w:sz', 'w:szCs', 'w:u', 'w:bdr', 'w:shd', 'w:vertAlign', 'w:jc', 'w:spacing', 'w:w', ]; + const expectedTypes = ['w:rFonts', 'w:b', 'w:bCs', 'w:i', 'w:iCs', 'w:strike', 'w:dstrike', 'w:color', 'w:sz', 'w:szCs', 'w:u', 'w:bdr', 'w:shd', 'w:vertAlign', 'w:jc', 'w:spacing', 'w:w', 'w:smallCaps']; const runProperties = {}; if (!elements) return runProperties; diff --git a/packages/super-editor/src/dev/components/DeveloperPlayground.vue b/packages/super-editor/src/dev/components/DeveloperPlayground.vue index a71a0ee607..fddbe083d0 100644 --- a/packages/super-editor/src/dev/components/DeveloperPlayground.vue +++ b/packages/super-editor/src/dev/components/DeveloperPlayground.vue @@ -58,7 +58,7 @@ const editorOptions = { const exportDocx = async () => { const result = await activeEditor?.exportDocx(); - const blob = new Blob([result], { type: DOC_TYPE }); + const blob = new Blob([result], { type: DOCX }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -192,7 +192,7 @@ const attachAnnotationEventHandlers = () => { /* Inputs pane and field annotations */ const initToolbar = () => { - return new SuperToolbar({ element: 'toolbar', editor: activeEditor }); + return new SuperToolbar({ element: 'toolbar', editor: activeEditor, isDev: true }); } onMounted(async () => { diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index d87c3e5696..b6af391c30 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -37,12 +37,16 @@ import { Italic } from './italic/index.js'; import { Underline } from './underline/index.js'; import { Strike } from './strike/index.js'; import { Link } from './link/index.js'; +import {TrackDelete} from "./track-changes/track-delete.js"; +import {TrackInsert} from "./track-changes/track-insert.js"; // Plugins import { DecorationClick } from './decoration-click/index.js'; import { CommentsPlugin } from './comment/index.js'; import { Placeholder } from './placeholder/index.js'; import { PopoverPlugin } from './popover-plugin/index.js'; +import {TrackChangesBase} from "./track-changes/track-changes-base.js"; +import {TrackMarks} from "./track-changes/track-marks.js"; const getRichTextExtensions = () => [ Bold, @@ -50,7 +54,7 @@ const getRichTextExtensions = () => [ Document, History, Italic, - Link,, + Link, Paragraph, Strike, Text, @@ -62,46 +66,52 @@ const getRichTextExtensions = () => [ Mention, ]; -const getStarterExtensions = () => [ - Bold, - BulletList, - Color, - CommentRangeStart, - CommentRangeEnd, - CommentReference, - Document, - FontFamily, - FontSize, - History, - Heading, - Italic, - ListItem, - LineHeight, - Link, - OrderedList, - Paragraph, - LineBreak, - RunItem, - Strike, - TabNode, - Text, - TextAlign, - TextIndent, - TextStyle, - Underline, - FormatCommands, - DecorationClick, - CommentsPlugin, - Table, - TableRow, - TableCell, - TableHeader, - FieldAnnotation, - DropCursor, - Image, - BookmarkStart, - Mention, -]; +const getStarterExtensions = () => { + return [ + Bold, + BulletList, + Color, + CommentRangeStart, + CommentRangeEnd, + CommentReference, + Document, + FontFamily, + FontSize, + History, + Heading, + Italic, + ListItem, + LineHeight, + Link, + OrderedList, + Paragraph, + LineBreak, + RunItem, + Strike, + TabNode, + Text, + TextAlign, + TextIndent, + TextStyle, + Underline, + FormatCommands, + DecorationClick, + CommentsPlugin, + Table, + TableRow, + TableCell, + TableHeader, + FieldAnnotation, + DropCursor, + Image, + BookmarkStart, + Mention, + TrackChangesBase, + TrackDelete, + TrackInsert, + TrackMarks, + ]; +}; export { History, diff --git a/packages/super-editor/src/extensions/track-changes/constants.js b/packages/super-editor/src/extensions/track-changes/constants.js new file mode 100644 index 0000000000..d24e7fa9fc --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/constants.js @@ -0,0 +1,4 @@ +export const TrackDeleteMarkName = 'trackDelete'; +export const TrackInsertMarkName = 'trackInsert'; +export const TrackMarksMarkName = 'trackMarks'; + diff --git a/packages/super-editor/src/extensions/track-changes/index.js b/packages/super-editor/src/extensions/track-changes/index.js new file mode 100644 index 0000000000..587cb801b5 --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/index.js @@ -0,0 +1,3 @@ + +export * from './track-delete.js'; +export * from './track-insert.js'; diff --git a/packages/super-editor/src/extensions/track-changes/track-changes-base.js b/packages/super-editor/src/extensions/track-changes/track-changes-base.js new file mode 100644 index 0000000000..92323f37e7 --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/track-changes-base.js @@ -0,0 +1,410 @@ +import {Extension} from '@core/Extension.js'; +import {Plugin, PluginKey, EditorState, Transaction} from "prosemirror-state"; +import {Decoration, DecorationSet} from "prosemirror-view"; +import {Mark, Node} from "prosemirror-model"; +import {TrackDeleteMarkName, TrackInsertMarkName, TrackMarksMarkName} from "./constants.js"; + + +const trackChangesCallback = (action, original, modified, modifiers) => { + const rearrangedModifiers = new Map(); + modifiers.forEach(modifier => { + if(!rearrangedModifiers.has(modifier.author)) { + rearrangedModifiers.set(modifier.author, []); + } + const dates = rearrangedModifiers.get(modifier.author) + if(dates.indexOf(modifier.date) === -1) { + dates.push(modifier.date); + } + }); + if(action === "accept") { + console.log("We accepted a change, the original text was", original, "the modified text was", modified, rearrangedModifiers); + } else { + console.log("We reverted a change, the original text was", original, "the modified text was", modified, rearrangedModifiers); + } +} + +export const TrackChangesBasePluginKey = new PluginKey("TrackChangesBase"); + +export const TrackChangesBase = Extension.create({ + addCommands() { + return { + toggleTrackChanges: () => ({state, dispatch}) => { + if (dispatch) { + const trackChangeState = TrackChangesBasePluginKey.getState(state); + if(trackChangeState === undefined) return false; + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "TRACK_CHANGES_ENABLE", value: !trackChangeState.isTrackChangesActive})); + } + return true; + }, + enableTrackChanges: () => ({state, dispatch}) => { + if (dispatch) { + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "TRACK_CHANGES_ENABLE", value: true})); + } + return true; + }, + disableTrackChanges: () => ({state, dispatch}) => { + if (dispatch) { + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "TRACK_CHANGES_ENABLE", value: false})); + } + return true; + }, + toggleTrackChangesShowOriginal: () => ({state, dispatch}) => { + if (dispatch) { + const trackChangeState = TrackChangesBasePluginKey.getState(state); + if(trackChangeState === undefined) return false; + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "SHOW_ONLY_ORIGINAL", value: !trackChangeState.onlyOriginalShown})); + } + return true; + }, + enableTrackChangesShowOriginal: () => ({state, dispatch}) => { + if (dispatch) { + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "SHOW_ONLY_ORIGINAL", value: true})); + } + return true; + }, + disableTrackChangesShowOriginal: () => ({state, dispatch}) => { + if (dispatch) { + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "SHOW_ONLY_ORIGINAL", value: false})); + } + return true; + }, + toggleTrackChangesShowFinal: () => ({state, dispatch}) => { + if (dispatch) { + const trackChangeState = TrackChangesBasePluginKey.getState(state); + if(trackChangeState === undefined) return false; + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "SHOW_ONLY_MODIFIED", value: !trackChangeState.onlyModifiedShown})); + } + return true; + }, + enableTrackChangesShowFinal: () => ({state, dispatch}) => { + if (dispatch) { + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "SHOW_ONLY_MODIFIED", value: true})); + } + return true; + }, + insertTestNodes: () => ({state, dispatch}) => { + if (dispatch) { + const tr = state.tr; + const node = state.schema.text("test"); + tr.insert(0, node); + + const node2 = state.schema.text("mytest2"); + tr.insert(5, node2); + tr.addMark(5, 13, state.schema.marks.trackInsert.create({})); + tr.addMark(0, 5, state.schema.marks.trackDelete.create({})); + dispatch(tr); + } + return true; + }, + applyChangeBetweenConcretePositions: (from, to, action) => ({state, dispatch}) => { + if (dispatch && (action === "accept" || action === "revert")) { + let acceptedTr; + let revertedTr; + if(action === "accept") { + acceptedTr = state.tr + revertedTr = EditorState.create({ doc: state.doc }).tr + } else if (action === "revert") { + acceptedTr = EditorState.create({ doc: state.doc }).tr + revertedTr = state.tr + } + const acceptedChanges = applyTrackChanges( "accept", state, acceptedTr, from, to); + const revertedChanges = applyTrackChanges("revert", state, revertedTr, from, to); + + trackChangesCallback( + action, + revertedTr.doc.textBetween(from, revertedChanges.offset + to, " "), + acceptedTr.doc.textBetween(from, acceptedChanges.offset + to, " "), + acceptedChanges.modifiers + ); + + if(action === "accept") { + dispatch(acceptedTr); + } else if (action === "revert") { + dispatch(revertedTr); + } + } + return true; + }, + applyChangesBetweenPositions: (from, to, action) => ({state, chain}) => { + let correctedFrom = from; + let correctedTo = to; + const textNodesFrom = findTextNodes(state, from); + const textNodesTo = findTextNodes(state, to); + if(!textNodesFrom || !textNodesTo) { + return chain(); + } + const {prevTextNode} = textNodesFrom; + const {nextTextNode, currentTextNode} = textNodesTo; + + if (prevTextNode) { + prevTextNode.node.marks.forEach(mark => { + if (mark.type.name === TrackDeleteMarkName || mark.type.name === TrackInsertMarkName || mark.type.name === TrackMarksMarkName) { + correctedFrom = Math.max(prevTextNode.offset, 0); + } + }); + } + + if (nextTextNode) { + nextTextNode.node.marks.forEach(mark => { + if (mark.type.name === TrackDeleteMarkName || mark.type.name === TrackInsertMarkName || mark.type.name === TrackMarksMarkName) { + correctedTo = Math.min(nextTextNode.offset + nextTextNode.node.nodeSize, state.doc.nodeSize); + } + }); + } else if (currentTextNode) { + currentTextNode.node.marks.forEach(mark => { + if (mark.type.name === TrackDeleteMarkName || mark.type.name === TrackInsertMarkName || mark.type.name === TrackMarksMarkName) { + correctedTo = Math.min(currentTextNode.offset + currentTextNode.node.nodeSize, state.doc.nodeSize); + } + }); + } + + if(from!== correctedFrom || to !== correctedTo) { + return chain().applyChangesBetweenPositions(correctedFrom, correctedTo, action); + } else { + return chain().applyChangeBetweenConcretePositions(correctedFrom, correctedTo, action); + } + }, + acceptChangesOnCursorPositions: () => ({state, chain}) => { + const {from, to} = state.selection; + return chain().applyChangesBetweenPositions(from, to, "accept"); + }, + revertChangesOnCursorPositions: () => ({state, chain}) => { + const {from, to} = state.selection; + return chain().applyChangesBetweenPositions(from, to, "revert"); + } + }; + }, + addPmPlugins() { + return [new Plugin( + { + key: TrackChangesBasePluginKey, + state: { + init() { + return { + isTrackChangesActive: false, + onlyOriginalShown: false, + onlyModifiedShown: false, + decorations: DecorationSet.empty, + }; + }, + apply(tr, oldState, prevEditorState, newEditorState) { + const meta = tr.getMeta(TrackChangesBasePluginKey); + if (!meta) { + return { + ...oldState, + decorations: recalcDecorations(newEditorState, oldState.onlyOriginalShown, oldState.onlyModifiedShown) + }; + } + + if(meta.type === "TRACK_CHANGES_ENABLE") { + return { + ...oldState, + isTrackChangesActive: meta.value === true, + decorations: recalcDecorations(newEditorState, oldState.onlyOriginalShown, oldState.onlyModifiedShown) + }; + } + + if(meta.type === "SHOW_ONLY_ORIGINAL") { + return { + ...oldState, + onlyOriginalShown: meta.value === true, + onlyModifiedShown: false, + decorations: recalcDecorations(newEditorState, meta.value === true, false) + }; + } + + if(meta.type === "SHOW_ONLY_MODIFIED") { + return { + ...oldState, + onlyOriginalShown: false, + onlyModifiedShown: meta.value === true, + decorations: recalcDecorations(newEditorState, false, meta.value === true) + }; + } + + return { + ...oldState, + decorations: recalcDecorations(newEditorState, oldState.onlyOriginalShown, oldState.onlyModifiedShown) + }; + }, + }, + props: { + decorations(t) { + return this.getState(t)?.decorations; + }, + }, + }), + ] + } +}); + + +/** + * + * @param {"accept" | "revert"} action + * @param {EditorState} state + * @param {Transaction} tr + * @param {number} from + * @param {number} to + * @returns {{offset: number, modifiers: *[]}} + */ +const applyTrackChanges = (action, state, tr, from, to) => { + let offset = 0; + const modifiers = []; + state.doc.nodesBetween(from, to, (node, pos) => { + node.marks.forEach(mark => { + if (mark.type.name === TrackDeleteMarkName) { + if (action === "accept") { + tr.deleteRange(pos + offset, pos + node.nodeSize + offset); + offset -= node.nodeSize; + modifiers.push({author: mark.attrs.author, date: mark.attrs.date}); + } else if (action === "revert") { + tr.removeMark(pos + offset, pos + node.nodeSize + offset, mark); + modifiers.push({author: mark.attrs.author, date: mark.attrs.date}); + } + } + if (mark.type.name === TrackInsertMarkName) { + if (action === "accept") { + tr.removeMark(pos + offset, pos + node.nodeSize + offset, mark); + modifiers.push({author: mark.attrs.author, date: mark.attrs.date}); + } else if (action === "revert") { + tr.deleteRange(pos + offset, pos + node.nodeSize + offset); + offset -= node.nodeSize; + modifiers.push({author: mark.attrs.author, date: mark.attrs.date}); + } + } + if (mark.type.name === TrackMarksMarkName) { + if (action === "accept") { + tr.removeMark(pos + offset, pos + node.nodeSize + offset, mark); + modifiers.push({author: mark.attrs.author, date: mark.attrs.date}); + } else if (action === "revert") { + const styleChangeMark = mark + tr.removeMark(pos + offset, pos + node.nodeSize + offset, styleChangeMark); + for(const mark of styleChangeMark.attrs.after) { + tr.removeMark(pos + offset, pos + node.nodeSize + offset, state.schema.marks[mark.type].create(mark.attrs)); + } + for(const mark of styleChangeMark.attrs.before) { + tr.addMark(pos + offset, pos + node.nodeSize + offset, state.schema.marks[mark.type].create(mark.attrs)); + } + modifiers.push({author: mark.attrs.author, date: mark.attrs.date}); + } + } + }); + }); + return {modifiers, offset}; +}; + +/** + * Recalculates decorations for the current state + * @param state + * @param {boolean} onlyOriginalShown + * @param {boolean} onlyModifiedShown + * @returns {DecorationSet} + */ +const recalcDecorations = (state, onlyOriginalShown,onlyModifiedShown ) => { + if(!state.doc || !state.doc.nodeSize || (onlyModifiedShown && onlyOriginalShown)) { + return DecorationSet.empty; + } + + const decorations = []; + state.doc.nodesBetween(0, state.doc.nodeSize-2, (node, pos) => { + node.marks.forEach(mark => { + if(mark.type.name === TrackInsertMarkName) { + if(onlyOriginalShown) { + const decoration = Decoration.inline(pos, pos + node.nodeSize, { + class: "insertionMark inline hidden", + }); + decorations.push(decoration); + } else if(onlyModifiedShown) { + const decoration = Decoration.inline(pos, pos + node.nodeSize, { + class: "insertionMark inline normal", + }); + decorations.push(decoration); + } else { + const decoration = Decoration.inline(pos, pos + node.nodeSize, { + class: "insertionMark inline highlighted", + }); + decorations.push(decoration); + } + } + if(mark.type.name === TrackDeleteMarkName) { + if(onlyOriginalShown) { + const decoration = Decoration.inline(pos, pos + node.nodeSize, { + class: "deletionMark inline normal", + }); + decorations.push(decoration); + } else if(onlyModifiedShown) { + const decoration = Decoration.inline(pos, pos + node.nodeSize, { + class: "deletionMark inline hidden", + }); + decorations.push(decoration); + } else { + const decorationInline = Decoration.inline(pos, pos + node.nodeSize, { + class: "deletionMark inline hidden", + }); + const decorationWidget = Decoration.widget(pos, () => { + const span = document.createElement("span"); + span.classList.add("deletionMark"); + span.classList.add("widget"); + span.innerHTML = node.textContent; + span.contentEditable = false; + return span; + }, {ignoreSelection: true}); + decorations.push(decorationInline); + decorations.push(decorationWidget); + } + } + if(mark.type.name === TrackMarksMarkName) { + if(onlyOriginalShown) { + // for this we should render the before array as marks + //TODO I don't have a solid idea for this yet + const decoration = Decoration.inline(pos, pos + node.nodeSize, { + class: "trackMarks inline before", + }); + decorations.push(decoration); + } else if(onlyModifiedShown) { + // for this we should render do nothing, we already have the applied marks on the text + const decoration = Decoration.inline(pos, pos + node.nodeSize, { + class: "trackMarks inline normal", + }); + decorations.push(decoration); + } else { + const decoration = Decoration.inline(pos, pos + node.nodeSize, { + class: "trackMarks inline highlighted", + }); + decorations.push(decoration); + } + } + }); + }); + + return DecorationSet.create(state.doc, decorations); +} + +const findTextNodes = (state, position ) => { + const pos = state.doc.resolve(position) + if(!pos) { + return undefined; + } + const parentPos = pos.start(pos.depth); + let currentTextNode; + let prevTextNode; + let nextTextNode + pos.node().content.forEach((node, offset) => { + const globalPos = offset + parentPos; + if(globalPos <= position) { + prevTextNode = currentTextNode; + currentTextNode = { + node, + offset: globalPos + } + } else if (!nextTextNode) { + nextTextNode = { + node, + offset: globalPos + } + } + }); + + return {prevTextNode, currentTextNode, nextTextNode}; +} \ No newline at end of file diff --git a/packages/super-editor/src/extensions/track-changes/track-changes-tr-modifier.js b/packages/super-editor/src/extensions/track-changes/track-changes-tr-modifier.js new file mode 100644 index 0000000000..bc252b1096 --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/track-changes-tr-modifier.js @@ -0,0 +1,375 @@ +import {TextSelection, Selection, Transaction, EditorState} from "prosemirror-state"; +import {Mapping, ReplaceStep, AddMarkStep, RemoveMarkStep} from "prosemirror-transform"; +import {EditorView} from "prosemirror-view"; +import {Slice, Fragment, Mark, Node} from "prosemirror-model"; +import {TrackInsertMarkName, TrackDeleteMarkName, TrackMarksMarkName} from "./constants.js"; +import {TrackChangesBasePluginKey} from "./track-changes-base.js"; +/** + * Amend transaction to track changes + * @param {Transaction} tr + * @param {EditorView} view + * @param {string} user + * @returns {Transaction} a modified transaction + */ +export const amendTransaction = (tr, view, user) => { + + //we keep history changes as-is above everything else, also reserve simple meta changes without steps + if ( + !tr.steps.length || + ["historyUndo", "historyRedo"].includes(tr.getMeta("inputType")) + ) { + return tr + } + + const trackChangeState = TrackChangesBasePluginKey.getState(view.state); + const isTrackChangesActive = trackChangeState?.isTrackChangesActive ?? false; + + if (!isTrackChangesActive) { + //we don't want to track changes if the plugin is not active + return removeTrackChangesFromTransaction(tr, view.state); + } else { + return trackTransaction( + tr, + view.state, + user, + ); + } +} + +const whitelistedMetaKeys = ["inputType", "uiEvent"] + +/** + * Amend transaction to track changes + * @param {Transaction} tr old transaction + * @param {Transaction} newTr the transaction we construct instead of the old one + * @param {Mapping | null | undefined} map + * @param {EditorState} state + * @returns {void} newTr is modified in place + */ +const keepTransactionNavigationParts = (tr, newTr, map, state) => { + // we copy all the meta keys that are whitelisted + whitelistedMetaKeys.forEach(key => { + if (tr.getMeta(key)) { + newTr.setMeta(key, tr.getMeta(key)) + } + }) + + if (tr.selectionSet && map) { + if (tr.selection instanceof TextSelection && ( + tr.selection.from < state.selection.from || tr.getMeta("inputType") === "deleteContentBackward" + )) { + const caretPos = map.map(tr.selection.from, -1) + newTr.setSelection( + new TextSelection( + newTr.doc.resolve( + caretPos + ) + ) + ) + } else { + newTr.setSelection(tr.selection.map(newTr.doc, map)) + } + } + if (tr.storedMarksSet) { + newTr.setStoredMarks(tr.storedMarks) + } + if (tr.scrolledIntoView) { + newTr.scrollIntoView() + } +} + +/** + * Remove marks from slice + * @param {Slice} slice + * @param {Schema} schema + * @param {string} markName + * @returns {Slice} a slice without the named marks + */ +const removeMarksFromSlice = (slice, schema, markName) => { + const targetMark = schema.marks[markName]; + const newContent = []; + + slice.content.forEach(node => { + const newMarks = node.marks.filter(mark => mark.type !== targetMark); + let newNode; + if (node.isText) { + newNode = schema.text(node.text, newMarks); + } else { + newNode = node.type.create(node.attrs, node.content, newMarks); + } + newContent.push(newNode); + }); + + return new Slice(Fragment.fromArray(newContent), slice.openStart, slice.openEnd); +}; + +/** + * Remove track changes from transaction + * @param {Transaction} tr + * @param {EditorState} state + * @returns {Transaction} a new transaction without track changes + */ +const removeTrackChangesFromTransaction = (tr, state) => { + const newTr = state.tr; + + tr.steps.forEach((step) => { + if (!step) { + return + } + if (step instanceof ReplaceStep && step.slice.size) { + const sliceWithoutDeleteMarks = removeMarksFromSlice(step.slice, state.schema, TrackDeleteMarkName); + const sliceWithoutInsertMarks = removeMarksFromSlice(sliceWithoutDeleteMarks, state.schema, TrackInsertMarkName); + const newStep = new ReplaceStep( + step.from, + step.to, + sliceWithoutInsertMarks, + step.structure + ) + newTr.step(newStep) + } else { + newTr.step(step) + } + }); + keepTransactionNavigationParts(tr, newTr, null, state); //we don't want to map the selection here + //we copy all meta just in case + Object.keys(tr.meta).forEach(key => { + newTr.setMeta(key, tr.getMeta(key)) + }) + return newTr; +} + +/** + * Mark insertion + * @param {Transaction} tr + * @param {number} from + * @param {number} to + * @param {string} user + * @param {string} date + * @returns {void} tr is modified in place + */ +const markInsertion = (tr, from, to, user, date) => { + const insertionMark = tr.doc.type.schema.marks[TrackInsertMarkName].create({author: user, date}) + tr.doc.nodesBetween( + from, + to, + (node, pos) => { + if (node.isInline) { + tr.removeMark(Math.max(from, pos), Math.min(pos + node.nodeSize, to), tr.doc.type.schema.marks[TrackDeleteMarkName]) + tr.removeMark(Math.max(from, pos), Math.min(pos + node.nodeSize, to), tr.doc.type.schema.marks[TrackInsertMarkName]) + tr.addMark(Math.max(from, pos), Math.min(pos + node.nodeSize, to), insertionMark) + return false + } /*else if (pos < from || ["bullet_list", "ordered_list"].includes(node.type.name)) { + return true + } else if (["table_row", "table_cell"].includes(node.type.name)) { + return false + }*/ + } + ) +} + +/** + * Mark deletion + * @param {Transaction} tr + * @param {number} from + * @param {number} to + * @param {string} user + * @param {string} date + * @returns {void} tr is modified in place + */ +const markDeletion = (tr, from, to, user, date) => { + const deletionMark = tr.doc.type.schema.marks[TrackDeleteMarkName].create({author: user, date}) + let firstTableCellChild = false + let listItem = false + const deletionMap = new Mapping() + // Add deletion mark to block nodes (figures, text blocks) and find already deleted inline nodes (and leave them alone) + tr.doc.nodesBetween( + from, + to, + (node, pos, _parent, _index) => { + if (pos < from && node.type.name === "table_cell") { + firstTableCellChild = true + return true + } else if (pos < from && node.isBlock || firstTableCellChild) { + firstTableCellChild = false + return true + } else if (["table_row", "table_cell"].includes(node.type.name)) { + return false + } else if (node.isInline && node.marks.find(mark => mark.type.name === "insertion" && mark.attrs.user === user && !mark.attrs.approved)) { + const removeStep = new ReplaceStep( + deletionMap.map(Math.max(from, pos)), + deletionMap.map(Math.min(to, pos + node.nodeSize)), + Slice.empty + ) + if (!tr.maybeStep(removeStep).failed) { + deletionMap.appendMap(removeStep.getMap()) + } + } else if (node.isInline && !node.marks.find(mark => mark.type.name === "deletion")) { + tr.addMark( + deletionMap.map(Math.max(from, pos)), + deletionMap.map(Math.min(to, pos + node.nodeSize)), + deletionMark + ) + } + } + ) + + return deletionMap; +} + +/** + * Handle replace step + * @param {EditorState} state the original editor state + * @param {Transaction} tr is the original transaction + * @param {ReplaceStep} step the original state we start from + * @param {number} stepIndex is the index of the original step in the original transaction + * @param {Transaction} newTr is the new transaction we construct + * @param {Mapping} map is the mapping of the newTr we construct + * @param {string} user + * @param {string} date + * @returns {void} newTr and map is modified in place + */ +const handleReplaceStep = (state, tr, step, stepIndex, newTr, map, user, date) => { + const newStep = + step.slice.size ? + new ReplaceStep( + step.to, // We insert all the same steps, but with "from"/"to" both set to "to" in order not to delete content. Mapped as needed. + step.to, + step.slice, + step.structure + ) : + false + // We didn't apply the original step in its original place. We adjust the map accordingly. + const invertStep = step.invert(tr.docs[stepIndex]).map(map) + if(invertStep) { + map.appendMap(invertStep.getMap()) + } + if (newStep) { + const trTemp = state.apply(newTr).tr + if (!trTemp.maybeStep(newStep).failed) { + const mappedNewStepTo = newStep.getMap().map(newStep.to) + markInsertion( + trTemp, + newStep.from, + mappedNewStepTo, + user, + date, + ) + // We condense it down to a single replace step. + const condensedStep = new ReplaceStep(newStep.from, newStep.to, trTemp.doc.slice(newStep.from, mappedNewStepTo)) + newTr.step(condensedStep) + const mirrorIndex = map.maps.length - 1 + map.appendMap(condensedStep.getMap(), mirrorIndex) + if (!newTr.selection.eq(trTemp.selection)) { + console.log(trTemp.selection.toJSON()) + newTr.setSelection(Selection.fromJSON(newTr.doc, trTemp.selection.toJSON())) + } + } + + } + if (step.from !== step.to) { + map.appendMapping( + markDeletion(newTr, step.from, step.to, user, date) + ) + } +} +/** + * Handle add mark step + * @param {EditorState} state + * @param {AddMarkStep | RemoveMarkStep} step + * @param {Transaction} newTr + * @param {string} user + * @param {string} date + */ +const handleMarkStep = (state, step, newTr, user, date) => { + newTr.doc.nodesBetween(step.from, step.to, (node, pos) => { + if (!node.isInline) { + return true + } + if (node.marks.find(mark => mark.type.name === TrackDeleteMarkName)) { + return false + } else if (step instanceof AddMarkStep) { + newTr.addMark( + Math.max(step.from, pos), + Math.min(step.to, pos + node.nodeSize), + step.mark + ) + } else if (step instanceof RemoveMarkStep) { + newTr.removeMark( + Math.max(step.from, pos), + Math.min(step.to, pos + node.nodeSize), + step.mark + ) + } + const formatChangeMark = node.marks.find(mark => mark.type.name === TrackMarksMarkName) + let before = [] + let after = [] + if (formatChangeMark) { + before = [...formatChangeMark.attrs.before]; + after = [...formatChangeMark.attrs.after]; + newTr.removeMark( + Math.max(step.from, pos), + Math.min(step.to, pos + node.nodeSize), + formatChangeMark + ) + } else { + before = node.marks.map(mark => ({ + type: mark.type.name, + attrs: {...mark.attrs} + })) + after = [...before] + } + if(step instanceof AddMarkStep) { + const addedMark = { + type: step.mark.type.name, + attrs: {...step.mark.attrs} + } + after.push(addedMark) + } else if (step instanceof RemoveMarkStep) { + after = after.filter(mark => mark.type !== step.mark.type.name); + } + newTr.addMark( + Math.max(step.from, pos), + Math.min(step.to, pos + node.nodeSize), + state.schema.marks[TrackMarksMarkName].create({ + author: user, + date, + before, + after, + }) + ) + }); +} + +/** + * Track transaction + * @param {Transaction} tr + * @param {EditorState} state + * @param {string} user + * @returns {Transaction} a new transaction with track changes + */ +export const trackTransaction = (tr, state, user) => { + const now = Date.now() + const fixedTimeTo10Minutes = Math.floor(now / 600000) * 600000 + const fixedTimeTo10MinutesString = new Date(fixedTimeTo10Minutes).toISOString() + const newTr = state.tr; + const map = new Mapping(); + + tr.steps.forEach((originalStep, originalStepIndex) => { + const step = originalStep.map(map); + if (!step) { + return + } + if (step instanceof ReplaceStep) { + handleReplaceStep(state, tr, step, originalStepIndex, newTr, map, user, fixedTimeTo10MinutesString) + } else if (step instanceof AddMarkStep) { + handleMarkStep(state, step, newTr, user, fixedTimeTo10MinutesString) + } else if (step instanceof RemoveMarkStep) { + handleMarkStep(state, step, newTr, user, fixedTimeTo10MinutesString) + } else { + newTr.step(step) + } + }) + + keepTransactionNavigationParts(tr, newTr, map, state); + return newTr +} \ No newline at end of file diff --git a/packages/super-editor/src/extensions/track-changes/track-changes-tr-modifier.test.js b/packages/super-editor/src/extensions/track-changes/track-changes-tr-modifier.test.js new file mode 100644 index 0000000000..87213abaf7 --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/track-changes-tr-modifier.test.js @@ -0,0 +1,271 @@ +import {EditorState} from "prosemirror-state"; +import {Slice, Fragment} from "prosemirror-model"; +import {Schema} from "../../core/index.js"; +import {getStarterExtensions} from "../index.js"; +import {trackTransaction} from "./track-changes-tr-modifier.js"; +import {TrackDeleteMarkName, TrackInsertMarkName, TrackMarksMarkName} from "./constants.js"; + +const createEmptyDocState = () => { + const emptyDoc = {content: [], type: "doc"} + const schema = Schema.createSchemaByExtensions(getStarterExtensions()) + const doc = schema.nodeFromJSON(emptyDoc); + return EditorState.create({ + schema, + doc, + }) +} + +describe('Track Changes TR Modifier', () => { + describe("handleReplaceStep", () => { + it("marks insertions", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.text("test"); + tr.insert(0, node); + const state2 = state.apply(trackTransaction(tr, state, "TestUser1")); + //check + const doc2Json = state2.doc.toJSON(); + expect(doc2Json.content.length).toBe(1); + expect(doc2Json.content[0].content.length).toBe(1); + expect(doc2Json.content[0].content[0].text).toEqual("test"); + expect(doc2Json.content[0].content[0].marks.length).toBe(1); + expect(doc2Json.content[0].content[0].marks[0].type).toBe(TrackInsertMarkName); + expect(doc2Json.content[0].content[0].marks[0].attrs.author).toBe("TestUser1"); + }); + it("marks deletions", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.text("test"); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.delete(1, 5); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + //check + const doc3Json = state3.doc.toJSON(); + expect(doc3Json.content.length).toBe(1); + expect(doc3Json.content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].text).toEqual("test"); + expect(doc3Json.content[0].content[0].marks.length).toBe(1); + expect(doc3Json.content[0].content[0].marks[0].type).toBe(TrackDeleteMarkName); + expect(doc3Json.content[0].content[0].marks[0].attrs.author).toBe("TestUser1"); + }); + it("marks replace", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.text("abcd"); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.replace(1, 5, new Slice(Fragment.from(state2.schema.text("dcba")), 0, 0)); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + //check + const doc3Json = state3.doc.toJSON(); + expect(doc3Json.content.length).toBe(1); + expect(doc3Json.content[0].content.length).toBe(2); + expect(doc3Json.content[0].content[0].text).toEqual("abcd"); + expect(doc3Json.content[0].content[0].marks.length).toBe(1); + expect(doc3Json.content[0].content[0].marks[0].type).toBe(TrackDeleteMarkName); + expect(doc3Json.content[0].content[0].marks[0].attrs.author).toBe("TestUser1"); + + expect(doc3Json.content[0].content[1].text).toEqual("dcba"); + expect(doc3Json.content[0].content[1].marks.length).toBe(1); + expect(doc3Json.content[0].content[1].marks[0].type).toBe(TrackInsertMarkName); + expect(doc3Json.content[0].content[1].marks[0].attrs.author).toBe("TestUser1"); + }); + }); + describe("handleMarks", () => { + it("properly handles a mark add to a non marked node", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.text("abcd"); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.addMark(1, 5, state2.schema.marks["bold"].create()); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + //check + const doc3Json = state3.doc.toJSON(); + expect(doc3Json.content.length).toBe(1); + expect(doc3Json.content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].text).toEqual("abcd"); + expect(doc3Json.content[0].content[0].marks.length).toBe(2); + const boldMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === "bold"); + expect(boldMark).toBeTruthy(); + const trackMarksMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === TrackMarksMarkName); + expect(trackMarksMark).toBeTruthy(); + expect(trackMarksMark.attrs.author).toBe("TestUser1"); + expect(trackMarksMark.attrs.before).toStrictEqual([]); + expect(trackMarksMark.attrs.after).toStrictEqual([{ + attrs: {}, + type: "bold" + }]); + }) + it("properly handles before/after marks on a node at mark add", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.text("abcd"); + tr.insert(0, node); + tr.addMark(1, 5, state.schema.marks["bold"].create()) + tr.addMark(1, 5, state.schema.marks["textStyle"].create({fontSize: "14pt", color: "#FF004D", fontFamily: null,})) + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.addMark(1, 5, state2.schema.marks["italic"].create()); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + //check + const doc3Json = state3.doc.toJSON(); + expect(doc3Json.content.length).toBe(1); + expect(doc3Json.content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].text).toEqual("abcd"); + expect(doc3Json.content[0].content[0].marks.length).toBe(4); + const boldMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === "bold"); + expect(boldMark).toBeTruthy(); + const italicMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === "italic"); + expect(italicMark).toBeTruthy(); + const textStyleMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === "textStyle"); + expect(textStyleMark).toBeTruthy(); + const trackMarksMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === TrackMarksMarkName); + expect(trackMarksMark).toBeTruthy(); + expect(trackMarksMark.attrs.author).toBe("TestUser1"); + expect(trackMarksMark.attrs.before).toStrictEqual([ + { + attrs: {}, + type: "bold" + }, { + attrs: { + color: "#FF004D", + fontFamily: null, + fontSize: "14pt" + }, + type: "textStyle" + }]); + expect(trackMarksMark.attrs.after).toStrictEqual([ + { + attrs: {}, + type: "bold" + }, { + attrs: { + color: "#FF004D", + fontFamily: null, + fontSize: "14pt" + }, + type: "textStyle" + }, { + attrs: {}, + type: "italic" + }]); + }) + it("properly handles before/after marks on a node at mark remove", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.text("abcd"); + tr.insert(0, node); + tr.addMark(1, 5, state.schema.marks["bold"].create()) + tr.addMark(1, 5, state.schema.marks["textStyle"].create({fontSize: "14pt", color: "#FF004D", fontFamily: null,})) + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.removeMark(1, 5, state2.schema.marks["bold"].create()); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + //check + const doc3Json = state3.doc.toJSON(); + expect(doc3Json.content.length).toBe(1); + expect(doc3Json.content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].text).toEqual("abcd"); + expect(doc3Json.content[0].content[0].marks.length).toBe(2); + const boldMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === "bold"); + expect(boldMark).toBeFalsy(); + const textStyleMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === "textStyle"); + expect(textStyleMark).toBeTruthy(); + const trackMarksMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === TrackMarksMarkName); + expect(trackMarksMark).toBeTruthy(); + expect(trackMarksMark.attrs.author).toBe("TestUser1"); + expect(trackMarksMark.attrs.before).toStrictEqual([ + { + attrs: {}, + type: "bold" + }, { + attrs: { + color: "#FF004D", + fontFamily: null, + fontSize: "14pt" + }, + type: "textStyle" + }]); + expect(trackMarksMark.attrs.after).toStrictEqual([ + { + attrs: { + color: "#FF004D", + fontFamily: null, + fontSize: "14pt" + }, + type: "textStyle" + }]); + }) + it("properly handles before/after marks on a node at mark attribute change", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.text("abcd"); + tr.insert(0, node); + tr.addMark(1, 5, state.schema.marks["bold"].create()) + const textStyleMark = state.schema.marks["textStyle"].create({fontSize: "14pt", color: "#FF004D", fontFamily: null,}) + tr.addMark(1, 5, textStyleMark) + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.removeMark(1, 5, textStyleMark); + tr2.addMark(1, 5, state.schema.marks["textStyle"].create({fontSize: "18pt", color: "#FFFF4D", fontFamily: null,})); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + //check + const doc3Json = state3.doc.toJSON(); + expect(doc3Json.content.length).toBe(1); + expect(doc3Json.content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].text).toEqual("abcd"); + expect(doc3Json.content[0].content[0].marks.length).toBe(3); + const boldMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === "bold"); + expect(boldMark).toBeTruthy(); + const textStyleMark2 = doc3Json.content[0].content[0].marks.find(mark => mark.type === "textStyle"); + expect(textStyleMark2).toBeTruthy(); + const trackMarksMark = doc3Json.content[0].content[0].marks.find(mark => mark.type === TrackMarksMarkName); + expect(trackMarksMark).toBeTruthy(); + expect(trackMarksMark.attrs.author).toBe("TestUser1"); + expect(trackMarksMark.attrs.before).toStrictEqual([ + { + attrs: {}, + type: "bold" + }, { + attrs: { + color: "#FF004D", + fontFamily: null, + fontSize: "14pt" + }, + type: "textStyle" + }]); + expect(trackMarksMark.attrs.after).toStrictEqual([ + { + attrs: {}, + type: "bold" + }, + { + attrs: { + "color": "#FFFF4D", + fontFamily: null, + fontSize: "18pt" + }, + type: "textStyle" + }]); + }) + }); +}); diff --git a/packages/super-editor/src/extensions/track-changes/track-delete.js b/packages/super-editor/src/extensions/track-changes/track-delete.js new file mode 100644 index 0000000000..2728c3225a --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/track-delete.js @@ -0,0 +1,53 @@ +import { Mark, Attribute } from '@core/index.js'; +import {TrackDeleteMarkName} from "./constants.js"; + +export const TrackDelete = Mark.create({ + name: TrackDeleteMarkName, + + addOptions() { + return { + htmlAttributes: {}, + } + }, + + addAttributes() { + return { + // word id like ` element.getAttribute('wid'), + renderHTML: attributes => { + return { + 'wid': attributes.wid, + } + }, + }, + author: { + default: "imported", + parseHTML: element => element.getAttribute('author'), + renderHTML: attributes => { + return { + 'author': attributes.author, + } + }, + }, + date: { + default: (new Date()).toISOString(), + parseHTML: element => element.getAttribute('date'), + renderHTML: attributes => { + return { + 'date': attributes.date, + } + }, + } + } + }, + + parseDOM() { + return false; + }, + + renderDOM({ htmlAttributes }) { + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes, {deleted: true}), 0]; + }, +}); diff --git a/packages/super-editor/src/extensions/track-changes/track-insert.js b/packages/super-editor/src/extensions/track-changes/track-insert.js new file mode 100644 index 0000000000..0a4e3a3b59 --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/track-insert.js @@ -0,0 +1,53 @@ +import { Mark, Attribute } from '@core/index.js'; +import {TrackInsertMarkName} from "./constants.js"; + +export const TrackInsert = Mark.create({ + name: TrackInsertMarkName, + + addOptions() { + return { + htmlAttributes: {}, + } + }, + + addAttributes() { + return { + // word id like ` element.getAttribute('wid'), + renderHTML: attributes => { + return { + 'wid': attributes.wid, + } + }, + }, + author: { + default: "imported", + parseHTML: element => element.getAttribute('author'), + renderHTML: attributes => { + return { + 'author': attributes.author, + } + }, + }, + date: { + default: (new Date()).toISOString(), + parseHTML: element => element.getAttribute('date'), + renderHTML: attributes => { + return { + 'date': attributes.date, + } + }, + } + } + }, + + parseDOM() { + return false; + }, + + renderDOM({ htmlAttributes }) { + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes, {inserted: true}), 0]; + }, +}); diff --git a/packages/super-editor/src/extensions/track-changes/track-marks.js b/packages/super-editor/src/extensions/track-changes/track-marks.js new file mode 100644 index 0000000000..b575ad6279 --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/track-marks.js @@ -0,0 +1,90 @@ +import { Mark, Attribute } from '@core/index.js'; +import {TrackMarksMarkName} from "./constants.js"; + +export const TrackMarks = Mark.create({ + name: TrackMarksMarkName, + + addOptions() { + return { + htmlAttributes: {}, + } + }, + + // before and after are arrays of objects with the following structure: + // { + // type: string, //the name of the mark + // attrs: object, //the attributes of the mark + // } + addAttributes() { + return { + // word id like ` element.getAttribute('wid'), + renderHTML: attributes => { + return { + 'wid': attributes.wid, + } + }, + }, + author: { + default: "imported", + parseHTML: element => element.getAttribute('author'), + renderHTML: attributes => { + return { + 'author': attributes.author, + } + }, + }, + date: { + default: (new Date()).toISOString(), + parseHTML: element => element.getAttribute('date'), + renderHTML: attributes => { + return { + 'date': attributes.date, + } + }, + }, + before: { + default: [], + parseHTML: element => { + try { + return JSON.parse(element.getAttribute('before')) + } catch (e) { + console.warn("Paste parse error on TrackMarks before", e) + } + return []; + }, + renderHTML: attributes => { + return { + 'before': JSON.stringify(attributes.before), + } + }, + }, + after: { + default: [], + parseHTML: element => { + try { + return JSON.parse(element.getAttribute('after')) + } catch (e) { + console.warn("Paste parse error on TrackMarks after", e) + } + return []; + }, + renderHTML: attributes => { + return { + 'after': JSON.stringify(attributes.after), + } + }, + } + } + }, + + parseDOM() { + return false; + }, + + renderDOM({ htmlAttributes }) { + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes, {inserted: true}), 0]; + }, +}); diff --git a/packages/super-editor/src/index.js b/packages/super-editor/src/index.js index f811c8dcab..2378106b28 100644 --- a/packages/super-editor/src/index.js +++ b/packages/super-editor/src/index.js @@ -1,6 +1,5 @@ import './style.css'; import { SuperConverter } from "@core/super-converter/SuperConverter"; -import { makeDefaultItems } from '@/components/toolbar/defaultItems.js'; import { getMarksFromSelection, getActiveFormatting } from '@/core/helpers/getMarksFromSelection.js'; import { getStarterExtensions } from '@extensions/index.js'; import { SuperToolbar } from '@components/toolbar/super-toolbar.js'; @@ -34,7 +33,6 @@ export { // Helpers helpers, fieldAnnotationHelpers, - makeDefaultItems, getMarksFromSelection, getActiveFormatting, getStarterExtensions, diff --git a/packages/super-editor/src/tests/fixtures/tracked-changes1/tracked-changes1/word/document.xml b/packages/super-editor/src/tests/fixtures/tracked-changes1/tracked-changes1/word/document.xml index 0a518c88d7..b134905885 100644 --- a/packages/super-editor/src/tests/fixtures/tracked-changes1/tracked-changes1/word/document.xml +++ b/packages/super-editor/src/tests/fixtures/tracked-changes1/tracked-changes1/word/document.xml @@ -1,2 +1,89 @@ -This is the original first sentence. Here is a new sentence.New paragraph. \ No newline at end of file + + + + + + + + + + This is the + + + + original + + + + + first + + + + + + + sentence. + + + + Here is a new sentence. + + + + + + + + + + + + + + New paragraph. + + + + + + + + + + + \ No newline at end of file diff --git a/packages/superdoc/src/core/Superdoc.js b/packages/superdoc/src/core/Superdoc.js index 0477a780f9..9fd6b400e5 100644 --- a/packages/superdoc/src/core/Superdoc.js +++ b/packages/superdoc/src/core/Superdoc.js @@ -34,6 +34,8 @@ export class Superdoc extends EventEmitter { documentMode; + version; + constructor(config) { super(); this.config = config; @@ -56,6 +58,7 @@ export class Superdoc extends EventEmitter { this.app.config.globalProperties.$superdoc = this; this.superdocStore = superdocStore; + this.version = config.version; // Toolbar this.toolbarElement = config.toolbar; @@ -67,6 +70,9 @@ export class Superdoc extends EventEmitter { // Directives this.app.mount(config.selector); + // Required editors + this.readyEditors = 0; + this.users = [ { name: 'Nick Bernal', email: 'nick@harbourshare.com' }, { name: 'Artem Nistuley', email: 'nick@harbourshare.com' }, @@ -77,6 +83,16 @@ export class Superdoc extends EventEmitter { // If a toolbar element is provided, render a toolbar this.addToolbar(this); } + get requiredNumberOfEditors() { + return this.superdocStore.documents.filter((d) => d.type === DOCX).length; + } + + get state() { + return { + documents: this.superdocStore.documents, + users: this.users, + } + } #preprocessDocuments(documents) { return documents.map((doc) => { @@ -94,7 +110,15 @@ export class Superdoc extends EventEmitter { }); } + broadcastReady() { + if (this.readyEditors === this.requiredNumberOfEditors) { + this.emit('ready', { superdoc: this }); + } + } + broadcastEditorCreate(editor) { + this.readyEditors++; + this.broadcastReady(); this.emit('editorCreate', { editor }); } @@ -120,6 +144,7 @@ export class Superdoc extends EventEmitter { const config = { element: this.toolbarElement || null, onToolbarCommand: this.onToolbarCommand.bind(this), + isDev: false, } this.toolbar = new SuperToolbar(config); this.toolbar.on('superdoc-command', this.onToolbarCommand.bind(this));