diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index cc3595e6b6..f73927206f 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -1440,23 +1440,116 @@ function translateImageNode(params, imageSize) { params.media[`${cleanUrl}_${hash}.${type}`] = src; } - const inlineAttrs = attrs.originalPadding || { + let inlineAttrs = attrs.originalPadding || { distT: 0, distB: 0, distL: 0, distR: 0, }; + const anchorElements = []; + let wrapProp = []; + + // Handle anchor image export + if (attrs.isAnchor) { + inlineAttrs = { + ...inlineAttrs, + simplePos: attrs.originalAttributes?.simplePos, + relativeHeight: 1, + behindDoc: attrs.originalAttributes?.behindDoc, + locked: attrs.originalAttributes?.locked, + layoutInCell: attrs.originalAttributes?.layoutInCell, + allowOverlap: attrs.originalAttributes?.allowOverlap, + }; + if (attrs.simplePos) { + anchorElements.push({ + name: 'wp:simplePos', + attributes: { + x: 0, + y: 0, + } + }); + } + + if (attrs.anchorData) { + anchorElements.push({ + name: 'wp:positionH', + attributes: { + relativeFrom: attrs.anchorData.hRelativeFrom, + }, + ...(attrs.marginOffset.left && { + elements: [{ + name: 'wp:posOffset', + elements: [{ + type: 'text', + text: pixelsToEmu(attrs.marginOffset.left).toString(), + }], + }] + }), + ...(attrs.anchorData.alignH && { + elements: [{ + name: 'wp:align', + elements: [{ + type: 'text', + text: attrs.anchorData.alignH, + }], + }] + }) + }); + anchorElements.push({ + name: 'wp:positionV', + attributes: { + relativeFrom: attrs.anchorData.vRelativeFrom, + }, + ...(attrs.marginOffset.top && { + elements: [{ + name: 'wp:posOffset', + elements: [{ + type: 'text', + text: pixelsToEmu(attrs.marginOffset.top).toString(), + }], + }] + }), + ...(attrs.anchorData.alignV && { + elements: [{ + name: 'wp:align', + elements: [{ + type: 'text', + text: attrs.anchorData.alignV, + }], + }] + }) + }); + } + + if (attrs.wrapText) { + wrapProp.push({ + name: 'wp:wrapSquare', + attributes: { + wrapText: attrs.wrapText, + } + }); + } + + if (attrs.wrapTopAndBottom) { + wrapProp.push({ + name: 'wp:wrapTopAndBottom', + }); + } + } + const drawingXmlns = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const pictureXmlns = 'http://schemas.openxmlformats.org/drawingml/2006/picture'; - return wrapTextInRun( + + const textNode = wrapTextInRun( { name: 'w:drawing', elements: [ { - name: 'wp:inline', + name: attrs.isAnchor ? 'wp:anchor' : 'wp:inline', attributes: inlineAttrs, elements: [ + ...anchorElements, { name: 'wp:extent', attributes: { @@ -1473,12 +1566,13 @@ function translateImageNode(params, imageSize) { b: 0, }, }, + ...wrapProp, { name: 'wp:docPr', attributes: { - id: 0, - name: '', - descr: '', + id: attrs.id || 0, + name: attrs.alt, + descr: attrs.title, }, }, { @@ -1511,9 +1605,8 @@ function translateImageNode(params, imageSize) { { name: 'pic:cNvPr', attributes: { - id: 0, - name: '', - desc: '', + id: attrs.id || 0, + name: attrs.title, }, }, { @@ -1537,12 +1630,8 @@ function translateImageNode(params, imageSize) { name: 'a:blip', attributes: { 'r:embed': imageId, - cstate: 'none', }, }, - { - name: 'a:srcRect', - }, { name: 'a:stretch', elements: [{ name: 'a:fillRect' }], @@ -1579,6 +1668,9 @@ function translateImageNode(params, imageSize) { attributes: { prst: 'rect' }, elements: [{ name: 'a:avLst' }], }, + { + name: 'a:noFill' + } ], }, ], @@ -1593,6 +1685,8 @@ function translateImageNode(params, imageSize) { }, [], ); + + return textNode; } /** 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 902944f496..f8d52ba4b3 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 @@ -26,7 +26,11 @@ export const handleDrawingNode = (params) => { // Some images are identified by wp:anchor const isAnchor = elements.find((el) => el.name === 'wp:anchor'); - if (isAnchor) result = handleImageImport(elements[0], currentFileName, params); + if (isAnchor) { + + result = handleImageImport(elements[0], currentFileName, params); + result.attrs.isAnchor = isAnchor; + } // Others, wp:inline const inlineImage = elements.find((el) => el.name === 'wp:inline'); @@ -57,7 +61,7 @@ export function handleImageImport(node, currentFileName, params) { const shapeURI = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"; if (!!uri && uri === shapeURI) { return handleShapeDrawing(params, node, graphicData); - }; + } const picture = graphicData.elements.find((el) => el.name === 'pic:pic'); if (!picture || !picture.elements) return null; @@ -68,11 +72,31 @@ export function handleImageImport(node, currentFileName, params) { const positionHTag = node.elements.find((el) => el.name === 'wp:positionH'); const positionH = positionHTag?.elements.find((el) => el.name === 'wp:posOffset'); const positionHValue = emuToPixels(positionH?.elements[0]?.text); + const hRelativeFrom = positionHTag?.attributes.relativeFrom; + const alignH = positionHTag?.elements.find((el) => el.name === 'wp:align')?.elements[0]?.text; const positionVTag = node.elements.find((el) => el.name === 'wp:positionV'); const positionV = positionVTag?.elements.find((el) => el.name === 'wp:posOffset'); const positionVValue = emuToPixels(positionV?.elements[0]?.text); - + const vRelativeFrom = positionVTag?.attributes.relativeFrom; + const alignV = positionVTag?.elements.find((el) => el.name === 'wp:align')?.elements[0]?.text; + + const simplePos = node.elements.find((el) => el.name === 'wp:simplePos'); + const wrapSquare = node.elements.find((el) => el.name === 'wp:wrapSquare'); + const wrapTopAndBottom = node.elements.find((el) => el.name === 'wp:wrapTopAndBottom'); + + const docPr = node.elements.find((el) => el.name === 'wp:docPr'); + + let anchorData = null; + if (hRelativeFrom || alignH || vRelativeFrom || alignV) { + anchorData = { + hRelativeFrom, + vRelativeFrom, + alignH, + alignV, + }; + } + const marginOffset = { left: positionHValue, top: positionVValue, @@ -96,17 +120,31 @@ export function handleImageImport(node, currentFileName, params) { type: 'image', attrs: { src: path, - alt: 'Image', + alt: docPr?.attributes.name || 'Image', + id: docPr?.attributes.id || '', + title: docPr?.attributes.descr || 'Image', inline: true, padding, marginOffset, size, + anchorData, + ...(simplePos && { + simplePos: { + x: simplePos.attributes.x, + y: simplePos.attributes.y, + } + }), + ...(wrapSquare && { + wrapText: wrapSquare.attributes.wrapText + }), + wrapTopAndBottom: !!wrapTopAndBottom, originalPadding: { distT: attributes['distT'], distB: attributes['distB'], distL: attributes['distL'], distR: attributes['distR'], }, + originalAttributes: node.attributes, rId: relAttributes['Id'], }, }; diff --git a/packages/super-editor/src/extensions/image/image.js b/packages/super-editor/src/extensions/image/image.js index 9306243e05..2cc5c8190a 100644 --- a/packages/super-editor/src/extensions/image/image.js +++ b/packages/super-editor/src/extensions/image/image.js @@ -1,5 +1,6 @@ import { Node, Attribute } from '@core/index.js'; import { ImagePlaceholderPlugin } from './imageHelpers/imagePlaceholderPlugin.js'; +import { ImagePositionPlugin } from './imageHelpers/imagePositionPlugin.js'; export const Image = Node.create({ name: 'image', @@ -39,6 +40,8 @@ export const Image = Node.create({ alt: { default: null, }, + + id: { rendered: false }, title: { default: null, @@ -53,6 +56,17 @@ export const Image = Node.create({ default: null, rendered: false, }, + originalAttributes: { rendered: false }, + wrapTopAndBottom: { rendered: false }, + + anchorData: { + default: null, + rendered: false, + }, + + isAnchor: { rendered: false }, + simplePos: { rendered: false }, + wrapText: { rendered: false }, size: { default: {}, @@ -64,6 +78,19 @@ export const Image = Node.create({ return { style }; }, }, + + padding: { + default: {}, + renderDOM: ({ padding, marginOffset }) => { + let { left = 0, top = 0, bottom = 0, right = 0 } = padding ?? {}; + let style = ''; + if (left && !marginOffset?.left) style += `margin-left: ${left}px;`; + if (top && !marginOffset?.top) style += `margin-top: ${top}px;`; + if (bottom) style += `margin-bottom: ${bottom}px;`; + if (right) style += `margin-right: ${right}px;`; + return { style }; + }, + }, marginOffset: { default: {}, @@ -113,6 +140,6 @@ export const Image = Node.create({ }, addPmPlugins() { - return [ImagePlaceholderPlugin()]; + return [ImagePlaceholderPlugin(), ImagePositionPlugin({editor: this.editor })]; }, }); diff --git a/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js b/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js new file mode 100644 index 0000000000..c720088cfb --- /dev/null +++ b/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js @@ -0,0 +1,120 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import { PaginationPluginKey } from '../../pagination/pagination-helpers.js'; + +const ImagePositionPluginKey = new PluginKey('ImagePosition'); +export const ImagePositionPlugin = ({ editor }) => { + const { view } = editor; + let shouldUpdate = false; + return new Plugin({ + name: 'ImagePositionPlugin', + key: ImagePositionPluginKey, + + state: { + init(_, state) { + return DecorationSet.empty; + }, + + apply(tr, oldDecorationSet, oldState, newState) { + const decorations = getImagePositionDecorations(newState, view); + return DecorationSet.create(newState.doc, decorations); + }, + }, + + view: (view) => { + return { + update: (view, lastState) => { + const pagination = PaginationPluginKey.getState(lastState); + if (shouldUpdate) { + shouldUpdate = false; + const decorations = getImagePositionDecorations(lastState, view); + const updateTransaction = view.state.tr.setMeta( + ImagePositionPluginKey, + { decorations } + ); + view.dispatch(updateTransaction); + } + if (pagination?.isReadyToInit) { + shouldUpdate = true; + } + }, + }; + }, + + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); +}; + +const getImagePositionDecorations = (state, view) => { + let decorations = []; + state.doc.descendants((node, pos) => { + if (node.attrs.anchorData) { + let style = ''; + let className = ''; + const { vRelativeFrom, alignH } = node.attrs.anchorData; + const { size, padding } = node.attrs; + + const pageBreak = findPreviousDomNodeWithClass(view, pos, 'pagination-break-wrapper'); + if (pageBreak) { + switch (alignH) { + case 'left': + style += 'float: left; left: 0; margin-left: 0; '; + break; + case 'right': + style += 'float: right; right: 0; margin-right: 0; '; + break; + case 'center': + style += 'display: block; margin-left: auto; margin-right: auto; '; + break; + } + style += vRelativeFrom === 'margin' ? `position: absolute; top: ${pageBreak?.offsetTop + pageBreak?.offsetHeight}px; ` : ''; + + if (vRelativeFrom === 'margin') { + const nextPos = view.posAtDOM(pageBreak, 1); + const imageBlock = document.createElement('div'); + imageBlock.className = 'anchor-image-placeholder'; + imageBlock.style.float = alignH; + imageBlock.style.width = size.width + parseInt(padding[alignH]) + 'px'; + imageBlock.style.height = size.height + parseInt(padding.top) + parseInt(padding.bottom) + 'px'; + decorations.push(Decoration.widget(nextPos, imageBlock, { key: 'stable-key' })); + } + } + + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { style, class: className }), + ); + } + }); + return decorations; +}; + +const findPreviousDomNodeWithClass = (view, pos, className) => { + let { node } = view.domAtPos(pos); + + // If you get a text node, go to its parent + if (node.nodeType === 3) { + node = node.parentNode; + } + + // Walk backward over siblings and their ancestors + while (node) { + if (node.classList && node.classList.contains(className)) { + return node; + } + if (node.previousSibling) { + node = node.previousSibling; + // Dive to the last child if it's an element with children + while (node && node.lastChild) { + node = node.lastChild; + } + } else { + node = node.parentNode; + } + } + + return null; // Not found +} diff --git a/packages/super-editor/src/tests/data/anchor_images.docx b/packages/super-editor/src/tests/data/anchor_images.docx new file mode 100644 index 0000000000..523d908cdb Binary files /dev/null and b/packages/super-editor/src/tests/data/anchor_images.docx differ diff --git a/packages/super-editor/src/tests/data/image_doc.docx b/packages/super-editor/src/tests/data/image_doc.docx index f9a55a41eb..27861b2c1c 100644 Binary files a/packages/super-editor/src/tests/data/image_doc.docx and b/packages/super-editor/src/tests/data/image_doc.docx differ diff --git a/packages/super-editor/src/tests/data/~$age_doc.docx b/packages/super-editor/src/tests/data/~$age_doc.docx new file mode 100644 index 0000000000..4185ad2fe3 Binary files /dev/null and b/packages/super-editor/src/tests/data/~$age_doc.docx differ diff --git a/packages/super-editor/src/tests/export/imageNodeExporter.test.js b/packages/super-editor/src/tests/export/imageNodeExporter.test.js index b672876e59..4e189f0681 100644 --- a/packages/super-editor/src/tests/export/imageNodeExporter.test.js +++ b/packages/super-editor/src/tests/export/imageNodeExporter.test.js @@ -15,13 +15,51 @@ describe('ImageNodeExporter', async () => { it('export image node correctly', () => { const imageNode = body.elements[0].elements[1].elements[0]; - expect(imageNode.elements[0].attributes.distT).toBe('114935'); - expect(imageNode.elements[0].attributes.distB).toBe('114935'); - expect(imageNode.elements[0].attributes.distL).toBe('114300'); - expect(imageNode.elements[0].attributes.distR).toBe('114300'); - + expect(imageNode.elements[0].attributes.distT).toBe('0'); + expect(imageNode.elements[0].attributes.distB).toBe('0'); + expect(imageNode.elements[0].attributes.distL).toBe('0'); + expect(imageNode.elements[0].attributes.distR).toBe('0'); + expect(imageNode.elements[0].elements[0].attributes.cx).toBe(5734050); expect(imageNode.elements[0].elements[0].attributes.cy).toBe(8601075); + expect(imageNode.elements[0].elements[4].elements[0].elements[0].elements[1].elements[0].attributes['r:embed']).toBe('rId4'); }); + + it('exports anchor image node correctly', async() => { + + }); +}); + +describe('ImageNodeExporter anchor image', async () => { + window.URL.createObjectURL = vi.fn().mockImplementation((file) => { + return file.name; + }); + + const fileName = 'anchor_images.docx'; + const result = await getExportedResult(fileName); + const body = {}; + + beforeEach(() => { + Object.assign(body, result.elements?.find((el) => el.name === 'w:body')); + }); + + it('exports anchor image node correctly', async() => { + const imageNode = body.elements[1].elements[4].elements[0]; + const anchorNode = imageNode.elements[0]; + + expect(anchorNode.attributes).toHaveProperty('simplePos', '0'); + expect(anchorNode.elements[0].name).toBe('wp:simplePos'); + console.log(anchorNode.elements[5]); + expect(anchorNode.elements[1].attributes.relativeFrom).toBe('margin'); + expect(anchorNode.elements[1].elements[0].name).toBe('wp:align'); + expect(anchorNode.elements[1].elements[0].elements[0].text).toBe('left'); + + expect(anchorNode.elements[2].attributes.relativeFrom).toBe('margin'); + expect(anchorNode.elements[2].elements[0].name).toBe('wp:align'); + expect(anchorNode.elements[2].elements[0].elements[0].text).toBe('top'); + + expect(anchorNode.elements[5].name).toBe('wp:wrapSquare'); + expect(anchorNode.elements[5].attributes.wrapText).toBe('bothSides'); + }); }); diff --git a/packages/super-editor/src/tests/import/imageImporter.test.js b/packages/super-editor/src/tests/import/imageImporter.test.js index 939ad16a2d..151ec5c2ed 100644 --- a/packages/super-editor/src/tests/import/imageImporter.test.js +++ b/packages/super-editor/src/tests/import/imageImporter.test.js @@ -17,7 +17,7 @@ describe('ImageNodeImporter', () => { const paragraphNode = nodes[0]; const drawingNode = paragraphNode.content[0]; const { attrs } = drawingNode; - const { padding, marginOffset, size } = attrs; + const { padding, size } = attrs; expect(paragraphNode.type).toBe('paragraph'); expect(drawingNode.type).toBe('image'); @@ -27,13 +27,31 @@ describe('ImageNodeImporter', () => { expect(size).toHaveProperty('width', 602); expect(size).toHaveProperty('height', 903); - - expect(marginOffset).toHaveProperty('left', 12); - expect(marginOffset).toHaveProperty('top', 12); - expect(padding).toHaveProperty('left', 12); - expect(padding).toHaveProperty('top', 12); - expect(padding).toHaveProperty('bottom', 12); - expect(padding).toHaveProperty('right', 12); + expect(padding).toHaveProperty('left', 0); + expect(padding).toHaveProperty('top', 0); + expect(padding).toHaveProperty('bottom', 0); + expect(padding).toHaveProperty('right', 0); + }); + + it('imports anchor image node correctly', async() => { + const dataName = 'anchor_images.docx'; + const docx = await getTestDataByFileName(dataName); + const documentXml = docx['word/document.xml']; + + const doc = documentXml.elements[0]; + const body = doc.elements[0]; + const content = body.elements; + const { nodes } = handleParagraphNode({ nodes: [content[1]], docx, nodeListHandler: defaultNodeListHandler() }); + + const paragraphNode = nodes[0]; + const drawingNode = paragraphNode.content[3]; + const { attrs } = drawingNode; + const { anchorData } = attrs; + + expect(anchorData).toHaveProperty('hRelativeFrom', 'margin'); + expect(anchorData).toHaveProperty('vRelativeFrom', 'margin'); + expect(anchorData).toHaveProperty('alignH', 'left'); + expect(anchorData).toHaveProperty('alignV', 'top'); }); });