From 58076594b79adafed096420136c297889ee2fe44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 6 Sep 2024 20:30:25 +0200 Subject: [PATCH 1/9] functional import refactor - not tested, not used --- .../core/super-converter/SuperConverter.js | 2 +- .../src/core/super-converter/v2/docxHelper.js | 92 ++++++ .../v2/importer/bookmarkNodeImporter.js | 28 ++ .../v2/importer/boomarkNodeImporter.test.js | 33 ++ .../v2/importer/docxImporter.js | 164 ++++++++++ .../v2/importer/hyperlinkImporter.js | 60 ++++ .../v2/importer/imageImporter.js | 77 +++++ .../v2/importer/importerHelpers.js | 85 +++++ .../v2/importer/lineBreakImporter.js | 24 ++ .../v2/importer/lineBreakImporter.test.js | 23 ++ .../v2/importer/listImporter.js | 309 ++++++++++++++++++ .../v2/importer/markImporter.js | 160 +++++++++ .../v2/importer/paragraphNodeImporter.js | 151 +++++++++ .../v2/importer/runNodeImporter.js | 27 ++ .../v2/importer/standardNodeImporter.js | 50 +++ .../v2/importer/tableImporter.js | 225 +++++++++++++ .../v2/importer/testUtils.test.js | 29 ++ .../v2/importer/textNodeImporter.js | 44 +++ .../v2/importer/textNodeImporter.test.js | 33 ++ .../v2/importer/trackChangesImporter.js | 37 +++ .../v2/importer/trackChangesImporter.test.js | 143 ++++++++ .../src/core/utilities/cabonCopy.js | 14 + 22 files changed, 1809 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/core/super-converter/v2/docxHelper.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/bookmarkNodeImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/boomarkNodeImporter.test.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/hyperlinkImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.test.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/listImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/markImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/testUtils.test.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.test.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js create mode 100644 packages/super-editor/src/core/utilities/cabonCopy.js diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 09ae8c7a7c..a521e21d34 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -14,7 +14,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', 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..43d8c1733c --- /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 {nodes: [], consumed: 0}; + } + const node = nodes[0]; + + const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; + if (!handleStandardNode) { + console.error('Standard node handler not found'); + return {nodes: [], consumed: 0}; + } + const result = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + if(result.nodes.length === 1) { + result.nodes[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..4d418ddc1a --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/boomarkNodeImporter.test.js @@ -0,0 +1,33 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleBookmarkNode} from "./bookmarkNodeImporter.js"; +import {createNodeListHandlerMock} from "./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.nodes.length).toBe(0); + expect(result.consumed).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.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].type).toBe('standardNodeHandler'); + expect(result.nodes[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.nodes.length).toBe(0); + expect(result.consumed).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..5a91ddacc8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -0,0 +1,164 @@ +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 { + 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"; + +/** + * @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) => {nodes: PmNodeJson[], consumed: number}} NodeHandler + * @typedef {{handlerName: string, handler: NodeHandler}} NodeHandlerEntry + */ + +/** + * + * @param {ParsedDocx} docx + * @returns {{pmNodes: 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].type === '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 { + pmNodes: result, + savedTagsToRestore: node, + pageStyles: getDocumentStyles(node), + }; + } + return null; +} + +export const defaultNodeListHandler = () => { + const entities = [ + runNodeHandlerEntity, + paragraphNodeHandlerEntity, + 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 processedElements = []; + + for (let index = 0; index < elements.length; index++) { + const {nodes, consumed} = nodeHandlers.reduce((res, handler) => { + if(res.consumed > 0) return res; + const nodesToHandle = elements.slice(index); + if(!nodesToHandle || nodesToHandle.length === 0) return res; + return handler.handler(nodesToHandle, docx, {handler: nodeListHandlerFn, handlerEntities: nodeHandlers}, insideTrackChange); + }, {nodes: [], consumed: 0}); + index += consumed-1; + if(consumed === 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); + } + } + } + return processedElements; + } + + 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..e6bcfce742 --- /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 {nodes: [], consumed: 0}; + } + 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 {nodes: updatedNode, consumed: 1}; +} + +/** + * @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..42776f4202 --- /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 {nodes: [], consumed: 0}; + } + 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); + return {nodes: result ? [result] : [], consumed: 1}; +} + +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..4e498a7b34 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js @@ -0,0 +1,85 @@ +import {handleStyleChangeMarks, parseMarks} from "./markImporter.js"; +import {SuperConverter} from "../../SuperConverter.js"; + + +/** + * + * @param {XmlNode} node + * @returns {{elements: *, attributes: {}, marks: *}} + * + */ +export function parseProperties(node) { + /** + * 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 attrValue = Object.keys(mark.attrs)[0]; + const value = mark.attrs[attrValue]; + attributes[attrValue] = value; + }); + marks = []; + } + return { elements: nodes, attributes, marks } +} + + +/** + * + * @param {XmlNode[]} elements + * @returns {{nodes: *, runProperties: *, sectionProperties: *, paragraphProperties: *}} + */ +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..eda9d6de7d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js @@ -0,0 +1,24 @@ +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleLineBreakNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:br') { + return {nodes: [], consumed: 0}; + } + + return { + nodes: [{ + type: 'lineBreak', + content: [], + attrs: {}, + }], consumed: 1 + }; +} + +/** + * @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..c8a89d5859 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.test.js @@ -0,0 +1,23 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleLineBreakNode} from "./lineBreakImporter.js"; +import {createNodeListHandlerMock} from "./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.nodes.length).toBe(0); + expect(result.consumed).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.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[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..de1500a64e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -0,0 +1,309 @@ +import {convertToSchema} from "./docxImporter.js"; +import {carbonCopy} from "../../../utilities/cabonCopy.js"; +import {hasTextNode} from "./importerHelpers.js"; + + +export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0 || nodes[0].name !== 'w:p') { + return {nodes: [], consumed: 0}; + } + const node = 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)) { + // Get all siblings that are list items and haven't been processed yet. + const siblings = carbonCopy(nodes); + const listItems = []; + let consumed = 0; + + // Iterate each item until we find the end of the list (a non-list item), + // then send to the list handler for processing. + let possibleList = siblings.shift(); + while (possibleList && testForList(possibleList, true)) { + listItems.push(possibleList); + possibleList = siblings.shift(); + if (possibleList?.elements && !hasTextNode(possibleList.elements)) { + listItems.push(possibleList); + possibleList = siblings.shift(); + } + } + + // TODO - Check that this change is OK + return {nodes: handleListNodes(listItems, 0, node), consumed: listItems.filter(i => i.seen).length}; + } else { + return {nodes: [], consumed: 0}; + } +} + + + + +/** + * List processing + * + * This recursive function takes a list of known list items and combines them into nested lists. + * + * It begins with listLevel = 0, and if we find an indented node, we call this function again and increase the level. + * with the same set of list items (as we do not know the node levels until we process them). + * + * @param {Array} listItems - Array of list items to process. + * @param {number} [listLevel=0] - The current indentation level of the list. + * @returns {Object} The processed list node with structured content. + */ +function handleListNodes(listItems, listLevel = 0) { + const parsedListItems = []; + let overallListType; + let listStyleType; + + for (let [index, item] of listItems.entries()) { + // Skip items we've already processed + if (item.seen) continue; + + // Sometimes there are paragraph nodes that only have pPr element and no text node - these are + // Spacers in the XML and need to be appended to the last item. + if (item.elements && !hasTextNode(item.elements)) { + const n = handleStandardNode(item, listItems, index); + parsedListItems[parsedListItems.length - 1]?.content.push(n); + item.seen = true; + continue; + } + + // 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(item); + const { + listType, + listOrderingType, + ilvl, + listrPrs, + listpPrs, + start, + lvlText, + lvlJc + } = getNodeNumberingDefinition(attributes, listLevel, docx); + listStyleType = listOrderingType; + const intLevel = parseInt(ilvl); + + // Append node if it belongs on this list level + const nodeAttributes = {}; + if (listLevel === intLevel) { + overallListType = listType; + item.seen = true; + + const schemaElements = []; + schemaElements.push({ + type: 'paragraph', + content: convertToSchema(elements)?.filter(n => n) + }); + + console.debug('\n\n LIST ITEM', listpPrs, listrPrs, start, lvlText, lvlJc, '\n\n') + + if (listpPrs) nodeAttributes['listParagraphProperties'] = listpPrs; + if (listrPrs) nodeAttributes['listRunProperties'] = listrPrs; + nodeAttributes['order'] = start; + nodeAttributes['lvlText'] = lvlText; + nodeAttributes['lvlJc'] = lvlJc; + nodeAttributes['attributes'] = { + parentAttributes: item?.attributes || null, + } + parsedListItems.push(createListItem(schemaElements, nodeAttributes, [])); + } + + // If this item belongs in a deeper list level, we need to process it by calling this function again + // But going one level deeper. + else if (listLevel < intLevel) { + const sublist = handleListNodes(listItems.slice(index), listLevel + 1); + const lastItem = parsedListItems[parsedListItems.length - 1]; + if (!lastItem) { + parsedListItems.push(createListItem([sublist], nodeAttributes, [])); + } else { + lastItem.content.push(sublist); + } + } + + // If this item belongs in a higher list level, we need to break out of the loop and return to higher levels + else break; + } + + return { + type: overallListType || 'bulletList', + content: parsedListItems, + attrs: { + 'list-style-type': listStyleType, + attributes: { + 'parentAttributes': listItems[0]?.attributes || null, + } + } + }; +} + + +/** + * + * @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 (●) +] + +/** + * Main function to get list item information from numbering.xml + * + * @param {object} attributes + * @param {int} level + * @param {ParsedDocx} docx + * @returns + */ +function getNodeNumberingDefinition(attributes, level, docx) { + if (!attributes) return; + + const def = docx['word/numbering.xml']; + if (!def) return {}; + + const { elements } = def; + const listData = elements[0]; + + 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']; + + // Get the list style id + const numIdTag = numPr.elements.find(style => style.name === 'w:numId'); + const numId = numIdTag.attributes['w:val']; + + + // 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, ilvl, numId, 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/markImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js new file mode 100644 index 0000000000..5178e7cdd6 --- /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 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..79c993ffe8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -0,0 +1,151 @@ +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 {nodes: [], consumed: 0}; + } + 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 {nodes: [], consumed: 0}; + } + + // 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 {nodes: [], consumed: 0}; + } + const result = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + if(result.nodes.length === 1) { + schemaNode = result.nodes[0]; + } + + if ('attributes' in node) { + const defaultStyleId = node.attributes['w:rsidRDefault']; + const { lineSpaceAfter, lineSpaceBefore } = getDefaultStyleDefinition(defaultStyleId, docx); + + if (!('attributes' in schemaNode)) schemaNode.attributes = {}; + schemaNode.attrs['paragraphSpacing'] = { lineSpaceAfter, lineSpaceBefore }; + } + return { nodes: schemaNode ? [schemaNode] : [], consumed: 1 }; +} + +/** + * @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 + */ +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..dc83bcaf50 --- /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 {nodes: [], consumed: 0}; + } + 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); + if (node.marks) marks.push(...node.marks); + processedRun = processedRun.map(n => ({ ...n, marks, attributes })); + } + return {nodes: processedRun, consumed: 1}; +} + +/** + * @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..7e75629693 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js @@ -0,0 +1,50 @@ +import {convertToSchema} from "./docxImporter.js"; +import {getElementName, parseProperties} from "./importerHelpers.js"; +import {handleTextNode} from "./textNodeImporter.js"; + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { + if(!nodes || nodes.length === 0) { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + // Parse properties + const { name, type } = node; + const { attributes, elements, marks = [] } = parseProperties(node); + + // 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)); + } + + const resultNode = { + type: getElementName(node), + content, + attrs: { ...attributes }, + marks: [], + }; + + if(node.name === 'w:tab') { + resultNode.content = [{ type: 'text', text: ' ' }]; + return { nodes: [resultNode], consumed: 1 }; + } else { + return { nodes: [resultNode], consumed: 1 }; + } +} + + +/** + * @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..44d3637ecc --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js @@ -0,0 +1,225 @@ +import {halfPointToPixels, twipsToInches, twipsToPixels} from "../../helpers.js"; + + +/** + * @type {import("docxImporter").NodeHandler} + */ +export const handleAllTableNodes = (nodes, docx, nodeListHandler, insideTrackChange) => { + if(nodes.length === 0) { + return {nodes: [], consumed: 0}; + } + const node = nodes[0]; + + switch (node.name) { + case 'w:tbl': + return {nodes: [handleTableNode(node, docx, nodeListHandler)], consumed: 1}; + case 'w:tr': + return {nodes: [handleTableRowNode(node, undefined, docx, nodeListHandler, insideTrackChange)], consumed: 1}; + case 'w:tc': + return {nodes: [handleTableCellNode(node, docx, nodeListHandler, insideTrackChange)], consumed: 1}; + } + + return {nodes: [], consumed: 0}; +} + +/** + * @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 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, + 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 {nodes: [], consumed: 0}; + } + const newNode = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + + 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; + newNode.attrs['borders'] = borders; + + if (rowHeight && newNode.attrs['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/testUtils.test.js b/packages/super-editor/src/core/super-converter/v2/importer/testUtils.test.js new file mode 100644 index 0000000000..64025bd0e1 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/testUtils.test.js @@ -0,0 +1,29 @@ +/** + * + * @returns {NodeListHandler} + */ +export const createNodeListHandlerMock = () => { + return { + handlerEntities: [ + { + handlerName: 'standardNodeHandler', + handler: () => ({ + nodes: [{ + type: 'standardNodeHandler', content: {}, attrs: {}, marks: [], + }], consumed: 1 + }) + }, + { + handlerName: 'textNodeHandler', + handler: () => ({ + nodes: [{ + type: 'textNodeHandler', content: {}, attrs: {}, marks: [], + }], consumed: 1 + }) + } + ], + handler: () => [{type: "dummyNode", content: {}, attrs: {}}] + } +} + +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..6631bf062d --- /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 {nodes: [], consumed: 0}; + } + const node = nodes[0]; + const { type } = node; + + // Parse properties + const { attributes, elements, marks = [] } = parseProperties(node); + + // 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 {nodes: [], consumed: 0}; + + return {nodes: [{ + type: getElementName(node), + text: text, + attrs: { type, attributes: attributes || {}, }, + marks, + }], consumed: 1}; +} + + +/** + * @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..50258165a1 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.test.js @@ -0,0 +1,33 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleTextNode} from "./textNodeImporter.js"; +import {createNodeListHandlerMock} from "./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.nodes.length).toBe(0); + expect(result.consumed).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.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].type).toBe('text'); + expect(result.nodes[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.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].type).toBe('text'); + expect(result.nodes[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..9f49f62cb9 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js @@ -0,0 +1,37 @@ +import {TrackDeleteMarkName, TrackInsertMarkName} from "../../../../extensions/track-changes/constants.js"; +import {parseProperties} from "./importerHelpers.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 {nodes: [], consumed: 0}; + } + const node = nodes[0]; + const { name } = node; + const { attributes, elements } = parseProperties(node); + + 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 {nodes: subs, consumed: 1}; +} + +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const trackChangeNodeHandlerEntity = { + handlerName: 'trackChangeNodeHandler', + handler: handleTrackChangeNode +}; \ No newline at end of file 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..0c7ae7489d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.test.js @@ -0,0 +1,143 @@ +import {SuperConverter} from "../../SuperConverter.js"; +import {handleTrackChangeNode, handleDelText} from "./trackChangesImporter.js"; +import {createNodeListHandlerMock} from "./testUtils.test.js"; +import { + 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.nodes.length).toBe(0); + expect(result.consumed).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.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].marks[0].type).toBe(TrackDeleteMarkName); + expect(result.nodes[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.nodes.length).toBe(1); + expect(result.consumed).toBe(1); + expect(result.nodes[0].marks[0].type).toBe(TrackInsertMarkName); + expect(result.nodes[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.nodes.length).toBe(1); + const insertionMark = result.nodes[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.nodes[0].text).toBe('short '); + }); + it("parses delete xml", () => { + const nodes = parseXmlToJson(deleteXml).elements + const result = handleTrackChangeNode(nodes, null, defaultNodeListHandler(), false); + expect(result.nodes.length).toBe(1); + const deletionMark = result.nodes[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.nodes[0].text).toBe('long '); + }); + it("parses mark change xml", () => { + const nodes = parseXmlToJson(markChangeXml).elements + console.log(nodes) + 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: {} + }, + ] + }); + }); +}); \ 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 From 05f2431f9cee89914051ff6bd5a6b2a07d07f795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Mon, 9 Sep 2024 11:57:24 +0200 Subject: [PATCH 2/9] functional import refactor v2 - used instead of importer v1 --- .../core/super-converter/SuperConverter.js | 13 +- .../v2/importer/docxImporter.js | 10 +- .../v2/importer/imageImporter.js | 2 +- .../v2/importer/lineBreakImporter.js | 8 +- .../v2/importer/listImporter.js | 1 - .../v2/importer/paragraphNodeImporter.js | 6 + .../v2/importer/standardNodeImporter.js | 4 +- .../v2/importer/tableImporter.js | 20 ++- .../v2/importer/tableImporter.test.js | 121 ++++++++++++++++++ .../v2/importer/trackChangesImporter.test.js | 1 - 10 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/tableImporter.test.js diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index a521e21d34..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 { @@ -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/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index 5a91ddacc8..792a7bed70 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -15,6 +15,7 @@ import {lineBreakNodeHandlerEntity} from "./lineBreakImporter.js"; import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; /** + * @typedef {import()} XmlNode * @typedef {{type: string, content: *, attrs: {}}} PmNodeJson * @typedef {{type: string, attrs: {}}} PmMarkJson * @@ -28,18 +29,19 @@ import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; /** * * @param {ParsedDocx} docx - * @returns {{pmNodes: PmNodeJson, savedTagsToRestore: XmlNode, pageStyles: *}|null} + * @returns {{pmDoc: PmNodeJson, savedTagsToRestore: XmlNode, pageStyles: *}|null} */ export const createDocumentJson = (docx) => { const json = carbonCopy(getInitialJSON(docx)); if (!json) return null; + console.log("JSON", json); console.debug('\n\n JSON', json,) const nodeListHandler = defaultNodeListHandler(); - if(json.elements[0].elements[0].type === 'w:body') { + 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 content = node.elements?.filter((n) => !ignoreNodes.includes(n.name)) ?? []; const result = { type: 'doc', @@ -49,7 +51,7 @@ export const createDocumentJson = (docx) => { } } return { - pmNodes: result, + pmDoc: result, savedTagsToRestore: node, pageStyles: getDocumentStyles(node), }; 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 index 42776f4202..728813fd9d 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js @@ -14,7 +14,7 @@ export const handleDrawingNode= (nodes, docx, nodeListHandler, insideTrackChange // Inline images const inlineImage = elements.find((el) => el.name === 'wp:inline'); - if (inlineImage) result = handleInlineImageNode(inlineImage); + if (inlineImage) result = handleInlineImageNode(inlineImage, docx); return {nodes: result ? [result] : [], consumed: 1}; } 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 index eda9d6de7d..e1324ba0d4 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js @@ -6,11 +6,17 @@ export const handleLineBreakNode = (nodes, docx, nodeListHandler, insideTrackCha return {nodes: [], consumed: 0}; } + const attrs = {}; + + const lineBreakType = nodes[0].attributes?.['w:type']; + if (lineBreakType) attrs['lineBreakType'] = lineBreakType; + + return { nodes: [{ type: 'lineBreak', content: [], - attrs: {}, + attrs, }], consumed: 1 }; } 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 index de1500a64e..4f99cdbfe8 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -1,4 +1,3 @@ -import {convertToSchema} from "./docxImporter.js"; import {carbonCopy} from "../../../utilities/cabonCopy.js"; import {hasTextNode} from "./importerHelpers.js"; 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 index 79c993ffe8..9ff885dbb9 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -42,6 +42,12 @@ export const handleParagraphNode = (nodes, docx, nodeListHandler, insideTrackCha 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 }; } 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 index 7e75629693..bb55b9bd7f 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js @@ -1,6 +1,4 @@ -import {convertToSchema} from "./docxImporter.js"; import {getElementName, parseProperties} from "./importerHelpers.js"; -import {handleTextNode} from "./textNodeImporter.js"; /** * @type {import("docxImporter").NodeHandler} @@ -22,7 +20,7 @@ export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChan el.marks.push(...marks); return el; }) - content.push(...nodeListHandler.handler(updatedElements)); + content.push(...nodeListHandler.handler(updatedElements, docx, insideTrackChange)); } const resultNode = { 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 index 44d3637ecc..eab25b4572 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js @@ -49,6 +49,13 @@ export function handleTableNode(node, docx, nodeListHandler, insideTrackChange) 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'); @@ -73,6 +80,9 @@ export function handleTableNode(node, docx, nodeListHandler, insideTrackChange) tableWidth, tableWidthType, gridColumnWidths, + tableStyleId, + tableIndent, + tableLayout, borders: borderData } } @@ -204,7 +214,12 @@ export function handleTableRowNode(node, rowBorders, docx, nodeListHandler, insi console.error('Standard node handler not found'); return {nodes: [], consumed: 0}; } - const newNode = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + + const newNodes = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); + if(newNodes.nodes.length === 0) { + return {nodes: [], consumed: 0}; + } + const newNode = newNodes.nodes[0]; const tPr = node.elements.find((el) => el.name === 'w:trPr'); const rowHeightTag = tPr?.elements.find((el) => el.name === 'w:trHeight'); @@ -214,9 +229,10 @@ export function handleTableRowNode(node, rowBorders, docx, nodeListHandler, insi 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']) { + if (rowHeight) { newNode.attrs['rowHeight'] = twipsToPixels(rowHeight); console.debug('Row node:', 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..dc24335b33 --- /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.nodes.length).toBe(1); + + expect(result.nodes[0].type).toBe('table'); + expect(result.nodes[0].content.length).toBe(2); + expect(result.nodes[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.nodes[0].content[0].type).toBe('tableRow'); + expect(result.nodes[0].content[0].content.length).toBe(2); + expect(result.nodes[0].content[0].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[0].content[0].content[0].content[0].text).toBe("COL 1 ROW 1"); + expect(result.nodes[0].content[0].content[1].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[1].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[0].content[1].content[0].content[0].text).toBe("COL 2 ROW 1"); + expect(result.nodes[0].content[0].attrs.borders).toBeDefined(); + + expect(result.nodes[0].content[1].type).toBe('tableRow'); + expect(result.nodes[0].content[1].content.length).toBe(2); + expect(result.nodes[0].content[1].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[1].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[1].content[0].content[0].content[0].text).toBe("COL 1 ROW 2"); + expect(result.nodes[0].content[1].content[1].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[1].content[1].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[1].content[1].content[0].content[0].text).toBe("COL 2 ROW 2"); + expect(result.nodes[0].content[1].attrs.borders).toBeDefined(); + + }); +}); \ No newline at end of file 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 index 0c7ae7489d..b4ee594231 100644 --- 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 @@ -111,7 +111,6 @@ describe("trackChanges live xml test", () => { }); it("parses mark change xml", () => { const nodes = parseXmlToJson(markChangeXml).elements - console.log(nodes) const handler = defaultNodeListHandler() const result = handler.handler(nodes, null, false); expect(result.length).toBe(1); From 18d206101c9fc8315495107ea8e5adc61beca51c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Mon, 9 Sep 2024 12:54:47 +0200 Subject: [PATCH 3/9] functional import refactor v3 - list fixes --- .../v2/importer/docxImporter.js | 3 +- .../v2/importer/listImporter.js | 39 +++- .../v2/importer/listImporter.test.js | 191 ++++++++++++++++++ .../v2/importer/paragraphNodeImporter.js | 2 +- 4 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/listImporter.test.js 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 index 792a7bed70..c1fa2eb9b1 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -13,6 +13,7 @@ import {paragraphNodeHandlerEntity} from "./paragraphNodeImporter.js"; import {standardNodeHandlerEntity} from "./standardNodeImporter.js"; import {lineBreakNodeHandlerEntity} from "./lineBreakImporter.js"; import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; +import {listHandlerEntity} from "./listImporter.js"; /** * @typedef {import()} XmlNode @@ -34,7 +35,6 @@ import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; export const createDocumentJson = (docx) => { const json = carbonCopy(getInitialJSON(docx)); if (!json) return null; - console.log("JSON", json); console.debug('\n\n JSON', json,) const nodeListHandler = defaultNodeListHandler(); @@ -63,6 +63,7 @@ export const defaultNodeListHandler = () => { const entities = [ runNodeHandlerEntity, paragraphNodeHandlerEntity, + listHandlerEntity, textNodeHandlerEntity, lineBreakNodeHandlerEntity, bookmarkNodeHandlerEntity, 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 index 4f99cdbfe8..a271ce4ebb 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -1,12 +1,15 @@ import {carbonCopy} from "../../../utilities/cabonCopy.js"; -import {hasTextNode} from "./importerHelpers.js"; - +import {hasTextNode, parseProperties} from "./importerHelpers.js"; +import {preProcessNodesForFldChar} from "./paragraphNodeImporter.js"; +/** + * @type {import("docxImporter").NodeHandler} + */ export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) => { if(nodes.length === 0 || nodes[0].name !== 'w:p') { return {nodes: [], consumed: 0}; } - const node = nodes[0]; + const node = carbonCopy(nodes[0]) let schemaNode; @@ -34,13 +37,22 @@ export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) } // TODO - Check that this change is OK - return {nodes: handleListNodes(listItems, 0, node), consumed: listItems.filter(i => i.seen).length}; + return { + nodes: [handleListNodes(listItems, docx, nodeListHandler, 0)], + consumed: listItems.filter(i => i.seen).length + }; } else { return {nodes: [], consumed: 0}; } } - +/** + * @type {import("docxImporter").NodeHandlerEntry} + */ +export const listHandlerEntity = { + handlerName: 'listHandler', + handler: handleListNode +} /** @@ -52,14 +64,23 @@ export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) * with the same set of list items (as we do not know the node levels until we process them). * * @param {Array} listItems - Array of list items to process. + * @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 {Object} The processed list node with structured content. */ -function handleListNodes(listItems, listLevel = 0) { +function handleListNodes(listItems, docx, nodeListHandler, insideTrackChange, listLevel = 0) { const parsedListItems = []; let overallListType; let listStyleType; + const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; + if (!handleStandardNode) { + console.error('Standard node handler not found'); + return {nodes: [], consumed: 0}; + } + for (let [index, item] of listItems.entries()) { // Skip items we've already processed if (item.seen) continue; @@ -67,7 +88,7 @@ function handleListNodes(listItems, listLevel = 0) { // Sometimes there are paragraph nodes that only have pPr element and no text node - these are // Spacers in the XML and need to be appended to the last item. if (item.elements && !hasTextNode(item.elements)) { - const n = handleStandardNode(item, listItems, index); + const n = handleStandardNode([item], docx, nodeListHandler, insideTrackChange).nodes[0]; parsedListItems[parsedListItems.length - 1]?.content.push(n); item.seen = true; continue; @@ -98,7 +119,7 @@ function handleListNodes(listItems, listLevel = 0) { const schemaElements = []; schemaElements.push({ type: 'paragraph', - content: convertToSchema(elements)?.filter(n => n) + content: nodeListHandler.handler(elements, docx, insideTrackChange)?.filter(n => n) }); console.debug('\n\n LIST ITEM', listpPrs, listrPrs, start, lvlText, lvlJc, '\n\n') @@ -117,7 +138,7 @@ function handleListNodes(listItems, listLevel = 0) { // If this item belongs in a deeper list level, we need to process it by calling this function again // But going one level deeper. else if (listLevel < intLevel) { - const sublist = handleListNodes(listItems.slice(index), listLevel + 1); + const sublist = handleListNodes(listItems.slice(index), docx, nodeListHandler, insideTrackChange, listLevel + 1); const lastItem = parsedListItems[parsedListItems.length - 1]; if (!lastItem) { parsedListItems.push(createListItem([sublist], nodeAttributes, [])); 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..87f2d71f76 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.test.js @@ -0,0 +1,191 @@ +import {parseXmlToJson} from "../docxHelper.js"; +import {handleAllTableNodes} from "./tableImporter.js"; +import {defaultNodeListHandler} from "./docxImporter.js"; +import {handleListNode} from "./listImporter.js"; + + +describe("table live xml test", () => { + it("parses simple bullet xml", () => { + const exampleSingleBulletXml = ` + + + + + + + + + + TEXTITEM + + + `; + const numberingXml = `` + const nodes = parseXmlToJson(exampleSingleBulletXml).elements + const numbering = parseXmlToJson(numberingXml) + const docx = { + 'word/numbering.xml': numbering + } + + const result = handleListNode(nodes, docx, defaultNodeListHandler(), false); + expect(result.nodes.length).toBe(1); + expect(result.nodes[0].type).toBe("bulletList"); + expect(result.nodes[0].content.length).toBe(1); + expect(result.nodes[0].content[0].type).toBe("listItem"); + expect(result.nodes[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[0].content[0].content[0].text).toBe("TEXTITEM"); + }); + + it("parses simple numbered xml", () => { + const exampleSingleNumberedXml = ` + + + + + + + + + + numbered + + + `; + const numberingXml = `` + const nodes = parseXmlToJson(exampleSingleNumberedXml).elements + const numbering = parseXmlToJson(numberingXml) + const docx = { + 'word/numbering.xml': numbering + } + + const result = handleListNode(nodes, docx, defaultNodeListHandler(), false); + expect(result.nodes.length).toBe(1); + expect(result.nodes[0].type).toBe("orderedList"); + expect(result.nodes[0].content.length).toBe(1); + expect(result.nodes[0].content[0].type).toBe("listItem"); + expect(result.nodes[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[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 numberingXml = `` + const nodes = parseXmlToJson(exampleMultiNestedListXml).elements[0].elements + const numbering = parseXmlToJson(numberingXml) + const docx = { + 'word/numbering.xml': numbering + } + + const result = handleListNode(nodes, docx, defaultNodeListHandler(), false); + expect(result.nodes.length).toBe(1); + expect(result.nodes[0].type).toBe("bulletList"); + expect(result.nodes[0].content.length).toBe(3); + expect(result.nodes[0].content[0].type).toBe("listItem"); + expect(result.nodes[0].content[0].content.length).toBe(2); + expect(result.nodes[0].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[0].content[0].content[0].content[0].text).toBe("L1: A"); + expect(result.nodes[0].content[0].content[1].type).toBe("bulletList"); + expect(result.nodes[0].content[0].content[1].content.length).toBe(1); + expect(result.nodes[0].content[0].content[1].content[0].type).toBe("listItem"); + expect(result.nodes[0].content[0].content[1].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[1].content[0].content[0].type).toBe("paragraph"); + expect(result.nodes[0].content[0].content[1].content[0].content[0].content.length).toBe(1); + expect(result.nodes[0].content[0].content[1].content[0].content[0].content[0].type).toBe("text"); + expect(result.nodes[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/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js index 9ff885dbb9..3c6cb97001 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -99,7 +99,7 @@ function getDefaultStyleDefinition(defaultStyleId, docx) { * @param {XmlNode[]} nodes * @returns */ -function preProcessNodesForFldChar(nodes) { +export function preProcessNodesForFldChar(nodes) { const processedNodes = []; const nodesToCombine = []; let isCombiningNodes = false; From 17f1916b6aa23f170bc97eca446dba518e698bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 10 Sep 2024 19:42:38 +0200 Subject: [PATCH 4/9] node type changes v1 --- .../src/extensions/track-changes/constants.js | 2 + .../track-changes/track-changes-base.js | 76 +++- .../track-changes-tr-modifier.js | 185 +++++++++- .../track-changes-tr-modifier.test.js | 330 +++++++++++++++++- .../extensions/track-changes/track-insert.js | 27 ++ 5 files changed, 597 insertions(+), 23 deletions(-) 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..460778aed8 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,40 @@ 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); + } + } else if (blockDelete) { + if (action === "accept") { + removeParentNode(state.schema, tr, node, pos); + } else if (action === "revert") { + tr.setNodeMarkup(pos + offset, null, { + ...node.attrs, + track: node.attrs.track.filter(track => track.type !== TrackDeleteMarkName) + }); + } + } else 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}; }; 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..88e9af617f 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,8 +1,13 @@ 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"; /** * Amend transaction to track changes @@ -158,11 +163,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; } ) } @@ -174,7 +185,7 @@ const markInsertion = (tr, from, to, user, date) => { * @param {number} to * @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 deletionMark = tr.doc.type.schema.marks[TrackDeleteMarkName].create({author: user, date}) @@ -186,15 +197,15 @@ const markDeletion = (tr, from, to, user, date) => { 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,12 +214,62 @@ 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 === TrackDeleteMarkName) && + !["bulletList", "orderedList"].includes(node.type.name) + ) { + if (node.attrs.track?.find( + trackAttr => trackAttr.type === TrackInsertMarkName && trackAttr.user === user.id + )) { + let removeStep + // user has created element. so (s)he is allowed to delete it again. + if (node.isTextblock && to < (pos + node.nodeSize)) { + // The node is a textblock. So we need to merge into the last possible + // position inside the last text block. + const selectionBefore = Selection.findFrom(tr.doc.resolve(pos), -1) + if (selectionBefore instanceof TextSelection) { + removeStep = new ReplaceStep( + deletionMap.map(selectionBefore.$anchor.pos), + deletionMap.map(to), + Slice.empty + ) + } + } else { + removeStep = new ReplaceStep( + deletionMap.map(Math.max(from, pos)), + deletionMap.map(Math.min(to, pos + node.nodeSize)), + Slice.empty + ) + } + + if (!tr.maybeStep(removeStep).failed) { + deletionMap.appendMap(removeStep.getMap()) + } + if (node.type.name === "listItem" && listItem) { + listItem = false + } + } else if (node.attrs.track) { + if (node.type.name === "listItem") { + listItem = true + } else if (listItem) { + // The first child of the first list item (likely a par) will not be merged with the paragraph + // before it. + listItem = false + return + } + const track = node.attrs.track.slice() + track.push({type: TrackDeleteMarkName, author: user, date: date}) + tr.setNodeMarkup(deletionMap.map(pos), null, Object.assign({}, node.attrs, {track}), node.marks) + } + if (node.type.name === "figure") { + return false + } } } ) @@ -216,6 +277,48 @@ const markDeletion = (tr, from, to, user, date) => { 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 +342,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()) } @@ -273,7 +376,7 @@ const handleReplaceStep = (state, tr, step, stepIndex, newTr, map, user, date) = } } /** - * Handle add mark step + * Handle add/remove mark step * @param {EditorState} state * @param {AddMarkStep | RemoveMarkStep} step * @param {Transaction} newTr @@ -340,6 +443,62 @@ 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 + const invertStep = tr.steps[stepIndex].invert(tr.docs[stepIndex]).map(map) + map.appendMap(invertStep.getMap()) + map.appendMap( + markDeletion(newTr, step.from, step.gapFrom, user, date) + ) + } 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 +524,8 @@ 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); } 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..5ca40c8a44 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,16 @@ import {EditorState} from "prosemirror-state"; import {Slice, Fragment} 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"; const createEmptyDocState = () => { const emptyDoc = {content: [], type: "doc"} @@ -103,8 +110,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 +144,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 +156,7 @@ describe('Track Changes TR Modifier', () => { }, type: "textStyle" }]); - expect(trackMarksMark.attrs.after).toStrictEqual([ + expect(trackMarksMark.attrs.after).toEqual([ { attrs: {}, type: "bold" @@ -191,7 +198,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 +210,7 @@ describe('Track Changes TR Modifier', () => { }, type: "textStyle" }]); - expect(trackMarksMark.attrs.after).toStrictEqual([ + expect(trackMarksMark.attrs.after).toEqual([ { attrs: { color: "#FF004D", @@ -241,7 +248,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 +260,7 @@ describe('Track Changes TR Modifier', () => { }, type: "textStyle" }]); - expect(trackMarksMark.attrs.after).toStrictEqual([ + expect(trackMarksMark.attrs.after).toEqual([ { attrs: {}, type: "bold" @@ -268,4 +275,309 @@ 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("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].attrs.track.length).toBe(1); + expect(doc3Json.content[0].content[0].attrs.track[0].type).toBe(TrackDeleteMarkName); + expect(doc3Json.content[0].content[0].attrs.track[0].author).toBe("TestUser1"); + expect(doc3Json.content[0].content[0].attrs.track[0].before).toBe(undefined); + expect(doc3Json.content[0].content[0].content[0].type).toBe("paragraph"); + }); + 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); + }); + }); + }); }); 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 ` Date: Fri, 13 Sep 2024 15:31:00 +0200 Subject: [PATCH 5/9] rework node type change so we see the current doc as is --- .../track-changes/track-changes-base.js | 35 +++- .../track-changes-tr-modifier.js | 152 ++++++++++------ .../track-changes-tr-modifier.test.js | 171 ++++++++++++++++-- 3 files changed, 286 insertions(+), 72 deletions(-) diff --git a/packages/super-editor/src/extensions/track-changes/track-changes-base.js b/packages/super-editor/src/extensions/track-changes/track-changes-base.js index 460778aed8..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 @@ -332,7 +332,7 @@ export const applyTrackChanges = (action, state, tr, from, to) => { 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 (blockTrack) { if (action === "accept") { tr.setNodeMarkup(pos + offset, null, { ...node.attrs, @@ -342,16 +342,18 @@ export const applyTrackChanges = (action, state, tr, from, to) => { const nodeType = state.schema.nodes[blockTrack.before.type]; tr.setNodeMarkup(pos + offset, nodeType, blockTrack.before.attrs); } - } else if (blockDelete) { + } + if (blockDelete) { if (action === "accept") { - removeParentNode(state.schema, tr, node, pos); - } else if (action === "revert") { 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 } - } else if (blockInsert) { + } + if (blockInsert) { if (action === "accept") { tr.setNodeMarkup(pos + offset, null, { ...node.attrs, @@ -448,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 88e9af617f..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 @@ -9,6 +9,7 @@ import { 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 @@ -183,15 +184,16 @@ 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 {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, @@ -220,60 +222,99 @@ const markDeletion = (tr, from, to, user, date) => { deletionMap.map(Math.min(to, pos + node.nodeSize)), deletionMark ) - } else if ( - !node.attrs.track?.find(trackAttr => trackAttr.type === TrackDeleteMarkName) && - !["bulletList", "orderedList"].includes(node.type.name) - ) { - if (node.attrs.track?.find( - trackAttr => trackAttr.type === TrackInsertMarkName && trackAttr.user === user.id - )) { - let removeStep - // user has created element. so (s)he is allowed to delete it again. - if (node.isTextblock && to < (pos + node.nodeSize)) { - // The node is a textblock. So we need to merge into the last possible - // position inside the last text block. - const selectionBefore = Selection.findFrom(tr.doc.resolve(pos), -1) - if (selectionBefore instanceof TextSelection) { - removeStep = new ReplaceStep( - deletionMap.map(selectionBefore.$anchor.pos), - deletionMap.map(to), - Slice.empty - ) - } - } else { - removeStep = new ReplaceStep( - deletionMap.map(Math.max(from, pos)), - deletionMap.map(Math.min(to, pos + node.nodeSize)), - Slice.empty - ) - } - - if (!tr.maybeStep(removeStep).failed) { - deletionMap.appendMap(removeStep.getMap()) - } - if (node.type.name === "listItem" && listItem) { - listItem = false - } - } else if (node.attrs.track) { - if (node.type.name === "listItem") { - listItem = true - } else if (listItem) { - // The first child of the first list item (likely a par) will not be merged with the paragraph - // before it. - listItem = false - return - } - const track = node.attrs.track.slice() - track.push({type: TrackDeleteMarkName, author: user, date: date}) - tr.setNodeMarkup(deletionMap.map(pos), null, Object.assign({}, node.attrs, {track}), node.marks) - } - if (node.type.name === "figure") { - return false + } 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; } @@ -363,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())) } } @@ -371,7 +411,7 @@ 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) ) } } @@ -462,11 +502,10 @@ const handleNodeTypeChanges = (state, tr, step, stepIndex, newTr, map, user, dat 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 - const invertStep = tr.steps[stepIndex].invert(tr.docs[stepIndex]).map(map) - map.appendMap(invertStep.getMap()) map.appendMap( - markDeletion(newTr, step.from, step.gapFrom, user, date) + 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) @@ -526,6 +565,7 @@ export const trackTransaction = (tr, state, user) => { 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 5ca40c8a44..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,5 +1,5 @@ -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"; @@ -11,6 +11,7 @@ import { 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"} @@ -19,7 +20,7 @@ const createEmptyDocState = () => { return EditorState.create({ schema, doc, - }) + }); } describe('Track Changes TR Modifier', () => { @@ -518,15 +519,35 @@ describe('Track Changes TR Modifier', () => { //check const doc3Json = state3.doc.toJSON(); expect(doc3Json.content.length).toBe(1); - expect(doc3Json.content[0].type).toBe("bulletList"); + expect(doc3Json.content[0].type).toBe("paragraph"); 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].attrs.track.length).toBe(1); - expect(doc3Json.content[0].content[0].attrs.track[0].type).toBe(TrackDeleteMarkName); - expect(doc3Json.content[0].content[0].attrs.track[0].author).toBe("TestUser1"); - expect(doc3Json.content[0].content[0].attrs.track[0].before).toBe(undefined); - expect(doc3Json.content[0].content[0].content[0].type).toBe("paragraph"); + 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 @@ -579,5 +600,133 @@ describe('Track Changes TR Modifier', () => { 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); + } + }); + }); }); }); From 83fd9467305fdb3b3207736d39a7265c69182c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Mon, 16 Sep 2024 16:45:52 +0200 Subject: [PATCH 6/9] track-changes - some list change imports --- .../v2/importer/boomarkNodeImporter.test.js | 2 +- .../v2/importer/importerHelpers.js | 97 +- .../v2/importer/lineBreakImporter.test.js | 2 +- .../v2/importer/listImporter.js | 42 +- .../v2/importer/listImporter.test.js | 10 +- .../v2/importer/markImporter.js | 2 +- .../v2/importer/runNodeImporter.js | 2 +- .../v2/importer/standardNodeImporter.js | 2 +- .../v2/importer/test-helpers/styles.xml | 938 ++++++++++++++++++ .../importer/test-helpers/testUtils.test.js | 37 + .../v2/importer/testUtils.test.js | 29 - .../v2/importer/textNodeImporter.js | 2 +- .../v2/importer/textNodeImporter.test.js | 2 +- .../v2/importer/trackChangesImporter.js | 2 +- .../v2/importer/trackChangesImporter.test.js | 138 ++- 15 files changed, 1255 insertions(+), 52 deletions(-) create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/test-helpers/styles.xml create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/test-helpers/testUtils.test.js delete mode 100644 packages/super-editor/src/core/super-converter/v2/importer/testUtils.test.js 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 index 4d418ddc1a..9f334bef22 100644 --- 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 @@ -1,6 +1,6 @@ import {SuperConverter} from "../../SuperConverter.js"; import {handleBookmarkNode} from "./bookmarkNodeImporter.js"; -import {createNodeListHandlerMock} from "./testUtils.test.js"; +import {createNodeListHandlerMock} from "./test-helpers/testUtils.test.js"; describe('BookmarkNodeImporter', () => { 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 index 4e498a7b34..93a9f12a43 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js @@ -1,14 +1,21 @@ import {handleStyleChangeMarks, parseMarks} from "./markImporter.js"; import {SuperConverter} from "../../SuperConverter.js"; +import {TrackDeleteMarkName} from "../../../../extensions/track-changes/constants.js"; +import {handleListNodes, testForList} from "./listImporter.js"; +import {carbonCopy} from "../../../utilities/cabonCopy.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) { +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 @@ -22,6 +29,7 @@ export function parseProperties(node) { if (runProperties && runProperties?.elements?.length) marks = parseMarks(runProperties); if (paragraphProperties && paragraphProperties.elements?.length) { marks.push(...parseMarks(paragraphProperties)); + marks.push(...parseParagraphChange(node, paragraphProperties, docx, nodeListHandler, insideTrackChange, listLevel)); } //add style change marks marks.push(...handleStyleChangeMarks(runProperties, marks)); @@ -34,15 +42,96 @@ export function parseProperties(node) { // If this is a paragraph, don't apply marks but apply attributes directly if (marks && node.name === 'w:p') { marks.forEach((mark) => { - const attrValue = Object.keys(mark.attrs)[0]; - const value = mark.attrs[attrValue]; - attributes[attrValue] = value; + 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} node + * @param {XmlNode} paragraphProperties + * @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. + */ +function parseParagraphChange(node, paragraphProperties, docx, nodeListHandler, insideTrackChange, listLevel=0) { + 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(node.elements); + const newElements = copiedElements.filter((el) => el.name !== 'w:pPr'); + newElements.push(changeProps) + const nodeWithChange = { + type: node.type, + name: node.name, + attributes: carbonCopy(node.attributes), + elements: newElements, + } + if (testForList(nodeWithChange)) { + const listNode = handleListNodes([nodeWithChange], docx, nodeListHandler, insideTrackChange, listLevel); + const wrappers = [] + const listParent = { + type: listNode.type, + attrs: listNode.attrs + } + wrappers.push(listParent); + const children = listNode.content; + if(children.length === 1) { + const listItem = { + type: children[0].type, + attrs: children[0].attrs + } + wrappers.push(listItem); + } + changes.push({ + attrs: { + track: [{ + type: TrackDeleteMarkName, + author: mappedAttributes.author, + date: mappedAttributes.date, + wid: mappedAttributes.wid, + before: { + wrappers + } + }] + } + }) + } + } + }); + + return changes; +} /** * 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 index c8a89d5859..f29bfac0c4 100644 --- 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 @@ -1,6 +1,6 @@ import {SuperConverter} from "../../SuperConverter.js"; import {handleLineBreakNode} from "./lineBreakImporter.js"; -import {createNodeListHandlerMock} from "./testUtils.test.js"; +import {createNodeListHandlerMock} from "./test-helpers/testUtils.test.js"; describe('LineBreakNodeImporter', () => { it("parses only line break nodes", () => { 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 index a271ce4ebb..71f75e2e43 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -1,6 +1,7 @@ import {carbonCopy} from "../../../utilities/cabonCopy.js"; import {hasTextNode, parseProperties} from "./importerHelpers.js"; import {preProcessNodesForFldChar} from "./paragraphNodeImporter.js"; +import {TrackChangeBlockChangeAttributeName} from "../../../../extensions/track-changes/constants.js"; /** * @type {import("docxImporter").NodeHandler} @@ -70,7 +71,7 @@ export const listHandlerEntity = { * @param {number} [listLevel=0] - The current indentation level of the list. * @returns {Object} The processed list node with structured content. */ -function handleListNodes(listItems, docx, nodeListHandler, insideTrackChange, listLevel = 0) { +export function handleListNodes(listItems, docx, nodeListHandler, insideTrackChange, listLevel = 0) { const parsedListItems = []; let overallListType; let listStyleType; @@ -96,7 +97,7 @@ function handleListNodes(listItems, docx, nodeListHandler, insideTrackChange, li // 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(item); + const { attributes, elements, marks = [] } = parseProperties(item, docx, nodeListHandler, insideTrackChange, listLevel); const { listType, listOrderingType, @@ -150,6 +151,38 @@ function handleListNodes(listItems, docx, nodeListHandler, insideTrackChange, li // If this item belongs in a higher list level, we need to break out of the loop and return to higher levels else break; } + const track = [] + if((listItems[0]?.attributes.track ?? []).length > 0) { + const trackOnLi = listItems[0].attributes.track[0] + if(trackOnLi.before.wrappers.length > 0) { + const firstWrapper = trackOnLi.before.wrappers[0]; + const secondWrapper = trackOnLi.before.wrappers[1]; + if(secondWrapper.type === 'listItem') { + track.push({ + type: TrackChangeBlockChangeAttributeName, + author: trackOnLi.author, + date: trackOnLi.date, + wid: trackOnLi.wid, + before: { + type: firstWrapper.type, + attrs: firstWrapper.attrs + } + }) + parsedListItems[0].attrs.track = [{ + type: TrackChangeBlockChangeAttributeName, + author: trackOnLi.author, + date: trackOnLi.date, + wid: trackOnLi.wid, + before: { + type: secondWrapper.type, + attrs: secondWrapper.attrs + } + }] + } + } + + + } return { type: overallListType || 'bulletList', @@ -158,7 +191,8 @@ function handleListNodes(listItems, docx, nodeListHandler, insideTrackChange, li 'list-style-type': listStyleType, attributes: { 'parentAttributes': listItems[0]?.attributes || null, - } + }, + track: track, } }; } @@ -248,7 +282,7 @@ function getNodeNumberingDefinition(attributes, level, docx) { // Get the indent level const ilvlTag = numPr.elements.find(style => style.name === 'w:ilvl'); - const ilvl = ilvlTag.attributes['w:val']; + const ilvl = ilvlTag?.attributes['w:val'] ?? 0; // Get the list style id const numIdTag = numPr.elements.find(style => style.name === 'w:numId'); 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 index 87f2d71f76..97d0d591e9 100644 --- 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 @@ -2,6 +2,7 @@ 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("table live xml test", () => { @@ -20,9 +21,8 @@ describe("table live xml test", () => { `; - const numberingXml = `` const nodes = parseXmlToJson(exampleSingleBulletXml).elements - const numbering = parseXmlToJson(numberingXml) + const numbering = parseXmlToJson(numberingBulletXml) const docx = { 'word/numbering.xml': numbering } @@ -54,9 +54,8 @@ describe("table live xml test", () => { `; - const numberingXml = `` const nodes = parseXmlToJson(exampleSingleNumberedXml).elements - const numbering = parseXmlToJson(numberingXml) + const numbering = parseXmlToJson(numberingBulletXml) const docx = { 'word/numbering.xml': numbering } @@ -161,9 +160,8 @@ describe("table live xml test", () => { `; - const numberingXml = `` const nodes = parseXmlToJson(exampleMultiNestedListXml).elements[0].elements - const numbering = parseXmlToJson(numberingXml) + const numbering = parseXmlToJson(numberingBulletXml) const docx = { 'word/numbering.xml': numbering } 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 index 5178e7cdd6..a857680e83 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js @@ -4,7 +4,7 @@ import {twipsToInches} from "../../helpers.js"; /** * - * @param property + * @param {XmlNode} property * @returns {PmMarkJson[]} */ export function parseMarks(property) { 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 index dc83bcaf50..4415f8db6f 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js @@ -11,7 +11,7 @@ const handleRunNode = (nodes, docx, nodeListHandler, insideTrackChange = false) 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); + const { marks = [], attributes = {} } = parseProperties(node, docx, nodeListHandler, insideTrackChange); if (node.marks) marks.push(...node.marks); processedRun = processedRun.map(n => ({ ...n, marks, attributes })); } 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 index bb55b9bd7f..07a18ef933 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js @@ -10,7 +10,7 @@ export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChan const node = nodes[0]; // Parse properties const { name, type } = node; - const { attributes, elements, marks = [] } = parseProperties(node); + const { attributes, elements, marks = [] } = parseProperties(node, docx, nodeListHandler, insideTrackChange); // Iterate through the children and build the schemaNode content const content = []; 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..950062872c --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/test-helpers/testUtils.test.js @@ -0,0 +1,37 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * + * @returns {NodeListHandler} + */ +export const createNodeListHandlerMock = () => { + return { + handlerEntities: [ + { + handlerName: 'standardNodeHandler', + handler: () => ({ + nodes: [{ + type: 'standardNodeHandler', content: {}, attrs: {}, marks: [], + }], consumed: 1 + }) + }, + { + handlerName: 'textNodeHandler', + handler: () => ({ + nodes: [{ + type: 'textNodeHandler', content: {}, attrs: {}, marks: [], + }], consumed: 1 + }) + } + ], + 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/testUtils.test.js b/packages/super-editor/src/core/super-converter/v2/importer/testUtils.test.js deleted file mode 100644 index 64025bd0e1..0000000000 --- a/packages/super-editor/src/core/super-converter/v2/importer/testUtils.test.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * - * @returns {NodeListHandler} - */ -export const createNodeListHandlerMock = () => { - return { - handlerEntities: [ - { - handlerName: 'standardNodeHandler', - handler: () => ({ - nodes: [{ - type: 'standardNodeHandler', content: {}, attrs: {}, marks: [], - }], consumed: 1 - }) - }, - { - handlerName: 'textNodeHandler', - handler: () => ({ - nodes: [{ - type: 'textNodeHandler', content: {}, attrs: {}, marks: [], - }], consumed: 1 - }) - } - ], - handler: () => [{type: "dummyNode", content: {}, attrs: {}}] - } -} - -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 index 6631bf062d..f4d5bae609 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js @@ -12,7 +12,7 @@ export const handleTextNode = (nodes, docx, nodeListHandler, insideTrackChange const { type } = node; // Parse properties - const { attributes, elements, marks = [] } = parseProperties(node); + 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; 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 index 50258165a1..5cdef878ff 100644 --- 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 @@ -1,6 +1,6 @@ import {SuperConverter} from "../../SuperConverter.js"; import {handleTextNode} from "./textNodeImporter.js"; -import {createNodeListHandlerMock} from "./testUtils.test.js"; +import {createNodeListHandlerMock} from "./test-helpers/testUtils.test.js"; describe('TextNodeImporter', () => { it("parses only text nodes", () => { 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 index 9f49f62cb9..68a0297db3 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js @@ -10,7 +10,7 @@ export const handleTrackChangeNode = (nodes, docx, nodeListHandler, insideTrackC } const node = nodes[0]; const { name } = node; - const { attributes, elements } = parseProperties(node); + const { attributes, elements } = parseProperties(node, docx, nodeListHandler, insideTrackChange); const subs = nodeListHandler.handler(elements, docx,true) const changeType = name === 'w:del' ? TrackDeleteMarkName : TrackInsertMarkName; 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 index b4ee594231..5a27f7ee7c 100644 --- 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 @@ -1,7 +1,13 @@ import {SuperConverter} from "../../SuperConverter.js"; import {handleTrackChangeNode, handleDelText} from "./trackChangesImporter.js"; -import {createNodeListHandlerMock} from "./testUtils.test.js"; import { + createNodeListHandlerMock, + numberingBulletXml, + numberingNodeChangeXml, + stylesXml +} from "./test-helpers/testUtils.test.js"; +import { + TrackChangeBlockChangeAttributeName, TrackDeleteMarkName, TrackInsertMarkName, TrackMarksMarkName @@ -139,4 +145,134 @@ describe("trackChanges live xml test", () => { ] }); }); + 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 From 7fb388fe696a824d4ea7a7175a7306dcbf6182ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 17 Sep 2024 13:00:00 +0200 Subject: [PATCH 7/9] import nodes one-by-one and aggregate them in separate step --- .../v2/importer/docxImporter.js | 10 +- .../v2/importer/listImporter.js | 226 ++++++++++++++++-- .../v2/importer/listImporter.test.js | 81 ++++--- .../src/core/utilities/windowingSplit.js | 85 +++++++ .../src/core/utilities/windowingSplit.test.js | 50 ++++ 5 files changed, 389 insertions(+), 63 deletions(-) create mode 100644 packages/super-editor/src/core/utilities/windowingSplit.js create mode 100644 packages/super-editor/src/core/utilities/windowingSplit.test.js 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 index c1fa2eb9b1..9876891aca 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -13,7 +13,7 @@ import {paragraphNodeHandlerEntity} from "./paragraphNodeImporter.js"; import {standardNodeHandlerEntity} from "./standardNodeImporter.js"; import {lineBreakNodeHandlerEntity} from "./lineBreakImporter.js"; import {bookmarkNodeHandlerEntity} from "./bookmarkNodeImporter.js"; -import {listHandlerEntity} from "./listImporter.js"; +import {listHandlerEntity, listNodeAggregator} from "./listImporter.js"; /** * @typedef {import()} XmlNode @@ -59,6 +59,9 @@ export const createDocumentJson = (docx) => { return null; } +/** + * @returns {NodeListHandler} + */ export const defaultNodeListHandler = () => { const entities = [ runNodeHandlerEntity, @@ -113,7 +116,10 @@ const createNodeListHandler = (nodeHandlers) => { } } } - return processedElements; + + const aggregatedNodes = listNodeAggregator(processedElements, docx); + + return aggregatedNodes; } return nodeListHandlerFn; 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 index 71f75e2e43..1c19073086 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -2,6 +2,7 @@ import {carbonCopy} from "../../../utilities/cabonCopy.js"; import {hasTextNode, parseProperties} from "./importerHelpers.js"; import {preProcessNodesForFldChar} from "./paragraphNodeImporter.js"; import {TrackChangeBlockChangeAttributeName} from "../../../../extensions/track-changes/constants.js"; +import {windowingSplit} from "../../../utilities/windowingSplit.js"; /** * @type {import("docxImporter").NodeHandler} @@ -12,35 +13,15 @@ export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) } 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)) { - // Get all siblings that are list items and haven't been processed yet. - const siblings = carbonCopy(nodes); - const listItems = []; - let consumed = 0; - - // Iterate each item until we find the end of the list (a non-list item), - // then send to the list handler for processing. - let possibleList = siblings.shift(); - while (possibleList && testForList(possibleList, true)) { - listItems.push(possibleList); - possibleList = siblings.shift(); - if (possibleList?.elements && !hasTextNode(possibleList.elements)) { - listItems.push(possibleList); - possibleList = siblings.shift(); - } - } - - // TODO - Check that this change is OK return { - nodes: [handleListNodes(listItems, docx, nodeListHandler, 0)], - consumed: listItems.filter(i => i.seen).length + nodes: singleNodeTransform(node, docx, nodeListHandler, insideTrackChange), + consumed: 1, }; } else { return {nodes: [], consumed: 0}; @@ -197,6 +178,135 @@ export function handleListNodes(listItems, docx, nodeListHandler, insideTrackCha }; } +/** + * 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, + } + }; +} + /** * @@ -325,6 +435,78 @@ function getNodeNumberingDefinition(attributes, level, docx) { return { listType, listOrderingType: listTypeDef, ilvl, numId, listrPrs, listpPrs, start, lvlText, lvlJc }; } +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); } 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 index 97d0d591e9..f5f12458b3 100644 --- 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 @@ -5,7 +5,7 @@ import {handleListNode} from "./listImporter.js"; import {numberingBulletXml} from "./test-helpers/testUtils.test.js"; -describe("table live xml test", () => { +describe("list live xml test", () => { it("parses simple bullet xml", () => { const exampleSingleBulletXml = ` @@ -27,16 +27,17 @@ describe("table live xml test", () => { 'word/numbering.xml': numbering } - const result = handleListNode(nodes, docx, defaultNodeListHandler(), false); - expect(result.nodes.length).toBe(1); - expect(result.nodes[0].type).toBe("bulletList"); - expect(result.nodes[0].content.length).toBe(1); - expect(result.nodes[0].content[0].type).toBe("listItem"); - expect(result.nodes[0].content[0].content.length).toBe(1); - expect(result.nodes[0].content[0].content[0].type).toBe("paragraph"); - expect(result.nodes[0].content[0].content[0].content.length).toBe(1); - expect(result.nodes[0].content[0].content[0].content[0].type).toBe("text"); - expect(result.nodes[0].content[0].content[0].content[0].text).toBe("TEXTITEM"); + 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", () => { @@ -60,16 +61,17 @@ describe("table live xml test", () => { 'word/numbering.xml': numbering } - const result = handleListNode(nodes, docx, defaultNodeListHandler(), false); - expect(result.nodes.length).toBe(1); - expect(result.nodes[0].type).toBe("orderedList"); - expect(result.nodes[0].content.length).toBe(1); - expect(result.nodes[0].content[0].type).toBe("listItem"); - expect(result.nodes[0].content[0].content.length).toBe(1); - expect(result.nodes[0].content[0].content[0].type).toBe("paragraph"); - expect(result.nodes[0].content[0].content[0].content.length).toBe(1); - expect(result.nodes[0].content[0].content[0].content[0].type).toBe("text"); - expect(result.nodes[0].content[0].content[0].content[0].text).toBe("numbered"); + 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"); }); @@ -166,24 +168,25 @@ describe("table live xml test", () => { 'word/numbering.xml': numbering } - const result = handleListNode(nodes, docx, defaultNodeListHandler(), false); - expect(result.nodes.length).toBe(1); - expect(result.nodes[0].type).toBe("bulletList"); - expect(result.nodes[0].content.length).toBe(3); - expect(result.nodes[0].content[0].type).toBe("listItem"); - expect(result.nodes[0].content[0].content.length).toBe(2); - expect(result.nodes[0].content[0].content[0].type).toBe("paragraph"); - expect(result.nodes[0].content[0].content[0].content.length).toBe(1); - expect(result.nodes[0].content[0].content[0].content[0].type).toBe("text"); - expect(result.nodes[0].content[0].content[0].content[0].text).toBe("L1: A"); - expect(result.nodes[0].content[0].content[1].type).toBe("bulletList"); - expect(result.nodes[0].content[0].content[1].content.length).toBe(1); - expect(result.nodes[0].content[0].content[1].content[0].type).toBe("listItem"); - expect(result.nodes[0].content[0].content[1].content[0].content.length).toBe(1); - expect(result.nodes[0].content[0].content[1].content[0].content[0].type).toBe("paragraph"); - expect(result.nodes[0].content[0].content[1].content[0].content[0].content.length).toBe(1); - expect(result.nodes[0].content[0].content[1].content[0].content[0].content[0].type).toBe("text"); - expect(result.nodes[0].content[0].content[1].content[0].content[0].content[0].text).toBe("L2: B"); + 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/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); + }); + }); +}); From d0f5b9f89c1ab70a6ffdefef840b57a11a6f551d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 17 Sep 2024 13:54:50 +0200 Subject: [PATCH 8/9] import cleanup --- .../v2/importer/bookmarkNodeImporter.js | 8 +- .../v2/importer/boomarkNodeImporter.test.js | 13 +- .../v2/importer/docxImporter.js | 11 +- .../v2/importer/hyperlinkImporter.js | 4 +- .../v2/importer/imageImporter.js | 4 +- .../v2/importer/lineBreakImporter.js | 8 +- .../v2/importer/lineBreakImporter.test.js | 8 +- .../v2/importer/listImporter.js | 232 +----------------- .../v2/importer/paragraphNodeImporter.js | 12 +- .../v2/importer/runNodeImporter.js | 4 +- .../v2/importer/standardNodeImporter.js | 6 +- .../v2/importer/tableImporter.js | 18 +- .../v2/importer/tableImporter.test.js | 44 ++-- .../importer/test-helpers/testUtils.test.js | 15 +- .../v2/importer/textNodeImporter.js | 8 +- .../v2/importer/textNodeImporter.test.js | 17 +- .../v2/importer/trackChangesImporter.js | 4 +- .../v2/importer/trackChangesImporter.test.js | 29 +-- 18 files changed, 107 insertions(+), 338 deletions(-) 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 index 43d8c1733c..90ffa8ea7a 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/bookmarkNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/bookmarkNodeImporter.js @@ -3,18 +3,18 @@ */ export const handleBookmarkNode = (nodes, docx, nodeListHandler, insideTrackChange) => { if(nodes.length === 0 || nodes[0].name !== 'w:bookmarkStart') { - return {nodes: [], consumed: 0}; + 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 {nodes: [], consumed: 0}; + return []; } const result = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); - if(result.nodes.length === 1) { - result.nodes[0].attrs.name = node.attributes['w:name']; + if(result.length === 1) { + result[0].attrs.name = node.attributes['w:name']; } return result; } 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 index 9f334bef22..4d6a10ccfe 100644 --- 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 @@ -9,25 +9,22 @@ describe('BookmarkNodeImporter', () => { const nodesOfNodes = names.map((name) => ([{name}])); for(const nodes of nodesOfNodes) { const result = handleBookmarkNode(nodes, null, null, false); - expect(result.nodes.length).toBe(0); - expect(result.consumed).toBe(0); + 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.nodes.length).toBe(1); - expect(result.consumed).toBe(1); - expect(result.nodes[0].type).toBe('standardNodeHandler'); - expect(result.nodes[0].attrs.name).toBe("bookmarkName"); + 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.nodes.length).toBe(0); - expect(result.consumed).toBe(0); + 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 index 9876891aca..b4dde91c5c 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -23,7 +23,7 @@ import {listHandlerEntity, listNodeAggregator} from "./listImporter.js"; * @typedef {(nodes: XmlNode[], docx: ParsedDocx, insideTrackCahange: boolean) => PmNodeJson[]} NodeListHandlerFn * @typedef {{handler: NodeListHandlerFn, handlerEntities: NodeHandlerEntry[]}} NodeListHandler * - * @typedef {(nodes: XmlNode[], docx: ParsedDocx, nodeListHandler: NodeListHandler, insideTrackCahange: boolean) => {nodes: PmNodeJson[], consumed: number}} NodeHandler + * @typedef {(nodes: XmlNode[], docx: ParsedDocx, nodeListHandler: NodeListHandler, insideTrackCahange: boolean) => PmNodeJson[]} NodeHandler * @typedef {{handlerName: string, handler: NodeHandler}} NodeHandlerEntry */ @@ -99,14 +99,13 @@ const createNodeListHandler = (nodeHandlers) => { const processedElements = []; for (let index = 0; index < elements.length; index++) { - const {nodes, consumed} = nodeHandlers.reduce((res, handler) => { - if(res.consumed > 0) return res; + const nodes = nodeHandlers.reduce((res, handler) => { + if(res.length > 0) return res; const nodesToHandle = elements.slice(index); if(!nodesToHandle || nodesToHandle.length === 0) return res; return handler.handler(nodesToHandle, docx, {handler: nodeListHandlerFn, handlerEntities: nodeHandlers}, insideTrackChange); - }, {nodes: [], consumed: 0}); - index += consumed-1; - if(consumed === 0) { + }, []); + if(nodes.length === 0) { console.warn("We have a node that we can't handle!", elements[index]) } for(let node of nodes) { 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 index e6bcfce742..9205d5d4f4 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/hyperlinkImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/hyperlinkImporter.js @@ -5,7 +5,7 @@ */ export const handleHyperlinkNode = (nodes, docx, nodeListHandler, insideTrackChange) => { if(nodes.length === 0 || nodes[0].name !== 'w:hyperlink') { - return {nodes: [], consumed: 0}; + return []; } const node = nodes[0]; @@ -48,7 +48,7 @@ export const handleHyperlinkNode = (nodes, docx, nodeListHandler, insideTrackCha } const updatedNode = nodeListHandler.handler([runNode], docx, insideTrackChange); - return {nodes: updatedNode, consumed: 1}; + return updatedNode; } /** 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 index 728813fd9d..b67bd35c73 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js @@ -5,7 +5,7 @@ import {emuToPixels} from "../../helpers.js"; */ export const handleDrawingNode= (nodes, docx, nodeListHandler, insideTrackChange) => { if(nodes.length === 0 || nodes[0].name !== 'w:drawing') { - return {nodes: [], consumed: 0}; + return []; } const node = nodes[0]; @@ -15,7 +15,7 @@ export const handleDrawingNode= (nodes, docx, nodeListHandler, insideTrackChange // Inline images const inlineImage = elements.find((el) => el.name === 'wp:inline'); if (inlineImage) result = handleInlineImageNode(inlineImage, docx); - return {nodes: result ? [result] : [], consumed: 1}; + return result ? [result] : []; } export function handleInlineImageNode(node, docx) { 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 index e1324ba0d4..485ae2b3a8 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/lineBreakImporter.js @@ -3,7 +3,7 @@ */ export const handleLineBreakNode = (nodes, docx, nodeListHandler, insideTrackChange) => { if(nodes.length === 0 || nodes[0].name !== 'w:br') { - return {nodes: [], consumed: 0}; + return []; } const attrs = {}; @@ -12,13 +12,11 @@ export const handleLineBreakNode = (nodes, docx, nodeListHandler, insideTrackCha if (lineBreakType) attrs['lineBreakType'] = lineBreakType; - return { - nodes: [{ + return [{ type: 'lineBreak', content: [], attrs, - }], consumed: 1 - }; + }]; } /** 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 index f29bfac0c4..f748e9a6e8 100644 --- 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 @@ -8,16 +8,14 @@ describe('LineBreakNodeImporter', () => { const nodesOfNodes = names.map((name) => ([{name}])); for(const nodes of nodesOfNodes) { const result = handleLineBreakNode(nodes, null, null, false); - expect(result.nodes.length).toBe(0); - expect(result.consumed).toBe(0); + 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.nodes.length).toBe(1); - expect(result.consumed).toBe(1); - expect(result.nodes[0].type).toBe('lineBreak'); + 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 index 1c19073086..e96add4a8e 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -1,7 +1,6 @@ import {carbonCopy} from "../../../utilities/cabonCopy.js"; -import {hasTextNode, parseProperties} from "./importerHelpers.js"; +import {parseProperties} from "./importerHelpers.js"; import {preProcessNodesForFldChar} from "./paragraphNodeImporter.js"; -import {TrackChangeBlockChangeAttributeName} from "../../../../extensions/track-changes/constants.js"; import {windowingSplit} from "../../../utilities/windowingSplit.js"; /** @@ -9,7 +8,7 @@ import {windowingSplit} from "../../../utilities/windowingSplit.js"; */ export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) => { if(nodes.length === 0 || nodes[0].name !== 'w:p') { - return {nodes: [], consumed: 0}; + return []; } const node = carbonCopy(nodes[0]) @@ -19,12 +18,9 @@ export const handleListNode = (nodes, docx, nodeListHandler, insideTrackChange) // Check if this paragraph node is a list if (testForList(node)) { - return { - nodes: singleNodeTransform(node, docx, nodeListHandler, insideTrackChange), - consumed: 1, - }; + return singleNodeTransform(node, docx, nodeListHandler, insideTrackChange) } else { - return {nodes: [], consumed: 0}; + return []; } } @@ -36,148 +32,6 @@ export const listHandlerEntity = { handler: handleListNode } - -/** - * List processing - * - * This recursive function takes a list of known list items and combines them into nested lists. - * - * It begins with listLevel = 0, and if we find an indented node, we call this function again and increase the level. - * with the same set of list items (as we do not know the node levels until we process them). - * - * @param {Array} listItems - Array of list items to process. - * @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 {Object} The processed list node with structured content. - */ -export function handleListNodes(listItems, docx, nodeListHandler, insideTrackChange, listLevel = 0) { - const parsedListItems = []; - let overallListType; - let listStyleType; - - const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; - if (!handleStandardNode) { - console.error('Standard node handler not found'); - return {nodes: [], consumed: 0}; - } - - for (let [index, item] of listItems.entries()) { - // Skip items we've already processed - if (item.seen) continue; - - // Sometimes there are paragraph nodes that only have pPr element and no text node - these are - // Spacers in the XML and need to be appended to the last item. - if (item.elements && !hasTextNode(item.elements)) { - const n = handleStandardNode([item], docx, nodeListHandler, insideTrackChange).nodes[0]; - parsedListItems[parsedListItems.length - 1]?.content.push(n); - item.seen = true; - continue; - } - - // 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(item, docx, nodeListHandler, insideTrackChange, listLevel); - const { - listType, - listOrderingType, - ilvl, - listrPrs, - listpPrs, - start, - lvlText, - lvlJc - } = getNodeNumberingDefinition(attributes, listLevel, docx); - listStyleType = listOrderingType; - const intLevel = parseInt(ilvl); - - // Append node if it belongs on this list level - const nodeAttributes = {}; - if (listLevel === intLevel) { - overallListType = listType; - item.seen = true; - - const schemaElements = []; - schemaElements.push({ - type: 'paragraph', - content: nodeListHandler.handler(elements, docx, insideTrackChange)?.filter(n => n) - }); - - console.debug('\n\n LIST ITEM', listpPrs, listrPrs, start, lvlText, lvlJc, '\n\n') - - if (listpPrs) nodeAttributes['listParagraphProperties'] = listpPrs; - if (listrPrs) nodeAttributes['listRunProperties'] = listrPrs; - nodeAttributes['order'] = start; - nodeAttributes['lvlText'] = lvlText; - nodeAttributes['lvlJc'] = lvlJc; - nodeAttributes['attributes'] = { - parentAttributes: item?.attributes || null, - } - parsedListItems.push(createListItem(schemaElements, nodeAttributes, [])); - } - - // If this item belongs in a deeper list level, we need to process it by calling this function again - // But going one level deeper. - else if (listLevel < intLevel) { - const sublist = handleListNodes(listItems.slice(index), docx, nodeListHandler, insideTrackChange, listLevel + 1); - const lastItem = parsedListItems[parsedListItems.length - 1]; - if (!lastItem) { - parsedListItems.push(createListItem([sublist], nodeAttributes, [])); - } else { - lastItem.content.push(sublist); - } - } - - // If this item belongs in a higher list level, we need to break out of the loop and return to higher levels - else break; - } - const track = [] - if((listItems[0]?.attributes.track ?? []).length > 0) { - const trackOnLi = listItems[0].attributes.track[0] - if(trackOnLi.before.wrappers.length > 0) { - const firstWrapper = trackOnLi.before.wrappers[0]; - const secondWrapper = trackOnLi.before.wrappers[1]; - if(secondWrapper.type === 'listItem') { - track.push({ - type: TrackChangeBlockChangeAttributeName, - author: trackOnLi.author, - date: trackOnLi.date, - wid: trackOnLi.wid, - before: { - type: firstWrapper.type, - attrs: firstWrapper.attrs - } - }) - parsedListItems[0].attrs.track = [{ - type: TrackChangeBlockChangeAttributeName, - author: trackOnLi.author, - date: trackOnLi.date, - wid: trackOnLi.wid, - before: { - type: secondWrapper.type, - attrs: secondWrapper.attrs - } - }] - } - } - - - } - - return { - type: overallListType || 'bulletList', - content: parsedListItems, - attrs: { - 'list-style-type': listStyleType, - attributes: { - 'parentAttributes': listItems[0]?.attributes || null, - }, - track: track, - } - }; -} - /** * Single node transform * @param {XmlNode} node @@ -365,76 +219,6 @@ const unorderedListTypes = [ "disc", // Disc bullets (●) ] -/** - * Main function to get list item information from numbering.xml - * - * @param {object} attributes - * @param {int} level - * @param {ParsedDocx} docx - * @returns - */ -function getNodeNumberingDefinition(attributes, level, docx) { - if (!attributes) return; - - const def = docx['word/numbering.xml']; - if (!def) return {}; - - const { elements } = def; - const listData = elements[0]; - - 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']; - - - // 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, ilvl, numId, listrPrs, listpPrs, start, lvlText, lvlJc }; -} - function getNumberingDefinitionsFromNode(attributes) { if (!attributes) return; @@ -491,10 +275,10 @@ function getListDefinition(numId, level, docx) { // 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); + if (pPr) listpPrs = processListParagraphProperties(pPr); const rPr = currentLevel.elements.find(style => style.name === 'w:rPr'); - if (rPr) listrPrs = _processListRunProperties(rPr); + if (rPr) listrPrs = processListRunProperties(rPr); // Get style for this list level let listType; @@ -511,7 +295,7 @@ function getDefinitionForLevel(data, level) { return data?.elements?.find((item) => Number(item.attributes['w:ilvl']) === level); } -function _processListParagraphProperties(data) { +function processListParagraphProperties(data) { const { elements } = data; const expectedTypes = ['w:ind', 'w:jc', 'w:tabs']; const paragraphProperties = {}; @@ -527,7 +311,7 @@ function _processListParagraphProperties(data) { return paragraphProperties; } -function _processListRunProperties(data) { +function processListRunProperties(data) { const { elements } = data; const expectedTypes = ['w:rFonts', 'w:b', 'w:bCs', 'w:i', 'w:iCs', 'w:strike', 'w:dstrike', 'w:color', 'w:sz', 'w:szCs', 'w:u', 'w:bdr', 'w:shd', 'w:vertAlign', 'w:jc', 'w:spacing', 'w:w', 'w:smallCaps']; const runProperties = {}; 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 index 3c6cb97001..6663b9f9e8 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -12,7 +12,7 @@ import {carbonCopy} from "../../../utilities/cabonCopy.js"; */ export const handleParagraphNode = (nodes, docx, nodeListHandler, insideTrackChange) => { if(nodes.length === 0 || nodes[0].name !== 'w:p') { - return {nodes: [], consumed: 0}; + return []; } const node = carbonCopy(nodes[0]) @@ -24,18 +24,18 @@ export const handleParagraphNode = (nodes, docx, nodeListHandler, insideTrackCha // Check if this paragraph node is a list if (testForList(node)) { - return {nodes: [], consumed: 0}; + 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 {nodes: [], consumed: 0}; + return []; } const result = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); - if(result.nodes.length === 1) { - schemaNode = result.nodes[0]; + if(result.length === 1) { + schemaNode = result[0]; } if ('attributes' in node) { @@ -51,7 +51,7 @@ export const handleParagraphNode = (nodes, docx, nodeListHandler, insideTrackCha if (!('attributes' in schemaNode)) schemaNode.attributes = {}; schemaNode.attrs['paragraphSpacing'] = { lineSpaceAfter, lineSpaceBefore }; } - return { nodes: schemaNode ? [schemaNode] : [], consumed: 1 }; + return schemaNode ? [schemaNode] : []; } /** 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 index 4415f8db6f..cee856577f 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js @@ -5,7 +5,7 @@ import {parseProperties} from "./importerHelpers.js"; */ const handleRunNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { if(nodes.length === 0 || nodes[0].name !== 'w:r') { - return {nodes: [], consumed: 0}; + return []; } const node = nodes[0]; let processedRun = nodeListHandler.handler(node.elements, docx, insideTrackChange)?.filter(n => n) || []; @@ -15,7 +15,7 @@ const handleRunNode = (nodes, docx, nodeListHandler, insideTrackChange = false) if (node.marks) marks.push(...node.marks); processedRun = processedRun.map(n => ({ ...n, marks, attributes })); } - return {nodes: processedRun, consumed: 1}; + return processedRun; } /** 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 index 07a18ef933..e67997bb9b 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js @@ -5,7 +5,7 @@ import {getElementName, parseProperties} from "./importerHelpers.js"; */ export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { if(!nodes || nodes.length === 0) { - return {nodes: [], consumed: 0}; + return []; } const node = nodes[0]; // Parse properties @@ -32,9 +32,9 @@ export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChan if(node.name === 'w:tab') { resultNode.content = [{ type: 'text', text: ' ' }]; - return { nodes: [resultNode], consumed: 1 }; + return [resultNode]; } else { - return { nodes: [resultNode], consumed: 1 }; + return [resultNode]; } } 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 index eab25b4572..e995cb8299 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/tableImporter.js @@ -6,20 +6,20 @@ import {halfPointToPixels, twipsToInches, twipsToPixels} from "../../helpers.js" */ export const handleAllTableNodes = (nodes, docx, nodeListHandler, insideTrackChange) => { if(nodes.length === 0) { - return {nodes: [], consumed: 0}; + return []; } const node = nodes[0]; switch (node.name) { case 'w:tbl': - return {nodes: [handleTableNode(node, docx, nodeListHandler)], consumed: 1}; + return [handleTableNode(node, docx, nodeListHandler)]; case 'w:tr': - return {nodes: [handleTableRowNode(node, undefined, docx, nodeListHandler, insideTrackChange)], consumed: 1}; + return [handleTableRowNode(node, undefined, docx, nodeListHandler, insideTrackChange)]; case 'w:tc': - return {nodes: [handleTableCellNode(node, docx, nodeListHandler, insideTrackChange)], consumed: 1}; + return [handleTableCellNode(node, docx, nodeListHandler, insideTrackChange)]; } - return {nodes: [], consumed: 0}; + return []; } /** @@ -212,14 +212,14 @@ export function handleTableRowNode(node, rowBorders, docx, nodeListHandler, insi const handleStandardNode = nodeListHandler.handlerEntities.find(e => e.handlerName === 'standardNodeHandler')?.handler; if (!handleStandardNode) { console.error('Standard node handler not found'); - return {nodes: [], consumed: 0}; + return []; } const newNodes = handleStandardNode([node], docx, nodeListHandler, insideTrackChange); - if(newNodes.nodes.length === 0) { - return {nodes: [], consumed: 0}; + if(newNodes.length === 0) { + return []; } - const newNode = newNodes.nodes[0]; + const newNode = newNodes[0]; const tPr = node.elements.find((el) => el.name === 'w:trPr'); const rowHeightTag = tPr?.elements.find((el) => el.name === 'w:trHeight'); 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 index dc24335b33..b56c2777ed 100644 --- 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 @@ -76,11 +76,11 @@ describe("table live xml test", () => { } const result = handleAllTableNodes(nodes, docx, defaultNodeListHandler(), false); - expect(result.nodes.length).toBe(1); + expect(result.length).toBe(1); - expect(result.nodes[0].type).toBe('table'); - expect(result.nodes[0].content.length).toBe(2); - expect(result.nodes[0].attrs).toEqual({ + 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'], @@ -97,25 +97,25 @@ describe("table live xml test", () => { } }); - expect(result.nodes[0].content[0].type).toBe('tableRow'); - expect(result.nodes[0].content[0].content.length).toBe(2); - expect(result.nodes[0].content[0].content[0].content[0].type).toBe("paragraph"); - expect(result.nodes[0].content[0].content[0].content[0].content[0].type).toBe("text"); - expect(result.nodes[0].content[0].content[0].content[0].content[0].text).toBe("COL 1 ROW 1"); - expect(result.nodes[0].content[0].content[1].content[0].type).toBe("paragraph"); - expect(result.nodes[0].content[0].content[1].content[0].content[0].type).toBe("text"); - expect(result.nodes[0].content[0].content[1].content[0].content[0].text).toBe("COL 2 ROW 1"); - expect(result.nodes[0].content[0].attrs.borders).toBeDefined(); + 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.nodes[0].content[1].type).toBe('tableRow'); - expect(result.nodes[0].content[1].content.length).toBe(2); - expect(result.nodes[0].content[1].content[0].content[0].type).toBe("paragraph"); - expect(result.nodes[0].content[1].content[0].content[0].content[0].type).toBe("text"); - expect(result.nodes[0].content[1].content[0].content[0].content[0].text).toBe("COL 1 ROW 2"); - expect(result.nodes[0].content[1].content[1].content[0].type).toBe("paragraph"); - expect(result.nodes[0].content[1].content[1].content[0].content[0].type).toBe("text"); - expect(result.nodes[0].content[1].content[1].content[0].content[0].text).toBe("COL 2 ROW 2"); - expect(result.nodes[0].content[1].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/testUtils.test.js b/packages/super-editor/src/core/super-converter/v2/importer/test-helpers/testUtils.test.js index 950062872c..ec1a98a2e0 100644 --- 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 @@ -10,19 +10,18 @@ export const createNodeListHandlerMock = () => { handlerEntities: [ { handlerName: 'standardNodeHandler', - handler: () => ({ - nodes: [{ + handler: () => ( + [{ type: 'standardNodeHandler', content: {}, attrs: {}, marks: [], - }], consumed: 1 - }) + }] + ) }, { handlerName: 'textNodeHandler', - handler: () => ({ - nodes: [{ + handler: () => ([{ type: 'textNodeHandler', content: {}, attrs: {}, marks: [], - }], consumed: 1 - }) + }] + ) } ], handler: () => [{type: "dummyNode", content: {}, attrs: {}}] 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 index f4d5bae609..c51dba34e5 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/textNodeImporter.js @@ -6,7 +6,7 @@ import {getElementName, parseProperties} from "./importerHelpers.js"; */ export const handleTextNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { if(nodes.length === 0 || !(nodes[0].name === 'w:t' || (insideTrackChange && nodes[0].name === 'w:delText'))) { - return {nodes: [], consumed: 0}; + return []; } const node = nodes[0]; const { type } = node; @@ -24,14 +24,14 @@ export const handleTextNode = (nodes, docx, nodeListHandler, insideTrackChange } // Ignore others - can catch other special cases here if necessary - else return {nodes: [], consumed: 0}; + else return []; - return {nodes: [{ + return [{ type: getElementName(node), text: text, attrs: { type, attributes: attributes || {}, }, marks, - }], consumed: 1}; + }]; } 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 index 5cdef878ff..2dc999b0c6 100644 --- 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 @@ -8,26 +8,23 @@ describe('TextNodeImporter', () => { const nodesOfNodes = names.map((name) => ([{name}])); for(const nodes of nodesOfNodes) { const result = handleTextNode(nodes, null, null, false); - expect(result.nodes.length).toBe(0); - expect(result.consumed).toBe(0); + 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.nodes.length).toBe(1); - expect(result.consumed).toBe(1); - expect(result.nodes[0].type).toBe('text'); - expect(result.nodes[0].text).toBe(" "); + 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.nodes.length).toBe(1); - expect(result.consumed).toBe(1); - expect(result.nodes[0].type).toBe('text'); - expect(result.nodes[0].text).toBe("This is a test text!"); + 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 index 68a0297db3..efda549ae2 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js @@ -6,7 +6,7 @@ import {parseProperties} from "./importerHelpers.js"; */ export const handleTrackChangeNode = (nodes, docx, nodeListHandler, insideTrackChange = false) => { if(nodes.length === 0 || !(nodes[0].name === 'w:del' || nodes[0].name === 'w:ins')) { - return {nodes: [], consumed: 0}; + return []; } const node = nodes[0]; const { name } = node; @@ -25,7 +25,7 @@ export const handleTrackChangeNode = (nodes, docx, nodeListHandler, insideTrackC subElement.marks.push({ type: changeType, attrs: mappedAttributes }); }); - return {nodes: subs, consumed: 1}; + return subs; } /** 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 index 5a27f7ee7c..40220f1e7f 100644 --- 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 @@ -21,8 +21,7 @@ describe('TrackChangesImporter', () => { const nodesOfNodes = names.map((name) => ([{name}])); for(const nodes of nodesOfNodes) { const result = handleTrackChangeNode(nodes, null, null, false); - expect(result.nodes.length).toBe(0); - expect(result.consumed).toBe(0); + expect(result.length).toBe(0); } }); @@ -34,10 +33,9 @@ describe('TrackChangesImporter', () => { {name: 'w:t', attributes: {}, elements: [{text: 'This is a test text!'}]} ]}]; const result = handleTrackChangeNode(nodes, null, createNodeListHandlerMock(), false); - expect(result.nodes.length).toBe(1); - expect(result.consumed).toBe(1); - expect(result.nodes[0].marks[0].type).toBe(TrackDeleteMarkName); - expect(result.nodes[0].marks[0].attrs).toEqual({wid: '1', date: '2023-10-01', author: 'Author'}); + 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", () => { @@ -48,10 +46,9 @@ describe('TrackChangesImporter', () => { {name: 'w:t', attributes: {}, elements: [{text: 'This is a test text!'}]} ]}]; const result = handleTrackChangeNode(nodes, null, createNodeListHandlerMock(), false); - expect(result.nodes.length).toBe(1); - expect(result.consumed).toBe(1); - expect(result.nodes[0].marks[0].type).toBe(TrackInsertMarkName); - expect(result.nodes[0].marks[0].attrs).toEqual({wid: '1', date: '2023-10-01', author: 'Author'}); + 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'}); }); }); @@ -92,28 +89,28 @@ describe("trackChanges live xml test", () => { it("parses insert xml", () => { const nodes = parseXmlToJson(inserXml).elements const result = handleTrackChangeNode(nodes, null, defaultNodeListHandler(), false); - expect(result.nodes.length).toBe(1); - const insertionMark = result.nodes[0].marks.find(mark => mark.type === TrackInsertMarkName); + 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.nodes[0].text).toBe('short '); + expect(result[0].text).toBe('short '); }); it("parses delete xml", () => { const nodes = parseXmlToJson(deleteXml).elements const result = handleTrackChangeNode(nodes, null, defaultNodeListHandler(), false); - expect(result.nodes.length).toBe(1); - const deletionMark = result.nodes[0].marks.find(mark => mark.type === TrackDeleteMarkName); + 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.nodes[0].text).toBe('long '); + expect(result[0].text).toBe('long '); }); it("parses mark change xml", () => { const nodes = parseXmlToJson(markChangeXml).elements From 02eb6ca80cfc73d7ee91b89d09a779a3ed161635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 17 Sep 2024 18:59:13 +0200 Subject: [PATCH 9/9] nodetype changes - recreate old state --- .../v2/importer/docxImporter.js | 13 ++- .../v2/importer/importerHelpers.js | 85 +------------------ .../v2/importer/trackChangesImporter.js | 71 +++++++++++++++- 3 files changed, 81 insertions(+), 88 deletions(-) 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 index b4dde91c5c..112beac5bd 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -4,6 +4,8 @@ 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"; @@ -96,6 +98,8 @@ const createNodeListHandler = (nodeHandlers) => { */ 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++) { @@ -103,7 +107,12 @@ const createNodeListHandler = (nodeHandlers) => { if(res.length > 0) return res; const nodesToHandle = elements.slice(index); if(!nodesToHandle || nodesToHandle.length === 0) return res; - return handler.handler(nodesToHandle, docx, {handler: nodeListHandlerFn, handlerEntities: nodeHandlers}, insideTrackChange); + 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]) @@ -116,7 +125,7 @@ const createNodeListHandler = (nodeHandlers) => { } } - const aggregatedNodes = listNodeAggregator(processedElements, docx); + const aggregatedNodes = listNodeAggregator(processedElements, docx, handlerObj); return aggregatedNodes; } 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 index 93a9f12a43..c29380a599 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js @@ -1,8 +1,5 @@ import {handleStyleChangeMarks, parseMarks} from "./markImporter.js"; import {SuperConverter} from "../../SuperConverter.js"; -import {TrackDeleteMarkName} from "../../../../extensions/track-changes/constants.js"; -import {handleListNodes, testForList} from "./listImporter.js"; -import {carbonCopy} from "../../../utilities/cabonCopy.js"; /** @@ -29,7 +26,6 @@ export function parseProperties(node, docx, nodeListHandler, insideTrackChange, if (runProperties && runProperties?.elements?.length) marks = parseMarks(runProperties); if (paragraphProperties && paragraphProperties.elements?.length) { marks.push(...parseMarks(paragraphProperties)); - marks.push(...parseParagraphChange(node, paragraphProperties, docx, nodeListHandler, insideTrackChange, listLevel)); } //add style change marks marks.push(...handleStyleChangeMarks(runProperties, marks)); @@ -54,91 +50,12 @@ export function parseProperties(node, docx, nodeListHandler, insideTrackChange, return { elements: nodes, attributes, marks } } -/** - * - * @param {XmlNode} node - * @param {XmlNode} paragraphProperties - * @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. - */ -function parseParagraphChange(node, paragraphProperties, docx, nodeListHandler, insideTrackChange, listLevel=0) { - 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(node.elements); - const newElements = copiedElements.filter((el) => el.name !== 'w:pPr'); - newElements.push(changeProps) - const nodeWithChange = { - type: node.type, - name: node.name, - attributes: carbonCopy(node.attributes), - elements: newElements, - } - if (testForList(nodeWithChange)) { - const listNode = handleListNodes([nodeWithChange], docx, nodeListHandler, insideTrackChange, listLevel); - const wrappers = [] - const listParent = { - type: listNode.type, - attrs: listNode.attrs - } - wrappers.push(listParent); - const children = listNode.content; - if(children.length === 1) { - const listItem = { - type: children[0].type, - attrs: children[0].attrs - } - wrappers.push(listItem); - } - changes.push({ - attrs: { - track: [{ - type: TrackDeleteMarkName, - author: mappedAttributes.author, - date: mappedAttributes.date, - wid: mappedAttributes.wid, - before: { - wrappers - } - }] - } - }) - } - } - }); - - return changes; -} - /** * * @param {XmlNode[]} elements * @returns {{nodes: *, runProperties: *, sectionProperties: *, paragraphProperties: *}} */ -function splitElementsAndProperties(elements) { +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'); 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 index efda549ae2..8e0f9304d8 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/trackChangesImporter.js @@ -1,5 +1,8 @@ import {TrackDeleteMarkName, TrackInsertMarkName} from "../../../../extensions/track-changes/constants.js"; -import {parseProperties} from "./importerHelpers.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} @@ -34,4 +37,68 @@ export const handleTrackChangeNode = (nodes, docx, nodeListHandler, insideTrackC export const trackChangeNodeHandlerEntity = { handlerName: 'trackChangeNodeHandler', handler: handleTrackChangeNode -}; \ No newline at end of file +}; + +/** + * + * @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; +}