diff --git a/packages/super-editor/src/core/super-converter/docx-helpers/document-rels.js b/packages/super-editor/src/core/super-converter/docx-helpers/document-rels.js index 13c7540d50..9b5e653c86 100644 --- a/packages/super-editor/src/core/super-converter/docx-helpers/document-rels.js +++ b/packages/super-editor/src/core/super-converter/docx-helpers/document-rels.js @@ -138,6 +138,10 @@ export const insertNewRelationship = (target, type, editor) => { }, }; + if (type === 'hyperlink') { + newRel.attributes.TargetMode = 'External'; + } + // Insert the new relationship relationshipsTag.elements.push(newRel); diff --git a/packages/super-editor/src/core/super-converter/docx-helpers/document-rels.test.js b/packages/super-editor/src/core/super-converter/docx-helpers/document-rels.test.js index 78b0ad2e8b..174f7eae5f 100644 --- a/packages/super-editor/src/core/super-converter/docx-helpers/document-rels.test.js +++ b/packages/super-editor/src/core/super-converter/docx-helpers/document-rels.test.js @@ -308,6 +308,7 @@ describe('insertNewRelationship', () => { Id: 'rId43', Type: RELATIONSHIP_TYPES.hyperlink, Target: 'bar', + TargetMode: 'External', }, }); }); diff --git a/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js b/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js index 0067a3cce0..74f38dd301 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js @@ -1,15 +1,15 @@ import { ImagePlaceholderPluginKey, findPlaceholder } from './imagePlaceholderPlugin.js'; import { handleImageUpload as handleImageUploadDefault } from './handleImageUpload.js'; import { processUploadedImage } from './processUploadedImage.js'; +import { insertNewRelationship } from '@core/super-converter/docx-helpers/document-rels.js'; export const startImageUpload = async ({ editor, view, file }) => { - // Handler from config or default - let imageUploadHandler = + const imageUploadHandler = typeof editor.options.handleImageUpload === 'function' ? editor.options.handleImageUpload : handleImageUploadDefault; - let fileSizeMb = (file.size / (1024 * 1024)).toFixed(4); + let fileSizeMb = Number((file.size / (1024 * 1024)).toFixed(4)); if (fileSizeMb > 5) { window.alert('Image size must be less than 5MB'); @@ -29,6 +29,16 @@ export const startImageUpload = async ({ editor, view, file }) => { return; } + await uploadImage({ + editor, + view, + file, + size: { width, height }, + uploadHandler: imageUploadHandler, + }); +}; + +export async function uploadImage({ editor, view, file, size, uploadHandler }) { // A fresh object to act as the ID for this upload let id = {}; @@ -52,45 +62,63 @@ export const startImageUpload = async ({ editor, view, file }) => { tr.setMeta(ImagePlaceholderPluginKey, imageMeta); view.dispatch(tr); - imageUploadHandler(file).then( - (url) => { - let fileName = file.name.replace(' ', '_'); - let placeholderPos = findPlaceholder(view.state, id); - - // If the content around the placeholder has been deleted, - // drop the image - if (placeholderPos == null) { - return; - } - - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder - let removeMeta = { type: 'remove', id }; - - let mediaPath = `word/media/${fileName}`; - let imageNode = schema.nodes.image.create({ - src: mediaPath, - size: { width, height }, - }); - - editor.storage.image.media = Object.assign(editor.storage.image.media, { [mediaPath]: url }); - - // If we are in collaboration, we need to share the image with other clients - if (editor.options.ydoc) { - editor.commands.addImageToCollaboration({ mediaPath, fileData: url }); - } - - view.dispatch( - view.state.tr - .replaceWith(placeholderPos, placeholderPos, imageNode) // or .insert(placeholderPos, imageNode) - .setMeta(ImagePlaceholderPluginKey, removeMeta), - ); - }, - () => { - let removeMeta = { type: 'remove', id }; - - // On failure, just clean up the placeholder - view.dispatch(tr.setMeta(ImagePlaceholderPluginKey, removeMeta)); - }, - ); -}; + try { + let url = await uploadHandler(file); + + let fileName = file.name.replace(' ', '_'); + let placeholderPos = findPlaceholder(view.state, id); + + // If the content around the placeholder has been deleted, + // drop the image + if (placeholderPos == null) { + return; + } + + // Otherwise, insert it at the placeholder's position, and remove + // the placeholder + let removeMeta = { type: 'remove', id }; + + let mediaPath = `word/media/${fileName}`; + + let rId = null; + if (editor.options.mode === 'docx') { + const [, path] = mediaPath.split('word/'); // Path without 'word/' part. + const id = addImageRelationship({ editor, path }); + if (id) rId = id; + } + + let imageNode = schema.nodes.image.create({ + src: mediaPath, + size, + rId, + }); + + editor.storage.image.media = Object.assign(editor.storage.image.media, { [mediaPath]: url }); + + // If we are in collaboration, we need to share the image with other clients + if (editor.options.ydoc) { + editor.commands.addImageToCollaboration({ mediaPath, fileData: url }); + } + + view.dispatch( + view.state.tr + .replaceWith(placeholderPos, placeholderPos, imageNode) // or .insert(placeholderPos, imageNode) + .setMeta(ImagePlaceholderPluginKey, removeMeta), + ); + } catch { + let removeMeta = { type: 'remove', id }; + // On failure, just clean up the placeholder + view.dispatch(tr.setMeta(ImagePlaceholderPluginKey, removeMeta)); + } +} + +function addImageRelationship({ editor, path }) { + const target = path; + const type = 'image'; + try { + const id = insertNewRelationship(target, type, editor); + return id; + } catch { + return null; + } +} diff --git a/packages/super-editor/src/extensions/link/link.js b/packages/super-editor/src/extensions/link/link.js index d12eae1838..828d53e367 100644 --- a/packages/super-editor/src/extensions/link/link.js +++ b/packages/super-editor/src/extensions/link/link.js @@ -12,6 +12,7 @@ import { Mark, Attribute } from '@core/index.js'; import { getMarkRange } from '@core/helpers/getMarkRange.js'; +import { insertNewRelationship } from '@core/super-converter/docx-helpers/document-rels.js'; /** * @module Link @@ -134,7 +135,15 @@ export const Link = Mark.create({ if (underlineMarkType) tr = tr.removeMark(from, to, underlineMarkType); if (underlineMarkType) tr = tr.addMark(from, to, underlineMarkType.create()); - tr = tr.addMark(from, to, linkMarkType.create({ href, text: finalText })); + + let rId = null; + if (editor.options.mode === 'docx') { + const id = addLinkRelationship({ editor, href }); + if (id) rId = id; + } + + const newLinkMarkType = linkMarkType.create({ href, text: finalText, rId }); + tr = tr.addMark(from, to, newLinkMarkType); dispatch(tr.scrollIntoView()); return true; @@ -239,3 +248,14 @@ const trimRange = (doc, from, to) => { // starting and ending without doc specific whitespace return { from, to }; }; + +function addLinkRelationship({ editor, href }) { + const target = href; + const type = 'hyperlink'; + try { + const id = insertNewRelationship(target, type, editor); + return id; + } catch { + return null; + } +} diff --git a/packages/super-editor/src/tests/editor/data/imageBase64.js b/packages/super-editor/src/tests/editor/data/imageBase64.js new file mode 100644 index 0000000000..b6589607c0 --- /dev/null +++ b/packages/super-editor/src/tests/editor/data/imageBase64.js @@ -0,0 +1 @@ +export const imageBase64 = `data:image/png;base64,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`; diff --git a/packages/super-editor/src/tests/editor/relationships.test.js b/packages/super-editor/src/tests/editor/relationships.test.js new file mode 100644 index 0000000000..2fa5882197 --- /dev/null +++ b/packages/super-editor/src/tests/editor/relationships.test.js @@ -0,0 +1,61 @@ +import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js'; +import { TextSelection } from 'prosemirror-state'; +import { expect } from 'vitest'; +import { getDocumentRelationshipElements } from '@core/super-converter/docx-helpers/document-rels.js'; +import { uploadImage } from '@extensions/image/imageHelpers/startImageUpload.js'; +import { handleImageUpload as handleImageUploadDefault } from '@extensions/image/imageHelpers/handleImageUpload.js'; +import { imageBase64 } from './data/imageBase64.js'; + +describe('Relationships tests', () => { + window.URL.createObjectURL = vi.fn().mockImplementation((file) => { + return file.name; + }); + + const filename = 'blank-doc.docx'; + let docx, media, mediaFiles, fonts, editor; + + beforeAll(async () => ({ docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(filename))); + beforeEach(() => ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }))); + + it('tests that the inserted link has a rId and a relationship', () => { + editor.commands.insertContentAt(0, 'link'); + + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 0, 5))); + editor.commands.setLink({ href: 'https://www.superdoc.dev' }); + + const linkMark = editor.state.doc.firstChild.firstChild.marks[0]; + + expect(linkMark.type.name).toBe('link'); + expect(linkMark.attrs.rId).toBeTruthy(); + + const relationships = getDocumentRelationshipElements(editor); + const found = relationships.find((i) => i.attributes.Id === linkMark.attrs.rId); + + expect(found).toBeTruthy(); + expect(found.attributes.Target).toBe('https://www.superdoc.dev'); + }); + + it('tests that the uploaded image has a rId and a relationship', async () => { + const blob = await fetch(imageBase64).then((res) => res.blob()); + const file = new File([blob], 'image.png', { type: 'image/png' }); + + await uploadImage({ + editor, + view: editor.view, + file, + size: { width: 100, height: 100 }, + uploadHandler: handleImageUploadDefault, + }); + + const imageNode = editor.state.doc.firstChild.firstChild; + + expect(imageNode.type.name).toBe('image'); + expect(imageNode.attrs.rId).toBeTruthy(); + + const relationships = getDocumentRelationshipElements(editor); + const found = relationships.find((i) => i.attributes.Id === imageNode.attrs.rId); + + expect(found).toBeTruthy(); + expect(found.attributes.Target).toBe('media/image.png'); + }); +});