From ba46c18fe1c0692ab2dd9a43bb027232afb0d69f Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Mon, 1 Sep 2025 17:51:29 +0200 Subject: [PATCH 1/4] feat: register images from paste + HTML insertion, fixes #790 --- .ackrc | 2 + package-lock.json | 2 +- .../assets/styles/elements/prosemirror.css | 18 +- .../src/components/toolbar/super-toolbar.js | 31 ++- .../src/core/super-converter/exporter.js | 1 + .../src/extensions/image/image.js | 4 +- .../image/imageHelpers/handleBase64.js | 34 +++ .../image/imageHelpers/handleUrl.js | 106 +++++++++ .../imageHelpers/imagePlaceholderPlugin.js | 57 ----- .../imageHelpers/imageRegistrationPlugin.js | 211 ++++++++++++++++++ .../extensions/image/imageHelpers/index.js | 2 +- .../imageHelpers/processUploadedImage.js | 12 +- .../image/imageHelpers/startImageUpload.js | 158 +++++++------ .../src/extensions/pagination/pagination.js | 8 +- .../src/tests/editor/relationships.test.js | 6 +- 15 files changed, 488 insertions(+), 164 deletions(-) create mode 100644 .ackrc create mode 100644 packages/super-editor/src/extensions/image/imageHelpers/handleBase64.js create mode 100644 packages/super-editor/src/extensions/image/imageHelpers/handleUrl.js delete mode 100644 packages/super-editor/src/extensions/image/imageHelpers/imagePlaceholderPlugin.js create mode 100644 packages/super-editor/src/extensions/image/imageHelpers/imageRegistrationPlugin.js diff --git a/.ackrc b/.ackrc new file mode 100644 index 000000000..834fca5d3 --- /dev/null +++ b/.ackrc @@ -0,0 +1,2 @@ +--ignore-dir=packages/superdoc/dist +--ignore-dir=packages/super-editor/dist/ diff --git a/package-lock.json b/package-lock.json index 81359a0fb..63aa3e8df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15974,7 +15974,7 @@ }, "packages/superdoc": { "name": "@harbour-enterprises/superdoc", - "version": "0.15.16-next.11", + "version": "0.15.17-next.6", "license": "AGPL-3.0", "dependencies": { "buffer-crc32": "^1.0.0", diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index d24b9bf76..f7b21e2b5 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -148,8 +148,8 @@ img.ProseMirror-separator { margin-bottom: 1.5px; } -/* -Tables +/* +Tables https://github.com/ProseMirror/prosemirror-tables/blob/master/style/tables.css https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html */ @@ -166,8 +166,8 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html scrollbar-width: thin; overflow: hidden; - /* - The border width does not need to be multiplied by two, + /* + The border width does not need to be multiplied by two, for tables it works differently. */ width: calc(100% + (var(--table-border-width) + var(--offset))); } @@ -292,19 +292,25 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* Collaboration cursors - end */ /* Image placeholder */ -.ProseMirror placeholder { +.ProseMirror placeholder, +.ProseMirror img.placeholder { display: inline; border: 1px solid #ccc; color: #ccc; } -.ProseMirror placeholder:after { +.ProseMirror placeholder:after, +.ProseMirror img.placeholder:after { content: '☁'; font-size: 200%; line-height: 0.1; font-weight: bold; } +.ProseMirror placeholder img { + display: none !important; +} + /* Gapcursor */ .ProseMirror-gapcursor { display: none; diff --git a/packages/super-editor/src/components/toolbar/super-toolbar.js b/packages/super-editor/src/components/toolbar/super-toolbar.js index 71b34b74c..12cacd421 100644 --- a/packages/super-editor/src/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/components/toolbar/super-toolbar.js @@ -5,7 +5,12 @@ import { makeDefaultItems } from './defaultItems'; import { getActiveFormatting } from '@core/helpers/getActiveFormatting.js'; import { vClickOutside } from '@harbour-enterprises/common'; import Toolbar from './Toolbar.vue'; -import { startImageUpload, getFileOpener } from '../../extensions/image/imageHelpers/index.js'; +import { + checkAndProcessImage, + replaceSelectionWithImagePlaceholder, + uploadAndInsertImage, + getFileOpener, +} from '../../extensions/image/imageHelpers/index.js'; import { findParentNode } from '@helpers/index.js'; import { toolbarIcons } from './toolbarIcons.js'; import { toolbarTexts } from './toolbarTexts.js'; @@ -366,10 +371,30 @@ export class SuperToolbar extends EventEmitter { return; } - startImageUpload({ - editor: this.activeEditor, + const { size, file } = await checkAndProcessImage({ view: this.activeEditor.view, file: result.file, + getMaxContentSize: () => this.activeEditor.getMaxContentSize(), + }); + + if (!file) { + return; + } + + const id = {}; + + replaceSelectionWithImagePlaceholder({ + view: this.activeEditor.view, + editorOptions: this.activeEditor.options, + id, + }); + + await uploadAndInsertImage({ + editor: this.activeEditor, + view: this.activeEditor.view, + file, + size, + id, }); }, diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index aa012ebf8..f44e6a769 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -1546,6 +1546,7 @@ function getScaledSize(originalWidth, originalHeight, maxWidth, maxHeight) { } function translateImageNode(params, imageSize) { + console.log('translateImageNode', { params, imageSize }); const { node: { attrs = {} }, tableCell, diff --git a/packages/super-editor/src/extensions/image/image.js b/packages/super-editor/src/extensions/image/image.js index c3f6e3d84..f7acb7a64 100644 --- a/packages/super-editor/src/extensions/image/image.js +++ b/packages/super-editor/src/extensions/image/image.js @@ -1,5 +1,5 @@ import { Attribute, Node } from '@core/index.js'; -import { ImagePlaceholderPlugin } from './imageHelpers/imagePlaceholderPlugin.js'; +import { ImageRegistrationPlugin } from './imageHelpers/imageRegistrationPlugin.js'; import { ImagePositionPlugin } from './imageHelpers/imagePositionPlugin.js'; export const Image = Node.create({ @@ -147,6 +147,6 @@ export const Image = Node.create({ }, addPmPlugins() { - return [ImagePlaceholderPlugin(), ImagePositionPlugin({ editor: this.editor })]; + return [ImageRegistrationPlugin({ editor: this.editor }), ImagePositionPlugin({ editor: this.editor })]; }, }); diff --git a/packages/super-editor/src/extensions/image/imageHelpers/handleBase64.js b/packages/super-editor/src/extensions/image/imageHelpers/handleBase64.js new file mode 100644 index 000000000..172595e2e --- /dev/null +++ b/packages/super-editor/src/extensions/image/imageHelpers/handleBase64.js @@ -0,0 +1,34 @@ +const simpleHash = (str) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash).toString(); +}; + +export const base64ToFile = (base64String) => { + const arr = base64String.split(','); + const mimeMatch = arr[0].match(/:(.*?);/); + const mimeType = mimeMatch ? mimeMatch[1] : ''; + const data = arr[1]; + + // Decode the base64 string + const binaryString = atob(data); + + // Generate filename using a hash of the binary data + const hash = simpleHash(binaryString); + const extension = mimeType.split('/')[1] || 'bin'; // Simple way to get extension + const filename = `image-${hash}.${extension}`; + + // Create a typed array from the binary string + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Create a Blob and then a File + const blob = new Blob([bytes], { type: mimeType }); + return new File([blob], filename, { type: mimeType }); +}; diff --git a/packages/super-editor/src/extensions/image/imageHelpers/handleUrl.js b/packages/super-editor/src/extensions/image/imageHelpers/handleUrl.js new file mode 100644 index 000000000..4c873f824 --- /dev/null +++ b/packages/super-editor/src/extensions/image/imageHelpers/handleUrl.js @@ -0,0 +1,106 @@ +/** + * Handles URL to File conversion with comprehensive CORS error handling + */ + +/** + * Converts a URL to a File object with proper CORS error handling + * @param {string} url - The image URL to fetch + * @param {string} [filename] - Optional filename for the resulting file + * @param {string} [mimeType] - Optional MIME type for the resulting file + * @returns {Promise} File object or null if CORS prevents access + */ +export const urlToFile = async (url, filename, mimeType) => { + try { + // Try to fetch the image with credentials mode set to 'omit' to avoid CORS preflight + const response = await fetch(url, { + mode: 'cors', + credentials: 'omit', + headers: { + // Add common headers that might help with CORS + Accept: 'image/*,*/*;q=0.8', + }, + }); + + if (!response.ok) { + console.warn(`Failed to fetch image from ${url}: ${response.status} ${response.statusText}`); + return null; + } + + const blob = await response.blob(); + + // Extract filename from URL if not provided + const finalFilename = filename || extractFilenameFromUrl(url); + + // Determine MIME type from response if not provided + const finalMimeType = mimeType || response.headers.get('content-type') || blob.type || 'image/jpeg'; + + return new File([blob], finalFilename, { type: finalMimeType }); + } catch (error) { + if (isCorsError(error)) { + console.warn(`CORS policy prevents accessing image from ${url}:`, error.message); + return null; + } + + console.error(`Error fetching image from ${url}:`, error); + return null; + } +}; + +/** + * Checks if an error is likely a CORS-related error + * @param {Error} error - The error to check + * @returns {boolean} True if the error appears to be CORS-related + */ +const isCorsError = (error) => { + const errorMessage = error.message.toLowerCase(); + const errorName = error.name.toLowerCase(); + + return ( + errorName.includes('cors') || + errorMessage.includes('cors') || + errorMessage.includes('cross-origin') || + errorMessage.includes('access-control') || + errorMessage.includes('network error') || // Often indicates CORS in browsers + errorMessage.includes('failed to fetch') // Common CORS error message + ); +}; + +/** + * Extracts a filename from a URL + * @param {string} url - The URL to extract filename from + * @returns {string} The extracted filename + */ +const extractFilenameFromUrl = (url) => { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const filename = pathname.split('/').pop(); + + // If no extension, add a default one + if (filename && !filename.includes('.')) { + return `${filename}.jpg`; + } + + return filename || 'image.jpg'; + } catch { + return 'image.jpg'; + } +}; + +/** + * Validates if a URL can be accessed without CORS issues + * @param {string} url - The URL to validate + * @returns {Promise} True if the URL is accessible without CORS issues + */ +export const validateUrlAccessibility = async (url) => { + try { + const response = await fetch(url, { + method: 'HEAD', + mode: 'cors', + credentials: 'omit', + }); + return response.ok; + } catch (error) { + return false; + } +}; diff --git a/packages/super-editor/src/extensions/image/imageHelpers/imagePlaceholderPlugin.js b/packages/super-editor/src/extensions/image/imageHelpers/imagePlaceholderPlugin.js deleted file mode 100644 index 11e1fdd66..000000000 --- a/packages/super-editor/src/extensions/image/imageHelpers/imagePlaceholderPlugin.js +++ /dev/null @@ -1,57 +0,0 @@ -import { Plugin, PluginKey } from 'prosemirror-state'; -import { Decoration, DecorationSet } from 'prosemirror-view'; - -export const ImagePlaceholderPluginKey = new PluginKey('ImagePlaceholder'); - -export const ImagePlaceholderPlugin = () => { - return new Plugin({ - key: ImagePlaceholderPluginKey, - - state: { - init() { - return DecorationSet.empty; - }, - - apply(tr, set) { - // For reference. - // let diffStart = tr.doc.content.findDiffStart(oldState.doc.content); - // let diffEnd = oldState.doc.content.findDiffEnd(tr.doc.content); - // let map = diffEnd && diffStart - // ? new StepMap([diffStart, diffEnd.a - diffStart, diffEnd.b - diffStart]) - // : new StepMap([0, 0, 0]); - // let pmMapping = new Mapping([map]); - // let set = value.map(pmMapping, tr.doc); - /// - - // Adjust decoration positions to changes made by the transaction - set = set.map(tr.mapping, tr.doc); - - // See if the transaction adds or removes any placeholders - let action = tr.getMeta(ImagePlaceholderPluginKey); - - if (action?.type === 'add') { - let widget = document.createElement('placeholder'); - let deco = Decoration.widget(action.pos, widget, { - id: action.id, - }); - set = set.add(tr.doc, [deco]); - } else if (action?.type === 'remove') { - set = set.remove(set.find(null, null, (spec) => spec.id == action.id)); - } - return set; - }, - }, - - props: { - decorations(state) { - return this.getState(state); - }, - }, - }); -}; - -export const findPlaceholder = (state, id) => { - let decos = ImagePlaceholderPluginKey.getState(state); - let found = decos?.find(null, null, (spec) => spec.id === id); - return found?.length ? found[0].from : null; -}; diff --git a/packages/super-editor/src/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/extensions/image/imageHelpers/imageRegistrationPlugin.js new file mode 100644 index 000000000..524652338 --- /dev/null +++ b/packages/super-editor/src/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -0,0 +1,211 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import { ReplaceStep, ReplaceAroundStep, StepMap } from 'prosemirror-transform'; +import { base64ToFile } from './handleBase64'; +import { urlToFile, validateUrlAccessibility } from './handleUrl'; +import { checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; + +const key = new PluginKey('ImageRegistration'); + +export const ImageRegistrationPlugin = ({ editor }) => { + const { view } = editor; + return new Plugin({ + key, + state: { + init() { + return { set: DecorationSet.empty }; + }, + + apply(tr, { set }) { + // For reference. + // let diffStart = tr.doc.content.findDiffStart(oldState.doc.content); + // let diffEnd = oldState.doc.content.findDiffEnd(tr.doc.content); + // let map = diffEnd && diffStart + // ? new StepMap([diffStart, diffEnd.a - diffStart, diffEnd.b - diffStart]) + // : new StepMap([0, 0, 0]); + // let pmMapping = new Mapping([map]); + // let set = value.map(pmMapping, tr.doc); + /// + const meta = tr.getMeta(key); + // If meta is set, it overrides the default behavior. + if (meta) { + set = meta.set; + return { set }; + } + // Adjust decoration positions to changes made by the transaction + set = set.map(tr.mapping, tr.doc); + + return { set }; + }, + }, + appendTransaction: (trs, _oldState, state) => { + let foundImages = []; + trs.forEach((tr) => { + if (tr.docChanged) { + // Check if there are any images in the incoming transaction. If so, we need to register them. + tr.steps.forEach((step, index) => { + const stepMap = step.getMap(); + foundImages = foundImages.map(({ node, pos, id }) => { + const mappedPos = stepMap.map(pos, -1); + return { node, pos: mappedPos, id }; + }); + if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { + // Check for new images. + (tr.docs[index + 1] || tr.doc).nodesBetween( + stepMap.map(step.from, -1), + stepMap.map(step.to, 1), + (node, pos) => { + if (node.type.name === 'image' && !node.attrs.src.startsWith('word/media')) { + // Node contains an image that is not yet registered. + const id = {}; + foundImages.push({ node, pos, id }); + } else { + return true; + } + }, + ); + } + }); + } + }); + + if (!foundImages || foundImages.length === 0) { + return null; + } + + // Register the images. (async process). + registerImages(foundImages, editor, view); + + // Remove all the images that were found. These will eventually be replaced by the updated images. + const tr = state.tr; + + // We need to delete the image nodes and replace them with decorations. This will change their positions. + + // Get the current decoration set + let { set } = key.getState(state); + + // Add decorations for the images first at their current positions + foundImages + .toSorted((a, b) => a.pos - b.pos) + .forEach(({ node, pos, id }) => { + let deco = Decoration.widget(pos, () => document.createElement('placeholder'), { + side: -1, + id, + }); + set = set.add(tr.doc, [deco]); + }); + + // Then delete the image nodes (highest position first to avoid position shifting issues) + foundImages + .toSorted((a, b) => b.pos - a.pos) + .forEach(({ node, pos }) => { + tr.delete(pos, pos + node.nodeSize); + }); + // Map the decoration set through the transaction to adjust positions + set = set.map(tr.mapping, tr.doc); + + // Set the updated decoration set in the transaction metadata + tr.setMeta(key, { set }); + + return tr; + }, + props: { + decorations(state) { + let { set } = key.getState(state); + return set; + }, + }, + }); +}; + +export const findPlaceholder = (state, id) => { + let { set } = key.getState(state); + let found = set?.find(null, null, (spec) => spec.id === id); + return found?.length ? found[0].from : null; +}; + +export const removeImagePlaceholder = (state, tr, id) => { + let { set } = key.getState(state); + set = set.map(tr.mapping, tr.doc); + set = set.remove(set.find(null, null, (spec) => spec.id == id)); + return tr.setMeta(key, { set, type: 'remove' }); +}; + +export const addImagePlaceholder = (state, tr, id, pos) => { + let { set } = key.getState(state); + set = set.map(tr.mapping, tr.doc); + let deco = Decoration.widget(pos, () => document.createElement('placeholder'), { + id, + }); + set = set.add(tr.doc, [deco]); + return tr.setMeta(key, { set, type: 'add' }); +}; + +export const getImageRegistrationMetaType = (tr) => { + const meta = tr.getMeta(key); + if (meta && meta.type) { + return meta.type; + } + return null; +}; + +const registerImages = async (foundImages, editor, view) => { + foundImages.forEach(async (image) => { + const src = image.node.attrs.src; + const id = image.id; + let file = null; + + if (src.startsWith('http')) { + // First check if the URL is accessible without CORS issues + const isAccessible = await validateUrlAccessibility(src); + + if (isAccessible) { + // Download image first, create fileobject, then proceed with registration. + file = await urlToFile(src); + } else { + console.warn(`Image URL ${src} is not accessible due to CORS or other restrictions. Using original URL.`); + // Fallback: Remove the placeholder. + const tr = view.state.tr; + removeImagePlaceholder(view.state, tr, id); + view.dispatch(tr); + return; + } + } else if (src.startsWith('data:')) { + file = base64ToFile(src); + } else { + console.error(`Unsupported image source: ${src}`); + } + + if (!file) { + // If file conversion failed, remove the placeholder to avoid stuck UI + const tr = view.state.tr; + removeImagePlaceholder(view.state, tr, id); + view.dispatch(tr); + return; + } + + try { + const process = await checkAndProcessImage({ + getMaxContentSize: () => editor.getMaxContentSize(), + view, + file, + }); + + if (!process.file) { + // Processing failed, remove placeholder + const tr = view.state.tr; + removeImagePlaceholder(view.state, tr, id); + view.dispatch(tr); + return; + } + + await uploadAndInsertImage({ editor, view, file: process.file, size: process.size, id }); + } catch (error) { + console.error(`Error processing image from ${src}:`, error); + // Ensure placeholder is removed even on error + const tr = view.state.tr; + removeImagePlaceholder(view.state, tr, id); + view.dispatch(tr); + } + }); +}; diff --git a/packages/super-editor/src/extensions/image/imageHelpers/index.js b/packages/super-editor/src/extensions/image/imageHelpers/index.js index 61bf20062..3d2da5344 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/index.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/index.js @@ -1,6 +1,6 @@ export * from './getFileOpener.js'; export * from './startImageUpload.js'; export * from './handleImageUpload.js'; -export * from './imagePlaceholderPlugin.js'; +export * from './imageRegistrationPlugin.js'; export * from './processUploadedImage.js'; export * from './imagePositionPlugin.js'; diff --git a/packages/super-editor/src/extensions/image/imageHelpers/processUploadedImage.js b/packages/super-editor/src/extensions/image/imageHelpers/processUploadedImage.js index 1b482f8e7..dc4037056 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/processUploadedImage.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/processUploadedImage.js @@ -3,13 +3,17 @@ * @param {string | File} fileData Base 64 string or File object. * @returns {Promise} Resolves with a base 64 string or File object. */ -export const processUploadedImage = (fileData, editor) => { +export const processUploadedImage = (fileData, getMaxContentSize) => { return new Promise((resolve, reject) => { const img = new window.Image(); img.onload = () => { const canvas = document.createElement('canvas'); - const { width: logicalWidth, height: logicalHeight } = getAllowedImageDimensions(img.width, img.height, editor); + const { width: logicalWidth, height: logicalHeight } = getAllowedImageDimensions( + img.width, + img.height, + getMaxContentSize, + ); // Set canvas to original image size first canvas.width = img.width; @@ -64,8 +68,8 @@ export const processUploadedImage = (fileData, editor) => { }); }; -export const getAllowedImageDimensions = (width, height, editor) => { - const { width: maxWidth, height: maxHeight } = editor.getMaxContentSize(); +export const getAllowedImageDimensions = (width, height, getMaxContentSize) => { + const { width: maxWidth, height: maxHeight } = getMaxContentSize(); if (!maxWidth || !maxHeight) return { width, height }; let adjustedWidth = width; diff --git a/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js b/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js index 74f38dd30..2fd535391 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js @@ -1,115 +1,109 @@ -import { ImagePlaceholderPluginKey, findPlaceholder } from './imagePlaceholderPlugin.js'; +import { findPlaceholder, removeImagePlaceholder, addImagePlaceholder } from './imageRegistrationPlugin.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 }) => { - const imageUploadHandler = - typeof editor.options.handleImageUpload === 'function' - ? editor.options.handleImageUpload - : handleImageUploadDefault; - +const fileTooLarge = (file) => { let fileSizeMb = Number((file.size / (1024 * 1024)).toFixed(4)); if (fileSizeMb > 5) { window.alert('Image size must be less than 5MB'); - return; + return true; + } + return false; +}; + +export const checkAndProcessImage = async ({ getMaxContentSize, view, file }) => { + if (fileTooLarge(file)) { + return { file: null, size: { width: 0, height: 0 } }; } let width; let height; try { // Will process the image file in place - const processedImageResult = await processUploadedImage(file, editor); - width = processedImageResult.width; - height = processedImageResult.height; - file = processedImageResult.file; + const processedImageResult = await processUploadedImage(file, getMaxContentSize); + const process = processedImageResult; + return { file: process.file, size: { width: process.width, height: process.height } }; } catch (err) { console.warn('Error processing image:', err); - return; + return { file: null, size: { width: 0, height: 0 } }; } - - 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 = {}; - +export function replaceSelectionWithImagePlaceholder({ editorOptions, view, id }) { // Replace the selection with a placeholder - let { tr, schema } = view.state; + let { tr } = view.state; let { selection } = tr; - if (editor.options.isHeaderOrFooter) { - selection = editor.options.lastSelection; + if (editorOptions.isHeaderOrFooter) { + selection = editorOptions.lastSelection; } - if (!selection.empty && !editor.options.isHeaderOrFooter) { + if (!selection.empty && !editorOptions.isHeaderOrFooter) { tr.deleteSelection(); } - let imageMeta = { - type: 'add', - pos: selection.from, - id, - }; + tr = addImagePlaceholder(view.state, tr, id, selection.from); - tr.setMeta(ImagePlaceholderPluginKey, imageMeta); view.dispatch(tr); +} - 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)); +export async function uploadAndInsertImage({ editor, view, file, size, id }) { + const imageUploadHandler = + typeof editor.options.handleImageUpload === 'function' + ? editor.options.handleImageUpload + : handleImageUploadDefault; + + // try { + let url = await imageUploadHandler(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; + } + + 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 = view.state.schema.nodes.image.create({ + src: mediaPath, + size, + id, + 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 }); } + + let tr = view.state.tr; + + tr.replaceWith(placeholderPos, placeholderPos, imageNode); + + tr = removeImagePlaceholder(view.state, tr, id); + // Otherwise, insert it at the placeholder's position, and remove + // the placeholder + + view.dispatch(tr); + // } catch { + + // const tr = removeImagePlaceholder(view.state.tr, id); + // // On failure, just clean up the placeholder + // view.dispatch(tr); + // } } function addImageRelationship({ editor, path }) { diff --git a/packages/super-editor/src/extensions/pagination/pagination.js b/packages/super-editor/src/extensions/pagination/pagination.js index bf803ecd3..94e4fce48 100644 --- a/packages/super-editor/src/extensions/pagination/pagination.js +++ b/packages/super-editor/src/extensions/pagination/pagination.js @@ -9,7 +9,7 @@ import { broadcastEditorEvents, } from './pagination-helpers.js'; import { CollaborationPluginKey } from '@extensions/collaboration/collaboration.js'; -import { ImagePlaceholderPluginKey } from '@extensions/image/imageHelpers/imagePlaceholderPlugin.js'; +import { getImageRegistrationMetaType } from '@extensions/image/imageHelpers/imageRegistrationPlugin.js'; import { LinkedStylesPluginKey } from '@extensions/linked-styles/index.js'; import { findParentNodeClosestToPos } from '@core/helpers/findParentNodeClosestToPos.js'; import { generateDocxRandomId } from '../../core/helpers/index.js'; @@ -122,9 +122,9 @@ export const Pagination = Extension.create({ } // We need special handling for images / the image placeholder plugin - const imagePluginTransaction = tr.getMeta(ImagePlaceholderPluginKey); - if (imagePluginTransaction) { - if (imagePluginTransaction.type === 'remove') { + const imageRegistrationMetaType = getImageRegistrationMetaType(tr); + if (imageRegistrationMetaType) { + if (imageRegistrationMetaType === 'remove') { onImageLoad(editor); } return { ...oldState }; diff --git a/packages/super-editor/src/tests/editor/relationships.test.js b/packages/super-editor/src/tests/editor/relationships.test.js index 2fa588219..45e546b32 100644 --- a/packages/super-editor/src/tests/editor/relationships.test.js +++ b/packages/super-editor/src/tests/editor/relationships.test.js @@ -2,8 +2,7 @@ import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpe 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 { uploadImageIntoSelection } from '@extensions/image/imageHelpers/startImageUpload.js'; import { imageBase64 } from './data/imageBase64.js'; describe('Relationships tests', () => { @@ -39,12 +38,11 @@ describe('Relationships tests', () => { const blob = await fetch(imageBase64).then((res) => res.blob()); const file = new File([blob], 'image.png', { type: 'image/png' }); - await uploadImage({ + await uploadImageIntoSelection({ editor, view: editor.view, file, size: { width: 100, height: 100 }, - uploadHandler: handleImageUploadDefault, }); const imageNode = editor.state.doc.firstChild.firstChild; From 7181284991186cd9db17384d3b92d74c383eaa6c Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Mon, 1 Sep 2025 22:54:50 +0200 Subject: [PATCH 2/4] chore: lint --- .../assets/styles/elements/prosemirror.css | 18 ++---- .../src/components/toolbar/super-toolbar.js | 1 - .../src/core/super-converter/exporter.js | 58 +++---------------- .../image/imageHelpers/handleUrl.js | 2 +- .../imageHelpers/imageRegistrationPlugin.js | 5 +- .../image/imageHelpers/startImageUpload.js | 4 +- 6 files changed, 17 insertions(+), 71 deletions(-) diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index f7b21e2b5..d24b9bf76 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -148,8 +148,8 @@ img.ProseMirror-separator { margin-bottom: 1.5px; } -/* -Tables +/* +Tables https://github.com/ProseMirror/prosemirror-tables/blob/master/style/tables.css https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html */ @@ -166,8 +166,8 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html scrollbar-width: thin; overflow: hidden; - /* - The border width does not need to be multiplied by two, + /* + The border width does not need to be multiplied by two, for tables it works differently. */ width: calc(100% + (var(--table-border-width) + var(--offset))); } @@ -292,25 +292,19 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* Collaboration cursors - end */ /* Image placeholder */ -.ProseMirror placeholder, -.ProseMirror img.placeholder { +.ProseMirror placeholder { display: inline; border: 1px solid #ccc; color: #ccc; } -.ProseMirror placeholder:after, -.ProseMirror img.placeholder:after { +.ProseMirror placeholder:after { content: '☁'; font-size: 200%; line-height: 0.1; font-weight: bold; } -.ProseMirror placeholder img { - display: none !important; -} - /* Gapcursor */ .ProseMirror-gapcursor { display: none; diff --git a/packages/super-editor/src/components/toolbar/super-toolbar.js b/packages/super-editor/src/components/toolbar/super-toolbar.js index 12cacd421..02b17ba06 100644 --- a/packages/super-editor/src/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/components/toolbar/super-toolbar.js @@ -372,7 +372,6 @@ export class SuperToolbar extends EventEmitter { } const { size, file } = await checkAndProcessImage({ - view: this.activeEditor.view, file: result.file, getMaxContentSize: () => this.activeEditor.getMaxContentSize(), }); diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index f44e6a769..cf79153ab 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -4,7 +4,6 @@ import { EditorState } from 'prosemirror-state'; import { SuperConverter } from './SuperConverter.js'; import { emuToPixels, - getTextIndentExportValue, inchesToTwips, linesToTwips, pixelsToEightPoints, @@ -74,7 +73,6 @@ export function exportSchemaToJson(params) { const router = { doc: translateDocumentNode, body: translateBodyNode, - heading: translateHeadingNode, paragraph: translateParagraphNode, text: translateTextNode, bulletList: translateList, @@ -170,30 +168,6 @@ const generateDefaultHeaderFooter = (type, id) => { }; }; -/** - * Translate a heading node to a paragraph with Word heading style - * - * @param {ExportParams} params The parameters object containing the heading node - * @returns {XmlReadyNode} JSON of the XML-ready paragraph node with heading style - */ -function translateHeadingNode(params) { - const { node } = params; - const { level = 1, ...otherAttrs } = node.attrs; - - // Convert heading to paragraph with appropriate Word heading style - const paragraphNode = { - type: 'paragraph', - content: node.content, - attrs: { - ...otherAttrs, - styleId: `Heading${level}`, // Maps to Heading1, Heading2, etc. in Word - }, - }; - - // Use existing paragraph translator with the modified node - return translateParagraphNode({ ...params, node: paragraphNode }); -} - /** * Translate a paragraph node * @@ -306,7 +280,7 @@ function generateParagraphProperties(node) { if (hanging || hanging === 0) attributes['w:hanging'] = pixelsToTwips(hanging); if (textIndent && !attributes['w:left']) { - attributes['w:left'] = getTextIndentExportValue(textIndent); + attributes['w:left'] = inchesToTwips(textIndent); } const indentElement = { @@ -318,7 +292,7 @@ function generateParagraphProperties(node) { const indentElement = { name: 'w:ind', attributes: { - 'w:left': getTextIndentExportValue(textIndent), + 'w:left': inchesToTwips(textIndent), }, }; pPrElements.push(indentElement); @@ -970,13 +944,7 @@ function translateLineBreak(params) { } return { - name: 'w:r', - elements: [ - { - name: 'w:br', - attributes, - }, - ], + name: 'w:br', attributes, }; } @@ -1546,7 +1514,6 @@ function getScaledSize(originalWidth, originalHeight, maxWidth, maxHeight) { } function translateImageNode(params, imageSize) { - console.log('translateImageNode', { params, imageSize }); const { node: { attrs = {} }, tableCell, @@ -1998,12 +1965,7 @@ function prepareUrlAnnotation(params) { * @param {String} annotationType * @returns {Function} handler for provided annotation type */ -function getTranslationByAnnotationType(annotationType, annotationFieldType) { - // invalid annotation - if (annotationType === 'text' && annotationFieldType === 'FILEUPLOADER') { - return null; - } - +function getTranslationByAnnotationType(annotationType) { const imageEmuSize = { w: 4286250, h: 4286250, @@ -2049,7 +2011,7 @@ const translateFieldAttrsToMarks = (attrs = {}) => { function translateFieldAnnotation(params) { const { node, isFinalDoc, fieldsHighlightColor } = params; const { attrs = {} } = node; - const annotationHandler = getTranslationByAnnotationType(attrs.type, attrs.fieldType); + const annotationHandler = getTranslationByAnnotationType(attrs.type); if (!annotationHandler) return {}; let processedNode; @@ -2321,14 +2283,8 @@ export class DocxExporter { if (name === 'w:instrText') { tags.push(elements[0].text); } else if (name === 'w:t' || name === 'w:delText' || name === 'wp:posOffset') { - try { - // test for valid string - let text = String(elements[0].text); - text = this.#replaceSpecialCharacters(text); - tags.push(text); - } catch (error) { - console.error('Text element does not contain valid string:', error); - } + const text = this.#replaceSpecialCharacters(elements[0].text); + tags.push(text); } else { if (elements) { for (let child of elements) { diff --git a/packages/super-editor/src/extensions/image/imageHelpers/handleUrl.js b/packages/super-editor/src/extensions/image/imageHelpers/handleUrl.js index 4c873f824..6373876d8 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/handleUrl.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/handleUrl.js @@ -100,7 +100,7 @@ export const validateUrlAccessibility = async (url) => { credentials: 'omit', }); return response.ok; - } catch (error) { + } catch (_error) { return false; } }; diff --git a/packages/super-editor/src/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/extensions/image/imageHelpers/imageRegistrationPlugin.js index 524652338..82223ddad 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/imageRegistrationPlugin.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/imageRegistrationPlugin.js @@ -1,6 +1,6 @@ import { Plugin, PluginKey } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; -import { ReplaceStep, ReplaceAroundStep, StepMap } from 'prosemirror-transform'; +import { ReplaceStep, ReplaceAroundStep } from 'prosemirror-transform'; import { base64ToFile } from './handleBase64'; import { urlToFile, validateUrlAccessibility } from './handleUrl'; import { checkAndProcessImage, uploadAndInsertImage } from './startImageUpload'; @@ -87,7 +87,7 @@ export const ImageRegistrationPlugin = ({ editor }) => { // Add decorations for the images first at their current positions foundImages .toSorted((a, b) => a.pos - b.pos) - .forEach(({ node, pos, id }) => { + .forEach(({ pos, id }) => { let deco = Decoration.widget(pos, () => document.createElement('placeholder'), { side: -1, id, @@ -187,7 +187,6 @@ const registerImages = async (foundImages, editor, view) => { try { const process = await checkAndProcessImage({ getMaxContentSize: () => editor.getMaxContentSize(), - view, file, }); diff --git a/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js b/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js index 2fd535391..51b22ed94 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.js @@ -13,13 +13,11 @@ const fileTooLarge = (file) => { return false; }; -export const checkAndProcessImage = async ({ getMaxContentSize, view, file }) => { +export const checkAndProcessImage = async ({ getMaxContentSize, file }) => { if (fileTooLarge(file)) { return { file: null, size: { width: 0, height: 0 } }; } - let width; - let height; try { // Will process the image file in place const processedImageResult = await processUploadedImage(file, getMaxContentSize); From 2e275c4f465fbd886dba25b76c2a0e4ae596d15a Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Mon, 1 Sep 2025 23:03:33 +0200 Subject: [PATCH 3/4] chore: lint test --- .../src/tests/editor/relationships.test.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/tests/editor/relationships.test.js b/packages/super-editor/src/tests/editor/relationships.test.js index 45e546b32..b9d71c62a 100644 --- a/packages/super-editor/src/tests/editor/relationships.test.js +++ b/packages/super-editor/src/tests/editor/relationships.test.js @@ -2,7 +2,10 @@ import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpe import { TextSelection } from 'prosemirror-state'; import { expect } from 'vitest'; import { getDocumentRelationshipElements } from '@core/super-converter/docx-helpers/document-rels.js'; -import { uploadImageIntoSelection } from '@extensions/image/imageHelpers/startImageUpload.js'; +import { + uploadAndInsertImage, + replaceSelectionWithImagePlaceholder, +} from '@extensions/image/imageHelpers/startImageUpload.js'; import { imageBase64 } from './data/imageBase64.js'; describe('Relationships tests', () => { @@ -38,11 +41,20 @@ describe('Relationships tests', () => { const blob = await fetch(imageBase64).then((res) => res.blob()); const file = new File([blob], 'image.png', { type: 'image/png' }); - await uploadImageIntoSelection({ + const id = {}; + + replaceSelectionWithImagePlaceholder({ + view: editor.view, + editorOptions: editor.options, + id, + }); + + await uploadAndInsertImage({ editor, view: editor.view, file, size: { width: 100, height: 100 }, + id, }); const imageNode = editor.state.doc.firstChild.firstChild; From 9ece4569d929197bc493bfa7b83606f59e265506 Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Tue, 2 Sep 2025 17:31:40 +0200 Subject: [PATCH 4/4] chore: reset exporter.js --- .../src/core/super-converter/exporter.js | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 82163820a..8a235ae46 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -4,6 +4,7 @@ import { EditorState } from 'prosemirror-state'; import { SuperConverter } from './SuperConverter.js'; import { emuToPixels, + getTextIndentExportValue, inchesToTwips, linesToTwips, pixelsToEightPoints, @@ -73,6 +74,7 @@ export function exportSchemaToJson(params) { const router = { doc: translateDocumentNode, body: translateBodyNode, + heading: translateHeadingNode, paragraph: translateParagraphNode, text: translateTextNode, bulletList: translateList, @@ -169,6 +171,30 @@ const generateDefaultHeaderFooter = (type, id) => { }; }; +/** + * Translate a heading node to a paragraph with Word heading style + * + * @param {ExportParams} params The parameters object containing the heading node + * @returns {XmlReadyNode} JSON of the XML-ready paragraph node with heading style + */ +function translateHeadingNode(params) { + const { node } = params; + const { level = 1, ...otherAttrs } = node.attrs; + + // Convert heading to paragraph with appropriate Word heading style + const paragraphNode = { + type: 'paragraph', + content: node.content, + attrs: { + ...otherAttrs, + styleId: `Heading${level}`, // Maps to Heading1, Heading2, etc. in Word + }, + }; + + // Use existing paragraph translator with the modified node + return translateParagraphNode({ ...params, node: paragraphNode }); +} + /** * Translate a paragraph node * @@ -281,7 +307,7 @@ function generateParagraphProperties(node) { if (hanging || hanging === 0) attributes['w:hanging'] = pixelsToTwips(hanging); if (textIndent && !attributes['w:left']) { - attributes['w:left'] = inchesToTwips(textIndent); + attributes['w:left'] = getTextIndentExportValue(textIndent); } const indentElement = { @@ -293,7 +319,7 @@ function generateParagraphProperties(node) { const indentElement = { name: 'w:ind', attributes: { - 'w:left': inchesToTwips(textIndent), + 'w:left': getTextIndentExportValue(textIndent), }, }; pPrElements.push(indentElement); @@ -945,7 +971,13 @@ function translateLineBreak(params) { } return { - name: 'w:br', + name: 'w:r', + elements: [ + { + name: 'w:br', + attributes, + }, + ], attributes, }; } @@ -1966,7 +1998,12 @@ function prepareUrlAnnotation(params) { * @param {String} annotationType * @returns {Function} handler for provided annotation type */ -function getTranslationByAnnotationType(annotationType) { +function getTranslationByAnnotationType(annotationType, annotationFieldType) { + // invalid annotation + if (annotationType === 'text' && annotationFieldType === 'FILEUPLOADER') { + return null; + } + const imageEmuSize = { w: 4286250, h: 4286250, @@ -2012,7 +2049,7 @@ const translateFieldAttrsToMarks = (attrs = {}) => { function translateFieldAnnotation(params) { const { node, isFinalDoc, fieldsHighlightColor } = params; const { attrs = {} } = node; - const annotationHandler = getTranslationByAnnotationType(attrs.type); + const annotationHandler = getTranslationByAnnotationType(attrs.type, attrs.fieldType); if (!annotationHandler) return {}; let processedNode; @@ -2284,8 +2321,14 @@ export class DocxExporter { if (name === 'w:instrText') { tags.push(elements[0].text); } else if (name === 'w:t' || name === 'w:delText' || name === 'wp:posOffset') { - const text = this.#replaceSpecialCharacters(elements[0].text); - tags.push(text); + try { + // test for valid string + let text = String(elements[0].text); + text = this.#replaceSpecialCharacters(text); + tags.push(text); + } catch (error) { + console.error('Text element does not contain valid string:', error); + } } else { if (elements) { for (let child of elements) {