From fa002f10e53a5806a38720cccc5cf8afc08015b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Wed, 28 Aug 2024 11:59:09 +0200 Subject: [PATCH 1/8] track changes insert/delete + import early implementation --- package-lock.json | 71 ++----- packages/super-editor/src/core/Editor.js | 17 +- .../super-editor/src/core/config/style.js | 12 ++ .../src/core/super-converter/importer.js | 114 ++++++++---- packages/super-editor/src/extensions/index.js | 88 +++++---- .../src/extensions/track-changes/constants.js | 2 + .../src/extensions/track-changes/index.js | 3 + .../track-changes-tr-modifier.js | 176 ++++++++++++++++++ .../extensions/track-changes/track-delete.js | 53 ++++++ .../extensions/track-changes/track-insert.js | 53 ++++++ .../tracked-changes1/word/document.xml | 89 ++++++++- 11 files changed, 546 insertions(+), 132 deletions(-) create mode 100644 packages/super-editor/src/extensions/track-changes/constants.js create mode 100644 packages/super-editor/src/extensions/track-changes/index.js create mode 100644 packages/super-editor/src/extensions/track-changes/track-changes-tr-modifier.js create mode 100644 packages/super-editor/src/extensions/track-changes/track-delete.js create mode 100644 packages/super-editor/src/extensions/track-changes/track-insert.js diff --git a/package-lock.json b/package-lock.json index 6140e99559..47ea5a0cec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -882,7 +882,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=12" } @@ -900,7 +899,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -918,7 +916,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -936,7 +933,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -954,7 +950,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -972,7 +967,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -990,7 +984,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -1008,7 +1001,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -1026,7 +1018,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -1044,7 +1035,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -1062,7 +1052,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -1080,7 +1069,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -1098,7 +1086,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -1116,7 +1103,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -1134,7 +1120,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -1152,7 +1137,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -1170,7 +1154,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -1188,7 +1171,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -1206,7 +1188,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -1224,7 +1205,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=12" } @@ -1242,7 +1222,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -1260,7 +1239,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -1278,7 +1256,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -1766,8 +1743,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.19.2", @@ -1781,8 +1757,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.19.2", @@ -1796,8 +1771,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.19.2", @@ -1811,8 +1785,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.19.2", @@ -1826,8 +1799,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.19.2", @@ -1841,8 +1813,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.19.2", @@ -1856,8 +1827,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.19.2", @@ -1871,8 +1841,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "version": "4.19.2", @@ -1886,8 +1855,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.19.2", @@ -1901,8 +1869,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.19.2", @@ -1916,8 +1883,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.19.2", @@ -1931,8 +1897,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.19.2", @@ -1946,8 +1911,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.19.2", @@ -1961,8 +1925,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.19.2", @@ -1976,8 +1939,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.19.2", @@ -1991,8 +1953,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@sinclair/typebox": { "version": "0.27.8", diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index b39321429a..0cbd78c503 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. @@ -461,8 +462,20 @@ export class Editor extends EventEmitter { if (this.view.isDestroyed) { return; } - - const state = this.state.apply(transaction); + + let state; + if(true) { + try { + const trackedTr = amendTransaction(transaction, this.view, "userId") + const {state: newState, transactions} = this.view.state.applyTransaction(trackedTr) + this.view.updateState(newState) + state = newState + } catch (e) { + console.log(e) + } + } else { + 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..8d37dca5e2 100644 --- a/packages/super-editor/src/core/config/style.js +++ b/packages/super-editor/src/core/config/style.js @@ -68,6 +68,18 @@ li.ProseMirror-selectednode:after { pointer-events: none; } +.insertionMark { + border: 1px dashed #0f0; + background-color: #f0f0f0; + pointer-events: none; +} + +.deletionMark { + border: 1px dashed #f00; + background-color: #f0f0f0; + pointer-events: none; +} + /* Protect against generic img rules */ img.ProseMirror-separator { display: inline !important; diff --git a/packages/super-editor/src/core/super-converter/importer.js b/packages/super-editor/src/core/super-converter/importer.js index 354dad7932..bad01d637f 100644 --- a/packages/super-editor/src/core/super-converter/importer.js +++ b/packages/super-editor/src/core/super-converter/importer.js @@ -1,12 +1,13 @@ import { SuperConverter } from './SuperConverter'; import { twipsToPixels, twipsToInches, halfPointToPixels, emuToPixels } from './helpers.js'; import { toKebabCase } from '@common/key-transform.js'; +import {TrackDeleteMarkName, TrackInsertMarkName} 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 +37,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 +51,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 +96,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 +137,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 +164,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 +182,7 @@ export class DocxImporter { const path = `word/${relAttributes['Target']}`; return { - type: 'image', + type: 'image', attrs: { src: media[path], alt: 'Image', @@ -198,7 +213,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 +254,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 +363,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 @@ -386,12 +401,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 +444,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 +457,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 +469,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 +488,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 +503,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 +626,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 +645,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 +690,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 +750,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, diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index d87c3e5696..c9a17e0032 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -37,6 +37,8 @@ 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'; @@ -50,7 +52,7 @@ const getRichTextExtensions = () => [ Document, History, Italic, - Link,, + Link, Paragraph, Strike, Text, @@ -62,46 +64,50 @@ 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, + TrackDelete, + TrackInsert, + ]; +}; 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..7acd29729a --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/constants.js @@ -0,0 +1,2 @@ +export const TrackDeleteMarkName = 'trackDelete'; +export const TrackInsertMarkName = 'trackInsert'; 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-tr-modifier.js b/packages/super-editor/src/extensions/track-changes/track-changes-tr-modifier.js new file mode 100644 index 0000000000..cff50a69f5 --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/track-changes-tr-modifier.js @@ -0,0 +1,176 @@ +import {TextSelection, Selection} from "prosemirror-state"; +import {Mapping, ReplaceStep} from "prosemirror-transform"; +import {Slice} from "prosemirror-model"; +import {TrackInsertMarkName, TrackDeleteMarkName} from "./constants.js"; + +export const amendTransaction = (tr, view, user) => { + if ( + !tr.steps.length || + // don't replace history TRs + ["historyUndo", "historyRedo"].includes(tr.getMeta("inputType")) + ) { + return tr + } else { + return trackTransaction( + tr, + view.state, + user, + ) + } +} + +const markInsertion = (tr, from, to, user) => { + const insertionMark = tr.doc.type.schema.marks[TrackInsertMarkName].create({user: user, createdAt: Date.now()}) + 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 + }*/ + } + ) +} + +const markDeletion = (tr, from, to, user) => { + const deletionMark = tr.doc.type.schema.marks[TrackDeleteMarkName].create({user: user, createdAt: Date.now()}) + 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; +} + +const trackTransaction = (tr, state, user) => { + const newTr = state.tr; + const map = new Mapping(); + + tr.steps.forEach((originalStep, originalStepIndex) => { + const step = originalStep.map(map); + const doc = newTr.doc; + if (!step) { + return + } + if (step instanceof ReplaceStep) { + console.log("1") + 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 = originalStep.invert(tr.docs[originalStepIndex]).map(map) + if(invertStep) { + map.appendMap(invertStep.getMap()) + } + if (newStep) { + console.log("2") + const trTemp = state.apply(newTr).tr + console.log("2", trTemp.maybeStep(newStep).failed) + if (!trTemp.maybeStep(newStep).failed) { + const mappedNewStepTo = newStep.getMap().map(newStep.to) + markInsertion( + trTemp, + newStep.from, + mappedNewStepTo, + user, + ) + // We condense it down to a single replace step. + const condensedStep = new ReplaceStep(newStep.from, newStep.to, trTemp.doc.slice(newStep.from, mappedNewStepTo)) + console.log(condensedStep); + 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) { + console.log("3") + map.appendMapping( + markDeletion(newTr, step.from, step.to, user) + ) + } + } else { + newTr.step(step) + } + }) + + // We copy the input type meta data from the original transaction. + if (tr.getMeta("inputType")) { + newTr.setMeta("inputType", tr.getMeta("inputType")) + } + if (tr.getMeta("uiEvent")) { + newTr.setMeta("uiEvent", tr.getMeta("uiEvent")) + } + + if (tr.selectionSet) { + 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() + } + + return newTr +} \ No newline at end of file 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..c0b69a3369 --- /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: null, + parseHTML: element => element.getAttribute('author'), + renderHTML: attributes => { + return { + 'author': attributes.author, + } + }, + }, + date: { + default: null, + parseHTML: element => element.getAttribute('date'), + renderHTML: attributes => { + return { + 'date': attributes.date, + } + }, + } + } + }, + + parseDOM() { + return false; + }, + + renderDOM({ htmlAttributes }) { + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes, {class: `deletionMark`}), 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..7e1862e0de --- /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: null, + parseHTML: element => element.getAttribute('author'), + renderHTML: attributes => { + return { + 'author': attributes.author, + } + }, + }, + date: { + default: null, + parseHTML: element => element.getAttribute('date'), + renderHTML: attributes => { + return { + 'date': attributes.date, + } + }, + } + } + }, + + parseDOM() { + return false; + }, + + renderDOM({ htmlAttributes }) { + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes, {class: `insertionMark`}), 0]; + }, +}); 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 From 58bdb56f89358c20645a2c969004c202f47677ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Thu, 29 Aug 2024 17:55:25 +0200 Subject: [PATCH 2/8] better tracking code + decorations + approve/revert --- .../src/components/toolbar/defaultItems.js | 45 +++- packages/super-editor/src/core/Editor.js | 17 +- .../super-editor/src/core/config/style.js | 6 + packages/super-editor/src/extensions/index.js | 2 + .../track-changes/track-changes-base.js | 245 ++++++++++++++++++ .../track-changes-tr-modifier.js | 149 +++++++---- .../extensions/track-changes/track-delete.js | 8 +- .../extensions/track-changes/track-insert.js | 8 +- 8 files changed, 416 insertions(+), 64 deletions(-) create mode 100644 packages/super-editor/src/extensions/track-changes/track-changes-base.js diff --git a/packages/super-editor/src/components/toolbar/defaultItems.js b/packages/super-editor/src/components/toolbar/defaultItems.js index 833730140d..58bc25dac8 100644 --- a/packages/super-editor/src/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/components/toolbar/defaultItems.js @@ -518,7 +518,6 @@ export const makeDefaultItems = (superToolbar) => { type: "button", disabled: true, name: "redo", - disabled: true, tooltip: "Redo", command: "redo", icon: "fa fa-rotate-right", @@ -529,6 +528,46 @@ 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", + }); + // search const search = useToolbarItem({ type: "button", @@ -676,6 +715,10 @@ export const makeDefaultItems = (superToolbar) => { const toolbarItems = [ undo, redo, + test, + trackChanges, + acceptChangesOnCursorPositions, + revertChangesOnCursorPositions, zoom, separator, fontButton, diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index 0cbd78c503..63f56cf29e 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -464,16 +464,13 @@ export class Editor extends EventEmitter { } let state; - if(true) { - try { - const trackedTr = amendTransaction(transaction, this.view, "userId") - const {state: newState, transactions} = this.view.state.applyTransaction(trackedTr) - this.view.updateState(newState) - state = newState - } catch (e) { - console.log(e) - } - } else { + 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); diff --git a/packages/super-editor/src/core/config/style.js b/packages/super-editor/src/core/config/style.js index 8d37dca5e2..2b7b74ae11 100644 --- a/packages/super-editor/src/core/config/style.js +++ b/packages/super-editor/src/core/config/style.js @@ -78,6 +78,12 @@ li.ProseMirror-selectednode:after { border: 1px dashed #f00; background-color: #f0f0f0; pointer-events: none; + &.inline { + display: none; + } + &.widget { + + } } /* Protect against generic img rules */ diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index c9a17e0032..8d0be2bacb 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -45,6 +45,7 @@ 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"; const getRichTextExtensions = () => [ Bold, @@ -104,6 +105,7 @@ const getStarterExtensions = () => { Image, BookmarkStart, Mention, + TrackChangesBase, TrackDelete, TrackInsert, ]; 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..41dccbac74 --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/track-changes-base.js @@ -0,0 +1,245 @@ +import {Extension} from "../../core/index.js"; +import {Plugin, PluginKey} from "prosemirror-state"; +import {Decoration, DecorationSet} from "prosemirror-view"; +import {TrackDeleteMarkName, TrackInsertMarkName} from "./constants.js"; + + +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; + const isTrackChangesActive = trackChangeState.isTrackChangesActive; + if(isTrackChangesActive) { + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "TRACK_CHANGES_DISABLE"})); + } else { + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "TRACK_CHANGES_ENABLE"})); + } + } + return true; + }, + enableTrackChanges: () => ({state, dispatch}) => { + if (dispatch) { + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "TRACK_CHANGES_ENABLE"})); + } + return true; + }, + disableTrackChanges: () => ({state, dispatch}) => { + if (dispatch) { + dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "TRACK_CHANGES_DISABLE"})); + } + 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; + }, + acceptChangeBetweenConcretePositions: (from, to) => ({state, dispatch}) => { + if (dispatch) { + const tr = state.tr; + let offset = 0; + state.doc.nodesBetween(from, to, (node, pos) => { + node.marks.forEach(mark => { + if(mark.type.name === TrackDeleteMarkName) { + //TODO we should signal the acceptance + tr.deleteRange(pos + offset, pos + node.nodeSize + offset); + offset -= node.nodeSize; + } + if(mark.type.name === TrackInsertMarkName) { + //TODO we should signal the acceptance + tr.removeMark(pos + offset, pos + node.nodeSize + offset, mark); + } + }); + }); + dispatch(tr); + } + return true; + }, + revertChangeBetweenConcretePositions: (from, to) => ({state, dispatch}) => { + if (dispatch) { + const tr = state.tr; + let offset = 0; + state.doc.nodesBetween(from, to, (node, pos) => { + node.marks.forEach(mark => { + if(mark.type.name === TrackDeleteMarkName) { + //TODO we should signal the revert + tr.removeMark(pos + offset, pos + node.nodeSize + offset, mark); + } + if(mark.type.name === TrackInsertMarkName) { + //TODO we should signal the revert + tr.deleteRange(pos + offset, pos + node.nodeSize + offset); + offset -= node.nodeSize; + } + }); + }); + dispatch(tr); + } + return true; + }, + signalChangesBetweenPositions: (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) { + correctedFrom = Math.max(prevTextNode.offset, 0); + } + }); + } + + if (nextTextNode) { + nextTextNode.node.marks.forEach(mark => { + if (mark.type.name === TrackDeleteMarkName || mark.type.name === TrackInsertMarkName) { + 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) { + correctedTo = Math.min(currentTextNode.offset + currentTextNode.node.nodeSize, state.doc.nodeSize); + } + }); + } + + if(from!== correctedFrom || to !== correctedTo) { + return chain().signalChangesBetweenPositions(correctedFrom, correctedTo, action); + } else { + if(action === "accept") { + return chain().acceptChangeBetweenConcretePositions(correctedFrom, correctedTo); + } else if (action === "revert") { + return chain().revertChangeBetweenConcretePositions(correctedFrom, correctedTo); + } else { + return chain(); + } + } + }, + acceptChangesOnCursorPositions: () => ({state, chain}) => { + const {from, to} = state.selection; + return chain().signalChangesBetweenPositions(from, to, "accept"); + }, + revertChangesOnCursorPositions: () => ({state, chain}) => { + const {from, to} = state.selection; + return chain().signalChangesBetweenPositions(from, to, "revert"); + } + }; + }, + addPmPlugins() { + return [new Plugin( + { + key: TrackChangesBasePluginKey, + state: { + init() { + return { + isTrackChangesActive: false, + decorations: DecorationSet.empty, + }; + }, + apply(tr, oldState, prevEditorState, newEditorState) { + const decorations = recalcDecorations(newEditorState); + const newState = {...oldState, decorations}; + + const meta = tr.getMeta(TrackChangesBasePluginKey); + if (!meta) return newState; + + if(meta.type === "TRACK_CHANGES_ENABLE") { + return {...newState, isTrackChangesActive: true}; + } + if(meta.type === "TRACK_CHANGES_DISABLE") { + return {...newState, isTrackChangesActive: false}; + } + + return newState; + }, + }, + props: { + decorations(t) { + return this.getState(t)?.decorations; + }, + }, + }), + ] + } +}); + +const recalcDecorations = (state) => { + if(!state.doc || !state.doc.nodeSize) return DecorationSet.empty; + + const decorations = []; + state.doc.nodesBetween(0, state.doc.nodeSize-2, (node, pos) => { + node.marks.forEach(mark => { + if(mark.type.name === TrackInsertMarkName) { + const decoration = Decoration.inline(pos, pos + node.nodeSize, { + class: "insertionMark", + }); + decorations.push(decoration); + } + if(mark.type.name === TrackDeleteMarkName) { + const decorationInline = Decoration.inline(pos, pos + node.nodeSize, { + class: "deletionMark inline", + }); + 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); + } + }); + }); + + 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 index cff50a69f5..f0d338e915 100644 --- 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 @@ -1,26 +1,117 @@ import {TextSelection, Selection} from "prosemirror-state"; import {Mapping, ReplaceStep} from "prosemirror-transform"; -import {Slice} from "prosemirror-model"; +import {Slice, Fragment} from "prosemirror-model"; import {TrackInsertMarkName, TrackDeleteMarkName} from "./constants.js"; +import {TrackChangesBasePluginKey} from "./track-changes-base.js"; 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 || - // don't replace history TRs ["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 markInsertion = (tr, from, to, user) => { - const insertionMark = tr.doc.type.schema.marks[TrackInsertMarkName].create({user: user, createdAt: Date.now()}) +const whitelistedMetaKeys = ["inputType", "uiEvent"] + +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() + } +} + +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); +}; + +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; +} + +const markInsertion = (tr, from, to, user, date) => { + const insertionMark = tr.doc.type.schema.marks[TrackInsertMarkName].create({author: user, date}) tr.doc.nodesBetween( from, to, @@ -39,8 +130,8 @@ const markInsertion = (tr, from, to, user) => { ) } -const markDeletion = (tr, from, to, user) => { - const deletionMark = tr.doc.type.schema.marks[TrackDeleteMarkName].create({user: user, createdAt: Date.now()}) +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() @@ -80,17 +171,18 @@ const markDeletion = (tr, from, to, user) => { } 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); - const doc = newTr.doc; if (!step) { return } if (step instanceof ReplaceStep) { - console.log("1") const newStep = step.slice.size ? new ReplaceStep( @@ -106,9 +198,7 @@ const trackTransaction = (tr, state, user) => { map.appendMap(invertStep.getMap()) } if (newStep) { - console.log("2") const trTemp = state.apply(newTr).tr - console.log("2", trTemp.maybeStep(newStep).failed) if (!trTemp.maybeStep(newStep).failed) { const mappedNewStepTo = newStep.getMap().map(newStep.to) markInsertion( @@ -116,10 +206,10 @@ const trackTransaction = (tr, state, user) => { newStep.from, mappedNewStepTo, user, + fixedTimeTo10MinutesString, ) // We condense it down to a single replace step. const condensedStep = new ReplaceStep(newStep.from, newStep.to, trTemp.doc.slice(newStep.from, mappedNewStepTo)) - console.log(condensedStep); newTr.step(condensedStep) const mirrorIndex = map.maps.length - 1 map.appendMap(condensedStep.getMap(), mirrorIndex) @@ -131,9 +221,8 @@ const trackTransaction = (tr, state, user) => { } if (step.from !== step.to) { - console.log("3") map.appendMapping( - markDeletion(newTr, step.from, step.to, user) + markDeletion(newTr, step.from, step.to, user, fixedTimeTo10MinutesString) ) } } else { @@ -141,36 +230,6 @@ const trackTransaction = (tr, state, user) => { } }) - // We copy the input type meta data from the original transaction. - if (tr.getMeta("inputType")) { - newTr.setMeta("inputType", tr.getMeta("inputType")) - } - if (tr.getMeta("uiEvent")) { - newTr.setMeta("uiEvent", tr.getMeta("uiEvent")) - } - - if (tr.selectionSet) { - 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() - } - + keepTransactionNavigationParts(tr, newTr, map, state); return newTr } \ No newline at end of file diff --git a/packages/super-editor/src/extensions/track-changes/track-delete.js b/packages/super-editor/src/extensions/track-changes/track-delete.js index c0b69a3369..2728c3225a 100644 --- a/packages/super-editor/src/extensions/track-changes/track-delete.js +++ b/packages/super-editor/src/extensions/track-changes/track-delete.js @@ -14,7 +14,7 @@ export const TrackDelete = Mark.create({ return { // word id like ` element.getAttribute('wid'), renderHTML: attributes => { return { @@ -23,7 +23,7 @@ export const TrackDelete = Mark.create({ }, }, author: { - default: null, + default: "imported", parseHTML: element => element.getAttribute('author'), renderHTML: attributes => { return { @@ -32,7 +32,7 @@ export const TrackDelete = Mark.create({ }, }, date: { - default: null, + default: (new Date()).toISOString(), parseHTML: element => element.getAttribute('date'), renderHTML: attributes => { return { @@ -48,6 +48,6 @@ export const TrackDelete = Mark.create({ }, renderDOM({ htmlAttributes }) { - return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes, {class: `deletionMark`}), 0]; + 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 index 7e1862e0de..0a4e3a3b59 100644 --- a/packages/super-editor/src/extensions/track-changes/track-insert.js +++ b/packages/super-editor/src/extensions/track-changes/track-insert.js @@ -14,7 +14,7 @@ export const TrackInsert = Mark.create({ return { // word id like ` element.getAttribute('wid'), renderHTML: attributes => { return { @@ -23,7 +23,7 @@ export const TrackInsert = Mark.create({ }, }, author: { - default: null, + default: "imported", parseHTML: element => element.getAttribute('author'), renderHTML: attributes => { return { @@ -32,7 +32,7 @@ export const TrackInsert = Mark.create({ }, }, date: { - default: null, + default: (new Date()).toISOString(), parseHTML: element => element.getAttribute('date'), renderHTML: attributes => { return { @@ -48,6 +48,6 @@ export const TrackInsert = Mark.create({ }, renderDOM({ htmlAttributes }) { - return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes, {class: `insertionMark`}), 0]; + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes, {inserted: true}), 0]; }, }); From c0a464525c82cc95afce29ba46585d4a5068d3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 30 Aug 2024 13:29:52 +0200 Subject: [PATCH 3/8] show/hide options + log on accept/revert --- .../src/components/toolbar/defaultItems.js | 22 ++ .../super-editor/src/core/config/style.js | 26 +- .../track-changes/track-changes-base.js | 290 ++++++++++++------ 3 files changed, 245 insertions(+), 93 deletions(-) diff --git a/packages/super-editor/src/components/toolbar/defaultItems.js b/packages/super-editor/src/components/toolbar/defaultItems.js index 58bc25dac8..99a6a58772 100644 --- a/packages/super-editor/src/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/components/toolbar/defaultItems.js @@ -568,6 +568,26 @@ export const makeDefaultItems = (superToolbar) => { 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", @@ -719,6 +739,8 @@ export const makeDefaultItems = (superToolbar) => { trackChanges, acceptChangesOnCursorPositions, revertChangesOnCursorPositions, + toggleTrackChangesOriginal, + toggleTrackChangesFinal, zoom, separator, fontButton, diff --git a/packages/super-editor/src/core/config/style.js b/packages/super-editor/src/core/config/style.js index 2b7b74ae11..472db35206 100644 --- a/packages/super-editor/src/core/config/style.js +++ b/packages/super-editor/src/core/config/style.js @@ -69,20 +69,32 @@ li.ProseMirror-selectednode:after { } .insertionMark { - border: 1px dashed #0f0; - background-color: #f0f0f0; - pointer-events: none; + &.inline { + &.hidden { + display: none; + } + &.normal { + } + &.highlighted { + border: 1px dashed #0f0; + background-color: #f0f0f0; + pointer-events: none; + } + } } .deletionMark { - border: 1px dashed #f00; - background-color: #f0f0f0; - pointer-events: none; &.inline { + &.hidden { display: none; + } + &.normal { + } } &.widget { - + border: 1px dashed #f00; + background-color: #f0f0f0; + pointer-events: none; } } 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 index 41dccbac74..22ffe44aaa 100644 --- a/packages/super-editor/src/extensions/track-changes/track-changes-base.js +++ b/packages/super-editor/src/extensions/track-changes/track-changes-base.js @@ -1,9 +1,27 @@ import {Extension} from "../../core/index.js"; -import {Plugin, PluginKey} from "prosemirror-state"; +import {Plugin, PluginKey, EditorState} from "prosemirror-state"; import {Decoration, DecorationSet} from "prosemirror-view"; import {TrackDeleteMarkName, TrackInsertMarkName} 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({ @@ -13,24 +31,53 @@ export const TrackChangesBase = Extension.create({ if (dispatch) { const trackChangeState = TrackChangesBasePluginKey.getState(state); if(trackChangeState === undefined) return false; - const isTrackChangesActive = trackChangeState.isTrackChangesActive; - if(isTrackChangesActive) { - dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "TRACK_CHANGES_DISABLE"})); - } else { - dispatch(state.tr.setMeta(TrackChangesBasePluginKey, {type: "TRACK_CHANGES_ENABLE"})); - } + 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"})); + 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_DISABLE"})); + 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; }, @@ -48,49 +95,36 @@ export const TrackChangesBase = Extension.create({ } return true; }, - acceptChangeBetweenConcretePositions: (from, to) => ({state, dispatch}) => { - if (dispatch) { - const tr = state.tr; - let offset = 0; - state.doc.nodesBetween(from, to, (node, pos) => { - node.marks.forEach(mark => { - if(mark.type.name === TrackDeleteMarkName) { - //TODO we should signal the acceptance - tr.deleteRange(pos + offset, pos + node.nodeSize + offset); - offset -= node.nodeSize; - } - if(mark.type.name === TrackInsertMarkName) { - //TODO we should signal the acceptance - tr.removeMark(pos + offset, pos + node.nodeSize + offset, mark); - } - }); - }); - dispatch(tr); - } - return true; - }, - revertChangeBetweenConcretePositions: (from, to) => ({state, dispatch}) => { - if (dispatch) { - const tr = state.tr; - let offset = 0; - state.doc.nodesBetween(from, to, (node, pos) => { - node.marks.forEach(mark => { - if(mark.type.name === TrackDeleteMarkName) { - //TODO we should signal the revert - tr.removeMark(pos + offset, pos + node.nodeSize + offset, mark); - } - if(mark.type.name === TrackInsertMarkName) { - //TODO we should signal the revert - tr.deleteRange(pos + offset, pos + node.nodeSize + offset); - offset -= node.nodeSize; - } - }); - }); - dispatch(tr); + 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; }, - signalChangesBetweenPositions: (from, to, action) => ({state, chain}) => { + applyChangesBetweenPositions: (from, to, action) => ({state, chain}) => { let correctedFrom = from; let correctedTo = to; const textNodesFrom = findTextNodes(state, from); @@ -124,24 +158,18 @@ export const TrackChangesBase = Extension.create({ } if(from!== correctedFrom || to !== correctedTo) { - return chain().signalChangesBetweenPositions(correctedFrom, correctedTo, action); + return chain().applyChangesBetweenPositions(correctedFrom, correctedTo, action); } else { - if(action === "accept") { - return chain().acceptChangeBetweenConcretePositions(correctedFrom, correctedTo); - } else if (action === "revert") { - return chain().revertChangeBetweenConcretePositions(correctedFrom, correctedTo); - } else { - return chain(); - } + return chain().applyChangeBetweenConcretePositions(correctedFrom, correctedTo, action); } }, acceptChangesOnCursorPositions: () => ({state, chain}) => { const {from, to} = state.selection; - return chain().signalChangesBetweenPositions(from, to, "accept"); + return chain().applyChangesBetweenPositions(from, to, "accept"); }, revertChangesOnCursorPositions: () => ({state, chain}) => { const {from, to} = state.selection; - return chain().signalChangesBetweenPositions(from, to, "revert"); + return chain().applyChangesBetweenPositions(from, to, "revert"); } }; }, @@ -153,24 +181,50 @@ export const TrackChangesBase = Extension.create({ init() { return { isTrackChangesActive: false, + onlyOriginalShown: false, + onlyModifiedShown: false, decorations: DecorationSet.empty, }; }, apply(tr, oldState, prevEditorState, newEditorState) { - const decorations = recalcDecorations(newEditorState); - const newState = {...oldState, decorations}; - const meta = tr.getMeta(TrackChangesBasePluginKey); - if (!meta) return newState; + if (!meta) { + return { + ...oldState, + decorations: recalcDecorations(newEditorState, oldState.onlyOriginalShown, oldState.onlyModifiedShown) + }; + } if(meta.type === "TRACK_CHANGES_ENABLE") { - return {...newState, isTrackChangesActive: true}; + return { + ...oldState, + isTrackChangesActive: meta.value === true, + decorations: recalcDecorations(newEditorState, oldState.onlyOriginalShown, oldState.onlyModifiedShown) + }; } - if(meta.type === "TRACK_CHANGES_DISABLE") { - return {...newState, isTrackChangesActive: false}; + + 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 newState; + return { + ...oldState, + decorations: recalcDecorations(newEditorState, oldState.onlyOriginalShown, oldState.onlyModifiedShown) + }; }, }, props: { @@ -183,32 +237,96 @@ export const TrackChangesBase = Extension.create({ } }); -const recalcDecorations = (state) => { - if(!state.doc || !state.doc.nodeSize) return DecorationSet.empty; + +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}); + } + } + }); + }); + 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) { - const decoration = Decoration.inline(pos, pos + node.nodeSize, { - class: "insertionMark", - }); - decorations.push(decoration); + 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) { - const decorationInline = Decoration.inline(pos, pos + node.nodeSize, { - class: "deletionMark inline", - }); - 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(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); + } } }); }); From 662e90f76f410bf53c03e4a7c8959ed1fbcba383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Mon, 2 Sep 2024 16:54:17 +0200 Subject: [PATCH 4/8] export --- .../core/super-converter/SuperConverter.js | 2 + .../src/core/super-converter/exporter.js | 52 +++++++++++++++++-- .../dev/components/DeveloperPlayground.vue | 2 +- .../track-changes/track-changes-base.js | 2 +- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 49cedcf565..91028f729f 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 953388fd8f..88f7628a6e 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -2,6 +2,7 @@ import { SuperConverter } from './SuperConverter.js'; import { toKebabCase } from '@common/key-transform.js'; import { inchesToTwips } from './helpers.js'; import { generateRandomId } from '@helpers/docxIdGenerator.js'; +import {TrackDeleteMarkName, TrackInsertMarkName} from "../../extensions/track-changes/constants.js"; export class DocxExporter { @@ -47,7 +48,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 +102,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); }); @@ -127,6 +131,7 @@ export class DocxExporter { #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 +357,39 @@ 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; + + 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: [ + { + 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 +422,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; @@ -489,7 +531,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/dev/components/DeveloperPlayground.vue b/packages/super-editor/src/dev/components/DeveloperPlayground.vue index cdcc688713..0586c45603 100644 --- a/packages/super-editor/src/dev/components/DeveloperPlayground.vue +++ b/packages/super-editor/src/dev/components/DeveloperPlayground.vue @@ -54,7 +54,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; 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 index 22ffe44aaa..da5498aed7 100644 --- a/packages/super-editor/src/extensions/track-changes/track-changes-base.js +++ b/packages/super-editor/src/extensions/track-changes/track-changes-base.js @@ -1,4 +1,4 @@ -import {Extension} from "../../core/index.js"; +import {Extension} from '@core/Extension.js'; import {Plugin, PluginKey, EditorState} from "prosemirror-state"; import {Decoration, DecorationSet} from "prosemirror-view"; import {TrackDeleteMarkName, TrackInsertMarkName} from "./constants.js"; From 8aa15eaeb89e4760ac05ce60ff502d050d975caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 6 Sep 2024 08:31:58 +0200 Subject: [PATCH 5/8] track styles --- .../src/core/super-converter/exporter.js | 61 +++- .../src/core/super-converter/importer.js | 40 ++- packages/super-editor/src/extensions/index.js | 2 + .../src/extensions/track-changes/constants.js | 2 + .../track-changes/track-changes-base.js | 23 +- .../track-changes-tr-modifier.js | 236 +++++++++++---- .../track-changes-tr-modifier.test.js | 271 ++++++++++++++++++ .../extensions/track-changes/track-marks.js | 85 ++++++ 8 files changed, 669 insertions(+), 51 deletions(-) create mode 100644 packages/super-editor/src/extensions/track-changes/track-changes-tr-modifier.test.js create mode 100644 packages/super-editor/src/extensions/track-changes/track-marks.js diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 88f7628a6e..b6c23560be 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -2,7 +2,11 @@ import { SuperConverter } from './SuperConverter.js'; import { toKebabCase } from '@common/key-transform.js'; import { inchesToTwips } from './helpers.js'; import { generateRandomId } from '@helpers/docxIdGenerator.js'; -import {TrackDeleteMarkName, TrackInsertMarkName} from "../../extensions/track-changes/constants.js"; +import { + TrackDeleteMarkName, + TrackInsertMarkName, + TrackMarksMarkName +} from "../../extensions/track-changes/constants.js"; export class DocxExporter { @@ -33,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; @@ -129,6 +157,11 @@ 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; @@ -364,6 +397,26 @@ export class DocxExporter { 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', @@ -377,6 +430,7 @@ export class DocxExporter { name: 'w:r', type: 'element', elements: [ + runProperties, { name: isInsert ? 'w:t' : 'w:delText', type: 'element', @@ -453,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', diff --git a/packages/super-editor/src/core/super-converter/importer.js b/packages/super-editor/src/core/super-converter/importer.js index bad01d637f..b067be118c 100644 --- a/packages/super-editor/src/core/super-converter/importer.js +++ b/packages/super-editor/src/core/super-converter/importer.js @@ -1,7 +1,11 @@ import { SuperConverter } from './SuperConverter'; import { twipsToPixels, twipsToInches, halfPointToPixels, emuToPixels } from './helpers.js'; import { toKebabCase } from '@common/key-transform.js'; -import {TrackDeleteMarkName, TrackInsertMarkName} from "../../extensions/track-changes/constants.js"; +import { + TrackDeleteMarkName, + TrackInsertMarkName, + TrackMarksMarkName +} from "../../extensions/track-changes/constants.js"; /** @@ -391,6 +395,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'); @@ -763,6 +772,11 @@ export class DocxImporter { } + /** + * + * @param property + * @returns {{type: string, attrs: {}}[]} + */ #parseMarks(property) { const marks = []; const seen = new Set(); @@ -816,6 +830,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']; @@ -900,6 +936,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/extensions/index.js b/packages/super-editor/src/extensions/index.js index 8d0be2bacb..b6af391c30 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -46,6 +46,7 @@ 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, @@ -108,6 +109,7 @@ const getStarterExtensions = () => { TrackChangesBase, TrackDelete, TrackInsert, + TrackMarks, ]; }; diff --git a/packages/super-editor/src/extensions/track-changes/constants.js b/packages/super-editor/src/extensions/track-changes/constants.js index 7acd29729a..d24e7fa9fc 100644 --- a/packages/super-editor/src/extensions/track-changes/constants.js +++ b/packages/super-editor/src/extensions/track-changes/constants.js @@ -1,2 +1,4 @@ export const TrackDeleteMarkName = 'trackDelete'; export const TrackInsertMarkName = 'trackInsert'; +export const TrackMarksMarkName = 'trackMarks'; + 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 index da5498aed7..a30af0732f 100644 --- a/packages/super-editor/src/extensions/track-changes/track-changes-base.js +++ b/packages/super-editor/src/extensions/track-changes/track-changes-base.js @@ -1,7 +1,7 @@ import {Extension} from '@core/Extension.js'; import {Plugin, PluginKey, EditorState} from "prosemirror-state"; import {Decoration, DecorationSet} from "prosemirror-view"; -import {TrackDeleteMarkName, TrackInsertMarkName} from "./constants.js"; +import {TrackDeleteMarkName, TrackInsertMarkName, TrackMarksMarkName} from "./constants.js"; const trackChangesCallback = (action, original, modified, modifiers) => { @@ -328,6 +328,27 @@ const recalcDecorations = (state, onlyOriginalShown,onlyModifiedShown ) => { 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); + } + } }); }); 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 index f0d338e915..bc252b1096 100644 --- 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 @@ -1,9 +1,16 @@ -import {TextSelection, Selection} from "prosemirror-state"; -import {Mapping, ReplaceStep} from "prosemirror-transform"; -import {Slice, Fragment} from "prosemirror-model"; -import {TrackInsertMarkName, TrackDeleteMarkName} from "./constants.js"; +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 @@ -31,6 +38,14 @@ export const amendTransaction = (tr, view, 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 => { @@ -63,6 +78,13 @@ const keepTransactionNavigationParts = (tr, newTr, map, state) => { } } +/** + * 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 = []; @@ -81,6 +103,12 @@ const removeMarksFromSlice = (slice, schema, markName) => { 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; @@ -110,6 +138,15 @@ const removeTrackChangesFromTransaction = (tr, state) => { 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( @@ -130,6 +167,15 @@ const markInsertion = (tr, from, to, user, date) => { ) } +/** + * 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 @@ -170,7 +216,138 @@ const markDeletion = (tr, from, to, user, date) => { return deletionMap; } -const trackTransaction = (tr, state, user) => { +/** + * 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() @@ -183,48 +360,11 @@ const trackTransaction = (tr, state, user) => { return } if (step instanceof ReplaceStep) { - 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 = originalStep.invert(tr.docs[originalStepIndex]).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, - fixedTimeTo10MinutesString, - ) - // 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, fixedTimeTo10MinutesString) - ) - } + 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) } 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-marks.js b/packages/super-editor/src/extensions/track-changes/track-marks.js new file mode 100644 index 0000000000..dd1a6a2491 --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/track-marks.js @@ -0,0 +1,85 @@ +import { Mark, Attribute } from '@core/index.js'; +import {TrackMarksMarkName} from "./constants.js"; + +export const TrackMarks = Mark.create({ + name: TrackMarksMarkName, + + 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, + } + }, + }, + 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]; + }, +}); From e21008f177a41bdb1d6077d759cc9937a19da744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 6 Sep 2024 08:59:55 +0200 Subject: [PATCH 6/8] track styles accept/revert --- .../track-changes/track-changes-base.js | 34 ++++++++++++++++--- .../extensions/track-changes/track-marks.js | 5 +++ 2 files changed, 35 insertions(+), 4 deletions(-) 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 index a30af0732f..92323f37e7 100644 --- a/packages/super-editor/src/extensions/track-changes/track-changes-base.js +++ b/packages/super-editor/src/extensions/track-changes/track-changes-base.js @@ -1,6 +1,7 @@ import {Extension} from '@core/Extension.js'; -import {Plugin, PluginKey, EditorState} from "prosemirror-state"; +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"; @@ -137,7 +138,7 @@ export const TrackChangesBase = Extension.create({ if (prevTextNode) { prevTextNode.node.marks.forEach(mark => { - if (mark.type.name === TrackDeleteMarkName || mark.type.name === TrackInsertMarkName) { + if (mark.type.name === TrackDeleteMarkName || mark.type.name === TrackInsertMarkName || mark.type.name === TrackMarksMarkName) { correctedFrom = Math.max(prevTextNode.offset, 0); } }); @@ -145,13 +146,13 @@ export const TrackChangesBase = Extension.create({ if (nextTextNode) { nextTextNode.node.marks.forEach(mark => { - if (mark.type.name === TrackDeleteMarkName || mark.type.name === TrackInsertMarkName) { + 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) { + if (mark.type.name === TrackDeleteMarkName || mark.type.name === TrackInsertMarkName || mark.type.name === TrackMarksMarkName) { correctedTo = Math.min(currentTextNode.offset + currentTextNode.node.nodeSize, state.doc.nodeSize); } }); @@ -238,6 +239,15 @@ export const TrackChangesBase = Extension.create({ }); +/** + * + * @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 = []; @@ -263,6 +273,22 @@ const applyTrackChanges = (action, state, tr, from, to) => { 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}; diff --git a/packages/super-editor/src/extensions/track-changes/track-marks.js b/packages/super-editor/src/extensions/track-changes/track-marks.js index dd1a6a2491..b575ad6279 100644 --- a/packages/super-editor/src/extensions/track-changes/track-marks.js +++ b/packages/super-editor/src/extensions/track-changes/track-marks.js @@ -10,6 +10,11 @@ export const TrackMarks = Mark.create({ } }, + // 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 ` Date: Fri, 6 Sep 2024 09:46:17 -0700 Subject: [PATCH 7/8] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit ab20317f9d8c7e73a9de22a0ae5d9230c9249581 Author: Nick Bernal Date: Thu Sep 5 10:31:43 2024 -0700 save commit 15f88f360546881db137744e1e65cebbe423e4c6 Author: Nick Bernal Date: Tue Sep 3 17:16:23 2024 -0700 save commit 4be2da762267ae09c1793b01240b8e0ec0679c71 Author: Nick Bernal Date: Tue Sep 3 09:12:09 2024 -0700 Squashed commit of the following: commit 662e90f76f410bf53c03e4a7c8959ed1fbcba383 Author: Gergő Törcsvári Date: Mon Sep 2 16:54:17 2024 +0200 export commit c0a464525c82cc95afce29ba46585d4a5068d3d0 Author: Gergő Törcsvári Date: Fri Aug 30 13:29:52 2024 +0200 show/hide options + log on accept/revert commit 58bdb56f89358c20645a2c969004c202f47677ef Author: Gergő Törcsvári Date: Thu Aug 29 17:55:25 2024 +0200 better tracking code + decorations + approve/revert commit fa002f10e53a5806a38720cccc5cf8afc08015b8 Author: Gergő Törcsvári Date: Wed Aug 28 11:59:09 2024 +0200 track changes insert/delete + import early implementation commit 26ed49405a691007145e84d364f8283bdc8f6b61 Author: Nick Bernal Date: Tue Sep 3 09:08:57 2024 -0700 save commit fc36747fb4519467666b92bd2658c9b09c55b836 Author: Nick Bernal Date: Thu Aug 29 12:35:56 2024 -0700 Remove toggleTrackChanges, move command to setDocumentMode commit a68782aa03473f227f720ae4fc09d4c7ec380b02 Author: Gergő Törcsvári Date: Thu Aug 29 17:55:25 2024 +0200 better tracking code + decorations + approve/revert commit c59cfe458ee154a79f7eb68a5715d7a539e50817 Author: Gergő Törcsvári Date: Wed Aug 28 11:59:09 2024 +0200 track changes insert/delete + import early implementation --- package-lock.json | 3 ++- packages/super-editor/src/core/Editor.js | 13 +++++----- .../super-editor/src/core/config/style.js | 8 +++---- .../src/core/super-converter/importer.js | 1 - .../src/core/super-converter/numbering.js | 2 +- packages/superdoc/src/core/Superdoc.js | 24 +++++++++++++++++++ 6 files changed, 38 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 552f9b820e..676b153214 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6570,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", @@ -6586,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/core/Editor.js b/packages/super-editor/src/core/Editor.js index b392c7544d..d41c12a7c1 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -96,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); @@ -224,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); } } diff --git a/packages/super-editor/src/core/config/style.js b/packages/super-editor/src/core/config/style.js index 472db35206..c7f74d5636 100644 --- a/packages/super-editor/src/core/config/style.js +++ b/packages/super-editor/src/core/config/style.js @@ -76,8 +76,8 @@ li.ProseMirror-selectednode:after { &.normal { } &.highlighted { - border: 1px dashed #0f0; - background-color: #f0f0f0; + border: 1px dashed #00853D; + background-color: #399C7222; pointer-events: none; } } @@ -92,8 +92,8 @@ li.ProseMirror-selectednode:after { } } &.widget { - border: 1px dashed #f00; - background-color: #f0f0f0; + border: 1px dashed #CB0E47; + background-color: #CB0E4722; pointer-events: none; } } diff --git a/packages/super-editor/src/core/super-converter/importer.js b/packages/super-editor/src/core/super-converter/importer.js index 94c3a9a69b..83c62f616f 100644 --- a/packages/super-editor/src/core/super-converter/importer.js +++ b/packages/super-editor/src/core/super-converter/importer.js @@ -1,6 +1,5 @@ import { SuperConverter } from './SuperConverter'; import { twipsToPixels, twipsToInches, halfPointToPixels, emuToPixels } from './helpers.js'; -import { toKebabCase } from '@harbour-enterprises/common'; import { TrackDeleteMarkName, TrackInsertMarkName, 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/superdoc/src/core/Superdoc.js b/packages/superdoc/src/core/Superdoc.js index 0477a780f9..7718287583 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 }); } From f1bc4fd880dc029679f9f974ffc5e46793930e0a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 6 Sep 2024 09:34:47 -0700 Subject: [PATCH 8/8] Add isDev flag to toolbar to include dev items --- .../super-editor/src/components/toolbar/defaultItems.js | 7 +++++-- .../super-editor/src/components/toolbar/super-toolbar.js | 7 ++++--- .../src/dev/components/DeveloperPlayground.vue | 2 +- packages/super-editor/src/index.js | 2 -- packages/superdoc/src/core/Superdoc.js | 1 + 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/super-editor/src/components/toolbar/defaultItems.js b/packages/super-editor/src/components/toolbar/defaultItems.js index 1bc68c7da8..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", @@ -740,7 +740,7 @@ export const makeDefaultItems = (superToolbar) => { } - const toolbarItems = [ + let toolbarItems = [ undo, redo, test, @@ -777,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/dev/components/DeveloperPlayground.vue b/packages/super-editor/src/dev/components/DeveloperPlayground.vue index 3bb29fa022..fddbe083d0 100644 --- a/packages/super-editor/src/dev/components/DeveloperPlayground.vue +++ b/packages/super-editor/src/dev/components/DeveloperPlayground.vue @@ -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/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/superdoc/src/core/Superdoc.js b/packages/superdoc/src/core/Superdoc.js index 7718287583..9fd6b400e5 100644 --- a/packages/superdoc/src/core/Superdoc.js +++ b/packages/superdoc/src/core/Superdoc.js @@ -144,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));