diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 09ae8c7a7c..396a4f8e02 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -1,9 +1,10 @@ import xmljs from 'xml-js'; import { getNodeNumberingDefinition } from './numbering'; -import { toKebabCase } from '@harbour-enterprises/common'; import { DocxExporter, exportSchemaToJson } from './exporter'; import { DocxImporter } from './importer'; +import {createDocumentJson} from "./v2/importer/docxImporter.js"; +import {getInitialJSON} from "./v2/docxHelper.js"; class SuperConverter { @@ -14,7 +15,7 @@ class SuperConverter { 'w:p': 'paragraph', 'w:r': 'run', 'w:t': 'text', - 'w:delText': 'deletedText', + 'w:delText': 'text', 'w:br': 'lineBreak', 'w:tbl': 'table', 'w:tr': 'tableRow', @@ -158,8 +159,14 @@ class SuperConverter { } getSchema() { - const importer = new DocxImporter(this); - return importer.getSchema(); + const result = createDocumentJson({...this.convertedXml, media: this.media}); + if (result) { + this.savedTagsToRestore.push({...result.savedTagsToRestore}); + this.pageStyles = result.pageStyles; + return result.pmDoc; + } else { + return null; + } } schemaToXml(data) { diff --git a/packages/super-editor/src/core/super-converter/v2/docxHelper.js b/packages/super-editor/src/core/super-converter/v2/docxHelper.js new file mode 100644 index 0000000000..2b4090b747 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/docxHelper.js @@ -0,0 +1,92 @@ +import xmljs from 'xml-js'; +/** + * @typedef {Object.} ParsedDocx + * this should have the `media` as a key for image handling + */ + +/** + * Convert docx to our inner representation + * @param {{name: string, content: string}[]} docxAsXmlFileList + * @returns {ParsedDocx} + */ +export const docxAsXmlFileList2ParsedDocx = (docxAsXmlFileList) => { + const convertedXml = {} + docxAsXmlFileList.forEach(file => { + convertedXml[file.name] = parseXmlToJson(file.content); + }); + return convertedXml; +} + +const defaultInitialXml = `` + +export const parseXmlToJson = (xml) => { + return JSON.parse(xmljs.xml2json(xml, null, 2)) +} + +/** + * + * @param {ParsedDocx} parsedDocx + * @param {string} [fallbackXml] + */ +export const getInitialJSON = (parsedDocx, fallbackXml = defaultInitialXml) => { + return parsedDocx['word/document.xml'] || parseXmlToJson(fallbackXml); +} +/** + * + * @param {ParsedDocx} parsedDocx + * @returns * + */ +const getDeclaration = (parsedDocx) => { + return getInitialJSON(parsedDocx).declaration; +} + +const getThemeInfo = (themeName) =>{ + themeName = themeName.toLowerCase(); + const theme1 = this.convertedXml['word/theme/theme1.xml']; + const themeData = theme1.elements.find((el) => el.name === 'a:theme'); + const themeElements = themeData.elements.find((el) => el.name === "a:themeElements"); + const fontScheme = themeElements.elements.find((el) => el.name === 'a:fontScheme'); + let fonts; + + if (themeName.startsWith('major')) { + fonts = fontScheme.elements.find((el) => el.name === 'a:majorFont').elements[0]; + } else if (themeName.startsWith('minor')) { + fonts = fontScheme.elements.find((el) => el.name === 'a:minorFont').elements[0]; + } + + const { typeface, panose } = fonts.attributes; + return { typeface, panose }; +} + +/** + * + * @param {ParsedDocx} parsedDocx + * @returns {{} | {fontSizePt: number, kern: string, typeface: string, panose: string}} + */ +const getDocumentDefaultStyles = (parsedDocx) => { + const styles = parsedDocx['word/styles.xml']; + if (!styles) return {}; + + const defaults = styles.elements[0].elements.find((el) => el.name === 'w:docDefaults'); + + // TODO: Check if we need this + // const pDefault = defaults.elements.find((el) => el.name === 'w:pPrDefault'); + + // Get the run defaults for this document - this will include font, theme etc. + const rDefault = defaults.elements.find((el) => el.name === 'w:rPrDefault'); + if ('elements' in rDefault) { + const rElements = rDefault.elements[0].elements + const fontThemeName = rElements.find((el) => el.name === 'w:rFonts')?.attributes['w:asciiTheme']; + let typeface, panose; + if (fontThemeName) { + const fontInfo = getThemeInfo(fontThemeName); + typeface = fontInfo.typeface; + panose = fontInfo.panose; + } + + const fontSizePt = Number(rElements.find((el) => el.name === 'w:sz')?.attributes['w:val']) / 2; + const kern = rElements.find((el) => el.name === 'w:kern')?.attributes['w:val']; + return { fontSizePt, kern, typeface, panose }; + } + return {}; +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/bookmarkNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/bookmarkNodeImporter.js new file mode 100644 index 0000000000..90ffa8ea7a --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/bookmarkNodeImporter.js @@ -0,0 +1,28 @@ +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleBookmarkNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:bookmarkStart') { + return []; + } + const node = nodes[0]; + + const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; + if (!handleStandardNode) { + console.error('Standard node handler not found'); + return []; + } + const result = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + if(result.length === 1) { + result[0].attrs.name = node.attributes['w:name']; + } + return result; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const bookmarkNodeHandlerEntity = { + handlerName: 'bookmarkNodeHandler', + handler: handleBookmarkNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/boomarkNodeImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/boomarkNodeImporter.test.js new file mode 100644 index 0000000000..4d6a10ccfe --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/boomarkNodeImporter.test.js @@ -0,0 +1,30 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleBookmarkNode} from "./bookmarkNodeImporter.js"; +import {createNodeListHandlerMock} from "./test-helpers/testUtils.test.js"; + + +describe('BookmarkNodeImporter', () => { + it("parses only bookmark nodes", () => { + const names = Object.keys(SuperConverter.allowedElements).filter((name) => name !== 'w:bookmarkStart'); + const nodesOfNodes = names.map((name) => ([{name}])); + for(const nodes of nodesOfNodes) { + const result = handleBookmarkNode(nodes, null, null, false); + expect(result.length).toBe(0); + } + }) + it("parses bookmark nodes and w:name attributes", () => { + const nodes = [{name: 'w:bookmarkStart', attributes: {'w:name': 'bookmarkName'}}]; + const result = handleBookmarkNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.length).toBe(1); + expect(result[0].type).toBe('standardNodeHandler'); + expect(result[0].attrs.name).toBe("bookmarkName"); + }) + it("parser relies on handleStandardNode", () => { + const consoleMock = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const nodes = [{name: 'w:bookmarkStart', attributes: {'w:name': 'bookmarkName'}}]; + const result = handleBookmarkNode(nodes, null, {handlerEntities: []}, false); + expect(result.length).toBe(0); + expect(consoleMock).toHaveBeenCalledOnce(); + }) +}) \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js new file mode 100644 index 0000000000..112beac5bd --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -0,0 +1,181 @@ +import {getInitialJSON} from "../docxHelper.js"; +import {carbonCopy} from "../../../utilities/cabonCopy.js"; +import {twipsToInches} from "../../helpers.js"; +import {tableNodeHandlerEntity} from "./tableImporter.js"; +import {drawingNodeHandlerEntity} from "./imageImporter.js"; +import { + handleParagraphNodeChange, + nodeTypeChangeAttributeChanger, + trackChangeNodeHandlerEntity +} from "./trackChangesImporter.js"; +import {hyperlinkNodeHandlerEntity} from "./hyperlinkImporter.js"; +import {runNodeHandlerEntity} from "./runNodeImporter.js"; +import {textNodeHandlerEntity} from "./textNodeImporter.js"; +import {paragraphNodeHandlerEntity} from "./paragraphNodeImporter.js"; +import {standardNodeHandlerEntity} from "./standardNodeImporter.js"; +import {lineBreakNodeHandlerEntity} from "./lineBreakImporter.js"; +import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; +import {listHandlerEntity, listNodeAggregator} from "./listImporter.js"; + +/** + * @typedef {import()} XmlNode + * @typedef {{type: string, content: *, attrs: {}}} PmNodeJson + * @typedef {{type: string, attrs: {}}} PmMarkJson + * + * @typedef {(nodes: XmlNode[], docx: ParsedDocx, insideTrackCahange: boolean) => PmNodeJson[]} NodeListHandlerFn + * @typedef {{handler: NodeListHandlerFn, handlerEntities: NodeHandlerEntry[]}} NodeListHandler + * + * @typedef {(nodes: XmlNode[], docx: ParsedDocx, nodeListHandler: NodeListHandler, insideTrackCahange: boolean) => PmNodeJson[]} NodeHandler + * @typedef {{handlerName: string, handler: NodeHandler}} NodeHandlerEntry + */ + +/** + * + * @param {ParsedDocx} docx + * @returns {{pmDoc: PmNodeJson, savedTagsToRestore: XmlNode, pageStyles: *}|null} + */ +export const createDocumentJson = (docx) => { + const json = carbonCopy(getInitialJSON(docx)); + if (!json) return null; + + console.debug('\n\n JSON', json,) + const nodeListHandler = defaultNodeListHandler(); + if(json.elements[0].elements[0].name === 'w:body') { + const node = json.elements[0].elements[0]; + const ignoreNodes = ['w:sectPr']; + const content = node.elements?.filter((n) => !ignoreNodes.includes(n.name)) ?? []; + + const result = { + type: 'doc', + content: nodeListHandler.handler(content, docx, false), + attrs: { + attributes: json.elements[0].attributes, + } + } + return { + pmDoc: result, + savedTagsToRestore: node, + pageStyles: getDocumentStyles(node), + }; + } + return null; +} + +/** + * @returns {NodeListHandler} + */ +export const defaultNodeListHandler = () => { + const entities = [ + runNodeHandlerEntity, + paragraphNodeHandlerEntity, + listHandlerEntity, + textNodeHandlerEntity, + lineBreakNodeHandlerEntity, + bookmarkNodeHandlerEntity, + hyperlinkNodeHandlerEntity, + drawingNodeHandlerEntity, + trackChangeNodeHandlerEntity, + tableNodeHandlerEntity, + standardNodeHandlerEntity, //this should be the last one, bcs this parses everything!!! + ] + const handler = createNodeListHandler(entities); + return { + handler, + handlerEntities: entities + } +} + +/** + * + * @param {NodeHandlerEntry[]} nodeHandlers + */ +const createNodeListHandler = (nodeHandlers) => { + /** + * @param {XmlNode[]} elements + * @param {ParsedDocx} docx + * @param {boolean} insideTrackChange + * @return {{type: string, content: *, attrs: {attributes}}[]} + */ + const nodeListHandlerFn = (elements, docx, insideTrackChange) => { + if (!elements || !elements.length) return []; + + const handlerObj = {handler: nodeListHandlerFn, handlerEntities: nodeHandlers} + const processedElements = []; + + for (let index = 0; index < elements.length; index++) { + const nodes = nodeHandlers.reduce((res, handler) => { + if(res.length > 0) return res; + const nodesToHandle = elements.slice(index); + if(!nodesToHandle || nodesToHandle.length === 0) return res; + const handlerResponse = handler.handler(nodesToHandle, docx, handlerObj, insideTrackChange); + if(handlerResponse.length > 0) { + handleParagraphNodeChange(nodesToHandle[0], handlerResponse[0], docx, handlerObj) + return handlerResponse; + } + return res + }, []); + if(nodes.length === 0) { + console.warn("We have a node that we can't handle!", elements[index]) + } + for(let node of nodes) { + if (node?.type) { + const ignore = ['runProperties']; + if (!ignore.includes(node.type)) processedElements.push(node); + } + } + } + + const aggregatedNodes = listNodeAggregator(processedElements, docx, handlerObj); + + return aggregatedNodes; + } + + return nodeListHandlerFn; +} + +/** + * + * @param {XmlNode} node + * @returns {*} + */ +function getDocumentStyles(node) { + const sectPr = node.elements.find((n) => n.name === 'w:sectPr'); + const styles = {}; + + sectPr.elements.forEach((el) => { + const { name, attributes } = el; + switch (name) { + case 'w:pgSz': + styles['pageSize'] = { + width: twipsToInches(attributes['w:w']), + height: twipsToInches(attributes['w:h']), + } + break; + case 'w:pgMar': + styles['pageMargins'] = { + top: twipsToInches(attributes['w:top']), + right: twipsToInches(attributes['w:right']), + bottom: twipsToInches(attributes['w:bottom']), + left: twipsToInches(attributes['w:left']), + header: twipsToInches(attributes['w:header']), + footer: twipsToInches(attributes['w:footer']), + gutter: twipsToInches(attributes['w:gutter']), + } + break; + case 'w:cols': + styles['columns'] = { + space: twipsToInches(attributes['w:space']), + num: attributes['w:num'], + equalWidth: attributes['w:equalWidth'], + } + break; + case 'w:docGrid': + styles['docGrid'] = { + linePitch: twipsToInches(attributes['w:linePitch']), + type: attributes['w:type'], + } + break; + } + }); + return styles; +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/hyperlinkImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/hyperlinkImporter.js new file mode 100644 index 0000000000..9205d5d4f4 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/hyperlinkImporter.js @@ -0,0 +1,60 @@ + + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleHyperlinkNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:hyperlink') { + return []; + } + const node = nodes[0]; + + const rels = docx['word/_rels/document.xml.rels']; + const relationships = rels.elements.find((el) => el.name === 'Relationships'); + const { elements } = relationships; + + const { attributes } = node; + const rId = attributes['r:id']; + const anchor = attributes['w:anchor']; + + // TODO: Check if we need this atr + const history = attributes['w:history']; + + 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 + const runNode = node.elements.find((el) => el.name === 'w:r'); + const linkMark = { type: 'link', attrs: { href } }; + + if (!runNode.marks) runNode.marks = []; + runNode.marks.push(linkMark); + + const rPr = runNode.elements.find((el) => el.name === 'w:rPr'); + if (rPr) { + const styleRel = rPr.elements.find((el) => el.name === 'w:rStyle'); + if (styleRel) { + const styles = docx['word/styles.xml']; + const { elements } = styles.elements[0]; + + const styleElements = elements.filter((el) => el.name === 'w:style'); + const style = styleElements.find((el) => el.attributes['w:styleId'] === 'Hyperlink'); + const styleRpr = style.elements.find((el) => el.name === 'w:rPr'); + if (styleRpr) runNode.elements.unshift(styleRpr); + } + } + + const updatedNode = nodeListHandler.handler([runNode], docx, insideTrackChange); + return updatedNode; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const hyperlinkNodeHandlerEntity = { + handlerName: 'hyperlinkNodeHandler', + handler: handleHyperlinkNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js new file mode 100644 index 0000000000..b67bd35c73 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js @@ -0,0 +1,77 @@ +import {emuToPixels} from "../../helpers.js"; + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleDrawingNode= (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:drawing') { + return []; + } + const node = nodes[0]; + + let result; + const { elements } = node; + + // Inline images + const inlineImage = elements.find((el) => el.name === 'wp:inline'); + if (inlineImage) result = handleInlineImageNode(inlineImage, docx); + return result ? [result] : []; +} + +export function handleInlineImageNode(node, docx) { + const { attributes } = node; + const padding = { + top: emuToPixels(attributes['distT']), + bottom: emuToPixels(attributes['distB']), + left: emuToPixels(attributes['distL']), + right: emuToPixels(attributes['distR']), + }; + + const extent = node.elements.find((el) => el.name === 'wp:extent'); + const size = { + width: emuToPixels(extent.attributes['cx']), + height: emuToPixels(extent.attributes['cy']) + } + + // TODO: Do we need this? + const effectExtent = node.elements.find((el) => el.name === 'wp:effectExtent'); + + 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'); + const { attributes: blipAttributes } = blip; + const rEmbed = blipAttributes['r:embed']; + const print = blipAttributes['r:print']; + + const rels = docx['word/_rels/document.xml.rels']; + const relationships = rels.elements.find((el) => el.name === 'Relationships'); + const { elements } = relationships; + + const rel = elements.find((el) => el.attributes['Id'] === rEmbed); + const { attributes: relAttributes } = rel; + const media = docx.media; + const path = `word/${relAttributes['Target']}`; + + return { + type: 'image', + attrs: { + src: media[path], + alt: 'Image', + title: 'Image', + inline: true, + padding, + size, + } + } +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const drawingNodeHandlerEntity = { + handlerName: 'drawingNodeHandler', + handler: handleDrawingNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js new file mode 100644 index 0000000000..c29380a599 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js @@ -0,0 +1,91 @@ +import {handleStyleChangeMarks, parseMarks} from "./markImporter.js"; +import {SuperConverter} from "../../SuperConverter.js"; + + +/** + * + * @param {XmlNode} node + * @param {ParsedDocx} docx - The parsed docx object. + * @param {NodeListHandler} nodeListHandler - The node list handler function. + * @param {boolean} insideTrackChange - Whether we are inside a track change. + * @param {number} [listLevel=0] - The current indentation level of the list. + * @returns {{elements: *, attributes: {}, marks: *}} + * + */ +export function parseProperties(node, docx, nodeListHandler, insideTrackChange, listLevel = 0) { + /** + * What does it mean for a node to have a properties element? + * It would have a child element that is: w:pPr, w:rPr, w:sectPr + */ + let marks = []; + const { attributes = {}, elements = [] } = node; + const { nodes, paragraphProperties = {}, runProperties = {} } = splitElementsAndProperties(elements); + paragraphProperties.elements = paragraphProperties?.elements?.filter((el) => el.name !== 'w:rPr'); + + // Get the marks from the run properties + if (runProperties && runProperties?.elements?.length) marks = parseMarks(runProperties); + if (paragraphProperties && paragraphProperties.elements?.length) { + marks.push(...parseMarks(paragraphProperties)); + } + //add style change marks + marks.push(...handleStyleChangeMarks(runProperties, marks)); + + // Maintain any extra properties + if (paragraphProperties && paragraphProperties.elements?.length) { + attributes['paragraphProperties'] = paragraphProperties; + } + + // If this is a paragraph, don't apply marks but apply attributes directly + if (marks && node.name === 'w:p') { + marks.forEach((mark) => { + const keys = Object.keys(mark.attrs) + if(keys.length > 0) { + const attrValue = keys[0]; + const value = mark.attrs[attrValue]; + attributes[attrValue] = value; + } + }); + marks = []; + } + return { elements: nodes, attributes, marks } +} + +/** + * + * @param {XmlNode[]} elements + * @returns {{nodes: *, runProperties: *, sectionProperties: *, paragraphProperties: *}} + */ +export function splitElementsAndProperties(elements) { + const pPr = elements.find((el) => el.name === 'w:pPr'); + const rPr = elements.find((el) => el.name === 'w:rPr'); + const sectPr = elements.find((el) => el.name === 'w:sectPr'); + const els = elements.filter((el) => el.name !== 'w:pPr' && el.name !== 'w:rPr' && el.name !== 'w:sectPr'); + + return { + nodes: els, + paragraphProperties: pPr, + runProperties: rPr, + sectionProperties: sectPr, + } +} + +/** + * + * @param {XmlNode} element + * @returns {*} + */ +export function getElementName(element) { + return SuperConverter.allowedElements[element.name || element.type]; +} + + +/** + * + * @param {XmlNode[]} elements + * @returns {*} + */ +export function hasTextNode(elements) { + const runs = elements.filter((el) => el.name === 'w:r'); + const runsHaveText = runs.some((run) => run.elements.some((el) => el.name === 'w:t')); + return runsHaveText; +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js new file mode 100644 index 0000000000..485ae2b3a8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js @@ -0,0 +1,28 @@ +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleLineBreakNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:br') { + return []; + } + + const attrs = {}; + + const lineBreakType = nodes[0].attributes?.['w:type']; + if (lineBreakType) attrs['lineBreakType'] = lineBreakType; + + + return [{ + type: 'lineBreak', + content: [], + attrs, + }]; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const lineBreakNodeHandlerEntity = { + handlerName: 'lineBreakNodeHandler', + handler: handleLineBreakNode +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.test.js new file mode 100644 index 0000000000..f748e9a6e8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.test.js @@ -0,0 +1,21 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleLineBreakNode} from "./lineBreakImporter.js"; +import {createNodeListHandlerMock} from "./test-helpers/testUtils.test.js"; + +describe('LineBreakNodeImporter', () => { + it("parses only line break nodes", () => { + const names = Object.keys(SuperConverter.allowedElements).filter((name) => name !== 'w:br'); + const nodesOfNodes = names.map((name) => ([{name}])); + for(const nodes of nodesOfNodes) { + const result = handleLineBreakNode(nodes, null, null, false); + expect(result.length).toBe(0); + } + }); + + it("parses line break nodes and w:br attributes", () => { + const nodes = [{name: 'w:br'}]; + const result = handleLineBreakNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.length).toBe(1); + expect(result[0].type).toBe('lineBreak'); + }); +}); \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js new file mode 100644 index 0000000000..e96add4a8e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -0,0 +1,329 @@ +import {carbonCopy} from "../../../utilities/cabonCopy.js"; +import {parseProperties} from "./importerHelpers.js"; +import {preProcessNodesForFldChar} from "./paragraphNodeImporter.js"; +import {windowingSplit} from "../../../utilities/windowingSplit.js"; + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:p') { + return []; + } + const node = carbonCopy(nodes[0]) + + // We need to pre-process paragraph nodes to combine various possible elements we will find ie: lists, links. + const processedElements = preProcessNodesForFldChar(node.elements); + node.elements = processedElements; + + // Check if this paragraph node is a list + if (testForList(node)) { + return singleNodeTransform(node, docx, nodeListHandler, insideTrackChange) + } else { + return []; + } +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const listHandlerEntity = { + handlerName: 'listHandler', + handler: handleListNode +} + +/** + * Single node transform + * @param {XmlNode} node + * @param {ParsedDocx} docx + * @param {NodeListHandler} nodeListHandler + * @param {boolean} insideTrackChange + * @returns {PmNodeJson[]} + */ +export const singleNodeTransform = (node, docx, nodeListHandler, insideTrackChange) => { + const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; + + // Get the properties of the node - this is where we will find depth level for the node + // As well as many other list properties + const { attributes, elements, marks = [] } = parseProperties(node, docx, nodeListHandler, insideTrackChange); + const { + ilvl, + numId + } = getNumberingDefinitionsFromNode(attributes); + const intLevel = parseInt(ilvl); + + const child = { + type: 'paragraph', + content: nodeListHandler.handler(elements, docx, insideTrackChange)?.filter(n => n) + } + + const nodeAttributes = { + numId, + ilvl, + intLevel, + insideTrackChange: insideTrackChange, + attributes: { + parentAttributes: node?.attributes || null, + } + }; + + return [createListItem([child], nodeAttributes, [])]; +} + +/** + * + * @param {PmNodeJson[]} pmNodes + * @param {ParsedDocx} docx + * @returns {PmNodeJson[]} + */ +export const listNodeAggregator = (pmNodes, docx) => { + const result = [] + const windows = windowingSplit(pmNodes, (node) => node.type === 'listItem', (a, b) => a.attrs.numId === b.attrs.numId) + for (let i = 0; i < windows.length; i++) { + const window = windows[i]; + if (window.matched) { + const list = handleListNodeList(window.list, docx, 0); + result.push(list); + } else { + result.push(...window.list); + } + } + return result; +} + +/** + * + * @param {PmNodeJson[]} listItems + * @param {ParsedDocx} docx + * @param {number} listLevel + * @returns {PmNodeJson} + */ +function handleListNodeList(listItems, docx, listLevel = 0) { + const parsedListItems = []; + let listStyleType; + let overallListType; + + for (let [index, item] of listItems.entries()) { + if(item.seen) continue; + + if (item.type !== 'listItem') { + parsedListItems[parsedListItems.length - 1]?.content.push(item); + continue; + } + + const { + listType, + listOrderingType, + listrPrs, + listpPrs, + start, + lvlText, + lvlJc + } = getListDefinition(item.attrs.numId, listLevel, docx); + listStyleType = listOrderingType; + const intLevel = item.attrs.intLevel; + + // Append node if it belongs on this list level + const nodeAttributes = {}; + if (listLevel === intLevel) { + overallListType = listType; + item.seen = true; + + item.attrs = { + ...item.attrs, + listrPrs, + listpPrs, + order: start, + lvlText, + lvlJc, + } + parsedListItems.push(item); + } else if (listLevel < intLevel) { + //we go into a deeper level so this is a sublist thing + const sublist = handleListNodeList(listItems.slice(index), docx, listLevel + 1); + const lastItem = parsedListItems[parsedListItems.length - 1]; + if (!lastItem) { + parsedListItems.push(sublist); + } else { + lastItem.content.push(sublist); + } + } else { + // we go into a higher level so it is time to break out + break; + } + } + return { + type: overallListType || 'bulletList', + content: parsedListItems, + attrs: { + 'list-style-type': listStyleType, + } + }; +} + + +/** + * + * @param {XmlNode} node + * @param {boolean} isInsideList + * @returns {boolean|*} + */ +export function testForList(node, isInsideList = false) { + const { elements } = node; + const pPr = elements?.find(el => el.name === 'w:pPr') + if (!pPr) return false; + + const paragraphStyle = pPr.elements?.find(el => el.name === 'w:pStyle'); + const isList = paragraphStyle?.attributes['w:val'] === 'ListParagraph'; + const hasNumPr = pPr.elements?.find(el => el.name === 'w:numPr'); + return isList || hasNumPr; +} + + +/** + * Creates a list item node with specified content and marks. + * + * @param {Array} content - The content of the list item. + * @param {Array} marks - The marks associated with the list item. + * @returns {Object} The created list item node. + */ +function createListItem(content, attrs, marks) { + return { + type: 'listItem', + content, + attrs, + marks, + }; +} + + + +const orderedListTypes = [ + "decimal", // eg: 1, 2, 3, 4, 5, ... + "decimalZero", // eg: 01, 02, 03, 04, 05, ... + "lowerRoman", // eg: i, ii, iii, iv, v, ... + "upperRoman", // eg: I, II, III, IV, V, ... + "lowerLetter", // eg: a, b, c, d, e, ... + "upperLetter", // eg: A, B, C, D, E, ... + "ordinal", // eg: 1st, 2nd, 3rd, 4th, 5th, ... + "cardinalText", // eg: one, two, three, four, five, ... + "ordinalText", // eg: first, second, third, fourth, fifth, ... + "hex", // eg: 0, 1, 2, ..., 9, A, B, C, ..., F, 10, 11, ... + "chicago", // eg: (0, 1, 2, ..., 9, 10, 11, 12, ..., 19, 1A, 1B, 1C, ..., 1Z, 20, 21, ..., 2Z) +]; + +const unorderedListTypes = [ + "bullet", // A standard bullet point (•) + "square", // Square bullets (▪) + "circle", // Circle bullets (◦) + "disc", // Disc bullets (●) +] + +function getNumberingDefinitionsFromNode(attributes) { + if (!attributes) return; + + const { paragraphProperties } = attributes; + const { elements: listStyles } = paragraphProperties; + const numPr = listStyles.find(style => style.name === 'w:numPr'); + if (!numPr) { + return {}; + throw new Error(`No numbering properties found in paragraph: ${JSON.stringify(attributes)}`); + } + + // Get the indent level + const ilvlTag = numPr.elements.find(style => style.name === 'w:ilvl'); + const ilvl = ilvlTag?.attributes['w:val'] ?? 0; + + // Get the list style id + const numIdTag = numPr.elements.find(style => style.name === 'w:numId'); + const numId = numIdTag.attributes['w:val']; + + return { ilvl, numId }; +} + +/** + * + * @param {string} numId + * @param {number} level + * @param {ParsedDocx} docx + * @returns {{lvlJc: *, listOrderingType: *, start: *, lvlText: *, listType: string, listpPrs: {}, listrPrs: {}}|{}} + */ +function getListDefinition(numId, level, docx) { + const def = docx['word/numbering.xml']; + if (!def) return {}; + + const { elements } = def; + const listData = elements[0]; + + // Get the list styles + const numberingElements = listData.elements; + const abstractDefinitions = numberingElements.filter(style => style.name === 'w:abstractNum') + const numDefinitions = numberingElements.filter(style => style.name === 'w:num') + const numDefinition = numDefinitions.find(style => style.attributes['w:numId'] === numId); + const abstractNumId = numDefinition?.elements[0].attributes['w:val'] + const listDefinitionForThisNumId = abstractDefinitions?.find(style => style.attributes['w:abstractNumId'] === abstractNumId); + + // Determine list type and formatting for this list level + const currentLevel = getDefinitionForLevel(listDefinitionForThisNumId, level); + if (!currentLevel) return {} + + const start = currentLevel.elements.find(style => style.name === 'w:start')?.attributes['w:val']; + const listTypeDef = currentLevel.elements.find(style => style.name === 'w:numFmt').attributes['w:val']; + const lvlText = currentLevel.elements.find(style => style.name === 'w:lvlText').attributes['w:val']; + const lvlJc = currentLevel.elements.find(style => style.name === 'w:lvlJc').attributes['w:val']; + + // Properties - there can be run properties and paragraph properties + const pPr = currentLevel.elements.find(style => style.name === 'w:pPr'); + let listpPrs, listrPrs; + if (pPr) listpPrs = processListParagraphProperties(pPr); + + const rPr = currentLevel.elements.find(style => style.name === 'w:rPr'); + if (rPr) listrPrs = processListRunProperties(rPr); + + // Get style for this list level + let listType; + if (unorderedListTypes.includes(listTypeDef.toLowerCase())) listType = 'bulletList'; + else if (orderedListTypes.includes(listTypeDef)) listType = 'orderedList'; + else { + throw new Error(`Unknown list type found during import: ${listTypeDef}`); + } + + return { listType, listOrderingType: listTypeDef, listrPrs, listpPrs, start, lvlText, lvlJc }; +} + +function getDefinitionForLevel(data, level) { + return data?.elements?.find((item) => Number(item.attributes['w:ilvl']) === level); +} + +function processListParagraphProperties(data) { + const { elements } = data; + const expectedTypes = ['w:ind', 'w:jc', 'w:tabs']; + const paragraphProperties = {}; + if (!elements) return paragraphProperties; + + elements.forEach((item) => { + if (!expectedTypes.includes(item.name)) throw new Error(`[numbering.xml] Unexpected list paragraph prop found: ${item.name}`); + const { attributes = {} } = item; + Object.keys(attributes).forEach(key => { + paragraphProperties[key] = attributes[key]; + }); + }); + return paragraphProperties; +} + +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', 'w:smallCaps']; + const runProperties = {}; + if (!elements) return runProperties; + + elements.forEach((item) => { + if (!expectedTypes.includes(item.name)) throw new Error(`[numbering.xml] Unexpected list run prop found: ${item.name}`); + const { attributes = {} } = item; + Object.keys(attributes).forEach(key => { + runProperties[key] = attributes[key]; + }); + }); + return runProperties; +} + diff --git a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.test.js new file mode 100644 index 0000000000..f5f12458b3 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.test.js @@ -0,0 +1,192 @@ +import {parseXmlToJson} from "../docxHelper.js"; +import {handleAllTableNodes} from "./tableImporter.js"; +import {defaultNodeListHandler} from "./docxImporter.js"; +import {handleListNode} from "./listImporter.js"; +import {numberingBulletXml} from "./test-helpers/testUtils.test.js"; + + +describe("list live xml test", () => { + it("parses simple bullet xml", () => { + const exampleSingleBulletXml = ` + + + + + + + + + + TEXTITEM + + + `; + const nodes = parseXmlToJson(exampleSingleBulletXml).elements + const numbering = parseXmlToJson(numberingBulletXml) + const docx = { + 'word/numbering.xml': numbering + } + + const handler = defaultNodeListHandler() + const result = handler.handler(nodes, docx, false); + expect(result.length).toBe(1); + expect(result[0].type).toBe("bulletList"); + expect(result[0].content.length).toBe(1); + expect(result[0].content[0].type).toBe("listItem"); + expect(result[0].content[0].content.length).toBe(1); + expect(result[0].content[0].content[0].type).toBe("paragraph"); + expect(result[0].content[0].content[0].content.length).toBe(1); + expect(result[0].content[0].content[0].content[0].type).toBe("text"); + expect(result[0].content[0].content[0].content[0].text).toBe("TEXTITEM"); + }); + + it("parses simple numbered xml", () => { + const exampleSingleNumberedXml = ` + + + + + + + + + + numbered + + + `; + const nodes = parseXmlToJson(exampleSingleNumberedXml).elements + const numbering = parseXmlToJson(numberingBulletXml) + const docx = { + 'word/numbering.xml': numbering + } + + const handler = defaultNodeListHandler() + const result = handler.handler(nodes, docx, false); + expect(result.length).toBe(1); + expect(result[0].type).toBe("orderedList"); + expect(result[0].content.length).toBe(1); + expect(result[0].content[0].type).toBe("listItem"); + expect(result[0].content[0].content.length).toBe(1); + expect(result[0].content[0].content[0].type).toBe("paragraph"); + expect(result[0].content[0].content[0].content.length).toBe(1); + expect(result[0].content[0].content[0].content[0].type).toBe("text"); + expect(result[0].content[0].content[0].content[0].text).toBe("numbered"); + }); + + + it("parses multi nested list xml", () => { + const exampleMultiNestedListXml = ` + + + + + + + + + + L1: A + + + + + + + + + + + + L2: B + + + + + + + + + + + + L1: C + + + + + + + + + + + + L2: D + + + + + + + + + + + + L3: E + + + + + + + + + + + + L2: F + + + + + + + + + + + + L1: G + + + `; + const nodes = parseXmlToJson(exampleMultiNestedListXml).elements[0].elements + const numbering = parseXmlToJson(numberingBulletXml) + const docx = { + 'word/numbering.xml': numbering + } + + const handler = defaultNodeListHandler() + const result = handler.handler(nodes, docx, false); + expect(result.length).toBe(1); + expect(result[0].type).toBe("bulletList"); + expect(result[0].content.length).toBe(3); + expect(result[0].content[0].type).toBe("listItem"); + expect(result[0].content[0].content.length).toBe(2); + expect(result[0].content[0].content[0].type).toBe("paragraph"); + expect(result[0].content[0].content[0].content.length).toBe(1); + expect(result[0].content[0].content[0].content[0].type).toBe("text"); + expect(result[0].content[0].content[0].content[0].text).toBe("L1: A"); + expect(result[0].content[0].content[1].type).toBe("bulletList"); + expect(result[0].content[0].content[1].content.length).toBe(1); + expect(result[0].content[0].content[1].content[0].type).toBe("listItem"); + expect(result[0].content[0].content[1].content[0].content.length).toBe(1); + expect(result[0].content[0].content[1].content[0].content[0].type).toBe("paragraph"); + expect(result[0].content[0].content[1].content[0].content[0].content.length).toBe(1); + expect(result[0].content[0].content[1].content[0].content[0].content[0].type).toBe("text"); + expect(result[0].content[0].content[1].content[0].content[0].content[0].text).toBe("L2: B"); + }); + +}); \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js new file mode 100644 index 0000000000..a857680e83 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js @@ -0,0 +1,160 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {TrackMarksMarkName} from "../../../../extensions/track-changes/constants.js"; +import {twipsToInches} from "../../helpers.js"; + +/** + * + * @param {XmlNode} property + * @returns {PmMarkJson[]} + */ +export function parseMarks(property) { + const marks = []; + const seen = new Set(); + + property.elements.forEach((element) => { + const marksForType = SuperConverter.markTypes.filter((mark) => mark.name === element.name); + if (!marksForType.length) { + const missingMarks = [ + 'w:shd', + 'w:rStyle', + 'w:pStyle', + 'w:numPr', + 'w:outlineLvl', + 'w:bdr', + 'w:pBdr', + 'w:noProof', + 'w:highlight', + 'w:contextualSpacing', + 'w:keepNext', + 'w:tabs', + 'w:keepLines' + ] + if (missingMarks.includes(element.name)) console.debug('❗️❗️ATTN: No marks found for element:', element.name); + // else throw new Error(`No marks found for element: ${element.name}`); + } + + marksForType.forEach((m) => { + if (!m || seen.has(m.type)) return; + seen.add(m.type); + + const { attributes = {} } = element; + const newMark = { type: m.type } + + if (attributes['w:val'] == "0" || attributes['w:val'] === 'none') { + return; + } + + // Use the parent mark (ie: textStyle) if present + if (m.mark) newMark.type = m.mark; + + // Marks with attrs: we need to get their values + if (Object.keys(attributes).length) { + const value = getMarkValue(m.type, attributes); + + newMark.attrs = {}; + newMark.attrs[m.property] = value; + } + marks.push(newMark); + }) + }); + return createImportMarks(marks); +} + + +/** + * + * @param {XmlNode} rPr + * @param {PmMarkJson[]} currentMarks + * @returns {PmMarkJson[]} a trackMarksMark, or an empty array + */ +export function 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 = parseMarks(styleChangeMark); + return [{type: TrackMarksMarkName, attrs: {...mappedAttributes, before: submarks, after: [...currentMarks]}}] +} + + +/** + * + * @param {PmMarkJson[]} marks + * @returns {PmMarkJson[]} + */ +export function createImportMarks(marks) { + const textStyleMarksToCombine = marks.filter((mark) => mark.type === 'textStyle'); + const remainingMarks = marks.filter((mark) => mark.type !== 'textStyle'); + + // Combine text style marks + const combinedTextAttrs = {}; + if (textStyleMarksToCombine.length) { + textStyleMarksToCombine.forEach((mark) => { + const { attrs } = mark; + + Object.keys(attrs).forEach((attr) => { + combinedTextAttrs[attr] = attrs[attr]; + }); + }); + }; + + const result = [...remainingMarks, { type: 'textStyle', attrs: combinedTextAttrs }]; + return result; +} + + +/** + * + * @param {string} markType + * @param attributes + * @returns {*} + */ +function getMarkValue(markType, attributes) { + if (markType === 'tabs') markType = 'textIndent'; + + const markValueMapper = { + color: () => `#${attributes['w:val']}`, + fontSize: () => `${attributes['w:val']/2}pt`, + textIndent: () => getIndentValue(attributes), + fontFamily: () => attributes['w:ascii'], + lineHeight: () => getLineHeightValue(attributes), + textAlign: () => attributes['w:val'], + link: () => attributes['href'], + underline: () => attributes['w:val'], + } + + if (!(markType in markValueMapper)) { + console.debug('\n\n ❗️❗️ No value mapper for:', markType, 'Attributes:', attributes) + }; + + // Returned the mapped mark value + if (markType in markValueMapper) { + const f = markValueMapper[markType]; + return markValueMapper[markType](); + } +} + + +function getIndentValue(attributes) { + let value = attributes['w:left']; + if (!value) value = attributes['w:firstLine']; + return `${twipsToInches(value)}in` +} + +function getLineHeightValue(attributes) { + let value = attributes['w:line']; + + // TODO: Figure out handling of additional line height attributes from docx + // if (!value) value = attributes['w:lineRule']; + // if (!value) value = attributes['w:after']; + // if (!value) value = attributes['w:before']; + if (!value || value === "0") return null; + return `${twipsToInches(value)}in`; +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js new file mode 100644 index 0000000000..6663b9f9e8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -0,0 +1,157 @@ +import {twipsToPixels} from "../../helpers.js"; +import {testForList} from "./listImporter.js"; +import {carbonCopy} from "../../../utilities/cabonCopy.js"; + +/** + * 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. + * + * @type {import("docxImporter").NodeHandler} + */ +export const handleParagraphNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:p') { + return []; + } + const node = carbonCopy(nodes[0]) + + let schemaNode; + + // We need to pre-process paragraph nodes to combine various possible elements we will find ie: lists, links. + const processedElements = preProcessNodesForFldChar(node.elements); + node.elements = processedElements; + + // Check if this paragraph node is a list + if (testForList(node)) { + return []; + } + + // If it is a standard paragraph node, process normally + const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; + if (!handleStandardNode) { + console.error('Standard node handler not found'); + return []; + } + const result = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + if(result.length === 1) { + schemaNode = result[0]; + } + + if ('attributes' in node) { + const defaultStyleId = node.attributes['w:rsidRDefault']; + const { lineSpaceAfter, lineSpaceBefore } = getDefaultStyleDefinition(defaultStyleId, docx); + + const pPr = node.elements.find((el) => el.name === 'w:pPr'); + const styleTag = pPr?.elements.find((el) => el.name === 'w:pStyle'); + if (styleTag) { + schemaNode.attrs['styleId'] = styleTag.attributes['w:val']; + } + + if (!('attributes' in schemaNode)) schemaNode.attributes = {}; + schemaNode.attrs['paragraphSpacing'] = { lineSpaceAfter, lineSpaceBefore }; + } + return schemaNode ? [schemaNode] : []; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const paragraphNodeHandlerEntity = { + handlerName: 'paragraphNodeHandler', + handler: handleParagraphNode +}; + + +/** + * TODO: There are so many possible styles here - confirm what else we need. + * @param {string} defaultStyleId + * @param {ParsedDocx} docx + */ +function getDefaultStyleDefinition(defaultStyleId, docx) { + const result = { lineSpaceBefore: null, lineSpaceAfter: null }; + const styles = docx['word/styles.xml']; + if (!styles) return result; + + const { elements } = styles.elements[0]; + // console.debug('Default style ID elements:', elements) + const elementsWithId = elements.filter((el) => { + return el.elements.some((e) => { + return 'attributes' in e && e.attributes['w:val'] === defaultStyleId; + }); + }); + + const firstMatch = elementsWithId[0]; + if (!firstMatch) return result; + + const pPr = firstMatch.elements.find((el) => el.name === 'w:pPr'); + const spacing = pPr?.elements.find((el) => el.name === 'w:spacing'); + if (!spacing) return result; + const lineSpaceBefore = twipsToPixels(spacing.attributes['w:before']); + const lineSpaceAfter = twipsToPixels(spacing.attributes['w:after']); + return { lineSpaceBefore, lineSpaceAfter }; +} + +/** + * 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 {XmlNode[]} nodes + * @returns + */ +export function preProcessNodesForFldChar(nodes) { + const processedNodes = []; + const nodesToCombine = []; + let isCombiningNodes = false; + nodes?.forEach((n) => { + const fldChar = n.elements?.find((el) => el.name === 'w:fldChar'); + if (fldChar) { + const fldType = fldChar.attributes['w:fldCharType']; + if (fldType === 'begin') { + isCombiningNodes = true; + nodesToCombine.push(n); + } else if (fldType === 'end') { + nodesToCombine.push(n); + isCombiningNodes = false; + } + } + + if (isCombiningNodes) { + nodesToCombine.push(n); + } else if (!isCombiningNodes && nodesToCombine.length) { + + // Need to extract all nodes between 'separate' and 'end' fldChar nodes + const textStart = nodesToCombine.findIndex((n) => n.elements?.some((el) => el.name === 'w:fldChar' && el.attributes['w:fldCharType'] === 'separate')); + const textEnd = nodesToCombine.findIndex((n) => n.elements?.some((el) => el.name === 'w:fldChar' && el.attributes['w:fldCharType'] === 'end')); + const textNodes = nodesToCombine.slice(textStart + 1, textEnd); + const instrText = nodesToCombine.find((n) => n.elements?.some((el) => el.name === 'w:instrText'))?.elements[0]?.elements[0].text; + const urlMatch = instrText.match(/HYPERLINK\s+"([^"]+)"/); + + if (!urlMatch || urlMatch?.length < 2) return []; + const url = urlMatch[1]; + + const textMarks = []; + textNodes.forEach((n) => { + const rPr = n.elements.find((el) => el.name === 'w:rPr'); + if (!rPr) return; + + const { elements } = rPr; + elements.forEach((el) => { + textMarks.push(el); + }); + }); + + // Create a rPr and replace all nodes with the updated node. + const linkMark = { name: 'link', attributes: { href: url} }; + const rPr = { name: 'w:rPr', type: 'element', elements: [linkMark, ...textMarks] } + processedNodes.push({ + name: 'w:r', + type: 'element', + elements: [rPr, ...textNodes] + }); + } else { + processedNodes.push(n); + } + }) + return processedNodes; +} \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js new file mode 100644 index 0000000000..cee856577f --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js @@ -0,0 +1,27 @@ +import {parseProperties} from "./importerHelpers.js"; + +/** + * @type {import("docxImporter").NodeHandler} + */ +const handleRunNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { + if(nodes.length === 0 || nodes[0].name !== 'w:r') { + return []; + } + const node = nodes[0]; + let processedRun = nodeListHandler.handler(node.elements, docx, insideTrackChange)?.filter(n => n) || []; + const hasRunProperties = node.elements.some(el => el.name === 'w:rPr'); + if (hasRunProperties) { + const { marks = [], attributes = {} } = parseProperties(node, docx, nodeListHandler, insideTrackChange); + if (node.marks) marks.push(...node.marks); + processedRun = processedRun.map(n => ({ ...n, marks, attributes })); + } + return processedRun; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const runNodeHandlerEntity = { + handlerName: 'runNodeHandler', + handler: handleRunNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js new file mode 100644 index 0000000000..e67997bb9b --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js @@ -0,0 +1,48 @@ +import {getElementName, parseProperties} from "./importerHelpers.js"; + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { + if(!nodes || nodes.length === 0) { + return []; + } + const node = nodes[0]; + // Parse properties + const { name, type } = node; + const { attributes, elements, marks = [] } = parseProperties(node, docx, nodeListHandler, insideTrackChange); + + // Iterate through the children and build the schemaNode content + const content = []; + if (elements && elements.length) { + const updatedElements = elements.map((el) => { + if (!el.marks) el.marks = []; + el.marks.push(...marks); + return el; + }) + content.push(...nodeListHandler.handler(updatedElements, docx, insideTrackChange)); + } + + const resultNode = { + type: getElementName(node), + content, + attrs: { ...attributes }, + marks: [], + }; + + if(node.name === 'w:tab') { + resultNode.content = [{ type: 'text', text: ' ' }]; + return [resultNode]; + } else { + return [resultNode]; + } +} + + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const standardNodeHandlerEntity = { + handlerName: 'standardNodeHandler', + handler: handleStandardNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js new file mode 100644 index 0000000000..e995cb8299 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js @@ -0,0 +1,241 @@ +import {halfPointToPixels, twipsToInches, twipsToPixels} from "../../helpers.js"; + + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleAllTableNodes = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0) { + return []; + } + const node = nodes[0]; + + switch (node.name) { + case 'w:tbl': + return [handleTableNode(node, docx, nodeListHandler)]; + case 'w:tr': + return [handleTableRowNode(node, undefined, docx, nodeListHandler, insideTrackChange)]; + case 'w:tc': + return [handleTableCellNode(node, docx, nodeListHandler, insideTrackChange)]; + } + + return []; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const tableNodeHandlerEntity = { + handlerName: 'tableNodeHandler', + handler: handleAllTableNodes +}; + + + +/** + * + * @param {XmlNode} node + * @param {ParsedDocx} docx + * @param {NodeListHandler} nodeListHandler + * @param {boolean} insideTrackChange + * @returns {{type: string, content: *, attrs: {borders: *, tableWidth: *, tableWidthType: *, gridColumnWidths: *}}} + */ +export function handleTableNode(node, docx, nodeListHandler, insideTrackChange) { + // Table styles + const tblPr = node.elements.find((el) => el.name === 'w:tblPr'); + + // Table borders can be specified in tblPr or inside a referenced style tag + const tableBordersElement = tblPr.elements.find((el) => el.name === 'w:tblBorders'); + const tableBorders = tableBordersElement?.elements || []; + const { borders, rowBorders } = processTableBorders(tableBorders); + const tblStyleTag = tblPr.elements.find((el) => el.name === 'w:tblStyle'); + + const tableStyleId = tblStyleTag?.attributes['w:val']; + + // Other table properties + const tableIndent = tblPr?.elements.find((el) => el.name === 'w:tblInd'); + const tableLayout = tblPr?.elements.find((el) => el.name === 'w:tblLayout'); + + const referencedStyles = getReferencedTableStyles(tblStyleTag, docx); + + const tblW = tblPr.elements.find((el) => el.name === 'w:tblW'); + const tableWidth = twipsToInches(tblW.attributes['w:w']); + const tableWidthType = tblW.attributes['w:type']; + + // TODO: What does this do? + // const tblLook = tblPr.elements.find((el) => el.name === 'w:tblLook'); + const tblGrid = node.elements.find((el) => el.name === 'w:tblGrid'); + const gridColumnWidths = tblGrid.elements.map((el) => twipsToInches(el.attributes['w:w'])); + + const rows = node.elements.filter((el) => el.name === 'w:tr'); + + const borderData = Object.keys(borders)?.length ? borders : referencedStyles.borders; + const borderRowData = Object.keys(rowBorders)?.length ? rowBorders : referencedStyles.rowBorders; + const content = rows.map((row) => handleTableRowNode(row, borderRowData, docx, nodeListHandler, insideTrackChange)); + + return { + type: 'table', + content, + attrs: { + tableWidth, + tableWidthType, + gridColumnWidths, + tableStyleId, + tableIndent, + tableLayout, + borders: borderData + } + } +} + + +/** + * + * @param node + * @param {ParsedDocx} docx + * @param {NodeListHandler} nodeListHandler + * @param {boolean} insideTrackChange + * @returns {{type: string, content: (*|*[]), attrs: {}}} + */ +export function handleTableCellNode(node, docx, nodeListHandler, insideTrackChange) { + const tcPr = node.elements.find((el) => el.name === 'w:tcPr'); + const borders = tcPr?.elements?.find((el) => el.name === 'w:tcBorders'); + const tcWidth = tcPr?.elements?.find((el) => el.name === 'w:tcW'); + const width = tcWidth ? twipsToInches(tcWidth.attributes['w:w']) : null; + const widthType = tcWidth?.attributes['w:type']; + + // TODO: Do we need other background attrs? + const backgroundColor = tcPr?.elements?.find((el) => el.name === 'w:shd'); + const background = { + color: backgroundColor?.attributes['w:fill'], + } + + const colspanTag = tcPr?.elements?.find((el) => el.name === 'w:gridSpan'); + const colspan = colspanTag?.attributes['w:val']; + + 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 marginBottom = marginTag?.elements?.find((el) => el.name === 'w:bottom'); + + const verticalAlignTag = tcPr?.elements?.find((el) => el.name === 'w:vAlign'); + const verticalAlign = verticalAlignTag?.attributes['w:val']; + + const attributes = {}; + if (width) attributes['width'] = width; + if (widthType) attributes['widthType'] = widthType; + if (colspan) attributes['colspan'] = colspan; + if (background) attributes['background'] = background; + if (verticalAlign) attributes['verticalAlign'] = verticalAlign; + + return { + type: 'tableCell', + content: nodeListHandler.handler(node.elements, docx, insideTrackChange), + attrs: attributes, + } +} + +/** + * + * @param tblStyleTag + * @param {ParsedDocx} docx + * @param {NodeListHandler} nodeListHandler + * @returns {{uiPriotity: *, borders: {}, name: *, rowBorders: {}, basedOn: *}|null} + */ +function getReferencedTableStyles(tblStyleTag, docx, nodeListHandler) { + if (!tblStyleTag) return null; + + const { attributes } = tblStyleTag; + const tableStyleReference = attributes['w:val']; + if (!tableStyleReference) return null; + + const styles = docx['word/styles.xml']; + const { elements } = styles.elements[0]; + const styleElements = elements.filter((el) => el.name === 'w:style'); + const styleTag = styleElements.find((el) => el.attributes['w:styleId'] === tableStyleReference); + if (!styleTag) return null; + + const name = styleTag.elements.find((el) => el.name === 'w:name'); + const basedOn = styleTag.elements.find((el) => el.name === 'w:basedOn'); + const uiPriotity = styleTag.elements.find((el) => el.name === 'w:uiPriority'); + + const tblPr = styleTag.elements.find((el) => el.name === 'w:tblPr'); + const tableBorders = tblPr?.elements.find((el) => el.name === 'w:tblBorders'); + const { elements: borderElements = [] } = tableBorders || {}; + const { borders, rowBorders } = processTableBorders(borderElements); + + return { + name, + basedOn, + uiPriotity, + borders, + rowBorders, + } +} + +function processTableBorders(borderElements) { + const borders = {}; + const rowBorders = {}; + borderElements.forEach((borderElement) => { + const { name } = borderElement; + const borderName = name.split('w:')[1]; + const { attributes } = borderElement; + + const attrs = {}; + const color = attributes['w:color']; + const size = attributes['w:sz']; + if (color && color !== 'auto') attrs['color'] = `#${color}`; + if (size && size !== 'auto') attrs['size'] = halfPointToPixels(size); + + const rowBorderNames = ['insideH', 'insideV']; + if (rowBorderNames.includes(borderName)) rowBorders[borderName] = attrs; + borders[borderName] = attrs; + }); + + return { + borders, + rowBorders + } +} + +/** + * + * @param node + * @param {undefined | null | {insideH?: *, insideV?: *}} rowBorders + * @param {ParsedDocx} docx + * @param {NodeListHandler} nodeListHandler + * @param {boolean} insideTrackChange + * @returns {*} + */ +export function handleTableRowNode(node, rowBorders, docx, nodeListHandler, insideTrackChange) { + const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; + if (!handleStandardNode) { + console.error('Standard node handler not found'); + return []; + } + + const newNodes = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + if(newNodes.length === 0) { + return []; + } + const newNode = newNodes[0]; + + const tPr = node.elements.find((el) => el.name === 'w:trPr'); + const rowHeightTag = tPr?.elements.find((el) => el.name === 'w:trHeight'); + const rowHeight = rowHeightTag?.attributes['w:val']; + const rowHeightRule = rowHeightTag?.attributes['w:hRule']; + + const borders = {}; + if (rowBorders?.insideH) borders['bottom'] = rowBorders.insideH; + if (rowBorders?.insideV) borders['right'] = rowBorders.insideV; + if(!newNode.attrs) newNode.attrs = {}; + newNode.attrs['borders'] = borders; + + if (rowHeight) { + newNode.attrs['rowHeight'] = twipsToPixels(rowHeight); + console.debug('Row node:', newNode); + } + + return newNode; +} diff --git a/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.test.js new file mode 100644 index 0000000000..b56c2777ed --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.test.js @@ -0,0 +1,121 @@ +import {parseXmlToJson} from "../docxHelper.js"; +import {handleTrackChangeNode} from "./trackChangesImporter.js"; +import {defaultNodeListHandler} from "./docxImporter.js"; +import {TrackInsertMarkName} from "../../../../extensions/track-changes/constants.js"; +import {handleAllTableNodes} from "./tableImporter.js"; + + +describe("table live xml test", () => { + const simpleTableStyleXml = `` + const simpleTableXml = ` + + + + + + + + + + + + + + + + + COL 1 ROW 1 + + + + + + + + + + COL 2 ROW 1 + + + + + + + + + + + + COL 1 ROW 2 + + + + + + + + + + COL 2 ROW 2 + + + + + ` + + it("parses simple xml", () => { + const nodes = parseXmlToJson(simpleTableXml).elements + const styles = parseXmlToJson(simpleTableStyleXml) + const docx = { + 'word/styles.xml': styles + } + + const result = handleAllTableNodes(nodes, docx, defaultNodeListHandler(), false); + expect(result.length).toBe(1); + + expect(result[0].type).toBe('table'); + expect(result[0].content.length).toBe(2); + expect(result[0].attrs).toEqual({ + tableWidth: '0.00', + tableWidthType: 'auto', + gridColumnWidths: ['3.25', '3.25'], + tableIndent: undefined, + tableLayout: undefined, + tableStyleId: "TableGrid", + borders: { + top: {size: '1.33'}, + left: {size: '1.33'}, + bottom: {size: '1.33'}, + right: {size: '1.33'}, + insideH: {size: '1.33'}, + insideV: {size: '1.33'} + } + }); + + expect(result[0].content[0].type).toBe('tableRow'); + expect(result[0].content[0].content.length).toBe(2); + expect(result[0].content[0].content[0].content[0].type).toBe("paragraph"); + expect(result[0].content[0].content[0].content[0].content[0].type).toBe("text"); + expect(result[0].content[0].content[0].content[0].content[0].text).toBe("COL 1 ROW 1"); + expect(result[0].content[0].content[1].content[0].type).toBe("paragraph"); + expect(result[0].content[0].content[1].content[0].content[0].type).toBe("text"); + expect(result[0].content[0].content[1].content[0].content[0].text).toBe("COL 2 ROW 1"); + expect(result[0].content[0].attrs.borders).toBeDefined(); + + expect(result[0].content[1].type).toBe('tableRow'); + expect(result[0].content[1].content.length).toBe(2); + expect(result[0].content[1].content[0].content[0].type).toBe("paragraph"); + expect(result[0].content[1].content[0].content[0].content[0].type).toBe("text"); + expect(result[0].content[1].content[0].content[0].content[0].text).toBe("COL 1 ROW 2"); + expect(result[0].content[1].content[1].content[0].type).toBe("paragraph"); + expect(result[0].content[1].content[1].content[0].content[0].type).toBe("text"); + expect(result[0].content[1].content[1].content[0].content[0].text).toBe("COL 2 ROW 2"); + expect(result[0].content[1].attrs.borders).toBeDefined(); + + }); +}); \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/test-helpers/styles.xml b/packages/super-editor/src/core/super-converter/v2/importer/test-helpers/styles.xml new file mode 100644 index 0000000000..427e1bbc9f --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/test-helpers/styles.xml @@ -0,0 +1,938 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/super-editor/src/core/super-converter/v2/importer/test-helpers/testUtils.test.js b/packages/super-editor/src/core/super-converter/v2/importer/test-helpers/testUtils.test.js new file mode 100644 index 0000000000..ec1a98a2e0 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/test-helpers/testUtils.test.js @@ -0,0 +1,36 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * + * @returns {NodeListHandler} + */ +export const createNodeListHandlerMock = () => { + return { + handlerEntities: [ + { + handlerName: 'standardNodeHandler', + handler: () => ( + [{ + type: 'standardNodeHandler', content: {}, attrs: {}, marks: [], + }] + ) + }, + { + handlerName: 'textNodeHandler', + handler: () => ([{ + type: 'textNodeHandler', content: {}, attrs: {}, marks: [], + }] + ) + } + ], + handler: () => [{type: "dummyNode", content: {}, attrs: {}}] + } +} + +export const stylesXml = fs.readFileSync(path.resolve(__dirname, 'styles.xml'), 'utf-8'); +export const numberingBulletXml = `` +export const numberingNodeChangeXml = `` + + +test.skip("", () => {}) \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js new file mode 100644 index 0000000000..c51dba34e5 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js @@ -0,0 +1,44 @@ +import {getElementName, parseProperties} from "./importerHelpers.js"; + + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleTextNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { + if(nodes.length === 0 || !(nodes[0].name === 'w:t' || (insideTrackChange && nodes[0].name === 'w:delText'))) { + return []; + } + const node = nodes[0]; + const { type } = node; + + // Parse properties + const { attributes, elements, marks = [] } = parseProperties(node, docx, nodeListHandler, insideTrackChange); + + // Text nodes have no children. Only text, and there should only be one child + let text; + if (elements.length === 1) text = elements[0].text; + + // Word sometimes will have an empty text node with a space attribute, in that case it should be a space + 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 []; + + return [{ + type: getElementName(node), + text: text, + attrs: { type, attributes: attributes || {}, }, + marks, + }]; +} + + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const textNodeHandlerEntity = { + handlerName: 'textNodeHandler', + handler: handleTextNode +}; \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.test.js new file mode 100644 index 0000000000..2dc999b0c6 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.test.js @@ -0,0 +1,30 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleTextNode} from "./textNodeImporter.js"; +import {createNodeListHandlerMock} from "./test-helpers/testUtils.test.js"; + +describe('TextNodeImporter', () => { + it("parses only text nodes", () => { + const names = Object.keys(SuperConverter.allowedElements).filter((name) => name !== 'w:t'); + const nodesOfNodes = names.map((name) => ([{name}])); + for(const nodes of nodesOfNodes) { + const result = handleTextNode(nodes, null, null, false); + expect(result.length).toBe(0); + } + }); + + it("parses text nodes with xml:space attribute", () => { + const nodes = [{name: 'w:t', attributes: {'xml:space': 'preserve'}, elements: []}]; + const result = handleTextNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.length).toBe(1); + expect(result[0].type).toBe('text'); + expect(result[0].text).toBe(" "); + }); + + it("parses text nodes", () => { + const nodes = [{name: 'w:t', attributes: {}, elements: [{text: "This is a test text!"}]}]; + const result = handleTextNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.length).toBe(1); + expect(result[0].type).toBe('text'); + expect(result[0].text).toBe("This is a test text!"); + }); +}); \ No newline at end of file diff --git a/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js new file mode 100644 index 0000000000..8e0f9304d8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js @@ -0,0 +1,104 @@ +import {TrackDeleteMarkName, TrackInsertMarkName} from "../../../../extensions/track-changes/constants.js"; +import {parseProperties, splitElementsAndProperties} from "./importerHelpers.js"; +import {parseMarks} from "./markImporter.js"; +import {carbonCopy} from "../../../utilities/cabonCopy.js"; +import {handleListNodes, testForList} from "./listImporter.js"; + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleTrackChangeNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { + if(nodes.length === 0 || !(nodes[0].name === 'w:del' || nodes[0].name === 'w:ins')) { + return []; + } + const node = nodes[0]; + const { name } = node; + const { attributes, elements } = parseProperties(node, docx, nodeListHandler, insideTrackChange); + + const subs = nodeListHandler.handler(elements, docx,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 => { + if(subElement.marks === undefined) subElement.marks = []; + subElement.marks.push({ type: changeType, attrs: mappedAttributes }); + }); + + return subs; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const trackChangeNodeHandlerEntity = { + handlerName: 'trackChangeNodeHandler', + handler: handleTrackChangeNode +}; + +/** + * + * @param {XmlNode} xmlNode + * @param {PmNodeJson} parsedPmNode + * @param {ParsedDocx} docx - The parsed docx object. + * @param {NodeListHandler} nodeListHandler - The node list handler function. + * @param {boolean} insideTrackChange - Whether we are inside a track change. + * @param {number} [listLevel=0] - The current indentation level of the list. + */ +export function handleParagraphNodeChange(xmlNode, parsedPmNode, docx, nodeListHandler) { + const { attributes = {}, elements = [] } = xmlNode; + const { nodes, paragraphProperties = {}, runProperties = {} } = splitElementsAndProperties(elements); + paragraphProperties.elements = paragraphProperties?.elements?.filter((el) => el.name !== 'w:rPr'); + + if(!paragraphProperties || !paragraphProperties.elements || !paragraphProperties.elements.length) { + return {}; + } + + + const changes = []; + const changeElements = paragraphProperties.elements.filter((el) => el.name === 'w:pPrChange'); + + changeElements.forEach((changeElement) => { + const changeProps = changeElement.elements.find((el) => el.name === 'w:pPr'); + const mappedAttributes = { + wid: changeElement.attributes['w:id'], + date: changeElement.attributes['w:date'], + author: changeElement.attributes['w:author'], + } + const marks = []; + if (changeProps) { + const { attributes } = changeProps; + marks.push(...parseMarks(changeProps)); + if(marks.length > 0) { + marks.forEach((mark) => { + const keys = Object.keys(mark.attrs) + if(keys.length > 0) { + const attrValue = keys[0]; + const value = mark.attrs[attrValue]; + //attributes[attrValue] = value; + } + }); + } + const copiedElements = carbonCopy(xmlNode.elements); + const newElements = copiedElements.filter((el) => el.name !== 'w:pPr'); + newElements.push(changeProps) + const nodeWithChange = { + type: xmlNode.type, + name: xmlNode.name, + attributes: carbonCopy(xmlNode.attributes), + elements: newElements, + } + const res = nodeListHandler.handler([nodeWithChange], docx, false) + if(res.length > 0) { + //TODO: here we should diff the old node and a new node, and add the attributes + console.log(parsedPmNode); + console.log(res) + } + } + }); + + return changes; +} diff --git a/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js new file mode 100644 index 0000000000..40220f1e7f --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js @@ -0,0 +1,275 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleTrackChangeNode, handleDelText} from "./trackChangesImporter.js"; +import { + createNodeListHandlerMock, + numberingBulletXml, + numberingNodeChangeXml, + stylesXml +} from "./test-helpers/testUtils.test.js"; +import { + TrackChangeBlockChangeAttributeName, + TrackDeleteMarkName, + TrackInsertMarkName, + TrackMarksMarkName +} from "../../../../extensions/track-changes/constants.js"; +import {parseXmlToJson} from "../docxHelper.js"; +import {defaultNodeListHandler} from "./docxImporter.js"; + +describe('TrackChangesImporter', () => { + it("parses only track change nodes", () => { + const names = Object.keys(SuperConverter.allowedElements).filter((name) => name !== 'w:del' && name !== 'w:ins'); + const nodesOfNodes = names.map((name) => ([{name}])); + for(const nodes of nodesOfNodes) { + const result = handleTrackChangeNode(nodes, null, null, false); + expect(result.length).toBe(0); + } + }); + + it("parses track change del node and their attributes", () => { + const nodes = [{ + name: 'w:del', + attributes: {'w:id': '1', 'w:date': '2023-10-01', 'w:author': 'Author'}, + elements: [ + {name: 'w:t', attributes: {}, elements: [{text: 'This is a test text!'}]} + ]}]; + const result = handleTrackChangeNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.length).toBe(1); + expect(result[0].marks[0].type).toBe(TrackDeleteMarkName); + expect(result[0].marks[0].attrs).toEqual({wid: '1', date: '2023-10-01', author: 'Author'}); + }); + + it("parses track change ins node and their attributes", () => { + const nodes = [{ + name: 'w:ins', + attributes: {'w:id': '1', 'w:date': '2023-10-01', 'w:author': 'Author'}, + elements: [ + {name: 'w:t', attributes: {}, elements: [{text: 'This is a test text!'}]} + ]}]; + const result = handleTrackChangeNode(nodes, null, createNodeListHandlerMock(), false); + expect(result.length).toBe(1); + expect(result[0].marks[0].type).toBe(TrackInsertMarkName); + expect(result[0].marks[0].attrs).toEqual({wid: '1', date: '2023-10-01', author: 'Author'}); + }); +}); + + +describe("trackChanges live xml test", () => { + const inserXml = ` + + + + + short + + ` + const deleteXml = ` + + + + + long + + ` + const markChangeXml = ` + + + + + + + + + + + + that + + ` + + it("parses insert xml", () => { + const nodes = parseXmlToJson(inserXml).elements + const result = handleTrackChangeNode(nodes, null, defaultNodeListHandler(), false); + expect(result.length).toBe(1); + const insertionMark = result[0].marks.find(mark => mark.type === TrackInsertMarkName); + expect(insertionMark).toBeDefined(); + expect(insertionMark.attrs).toEqual({ + wid: '0', + date: '2024-09-02T15:56:00Z', + author: 'torcsi@harbourcollaborators.com', + }); + expect(result[0].text).toBe('short '); + }); + it("parses delete xml", () => { + const nodes = parseXmlToJson(deleteXml).elements + const result = handleTrackChangeNode(nodes, null, defaultNodeListHandler(), false); + expect(result.length).toBe(1); + const deletionMark = result[0].marks.find(mark => mark.type === TrackDeleteMarkName); + expect(deletionMark).toBeDefined(); + expect(deletionMark.attrs).toEqual({ + wid: '1', + date: '2024-09-02T15:56:00Z', + author: 'torcsi@harbourcollaborators.com', + }); + expect(result[0].text).toBe('long '); + }); + it("parses mark change xml", () => { + const nodes = parseXmlToJson(markChangeXml).elements + const handler = defaultNodeListHandler() + const result = handler.handler(nodes, null, false); + expect(result.length).toBe(1); + expect(result[0].type).toBe('paragraph'); + expect(result[0].content.length).toBe(1); + const changeMark = result[0].content[0].marks.find(mark => mark.type === TrackMarksMarkName); + expect(changeMark).toBeDefined(); + expect(changeMark.attrs).toEqual({ + wid: '2', + date: '2024-09-04T09:29:00Z', + author: 'torcsi@harbourcollaborators.com', + before: [ + { + type: 'textStyle', + attrs: {} + } + ], + after: [ + { + type: 'bold', + }, + { + type: 'textStyle', + attrs: {} + }, + ] + }); + }); + it("heading to title conversion", () => { + const xml = ` + + + + + + + + + + + + + + + + This was a title1 + + ` + const nodes = parseXmlToJson(xml).elements + const styles = parseXmlToJson(stylesXml) + const docx = { + 'word/styles.xml': styles + } + const handler = defaultNodeListHandler() + const result = handler.handler(nodes, docx, false); + expect(result.length).toBe(1); + console.log(result) + }); + it("list to paragraph conversion", () => { + const xml = ` + + + + + + + + + + + + + + + + + + + This was an ordered list + + ` + const nodes = parseXmlToJson(xml).elements + const styles = parseXmlToJson(stylesXml) + const numbering = parseXmlToJson(numberingNodeChangeXml) + const docx = { + 'word/styles.xml': styles, + 'word/numbering.xml': numbering + } + const handler = defaultNodeListHandler() + const result = handler.handler(nodes, docx, false); + expect(result.length).toBe(1); + expect(result[0].type).toBe('paragraph'); + const track = result[0].attrs.track + expect(track).toBeDefined(); + expect(track.length).toBe(1); + expect(track[0].type).toBe(TrackDeleteMarkName); + expect(track[0].wid).toBe('2'); + expect(track[0].author).toBe('torcsi@harbourcollaborators.com'); + expect(track[0].date).toBe('2024-09-09T16:29:00Z'); + expect(track[0].before).toBeDefined(); + expect(track[0].before.wrappers).toBeDefined(); + expect(track[0].before.wrappers.length).toBe(2); + expect(track[0].before.wrappers[0].type).toBe('orderedList'); + expect(track[0].before.wrappers[1].type).toBe('listItem'); + }); + it("unordered list changed to ordered list", () => { + const xml = ` + + + + + + + + + + + + + + + + + + + + + + + + This was an unordered list + + ` + const nodes = parseXmlToJson(xml).elements + const styles = parseXmlToJson(stylesXml) + const numbering = parseXmlToJson(numberingNodeChangeXml) + const docx = { + 'word/styles.xml': styles, + 'word/numbering.xml': numbering + } + const handler = defaultNodeListHandler() + const result = handler.handler(nodes, docx, false); + expect(result.length).toBe(1); + expect(result[0].type).toBe('orderedList'); + const track = result[0].attrs.track + expect(track).toBeDefined(); + expect(track.length).toBe(1); + expect(track[0].type).toBe(TrackChangeBlockChangeAttributeName); + expect(track[0].wid).toBe('3'); + expect(track[0].author).toBe('torcsi@harbourcollaborators.com'); + expect(track[0].date).toBe('2024-09-09T16:29:00Z'); + expect(track[0].before).toBeDefined(); + expect(track[0].before.type).toBeDefined(); + expect(track[0].before.type).toBe('bulletList'); + }); +}); \ No newline at end of file diff --git a/packages/super-editor/src/core/utilities/cabonCopy.js b/packages/super-editor/src/core/utilities/cabonCopy.js new file mode 100644 index 0000000000..63bd3cd0d9 --- /dev/null +++ b/packages/super-editor/src/core/utilities/cabonCopy.js @@ -0,0 +1,14 @@ +/** + * + * @template T + * @param {T} obj + * @returns {T} + */ +export const carbonCopy = (obj) => { + try { + return JSON.parse(JSON.stringify(obj)); + } catch (e) { + console.error('Error in carbonCopy', obj, e); + return undefined; + } +} \ No newline at end of file diff --git a/packages/super-editor/src/core/utilities/windowingSplit.js b/packages/super-editor/src/core/utilities/windowingSplit.js new file mode 100644 index 0000000000..265a3bd299 --- /dev/null +++ b/packages/super-editor/src/core/utilities/windowingSplit.js @@ -0,0 +1,85 @@ +/** + * @template T + * @typedef {{matched: boolean, list: T[]}} Chunk + */ + +/** + * Splits an array into chunks where each chunk contains a list of elements + * grouped based on whether they are "interesting" (according to the matcher) and + * whether the interesting elements are compatible (according to the eq comparator). + * + * @template T + * @param {T[]} array - The input array of elements to be split. + * @param {(item: T) => boolean} matcher - A function to determine if an element is "interesting". + * @param {(a: T, b: T) => boolean} eq - A function to compare two "interesting" elements for compatibility. + * @returns {Chunk[]} - The array of chunks where each chunk has a `matched` flag and a `list`. + */ +export function windowingSplit(array, matcher, eq) { + const result = []; + let currentChunk = { matched: false, list: [] }; + + for (let i = 0; i < array.length; i++) { + const item = array[i]; + const isInteresting = matcher(item); + + if (isInteresting) { + if(!currentChunk.matched){ + if(currentChunk.list.length > 0){ + result.push(currentChunk); + } + currentChunk = { matched: true, list: [item] }; + } else if (currentChunk.matched && currentChunk.list.length > 0 && !eq(currentChunk.list[0], item)) { + result.push(...splitChunk(currentChunk, matcher)) + currentChunk = { matched: true, list: [item] }; + } else { + // If the current chunk is interesting and this item is interesting and compatible, add to the current chunk + currentChunk.list.push(item); + } + } else { + currentChunk.list.push(item); + } + } + + // Push the last chunk if it has elements + if (currentChunk.list.length > 0) { + result.push(...splitChunk(currentChunk, matcher)) + } + + return result; +} + +/** + * Splits a chunk into two parts based on the matcher. + * @template T + * @param {Chunk} chunk + * @param {(element: T) => boolean} matcher + * @returns {Chunk[]} + * It may inplace modify the input chunk. + */ +const splitChunk = (chunk, matcher) => { + if(chunk.matched === false) { + return [chunk]; + } + const result = []; + // find the last matching index in the current chunk + let lastMatchingIndex = 0; + for (let j = chunk.list.length - 1; j >= 0; j--) { + if (matcher(chunk.list[j])) { + lastMatchingIndex = j; + break; + } + } + // split the current chunk list into two parts + const firstPart = chunk.list.slice(0, lastMatchingIndex + 1) + const secondPart = chunk.list.slice(lastMatchingIndex + 1) + + chunk.list = firstPart + result.push(chunk) + if(secondPart.length > 0) { + chunk = { matched: false, list: secondPart } + result.push(chunk) + } + + chunk = {matched: false, list: []} + return result; +} diff --git a/packages/super-editor/src/core/utilities/windowingSplit.test.js b/packages/super-editor/src/core/utilities/windowingSplit.test.js new file mode 100644 index 0000000000..4ec9777f66 --- /dev/null +++ b/packages/super-editor/src/core/utilities/windowingSplit.test.js @@ -0,0 +1,50 @@ +// Define test function for readability + +import {windowingSplit} from "./windowingSplit.js"; + +const matcher = (x) => x % 2 === 0; +const eq = (a, b) => a === b; + +const testCases = [ + { + input: [1, 1, 2, 2, 4, 4], + expected: [{ matched: false, list: [1, 1] }, { matched: true, list: [2, 2] }, { matched: true, list: [4, 4] }], + }, + { + input: [2, 1, 2], + expected: [{ matched: true, list: [2, 1, 2] }], + }, + { + input: [2, 1, 4], + expected: [{ matched: true, list: [2] }, { matched: false, list: [1] }, { matched: true, list: [4] }], + }, + { + input: [2, 1], + expected: [{ matched: true, list: [2] }, { matched: false, list: [1] }], + }, + { + input: [], + expected: [], + }, + { + input: [4, 4, 4, 5], + expected: [{ matched: true, list: [4, 4, 4] }, { matched: false, list: [5] }], + }, + { + input: [1, 3, 5, 7], + expected: [{ matched: false, list: [1, 3, 5, 7] }], + }, + { + input: [2, 2, 3, 2], + expected: [{ matched: true, list: [2, 2, 3, 2] }], + }, +]; + +describe("windowingSplit", () => { + testCases.forEach(({ input, expected }, index) => { + it(`input: ${JSON.stringify(input)}`, () => { + const result = windowingSplit(input, matcher, eq); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/packages/super-editor/src/extensions/track-changes/constants.js b/packages/super-editor/src/extensions/track-changes/constants.js index d24e7fa9fc..b22fc71614 100644 --- a/packages/super-editor/src/extensions/track-changes/constants.js +++ b/packages/super-editor/src/extensions/track-changes/constants.js @@ -2,3 +2,5 @@ export const TrackDeleteMarkName = 'trackDelete'; export const TrackInsertMarkName = 'trackInsert'; export const TrackMarksMarkName = 'trackMarks'; +export const TrackChangeBlockChangeAttributeName = 'blockChange'; + 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 92323f37e7..7d941a5319 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,8 +1,14 @@ import {Extension} from '@core/Extension.js'; import {Plugin, PluginKey, EditorState, Transaction} from "prosemirror-state"; import {Decoration, DecorationSet} from "prosemirror-view"; +import { liftTarget } from "prosemirror-transform"; import {Mark, Node} from "prosemirror-model"; -import {TrackDeleteMarkName, TrackInsertMarkName, TrackMarksMarkName} from "./constants.js"; +import { + TrackChangeBlockChangeAttributeName, + TrackDeleteMarkName, + TrackInsertMarkName, + TrackMarksMarkName +} from "./constants.js"; const trackChangesCallback = (action, original, modified, modifiers) => { @@ -238,6 +244,38 @@ export const TrackChangesBase = Extension.create({ } }); +/** + * + * @param schema + * @param {Transaction} tr + * @param {Node} node + * @param {number} pos + * @returns void - modifies the tr inplace + */ +const removeParentNode = (schema, tr, node, pos) => { + // Define the starting and ending positions for the paragraphs within the listItem + const startPos = pos + 1; // Position where the first child (paragraph) starts + const endPos = startPos + node.nodeSize - 2; // The position after the last child + + // Resolve positions for lifting + const startResolved = tr.doc.resolve(startPos); + const endResolved = tr.doc.resolve(endPos); + + // Create a block range for all paragraphs within the listItem + const blockRange = startResolved.blockRange(endResolved); + + if(blockRange === null) { + return; + } + + // Find the lift target + const target = liftTarget(blockRange); + + // If there is a valid lift target, apply the transformation + if (target != null) { + tr.lift(blockRange, target); + } +} /** * @@ -248,7 +286,7 @@ export const TrackChangesBase = Extension.create({ * @param {number} to * @returns {{offset: number, modifiers: *[]}} */ -const applyTrackChanges = (action, state, tr, from, to) => { +export const applyTrackChanges = (action, state, tr, from, to) => { let offset = 0; const modifiers = []; state.doc.nodesBetween(from, to, (node, pos) => { @@ -290,6 +328,42 @@ const applyTrackChanges = (action, state, tr, from, to) => { } } }); + if(node.attrs && node.attrs.track && Array.isArray(node.attrs.track) && node.attrs.track.length > 0) { + let blockTrack = node.attrs.track.find(track => track.type === TrackChangeBlockChangeAttributeName) + let blockDelete = node.attrs.track.find(track => track.type === TrackDeleteMarkName) + let blockInsert = node.attrs.track.find(track => track.type === TrackInsertMarkName) + if (blockTrack) { + if (action === "accept") { + tr.setNodeMarkup(pos + offset, null, { + ...node.attrs, + track: node.attrs.track.filter(track => track.type !== TrackChangeBlockChangeAttributeName) + }); + } else if (action === "revert") { + const nodeType = state.schema.nodes[blockTrack.before.type]; + tr.setNodeMarkup(pos + offset, nodeType, blockTrack.before.attrs); + } + } + if (blockDelete) { + if (action === "accept") { + tr.setNodeMarkup(pos + offset, null, { + ...node.attrs, + track: node.attrs.track.filter(track => track.type !== TrackDeleteMarkName) + }); + } else if (action === "revert") { + //TODO: we should create all of the marks again + } + } + if (blockInsert) { + if (action === "accept") { + tr.setNodeMarkup(pos + offset, null, { + ...node.attrs, + track: node.attrs.track.filter(track => track.type !== TrackInsertMarkName) + }); + } else if (action === "revert") { + removeParentNode(state.schema, tr, node, pos); + } + } + } }); return {modifiers, offset}; }; @@ -376,6 +450,29 @@ const recalcDecorations = (state, onlyOriginalShown,onlyModifiedShown ) => { } } }); + if(node.attrs.track && node.attrs.track.length > 0) { + let blockTrack = node.attrs.track.find(track => track.type === TrackChangeBlockChangeAttributeName) + let blockDelete = node.attrs.track.find(track => track.type === TrackDeleteMarkName) + let blockInsert = node.attrs.track.find(track => track.type === TrackInsertMarkName) + if (blockTrack || blockDelete || blockInsert) { + if(onlyOriginalShown) { + const decoration = Decoration.node(pos, pos + node.nodeSize, { + class: "blockChange normal", + }); + decorations.push(decoration); + } else if(onlyModifiedShown) { + const decoration = Decoration.node(pos, pos + node.nodeSize, { + class: "blockChange hidden", + }); + decorations.push(decoration); + } else { + const decoration = Decoration.node(pos, pos + node.nodeSize, { + class: "blockChange highlighted", + }); + decorations.push(decoration); + } + } + } }); return DecorationSet.create(state.doc, decorations); 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 bc252b1096..cca8460a87 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,15 @@ import {TextSelection, Selection, Transaction, EditorState} from "prosemirror-state"; -import {Mapping, ReplaceStep, AddMarkStep, RemoveMarkStep} from "prosemirror-transform"; +import {Mapping, ReplaceStep, AddMarkStep, RemoveMarkStep, ReplaceAroundStep} from "prosemirror-transform"; import {EditorView} from "prosemirror-view"; import {Slice, Fragment, Mark, Node} from "prosemirror-model"; -import {TrackInsertMarkName, TrackDeleteMarkName, TrackMarksMarkName} from "./constants.js"; +import { + TrackInsertMarkName, + TrackDeleteMarkName, + TrackMarksMarkName, + TrackChangeBlockChangeAttributeName +} from "./constants.js"; import {TrackChangesBasePluginKey} from "./track-changes-base.js"; +import {carbonCopy} from "../../core/utilities/cabonCopy.js"; /** * Amend transaction to track changes * @param {Transaction} tr @@ -158,11 +164,17 @@ const markInsertion = (tr, from, to, user, date) => { 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)) { + } else if (pos < from || ["bulletList", "orderedList"].includes(node.type.name)) { return true - } else if (["table_row", "table_cell"].includes(node.type.name)) { + } else if (["tableRow", "tableCell"].includes(node.type.name)) { return false - }*/ + } + if (node.attrs.track) { + const track = [] + track.push({type: TrackInsertMarkName, author: user, date}) + tr.setNodeMarkup(pos, null, Object.assign({}, node.attrs, {track}), node.marks) + } + return true; } ) } @@ -172,29 +184,30 @@ const markInsertion = (tr, from, to, user, date) => { * @param {Transaction} tr * @param {number} from * @param {number} to + * @param {number | null} to2 * @param {string} user * @param {string} date - * @returns {void} tr is modified in place + * @returns {Mapping} - tr is modified in place, but the mapping is returned */ -const markDeletion = (tr, from, to, user, date) => { +const markDeletion = (tr, from, to, to2, user, date) => { const deletionMark = tr.doc.type.schema.marks[TrackDeleteMarkName].create({author: user, date}) let firstTableCellChild = false - let listItem = false const deletionMap = new Mapping() + const deletedNodes = [] // 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") { + if (pos < from && node.type.name === "tableCell") { 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)) { + } else if (["tableRow", "tableCell"].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)) { + } else if (node.isInline && node.marks.find(mark => mark.type.name === TrackInsertMarkName && mark.attrs.user === user)) { const removeStep = new ReplaceStep( deletionMap.map(Math.max(from, pos)), deletionMap.map(Math.min(to, pos + node.nodeSize)), @@ -203,19 +216,150 @@ const markDeletion = (tr, from, to, user, date) => { if (!tr.maybeStep(removeStep).failed) { deletionMap.appendMap(removeStep.getMap()) } - } else if (node.isInline && !node.marks.find(mark => mark.type.name === "deletion")) { + } else if (node.isInline && !node.marks.find(mark => mark.type.name === TrackInsertMarkName)) { tr.addMark( deletionMap.map(Math.max(from, pos)), deletionMap.map(Math.min(to, pos + node.nodeSize)), deletionMark ) + } else if (node.attrs.track?.find( + trackAttr => trackAttr.type === TrackInsertMarkName + )) { + //we do nothing, it will be simply deleted by the step + } else if (!node.isInline && node.attrs.track){ + /* + if we have a; + - list1>>>>> + - and we first remove the middle list + - list1>> + - and + 1. we remove the outer list: + - list3[removed: list1, listItem1, list2, listItem2]>> + 2. we remove the inner list: + - list1>> + */ + const deleted = node.attrs.track.find(track => track.type === TrackDeleteMarkName) + if(deleted) { + deletedNodes.push(...deleted.before.wrappers) + } + deletedNodes.push({ + type: node.type.name, + attrs: {...node.attrs, track: node.attrs.track.filter(track => track.type !== TrackDeleteMarkName)}, + }) } } ) + if(to !== from && to2 === null) { + const lastPos = tr.doc.resolve(to) + const lastNode = lastPos.parent + const fromAndToInLastNode = lastPos.start(lastPos.depth) <= from && lastPos.end(lastPos.depth) >= to + if (lastNode && !fromAndToInLastNode && lastPos.before(lastPos.depth) <= to && lastPos.after(lastPos.depth) >= to) { + to2 = to + lastNode.nodeSize - 1 + const removeStep = new ReplaceStep( + deletionMap.map(Math.max(from, lastPos.before(lastPos.depth)-1)), + deletionMap.map(to), + Slice.empty + ) + if (!tr.maybeStep(removeStep).failed) { + deletionMap.appendMap(removeStep.getMap()) + } + } + } + + if(deletedNodes.length > 0 && to2 !== null ) { + tr.doc.nodesBetween( + deletionMap.map(to), + deletionMap.map(to2), + (nodeToMod, nodeToModPos) => { + if(nodeToModPos < to) { + return true + } + if (nodeToMod.attrs.track && nodeToMod.type.name !== "listItem") { + const deletedMark = nodeToMod.attrs.track.find(track => track.type === TrackDeleteMarkName) + const track = carbonCopy(nodeToMod.attrs.track.filter(track => track.type !== TrackDeleteMarkName)) + if(deletedMark) { + const newDeletedMark = carbonCopy(deletedMark) + newDeletedMark.author = user + newDeletedMark.date = date + const newWrappers = newDeletedMark.before.wrappers ?? [] + if(deletedNodes[deletedNodes.length - 1].type === "listItem") { + if(newWrappers.length > 0 && newWrappers[0].type === "listItem") { + newWrappers.shift() //we inplace remove the first item so when we collapse lists we won't have a [list, listItem, listItem, listItem] + } + } + newDeletedMark.before.wrappers = [...deletedNodes, ...newWrappers] + track.push(newDeletedMark) + } else { + track.push({ + type: TrackDeleteMarkName, author: user, date: date, before: { + wrappers: [...deletedNodes] + } + }) + } + //console.log(nodeToModPos, nodeToMod) + let newNode = nodeToMod.type.create(Object.assign({}, nodeToMod.attrs, {track}), null, nodeToMod.marks) + const setNodeMarkupStep = new ReplaceAroundStep( + nodeToModPos, + nodeToModPos + nodeToMod.nodeSize, + nodeToModPos + 1, + nodeToModPos + nodeToMod.nodeSize - 1, + new Slice(Fragment.from(newNode), 0, 0), + 1, + true) + if (!tr.maybeStep(setNodeMarkupStep).failed) { + deletionMap.appendMap(setNodeMarkupStep.getMap()) + } + //we won't recurse to children + return false; + } + }); + } return deletionMap; } +/** + * + * @param {Transaction} tr + * @param {number} pos + * @param {Node} oldNode + * @param {Node} newNode + * @param {string} user + * @param {string} date + */ +function markWrapping( + tr, + pos, + oldNode, + newNode, + user, + date +) { + let track = [...(oldNode.attrs?.track ?? [])]; + let blockTrack = track.find(track => track.type === TrackChangeBlockChangeAttributeName) + + if (blockTrack) { + track = track.filter(track => track !== blockTrack) + if (blockTrack.before.type !== newNode.type.name || blockTrack.before.attrs.level !== newNode.attrs.level) { + blockTrack = {type: TrackChangeBlockChangeAttributeName, author: user, date: date, before: blockTrack.before} + track.push(blockTrack) + } + } else { + blockTrack = {type: TrackChangeBlockChangeAttributeName, author: user, date: date, before: {type: oldNode.type.name, attrs: oldNode.attrs}} + if (blockTrack.before.attrs.id) { + delete blockTrack.before.attrs.id + } + if (blockTrack.before.attrs.track) { + delete blockTrack.before.attrs.track + } + track.push(blockTrack) + } + + if (tr.doc.nodeAt(pos)) { + tr.setNodeMarkup(pos, null, {...newNode.attrs, track}) + } +} + /** * Handle replace step * @param {EditorState} state the original editor state @@ -239,7 +383,7 @@ const handleReplaceStep = (state, tr, step, stepIndex, newTr, map, user, date) = ) : 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) + const invertStep = tr.steps[stepIndex].invert(tr.docs[stepIndex]).map(map) if(invertStep) { map.appendMap(invertStep.getMap()) } @@ -260,7 +404,6 @@ const handleReplaceStep = (state, tr, step, stepIndex, newTr, map, user, date) = 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())) } } @@ -268,12 +411,12 @@ const handleReplaceStep = (state, tr, step, stepIndex, newTr, map, user, date) = } if (step.from !== step.to) { map.appendMapping( - markDeletion(newTr, step.from, step.to, user, date) + markDeletion(newTr, step.from, step.to, null, user, date) ) } } /** - * Handle add mark step + * Handle add/remove mark step * @param {EditorState} state * @param {AddMarkStep | RemoveMarkStep} step * @param {Transaction} newTr @@ -340,6 +483,61 @@ const handleMarkStep = (state, step, newTr, user, date) => { }); } +/** + * Handle node type changes + * @param {EditorState} state the original editor state + * @param {Transaction} tr is the original transaction + * @param {ReplaceAroundStep} 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 handleNodeTypeChanges = (state, tr, step, stepIndex, newTr, map, user, date) => { + if (step.from === step.gapFrom && step.to === step.gapTo) { // wrapped in something + newTr.step(step) + const from = step.getMap().map(step.from, -1) + const to = step.getMap().map(step.gapFrom) + markInsertion(newTr, from, to, user, date) // we only mark the wrapping node itself which is indded an insertion + } else if (!step.slice.size || step.slice.content.content.length === 2) {// unwrapped from something + map.appendMap( + markDeletion(newTr, step.from, step.gapFrom, step.gapTo, user, date) + ) + newTr.step(step) + } else if (step.slice.size === 2 && step.gapFrom - step.from === 1 && step.to - step.gapTo === 1) { // Replaced one wrapping with another + const oldNode = newTr.doc.nodeAt(step.from) + newTr.step(step) + if (oldNode) { + markWrapping( + newTr, + step.from, + oldNode, + step.slice.content.firstChild, + user, + date + ) + } + } else { + newTr.step(step) + const ranges = [ + {from: step.getMap().map(step.from, -1), to: step.getMap().map(step.gapFrom)}, + {from: step.getMap().map(step.gapTo, -1), to: step.getMap().map(step.to)} + ] + ranges.forEach( + range => newTr.doc.nodesBetween(range.from, range.to, (node, pos) => { + if ( + pos < range.from + ) { + return true + } + markInsertion(newTr, range.from, range.to, user, date) + }) + ) + } +} + /** * Track transaction * @param {Transaction} tr @@ -365,6 +563,9 @@ export const trackTransaction = (tr, state, user) => { handleMarkStep(state, step, newTr, user, fixedTimeTo10MinutesString) } else if (step instanceof RemoveMarkStep) { handleMarkStep(state, step, newTr, user, fixedTimeTo10MinutesString) + } else if (step instanceof ReplaceAroundStep){ + handleNodeTypeChanges(state, tr, step, originalStepIndex, newTr, map, user, fixedTimeTo10MinutesString); + //newTr.step(step) } 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 index 87213abaf7..734bb0dc9a 100644 --- 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 @@ -1,9 +1,17 @@ -import {EditorState} from "prosemirror-state"; -import {Slice, Fragment} from "prosemirror-model"; +import {EditorState, TextSelection} from "prosemirror-state"; +import {Fragment, Slice} from "prosemirror-model"; +import {findWrapping, liftTarget} from "prosemirror-transform"; 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"; +import { + TrackChangeBlockChangeAttributeName, + TrackDeleteMarkName, + TrackInsertMarkName, + TrackMarksMarkName +} from "./constants.js"; +import {applyTrackChanges} from "./track-changes-base.js"; +import {liftListItem} from "../../core/commands/index.js"; const createEmptyDocState = () => { const emptyDoc = {content: [], type: "doc"} @@ -12,7 +20,7 @@ const createEmptyDocState = () => { return EditorState.create({ schema, doc, - }) + }); } describe('Track Changes TR Modifier', () => { @@ -103,8 +111,8 @@ describe('Track Changes TR Modifier', () => { 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([{ + expect(trackMarksMark.attrs.before).toEqual([]); + expect(trackMarksMark.attrs.after).toEqual([{ attrs: {}, type: "bold" }]); @@ -137,7 +145,7 @@ describe('Track Changes TR Modifier', () => { 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.before).toEqual([ { attrs: {}, type: "bold" @@ -149,7 +157,7 @@ describe('Track Changes TR Modifier', () => { }, type: "textStyle" }]); - expect(trackMarksMark.attrs.after).toStrictEqual([ + expect(trackMarksMark.attrs.after).toEqual([ { attrs: {}, type: "bold" @@ -191,7 +199,7 @@ describe('Track Changes TR Modifier', () => { 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.before).toEqual([ { attrs: {}, type: "bold" @@ -203,7 +211,7 @@ describe('Track Changes TR Modifier', () => { }, type: "textStyle" }]); - expect(trackMarksMark.attrs.after).toStrictEqual([ + expect(trackMarksMark.attrs.after).toEqual([ { attrs: { color: "#FF004D", @@ -241,7 +249,7 @@ describe('Track Changes TR Modifier', () => { 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.before).toEqual([ { attrs: {}, type: "bold" @@ -253,7 +261,7 @@ describe('Track Changes TR Modifier', () => { }, type: "textStyle" }]); - expect(trackMarksMark.attrs.after).toStrictEqual([ + expect(trackMarksMark.attrs.after).toEqual([ { attrs: {}, type: "bold" @@ -268,4 +276,457 @@ describe('Track Changes TR Modifier', () => { }]); }) }); + describe("handleNodeChanges", () => { + describe("attribute change", () => { + test("change a H1 to H2", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["heading"].create({level: 1}, state.schema.text("test")); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.setNodeMarkup(0, null, {level: 2}); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + //check + const doc3Json = state3.doc.toJSON(); + expect(doc3Json.content.length).toBe(1); + expect(doc3Json.content[0].type).toBe("heading"); + expect(doc3Json.content[0].attrs.level).toBe(2); + expect(doc3Json.content[0].attrs.track.length).toBe(1); + expect(doc3Json.content[0].attrs.track[0].type).toBe(TrackChangeBlockChangeAttributeName); + expect(doc3Json.content[0].attrs.track[0].author).toBe("TestUser1"); + expect(doc3Json.content[0].attrs.track[0].before.type).toBe("heading"); + expect(doc3Json.content[0].attrs.track[0].before.attrs).toEqual({ + lineHeight: null, + textAlign: undefined, + textIndent: null, + level: 1, + }); + }); + test("change a H1 to H2 and accept", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["heading"].create({level: 1}, state.schema.text("test")); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.setNodeMarkup(0, null, {level: 2}); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + const tr3 = state3.tr; + applyTrackChanges("accept", state3, tr3, 0, state3.doc.nodeSize - 2) + const state4 = state3.apply(tr3); + //check + const doc4Json = state4.doc.toJSON(); + expect(doc4Json.content.length).toBe(1); + expect(doc4Json.content[0].type).toBe("heading"); + expect(doc4Json.content[0].attrs.level).toBe(2); + expect(doc4Json.content[0].attrs.track.length).toBe(0); + }); + test("change a H1 to H2 and revert", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["heading"].create({level: 1}, state.schema.text("test")); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.setNodeMarkup(0, null, {level: 2}); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + const tr3 = state3.tr; + applyTrackChanges("revert", state3, tr3, 0, state3.doc.nodeSize - 2) + const state4 = state3.apply(tr3); + //check + const doc4Json = state4.doc.toJSON(); + expect(doc4Json.content.length).toBe(1); + expect(doc4Json.content[0].type).toBe("heading"); + expect(doc4Json.content[0].attrs.level).toBe(1); + expect(doc4Json.content[0].attrs.track.length).toBe(0); + }); + }); + describe("node type change", () => { + test("change an orderedList to unorderedList", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["orderedList"].create(null, state.schema.nodes["listItem"].create(null, state.schema.text("test"))); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.setNodeMarkup(0, state2.schema.nodes["bulletList"], 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].type).toBe("bulletList"); + expect(doc3Json.content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].type).toBe("listItem"); + expect(doc3Json.content[0].content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].content[0].text).toBe("test"); + expect(doc3Json.content[0].attrs.track.length).toBe(1); + expect(doc3Json.content[0].attrs.track[0].type).toBe(TrackChangeBlockChangeAttributeName); + expect(doc3Json.content[0].attrs.track[0].author).toBe("TestUser1"); + expect(doc3Json.content[0].attrs.track[0].before.type).toBe("orderedList"); + }); + test("change an orderedList to unorderedList and accept", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["orderedList"].create(null, state.schema.nodes["listItem"].create(null, state.schema.text("test"))); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.setNodeMarkup(0, state2.schema.nodes["bulletList"], null); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + const tr3 = state3.tr; + applyTrackChanges("accept", state3, tr3, 0, state3.doc.nodeSize - 2) + const state4 = state3.apply(tr3); + //check + const doc4Json = state4.doc.toJSON(); + expect(doc4Json.content.length).toBe(1); + expect(doc4Json.content[0].type).toBe("bulletList"); + expect(doc4Json.content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].type).toBe("listItem"); + expect(doc4Json.content[0].content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].content[0].text).toBe("test"); + expect(doc4Json.content[0].attrs.track.length).toBe(0); + }); + test("change an orderedList to unorderedList and revert", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["orderedList"].create(null, state.schema.nodes["listItem"].create(null, state.schema.text("test"))); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + tr2.setNodeMarkup(0, state2.schema.nodes["bulletList"], null); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + const tr3 = state3.tr; + applyTrackChanges("revert", state3, tr3, 0, state3.doc.nodeSize - 2) + const state4 = state3.apply(tr3); + //check + const doc4Json = state4.doc.toJSON(); + expect(doc4Json.content.length).toBe(1); + expect(doc4Json.content[0].type).toBe("orderedList"); + expect(doc4Json.content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].type).toBe("listItem"); + expect(doc4Json.content[0].content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].content[0].text).toBe("test"); + expect(doc4Json.content[0].attrs.track.length).toBe(0); + }); + }); + describe("wrap a node", () => { + test("wrap a paragraph to an unorderedList", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["paragraph"].create(null, state.schema.text("test")); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + const nodeRange = state2.doc.resolve(0).blockRange(state2.doc.resolve(state2.doc.nodeSize - 2)); + const wrapParam = findWrapping(nodeRange, state2.schema.nodes["bulletList"]); + tr2.wrap(nodeRange, wrapParam); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + //check + const doc3Json = state3.doc.toJSON(); + expect(doc3Json.content.length).toBe(1); + expect(doc3Json.content[0].type).toBe("bulletList"); + expect(doc3Json.content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].type).toBe("listItem"); + expect(doc3Json.content[0].content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].content[0].content[0].text).toBe("test"); + expect(doc3Json.content[0].content[0].attrs.track.length).toBe(1); + expect(doc3Json.content[0].content[0].attrs.track[0].type).toBe(TrackInsertMarkName); + expect(doc3Json.content[0].content[0].attrs.track[0].author).toBe("TestUser1"); + expect(doc3Json.content[0].content[0].attrs.track[0].before).toBe(undefined); + }); + test("wrap a paragraph to an unorderedList and accept", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["paragraph"].create(null, state.schema.text("test")); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + const nodeRange = state2.doc.resolve(0).blockRange(state2.doc.resolve(state2.doc.nodeSize - 2)); + const wrapParam = findWrapping(nodeRange, state2.schema.nodes["bulletList"]); + tr2.wrap(nodeRange, wrapParam); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + const tr3 = state3.tr; + applyTrackChanges("accept", state3, tr3, 0, state3.doc.nodeSize - 2) + const state4 = state3.apply(tr3); + //check + const doc4Json = state4.doc.toJSON(); + expect(doc4Json.content.length).toBe(1); + expect(doc4Json.content[0].type).toBe("bulletList"); + expect(doc4Json.content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].type).toBe("listItem"); + expect(doc4Json.content[0].content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].content[0].content[0].text).toBe("test"); + expect(doc4Json.content[0].content[0].attrs.track.length).toBe(0); + }); + test("wrap a paragraph to an unorderedList and revert", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["paragraph"].create(null, state.schema.text("test")); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + const nodeRange = state2.doc.resolve(0).blockRange(state2.doc.resolve(state2.doc.nodeSize - 2)); + const wrapParam = findWrapping(nodeRange, state2.schema.nodes["bulletList"]); + tr2.wrap(nodeRange, wrapParam); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")) + const tr3 = state3.tr; + applyTrackChanges("revert", state3, tr3, 0, state3.doc.nodeSize - 2) + const state4 = state3.apply(tr3); + //check + const doc4Json = state4.doc.toJSON(); + expect(doc4Json.content.length).toBe(1); + expect(doc4Json.content[0].type).toBe("paragraph"); + expect(doc4Json.content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].text).toBe("test"); + expect(doc4Json.content[0].attrs.track.length).toBe(0); + }); + }); + describe("unwrap a node", () => { + test("unwrap a paragraph from an unorderedList", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["bulletList"].create(null, state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test")))); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + const nodeRange = state2.doc.resolve(2).blockRange(state2.doc.resolve(state2.doc.nodeSize - 4)); + const target = liftTarget(nodeRange); + tr2.lift(nodeRange, target); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")); + //check + const doc3Json = state3.doc.toJSON(); + expect(doc3Json.content.length).toBe(1); + expect(doc3Json.content[0].type).toBe("paragraph"); + expect(doc3Json.content[0].content.length).toBe(1); + expect(doc3Json.content[0].content[0].type).toBe("text"); + expect(doc3Json.content[0].attrs.track.length).toBe(1); + expect(doc3Json.content[0].attrs.track[0].type).toBe(TrackDeleteMarkName); + expect(doc3Json.content[0].attrs.track[0].author).toBe("TestUser1"); + expect(doc3Json.content[0].attrs.track[0].before.wrappers).toBeDefined(); + expect(doc3Json.content[0].attrs.track[0].before.wrappers).toEqual([ + { + type: "bulletList", + attrs: { + attributes: null, + "list-style-type": "bullet", + track: [], + + }, + }, + { + type: "listItem", + attrs: { + attributes: null, + listParagraphProperties: null, + listRunProperties: null, + lvlJc: null, + lvlText: null, + track: [], + }, + }, + ]); + }); + test("unwrap a paragraph from an unorderedList and accept", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["bulletList"].create(null, state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test")))); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + const nodeRange = state2.doc.resolve(2).blockRange(state2.doc.resolve(state2.doc.nodeSize - 4)); + const target = liftTarget(nodeRange); + tr2.lift(nodeRange, target); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")); + const tr3 = state3.tr; + applyTrackChanges("accept", state3, tr3, 0, state3.doc.nodeSize - 2) + const state4 = state3.apply(tr3); + //check + const doc4Json = state4.doc.toJSON(); + expect(doc4Json.content.length).toBe(1); + expect(doc4Json.content[0].type).toBe("paragraph"); + expect(doc4Json.content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].text).toBe("test"); + expect(doc4Json.content[0].attrs.track.length).toBe(0); + }); + test("unwrap a paragraph from an unorderedList and revert", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["bulletList"].create(null, state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test")))); + tr.insert(0, node); + const state2 = state.apply(tr); + const tr2 = state2.tr; + const nodeRange = state2.doc.resolve(2).blockRange(state2.doc.resolve(state2.doc.nodeSize - 4)); + const target = liftTarget(nodeRange); + tr2.lift(nodeRange, target); + const state3 = state2.apply(trackTransaction(tr2, state2, "TestUser1")); + const tr3 = state3.tr; + applyTrackChanges("revert", state3, tr3, 0, state3.doc.nodeSize - 2) + const state4 = state3.apply(tr3); + //check + const doc4Json = state4.doc.toJSON(); + expect(doc4Json.content.length).toBe(1); + expect(doc4Json.content[0].type).toBe("bulletList"); + expect(doc4Json.content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].type).toBe("listItem"); + expect(doc4Json.content[0].content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[0].content[0].content[0].text).toBe("test"); + expect(doc4Json.content[0].attrs.track.length).toBe(0); + }); + }); + describe("unwrap multiple nodes", () => { + test("unwrap 4 paragraphs from an unorderedList", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["bulletList"].create(null, [ + state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test1"))), + state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test2"))), + state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test3"))), + state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test4"))), + ]); + tr.insert(0, node); + tr.setSelection(TextSelection.create(tr.doc, 4, tr.doc.nodeSize - 6)); + const state2 = state.apply(tr); + + let state3; + const dispatch = (tr) => { + state3 = state2.apply(trackTransaction(tr, state2, "TestUser1")); + } + liftListItem("listItem")({state: state2, dispatch}); + + //check + const doc3Json = state3.doc.toJSON(); + expect(doc3Json.content.length).toBe(4); + for(let i = 0; i < 4; i++) { + expect(doc3Json.content[i].type).toBe("paragraph"); + expect(doc3Json.content[i].content.length).toBe(1); + expect(doc3Json.content[i].content[0].text).toBe(`test${i+1}`); + expect(doc3Json.content[i].attrs.track.length).toBe(1); + expect(doc3Json.content[i].attrs.track[0].type).toBe(TrackDeleteMarkName); + expect(doc3Json.content[i].attrs.track[0].author).toBe("TestUser1"); + expect(doc3Json.content[i].attrs.track[0].before.wrappers).toBeDefined(); + expect(doc3Json.content[i].attrs.track[0].before.wrappers).toEqual([ + { + type: "bulletList", + attrs: { + attributes: null, + "list-style-type": "bullet", + track: [], + + }, + }, + { + type: "listItem", + attrs: { + attributes: null, + listParagraphProperties: null, + listRunProperties: null, + lvlJc: null, + lvlText: null, + track: [], + }, + }, + ]); + } + }); + test("unwrap 3 paragraphs from an unorderedList and accept", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["bulletList"].create(null, [ + state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test1"))), + state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test2"))), + state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test3"))), + ]); + tr.insert(0, node); + tr.setSelection(TextSelection.create(tr.doc, 4, tr.doc.nodeSize - 6)); + const state2 = state.apply(tr); + + let state3; + const dispatch = (tr) => { + state3 = state2.apply(trackTransaction(tr, state2, "TestUser1")); + } + liftListItem("listItem")({state: state2, dispatch}); + + const tr3 = state3.tr; + applyTrackChanges("accept", state3, tr3, 0, state3.doc.nodeSize - 2) + const state4 = state3.apply(tr3); + //check + const doc4Json = state4.doc.toJSON(); + expect(doc4Json.content.length).toBe(3); + for(let i = 0; i < 3; i++) { + expect(doc4Json.content[i].type).toBe("paragraph"); + expect(doc4Json.content[i].content.length).toBe(1); + expect(doc4Json.content[i].content[0].text).toBe(`test${i+1}`); + expect(doc4Json.content[i].attrs.track.length).toBe(0); + } + }); + test("unwrap 3 paragraphs from an unorderedList and revert", () => { + //init + const state = createEmptyDocState(); + //mod + const tr = state.tr; + const node = state.schema.nodes["bulletList"].create(null, [ + state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test1"))), + state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test2"))), + state.schema.nodes["listItem"].create(null, state.schema.nodes["paragraph"].create(null, state.schema.text("test3"))), + ]); + tr.insert(0, node); + tr.setSelection(TextSelection.create(tr.doc, 4, tr.doc.nodeSize - 6)); + const state2 = state.apply(tr); + + let state3; + const dispatch = (tr) => { + state3 = state2.apply(trackTransaction(tr, state2, "TestUser1")); + } + liftListItem("listItem")({state: state2, dispatch}); + + const tr3 = state3.tr; + applyTrackChanges("revert", state3, tr3, 0, state3.doc.nodeSize - 2) + const state4 = state3.apply(tr3); + //check + const doc4Json = state4.doc.toJSON(); + expect(doc4Json.content.length).toBe(1); + expect(doc4Json.content[0].type).toBe("bulletList"); + expect(doc4Json.content[0].content.length).toBe(3); + for(let i = 0; i < 3; i++) { + expect(doc4Json.content[0].content[i].type).toBe("listItem"); + expect(doc4Json.content[0].content[i].content.length).toBe(1); + expect(doc4Json.content[0].content[i].content[0].type).toBe("paragraph"); + expect(doc4Json.content[0].content[i].content[0].content.length).toBe(1); + expect(doc4Json.content[0].content[i].content[0].content[0].text).toBe(`test${i+1}`); + expect(doc4Json.content[0].content[i].attrs.track.length).toBe(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 0a4e3a3b59..9920e6f32f 100644 --- a/packages/super-editor/src/extensions/track-changes/track-insert.js +++ b/packages/super-editor/src/extensions/track-changes/track-insert.js @@ -10,6 +10,33 @@ export const TrackInsert = Mark.create({ } }, + addGlobalAttributes() { + return [{ + types: ["bulletList", "orderedList", "listItem", "paragraph", "heading", "blockQuote", "codeBlock", "table", "tableRow", "tableCell", "tableHeader"], + attributes: { + track: { + default: [], + parseHTML: element => { + try { + const res = JSON.parse(element.getAttribute('track')); + if(Array.isArray(res)) { + return res; + } + } catch (e) { + return []; + } + return []; + }, + renderHTML: attributes => { + return { + 'track': JSON.stringify(attributes.track), + } + }, + } + } + }] + }, + addAttributes() { return { // word id like `